第12章 WebSocket网关高可用与总结
从一次线上事故说起
去年有个朋友的公司做在线教育,直播课堂用 WebSocket 做实时互动。某天晚上高峰期,网关服务突然内存飙升到百分之九十,连接数断崖式下跌,三万学生的直播间同时掉线。整个技术团队通宵排查,最后发现问题的根源出在三个地方:没有做连接熔断导致下游变慢时网关被拖垮、消息重试机制形同虚设导致大量消息丢失、监控告警延迟了整整八分钟才发现问题。
事后复盘的时候,CTO 问了一个问题:我们的网关到底能不能扛住生产流量?这个问题让整个团队沉默了。他们之前做的所有测试都是在理想网络环境下,单机跑几万个连接,消息收发正常。但生产环境的网络抖动、下游服务变慢、恶意流量攻击、机器故障这些因素叠加在一起,整个系统的表现就完全不一样了。
这不是个例。我在做 WebSocket 网关这几年,见过太多类似的事故。很多人觉得 WebSocket 网关就是建个连接、转发消息,画个架构图,写个 demo,跑通就觉得可以上线了。上线才发现水深得很:容错怎么做?消息丢了怎么办?被刷连接攻击怎么防?节点挂了连接怎么迁移?这些问题在生产环境必须有答案,而且答案不能是"重启试试"。
我是怕浪猫,一个在生产环境踩过无数 WebSocket 坑的 Go 后端工程师。这一章是整个 WebSocket 网关联列的收尾篇,我会把高可用、消息可靠性、安全设计、监控运维和项目复盘全部讲透。这一章的内容不是理论推演,而是用真实事故和线上数据喂出来的经验总结。如果你正在做即时通讯、实时推送、在线教育或任何基于 WebSocket 的系统,这一章值得反复读。
高可用不是"加几台机器"那么简单,而是从连接建立到消息投递的每一个环节都要有兜底方案。任何一个环节没有兜底,整个链路就不可靠。
一、WebSocket网关容错方案
1.1 为什么网关需要容错
WebSocket 网关是长连接系统,和传统 HTTP 服务有本质区别。HTTP 服务一个请求一个响应,请求结束连接就释放了,服务挂了重启就行,影响范围只是那几秒钟的请求。但 WebSocket 不一样,它是持久化的有状态连接。一条连接上可能跑着成百上千条消息,连接断了状态就丢了。用户正在看直播,连接断了,弹幕停了、互动停了,用户体验直接归零。
容错要解决的核心问题有三个。
第一个是连接漂移问题。某台网关节点挂了,它上面的几万个连接瞬间全部断开。这些客户端会立刻尝试重连,如果所有客户端同时重连,就会形成"重连风暴",可能把其他节点也打挂。所以需要做重连退避和抖动,让重连分散在一段时间内。
第二个是消息积压问题。下游业务服务变慢了,消息在网关层堆积。如果不做处理,内存会持续增长直到 OOM。所以需要做背压机制,当下游变慢时,网关要能感知到并降低接收速率,而不是无限制地缓存。
第三个是级联故障问题。一个节点故障后,它的流量会涌入其他节点。如果其他节点本来就很满,这波流量可能导致它们也挂掉,形成雪崩。所以需要做熔断和限流,保护存量连接的稳定性。
这三个问题不是独立的,而是相互关联的。连接漂移会触发重连风暴,重连风暴会加剧消息积压,消息积压会导致级联故障。一个好的容错方案需要同时考虑这三个维度,缺一不可。而且容错方案不是写完就一劳永逸的,需要定期做混沌工程演练,故意注入故障,验证容错机制是否真的有效。很多团队的容错方案在纸面上看起来很完整,但真到故障发生时完全不管用,就是因为从来没有真正演练过。
在分布式系统里,故障不是"如果"的问题,而是"何时"的问题。你的系统对故障的容忍度,决定了它的可用性上限。
1.2 连接迁移方案
先说一个很多人误解的概念。很多人以为 WebSocket 连接可以"无损迁移",就像 HTTP 负载均衡那样把请求转发到另一台机器。但这是不可能的。WebSocket 连接底层是 TCP 连接,TCP 连接是内核维护的四元组(源IP、源端口、目标IP、目标端口),进程死了这条连接就没了,没有任何办法把它搬走。
所以我们做的不是"迁移连接",而是"快速重建加上状态恢复"。客户端检测到连接断开后,立刻发起重连,负载均衡把新连接分配到一个健康的节点上,新节点从 Redis 中恢复这个客户端的会话状态。整个过程对用户来说是透明的,可能只是感觉到一个短暂的卡顿。
这里的关键是客户端重连策略。很多团队的实现就是写个定时器固定间隔重连,比如每三秒重连一次。这种做法在测试环境没问题,但在生产环境有两个严重问题。第一,如果所有客户端同时断开(比如某台网关节点挂了),它们会同时重连,形成重连风暴。第二,如果服务端还没恢复,固定间隔的重连只会白白消耗资源。
我建议使用"指数退避加抖动"策略。指数退避的意思是每次重连的间隔翻倍:第一次等500毫秒,第二次等1秒,第三次等2秒,以此类推。但纯指数退避还是可能让多个客户端同步重连,所以要加抖动:在退避时间的基础上加上一个随机偏移量,让各个客户端的重连时间分散开。
服务端这边要做的是状态恢复。客户端重连成功后,网关从 Redis 中加载这个客户端的会话信息,包括用户ID、设备ID、所在房间、最后收到的消息序号等。然后根据最后消息序号,把缺失的消息补发给客户端。这样客户端的体验就是断了一下又恢复了,不会丢消息。
来看看具体的代码实现。首先是客户端重连策略:
package gateway
import (
"context"
"math"
"math/rand"
"time"
)
// ReconnectStrategy 重连策略
type ReconnectStrategy struct {
baseDelay time.Duration // 基础延迟
maxDelay time.Duration // 最大延迟
maxRetries int // 最大重试次数
jitterFactor float64 // 抖动因子 0~1
}
func NewReconnectStrategy() *ReconnectStrategy {
return &ReconnectStrategy{
baseDelay: 500 * time.Millisecond,
maxDelay: 30 * time.Second,
maxRetries: 10,
jitterFactor: 0.3,
}
}
// NextDelay 计算下一次重连延迟
func (r *ReconnectStrategy) NextDelay(retryCount int) time.Duration {
if retryCount >= r.maxRetries {
return r.maxDelay
}
// 指数退避: base * 2^retry
delay := float64(r.baseDelay) * math.Pow(2, float64(retryCount))
if delay > float64(r.maxDelay) {
delay = float64(r.maxDelay)
}
// 添加抖动,防止大量客户端同时重连
jitter := delay * r.jitterFactor * (2*rand.Float64() - 1)
delay = delay + jitter
if delay < float64(r.baseDelay) {
delay = float64(r.baseDelay)
}
return time.Duration(delay)
}
// ExecuteWithRetry 带重试的连接执行
func (r *ReconnectStrategy) ExecuteWithRetry(ctx context.Context, connectFn func() error) error {
var lastErr error
for i := 0; i < r.maxRetries; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := connectFn(); err != nil {
lastErr = err
delay := r.NextDelay(i)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
return lastErr
}这段代码的核心逻辑是 NextDelay 方法。它先计算指数退避的基础延迟,然后在这个基础上加一个正负百分之三十的随机抖动。抖动因子设为零点三是个经验值,太小了起不到分散效果,太大了重连时间不可控。
服务端的状态恢复需要把会话信息外置到 Redis。这一点非常关键,因为网关节点是无状态的,任何一个节点都能恢复任何一个客户端的会话。如果会话信息只存在内存里,节点一挂就全丢了:
package gateway
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// SessionManager 会话管理器,将会话状态存储在Redis中
type SessionManager struct {
rdb *redis.Client
ttl time.Duration
keyPrefix string
}
type Session struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
RoomID string `json:"room_id,omitempty"`
LastSeq int64 `json:"last_seq"` // 最后收到的消息序号
Metadata map[string]string `json:"metadata,omitempty"`
NodeID string `json:"node_id"` // 当前连接所在的网关节点
ConnectedAt time.Time `json:"connected_at"`
}
func NewSessionManager(rdb *redis.Client) *SessionManager {
return &SessionManager{
rdb: rdb,
ttl: 2 * time.Hour,
keyPrefix: "ws:session:",
}
}
// Save 保存会话状态
func (sm *SessionManager) Save(ctx context.Context, session *Session) error {
data, err := json.Marshal(session)
if err != nil {
return err
}
key := sm.keyPrefix + session.UserID + ":" + session.DeviceID
return sm.rdb.Set(ctx, key, data, sm.ttl).Err()
}
// Load 加载会话状态
func (sm *SessionManager) Load(ctx context.Context, userID, deviceID string) (*Session, error) {
key := sm.keyPrefix + userID + ":" + deviceID
data, err := sm.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil // 会话不存在
}
if err != nil {
return nil, err
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
return nil, err
}
return &session, nil
}
// Delete 删除会话
func (sm *SessionManager) Delete(ctx context.Context, userID, deviceID string) error {
key := sm.keyPrefix + userID + ":" + deviceID
return sm.rdb.Del(ctx, key).Err()
}
// TransferSession 迁移会话到新节点
func (sm *SessionManager) TransferSession(ctx context.Context, userID, deviceID, newNodeID string) error {
session, err := sm.Load(ctx, userID, deviceID)
if err != nil {
return err
}
if session == nil {
return ErrSessionNotFound
}
session.NodeID = newNodeID
session.ConnectedAt = time.Now()
return sm.Save(ctx, session)
}会话的 TTL 设为两小时,意思是如果一个客户端两小时都没有重连,就认为它真的离线了,会话信息可以清理掉。这个值要根据业务场景调整,如果是直播场景,直播结束用户就走了,TTL 可以设短一点,比如三十分钟。如果是即时通讯场景,用户可能只是暂时进了电梯没信号,TTL 要设长一点,比如四小时甚至更长。TTL 设太短会导致用户短暂断线后会话丢失,无法恢复上下文。TTL 设太长会导致 Redis 中积累大量僵尸会话,浪费内存。建议根据用户重连数据的分布来设定:统计百分之九十五的用户在断线后多长时间内重连成功,把这个时间作为 TTL。
抖动是分布式系统的减震器。没有抖动的退避只是另一种形式的 DDoS。
1.3 连接熔断与限流
网关节点的资源是有限的,CPU、内存、文件描述符都有上限。不可能无限制地接收连接。当连接数接近上限时,继续接收新连接会导致已有连接的质量下降,甚至触发 OOM。所以必须在入口处设置熔断和限流机制。
熔断器的思路借鉴了电路中的保险丝。当电流过大时保险丝熔断,保护电器不被烧毁。在软件系统中,当连接数超过阈值时,熔断器"跳闸",拒绝新连接,保护已有连接的稳定性。等连接数降下来后,熔断器"恢复",重新允许新连接。
我实现了一个三态熔断器:Closed(正常)、Open(熔断)、Half-Open(半开)。正常状态下自由接收连接,连接数超限时跳到 Open 状态。Open 状态下拒绝所有新连接,等待一段时间后进入 Half-Open 状态。Half-Open 状态下允许少量连接试探,如果这些连接正常完成,说明系统恢复了,回到 Closed;如果还有问题,回到 Open。
package gateway
import (
"sync"
"sync/atomic"
"time"
)
// CircuitBreaker 连接熔断器
type CircuitBreaker struct {
maxConnections int64
currentConns int64
halfOpenThreshold int64
state int32 // 0:closed 1:open 2:half-open
openTime time.Time
openDuration time.Duration
mu sync.RWMutex
}
func NewCircuitBreaker(maxConns int) *CircuitBreaker {
return &CircuitBreaker{
maxConnections: int64(maxConns),
halfOpenThreshold: int64(maxConns / 10), // 半开状态允许10%的连接
state: 0,
openDuration: 30 * time.Second,
}
}
// Acquire 尝试获取连接许可
func (cb *CircuitBreaker) Acquire() bool {
state := atomic.LoadInt32(&cb.state)
switch state {
case 0: // closed - 正常工作
current := atomic.AddInt64(&cb.currentConns, 1)
if current > cb.maxConnections {
atomic.AddInt64(&cb.currentConns, -1)
cb.trip()
return false
}
return true
case 1: // open - 熔断状态
cb.mu.RLock()
if time.Since(cb.openTime) > cb.openDuration {
cb.mu.RUnlock()
cb.mu.Lock()
if atomic.LoadInt32(&cb.state) == 1 {
atomic.StoreInt32(&cb.state, 2) // 切换到半开
}
cb.mu.Unlock()
current := atomic.AddInt64(&cb.currentConns, 1)
if current <= cb.halfOpenThreshold {
return true
}
atomic.AddInt64(&cb.currentConns, -1)
return false
}
cb.mu.RUnlock()
return false
case 2: // half-open - 半开状态
current := atomic.AddInt64(&cb.currentConns, 1)
if current <= cb.halfOpenThreshold {
return true
}
atomic.AddInt64(&cb.currentConns, -1)
return false
}
return false
}
// Release 释放连接
func (cb *CircuitBreaker) Release() {
current := atomic.AddInt64(&cb.currentConns, -1)
if current < 0 {
atomic.StoreInt64(&cb.currentConns, 0)
}
// 半开状态下连接正常释放,恢复到closed
if atomic.LoadInt32(&cb.state) == 2 {
if atomic.LoadInt64(&cb.currentConns) == 0 {
cb.mu.Lock()
if atomic.LoadInt32(&cb.state) == 2 {
atomic.StoreInt32(&cb.state, 0)
}
cb.mu.Unlock()
}
}
}
func (cb *CircuitBreaker) trip() {
cb.mu.Lock()
defer cb.mu.Unlock()
if atomic.LoadInt32(&cb.state) == 0 {
atomic.StoreInt32(&cb.state, 1)
cb.openTime = time.Now()
}
}
// Stats 获取熔断器状态
func (cb *CircuitBreaker) Stats() (state string, current, max int64) {
s := atomic.LoadInt32(&cb.state)
switch s {
case 0:
state = "closed"
case 1:
state = "open"
case 2:
state = "half-open"
}
return state, atomic.LoadInt64(&cb.currentConns), cb.maxConnections
}注意这段代码中使用了 atomic 操作来保证并发安全,避免了在高频的 Acquire 和 Release 路径上加锁。只有在状态转换的时候才使用互斥锁,这是一个性能优化的技巧。在高并发场景下,锁争用是性能杀手,能用 atomic 就不要用 mutex。atomic 操作的底层是 CPU 的 CAS 指令,不需要操作系统级别的锁,性能比 mutex 高一到两个数量级。但 atomic 只能做简单的加减和比较交换操作,复杂的逻辑还是要用 mutex。这段代码中的技巧是:把高频路径(Acquire 和 Release)用 atomic 实现,低频路径(状态转换)用 mutex 实现,从而在正确性和性能之间取得平衡。
熔断器不是"防止故障",而是"控制故障的爆炸半径"。一个节点挂了不应该带着整个系统一起死。
1.4 优雅上下线
网关节点上下线如果不做优雅处理,会导致大量客户端同时断开重连。上线时还好,主要是下线时的处理。很多人发版就是直接 kill 进程,几万个连接瞬间断开,客户端疯狂重连,新节点还没起来,整个系统就抖了一下。
正确的下线流程分四步。第一步,从服务发现摘除,让负载均衡不再把新连接分配过来。第二步,等待一段时间,让存量连接的自然业务流程结束。第三步,向存量连接发送"重定向"消息,告知客户端服务器即将下线,建议重连。第四步,等待连接逐步关闭,超时后强制关闭剩余连接。
package gateway
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// GatewayServer WebSocket网关服务器
type GatewayServer struct {
httpServer *http.Server
connManager *ConnectionManager
breaker *CircuitBreaker
shutdownTimeout time.Duration
}
func NewGatewayServer(addr string, cm *ConnectionManager, cb *CircuitBreaker) *GatewayServer {
mux := http.NewServeMux()
server := &GatewayServer{
httpServer: &http.Server{
Addr: addr,
Handler: mux,
},
connManager: cm,
breaker: cb,
shutdownTimeout: 30 * time.Second,
}
mux.HandleFunc("/ws", server.handleWebSocket)
mux.HandleFunc("/health", server.handleHealth)
return server
}
// Start 启动服务器
func (s *GatewayServer) Start() error {
go func() {
log.Printf("WebSocket gateway started on %s", s.httpServer.Addr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down gateway server...")
return s.GracefulShutdown()
}
// GracefulShutdown 优雅关闭
func (s *GatewayServer) GracefulShutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout)
defer cancel()
// 第一步:从服务发现摘除
log.Println("Step 1: Deregistering from service discovery...")
time.Sleep(2 * time.Second) // 等待LB刷新
// 第二步:停止接收新连接
log.Println("Step 2: Stopping new connections...")
// 第三步:向存量连接发送重定向消息
log.Println("Step 3: Notifying existing connections to reconnect...")
connections := s.connManager.GetAll()
for _, conn := range connections {
redirectMsg := &Message{
Type: "system_redirect",
Payload: map[string]interface{}{
"reason": "server_shutdown",
"delay": 100, // 建议客户端100ms后重连
},
}
conn.Send(redirectMsg)
}
// 第四步:等待连接自然关闭
log.Println("Step 4: Waiting for connections to close gracefully...")
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
remaining := s.connManager.Count()
if remaining > 0 {
log.Printf("Timeout! Force closing %d remaining connections...", remaining)
s.connManager.CloseAll()
}
return s.httpServer.Shutdown(context.Background())
case <-ticker.C:
if s.connManager.Count() == 0 {
log.Println("All connections closed gracefully")
return s.httpServer.Shutdown(context.Background())
}
log.Printf("Waiting... %d connections remaining", s.connManager.Count())
}
}
}
func (s *GatewayServer) handleHealth(w http.ResponseWriter, r *http.Request) {
state, current, max := s.breaker.Stats()
if state == "open" {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"status":"unhealthy","reason":"circuit_breaker_open"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"status":"healthy","connections":%d,"max":%d}`, current, max)))
}健康检查接口很重要。负载均衡通过健康检查决定是否把流量分发到这个节点。当节点要下线时,健康检查返回不健康,负载均衡就不再分发新流量了。但存量连接不会立刻断开,因为它们已经建立了 TCP 连接。所以还需要主动发送重定向消息。
重定向消息中带了一个 delay 字段,建议客户端在多少毫秒后重连。这个值设为一百毫秒,配合指数退避策略,能让重连流量分散在一到两秒内,不会形成风暴。这里有个细节需要注意:发送重定向消息本身也需要消耗连接的发送通道,如果连接已经不可写了(比如客户端已经断开),发送操作会阻塞。所以发送重定向消息时要设置超时,超时就跳过,反正这个连接马上就要关闭了。另外,不是所有客户端都能正确处理重定向消息,老版本的客户端可能不认识这个消息类型。所以优雅关闭流程的最后一步必须有超时强制关闭的兜底,不能完全依赖客户端配合。
优雅下线是工程素养的试金石。粗暴地 kill -9 告诉所有人:你没有运维意识。
二、消息可靠性保障
2.1 消息可靠性的三个层次
聊消息可靠性之前,先搞清楚一个概念:可靠性不是非黑即白的,它有三个层次。
第一层是 At Most Once,最多一次。消息可能丢,但不会重复。这种级别适合心跳、状态同步等容忍丢失的场景。比如你在直播间显示在线人数,丢一两条更新无所谓,下一个更新来了就修正了。
第二层是 At Least Once,至少一次。消息不会丢,但可能重复。需要消费端做幂等处理。大多数业务消息都应该做到这个级别。比如聊天消息,丢一条用户就会投诉,但重复一条用户还能接受。
第三层是 Exactly Once,恰好一次。不丢不重。成本最高,需要分布式事务或两阶段提交。只在关键场景使用,比如支付通知、交易指令。
消息可靠性不是越强越好。每提升一个级别,系统复杂度翻倍。选对级别比做到最高级别更重要。
在 WebSocket 网关场景下,我的建议是默认做到 At Least Once,关键业务消息通过端到端的去重做到"逻辑上的 Exactly Once"。不要在网关层追求 Exactly Once,那是业务层的事。为什么?因为网关层要做到 Exactly Once 需要引入分布式事务,而分布式事务会严重影响吞吐量。在每秒几万条消息的场景下,分布式事务的开销是不可接受的。而且 Exactly Once 在网络分区的情况下是做不到严格保证的,与其追求一个做不到的承诺,不如做好 At Least Once 加幂等,效果是一样的。
2.2 消息序号与去重
At Least Once 的核心实现是"消息序号加上去重缓存"。每条消息有一个全局唯一 ID 和一个会话内序号。全局 ID 用于跨会话去重,会话序号用于检测消息缺失和乱序。
发送消息时,网关通过 Redis 的 INCR 命令生成一个单调递增的序号。这个序号是房间维度或用户维度的,不是全局的。因为不同房间的消息序号没有可比性,每个房间维护自己的序号空间就行。
去重检查分两级。第一级是本地内存缓存,用 sync.Map 或带过期的 map 实现。如果一个消息 ID 在本地缓存中存在,直接丢弃,不需要访问 Redis。第二级是 Redis 的 SetNX 操作,作为兜底。本地缓存有容量和过期时间的限制,过期后可能误判,所以需要 Redis 来保证准确性。这种两级缓存的设计能把 Redis 的访问量降低百分之九十以上。为什么这么说?因为重复消息在正常情况下是很少的,绝大部分消息都是新的。但一旦发生重试(比如网络抖动导致发送方不确定消息是否到达,重新发送),就会产生大量重复消息。如果每条消息都去 Redis 查一遍去重,Redis 的 QPS 会很高。而本地缓存能拦截掉绝大部分重复消息,只有本地缓存过期后才会回源到 Redis。这是一个经典的缓存穿透防护设计。
package messaging
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// MessageBroker 消息中间件
type MessageBroker struct {
rdb *redis.Client
seqKey string
dedupTTL time.Duration
localCache *LocalDedupCache
}
// Message 消息结构
type Message struct {
MsgID string `json:"msg_id"`
Seq int64 `json:"seq"`
Type string `json:"type"`
Payload interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id,omitempty"`
RoomID string `json:"room_id,omitempty"`
}
func NewMessageBroker(rdb *redis.Client) *MessageBroker {
return &MessageBroker{
rdb: rdb,
seqKey: "ws:msg:seq",
dedupTTL: 10 * time.Minute,
localCache: NewLocalDedupCache(5 * time.Minute),
}
}
// Publish 发送消息
func (mb *MessageBroker) Publish(ctx context.Context, msg *Message) error {
if msg.MsgID == "" {
msg.MsgID = mb.generateMsgID(ctx)
}
seq, err := mb.rdb.Incr(ctx, mb.seqKey+":"+msg.RoomID).Result()
if err != nil {
return err
}
msg.Seq = seq
queueKey := "ws:msg:queue:" + msg.RoomID
data, _ := json.Marshal(msg)
if err := mb.rdb.LPush(ctx, queueKey, data).Err(); err != nil {
return err
}
mb.rdb.Expire(ctx, queueKey, 24*time.Hour)
return nil
}
// DedupCheck 去重检查
func (mb *MessageBroker) DedupCheck(ctx context.Context, msgID string) (bool, error) {
// 先查本地缓存
if mb.localCache.Exists(msgID) {
return false, nil
}
// 再查Redis
key := "ws:msg:dedup:" + msgID
set, err := mb.rdb.SetNX(ctx, key, "1", mb.dedupTTL).Result()
if err != nil {
return false, err
}
if !set {
return false, nil
}
mb.localCache.Set(msgID)
return true, nil
}
// LocalDedupCache 本地去重缓存
type LocalDedupCache struct {
items map[string]time.Time
mu sync.RWMutex
ttl time.Duration
}
func NewLocalDedupCache(ttl time.Duration) *LocalDedupCache {
cache := &LocalDedupCache{
items: make(map[string]time.Time),
ttl: ttl,
}
go cache.cleanup()
return cache
}
func (c *LocalDedupCache) Exists(key string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
if t, ok := c.items[key]; ok {
if time.Since(t) < c.ttl {
return true
}
}
return false
}
func (c *LocalDedupCache) Set(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = time.Now()
}
func (c *LocalDedupCache) cleanup() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, t := range c.items {
if now.Sub(t) > c.ttl {
delete(c.items, k)
}
}
c.mu.Unlock()
}
}
func (mb *MessageBroker) generateMsgID(ctx context.Context) string {
id, _ := mb.rdb.Incr(ctx, "ws:msg:global_id").Result()
return fmt.Sprintf("msg_%d_%d", time.Now().UnixNano(), id)
}2.3 消息ACK机制
光有序号和去重还不够。消息从网关发到客户端的过程中可能丢失,网络抖动、客户端崩溃、中间路由器丢包都可能导致消息没送达。如果没有 ACK 机制,网关以为消息发出去了,客户端其实没收到,这就形成了"消息黑洞"。
ACK 机制的原理很简单:网关发送消息后,把消息放入待确认队列,同时启动一个超时定时器。客户端收到消息后,回传一个 ACK 确认。网关收到 ACK 后,从待确认队列中删除这条消息。如果超时没收到 ACK,网关重新发送。超过最大重试次数就放弃,同时记录日志告警。
这里面有一个性能考量。如果每条消息都等 ACK,消息投递的吞吐量会下降。优化方案是"批量 ACK":客户端不需要每条消息都回 ACK,可以攒几条一起确认。比如客户端收到序号一百的消息,只需要回 ACK seq=100,网关就知道一百之前的消息都收到了。这种方案在 TCP 协议里也在用,是一个经过验证的优化手段。
package messaging
import (
"log"
"sync"
"time"
)
// AckManager ACK管理器
type AckManager struct {
pendingAcks map[int64]*PendingMessage
mu sync.RWMutex
maxRetry int
retryDelay time.Duration
ackTimeout time.Duration
}
type PendingMessage struct {
Msg *Message
SendTime time.Time
RetryCount int
ConnID string
}
type AckConfig struct {
MaxRetry int
RetryDelay time.Duration
AckTimeout time.Duration
}
func NewAckManager(cfg AckConfig) *AckManager {
am := &AckManager{
pendingAcks: make(map[int64]*PendingMessage),
maxRetry: cfg.MaxRetry,
retryDelay: cfg.RetryDelay,
ackTimeout: cfg.AckTimeout,
}
go am.retryLoop()
return am
}
// WaitForAck 等待消息确认
func (am *AckManager) WaitForAck(msg *Message, connID string) {
am.mu.Lock()
defer am.mu.Unlock()
am.pendingAcks[msg.Seq] = &PendingMessage{
Msg: msg,
SendTime: time.Now(),
ConnID: connID,
}
}
// HandleAck 处理客户端的ACK
func (am *AckManager) HandleAck(seq int64) {
am.mu.Lock()
defer am.mu.Unlock()
// 批量ACK:删除所有seq<=指定值的待确认消息
for s := range am.pendingAcks {
if s <= seq {
delete(am.pendingAcks, s)
}
}
}
// retryLoop 重试循环
func (am *AckManager) retryLoop() {
ticker := time.NewTicker(am.retryDelay)
defer ticker.Stop()
for range ticker.C {
am.mu.Lock()
now := time.Now()
var toRetry []*PendingMessage
var toRemove []int64
for seq, pm := range am.pendingAcks {
if now.Sub(pm.SendTime) < am.ackTimeout {
continue
}
if pm.RetryCount >= am.maxRetry {
toRemove = append(toRemove, seq)
continue
}
pm.RetryCount++
pm.SendTime = now
toRetry = append(toRetry, pm)
}
for _, seq := range toRemove {
delete(am.pendingAcks, seq)
}
am.mu.Unlock()
for _, pm := range toRetry {
log.Printf("Retrying message seq=%d, retry=%d", pm.Msg.Seq, pm.RetryCount)
}
}
}
// GetPendingCount 获取待确认消息数量
func (am *AckManager) GetPendingCount() int {
am.mu.RLock()
defer am.mu.RUnlock()
return len(am.pendingAcks)
}注意 HandleAck 方法实现的是批量 ACK。客户端回 ACK seq=100,意思是"序号一百及之前的消息我都收到了",所以要把待确认队列中所有序号小于等于一百的消息都删掉。这样客户端不需要每条消息都回 ACK,大幅减少网络开销。批量 ACK 的频率需要权衡:太频繁了省不了多少网络开销,太稀疏了一旦需要重传就要重传大量消息。经验值是每十到二十条消息回一次 ACK,或者每隔一到两秒回一次,哪个先到就触发。另外 ACK 的发送也要做超时处理,如果 ACK 消息发不出去(网络问题),待确认队列会持续增长。所以要监控待确认消息数量,超过阈值就告警。
ACK 机制是消息可靠性的最后一块拼图。没有 ACK 的"已发送"和"已送达"之间隔着一个太平洋。
2.4 离线消息处理
客户端不在线时消息怎么办?这取决于业务场景。如果是实时聊天,消息必须存下来等客户端上线后补发。如果是直播弹幕,消息丢了就丢了,没必要存。
离线消息的存储方案我推荐用 Redis List。LPUSH 写入消息,LRange 读取消息,Del 清空消息。简单高效。每个用户的离线消息列表设一个容量上限,比如五百条,超过后自动丢弃最旧的消息。同时设一个过期时间,比如七天,超过七天自动清理。
客户端上线时,先从 Redis 中拉取离线消息。拉取时需要带上最后收到的消息序号,只拉取比这个序号大的消息。拉取完成后,客户端确认收到,服务端清空离线队列。这里有一个需要注意的边界情况:如果客户端拉取了离线消息但在确认之前又断线了,下次上线会再次拉取到同样的消息。这不要紧,因为消息有序号,客户端可以做去重。但要注意离线消息队列的容量上限,如果一个用户很受欢迎,给他发了大量消息,超过五百条上限后旧消息会被丢弃。如果业务要求不丢消息,可以引入持久化存储(如 MongoDB)作为冷备份,Redis 只存最近的消息。
package messaging
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// OfflineMessageStore 离线消息存储
type OfflineMessageStore struct {
rdb *redis.Client
maxStored int
expireTime time.Duration
}
func NewOfflineMessageStore(rdb *redis.Client) *OfflineMessageStore {
return &OfflineMessageStore{
rdb: rdb,
maxStored: 500,
expireTime: 7 * 24 * time.Hour,
}
}
// Store 存储离线消息
func (s *OfflineMessageStore) Store(ctx context.Context, userID string, msg *Message) error {
key := "ws:offline:" + userID
data, err := json.Marshal(msg)
if err != nil {
return err
}
pipe := s.rdb.TxPipeline()
pipe.LPush(ctx, key, data)
pipe.LTrim(ctx, key, 0, int64(s.maxStored-1))
pipe.Expire(ctx, key, s.expireTime)
_, err = pipe.Exec(ctx)
return err
}
// Fetch 获取离线消息
func (s *OfflineMessageStore) Fetch(ctx context.Context, userID string, lastSeq int64) ([]*Message, error) {
key := "ws:offline:" + userID
data, err := s.rdb.LRange(ctx, key, 0, -1).Result()
if err != nil {
return nil, err
}
var messages []*Message
for _, d := range data {
var msg Message
if err := json.Unmarshal([]byte(d), &msg); err != nil {
continue
}
if msg.Seq > lastSeq {
messages = append(messages, &msg)
}
}
return messages, nil
}
// Clear 清除离线消息
func (s *OfflineMessageStore) Clear(ctx context.Context, userID string) error {
return s.rdb.Del(ctx, "ws:offline:"+userID).Err()
}2.5 消息可靠性完整流程清单
把上面几个模块串起来,完整的消息投递流程如下:
| 步骤 | 操作 | 模块 | 异常处理 |
|---|---|---|---|
| 1 | 客户端发送消息 | WebSocket连接 | 连接断开则走离线消息 |
| 2 | 网关接收消息 | 连接管理器 | 限流拒绝,返回错误码 |
| 3 | 生成消息ID和序号 | MessageBroker | Redis故障则降级为本地ID |
| 4 | 去重检查 | DedupCheck | 重复消息直接丢弃 |
| 5 | 持久化到消息队列 | Redis Queue | 写入失败返回发送失败 |
| 6 | 投递给接收方 | ConnectionManager | 接收方不在线则存离线消息 |
| 7 | 等待接收方ACK | AckManager | 超时重试,超过次数放弃 |
| 8 | 收到ACK清理待确认 | AckManager | - |
| 9 | 接收方上线拉取离线消息 | OfflineStore | 拉取后清空离线队列 |
这个流程看起来很长,但每个步骤都是必要的。很多人做 WebSocket 消息系统只做了步骤一到六,没有 ACK、没有离线消息,上线后发现消息丢失率很高,但不知道丢在哪一步。有了这个完整流程,每一步都有日志和监控,丢了消息能定位到具体环节。
一条消息从发送到确认,要经过九个环节。任何一个环节没有兜底,整条链路就不可靠。
三、安全设计
3.1 鉴权方案
WebSocket 的鉴权比 HTTP 复杂。HTTP 每次请求都可以带 Cookie 或 Token,服务端每次都验证。但 WebSocket 是长连接,握手阶段是 HTTP,建立连接后就变成了 WebSocket 协议,后续的消息帧里没有 HTTP 头了。所以鉴权必须在握手阶段完成。
常见的鉴权方式有三种。第一种是查询参数传 Token,在 WebSocket URL 后面加上问号 token 等于 xxx。这种方式最简单,但 Token 会出现在服务器日志和浏览器历史中,有安全风险。第二种是 Header 传 Token,在握手请求的 Authorization 头中带 Bearer Token。这种方式更安全,但浏览器原生 WebSocket API 不支持自定义 Header,需要通过 SDK 封装。第三种是 Cookie 传 Token,适合 Web 端,但要注意跨域问题。
生产环境我推荐用 Header 方式(客户端 SDK 场景)和 Cookie 方式(Web 端场景)结合,查询参数方式只用于调试。同时 Token 要设合理的过期时间,长连接场景下建议两小时过期,过期前通过消息通道下发新 Token 让客户端刷新。
package gateway
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
)
// AuthManager 鉴权管理器
type AuthManager struct {
jwtSecret []byte
tokenTTL time.Duration
refreshTTL time.Duration
}
type Claims struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func NewAuthManager(secret string) *AuthManager {
return &AuthManager{
jwtSecret: []byte(secret),
tokenTTL: 2 * time.Hour,
refreshTTL: 7 * 24 * time.Hour,
}
}
// GenerateToken 生成JWT Token
func (am *AuthManager) GenerateToken(userID, deviceID, role string) (string, error) {
claims := &Claims{
UserID: userID,
DeviceID: deviceID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(am.tokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "ws-gateway",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(am.jwtSecret)
}
// ValidateToken 验证JWT Token
func (am *AuthManager) ValidateToken(tokenStr string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return am.jwtSecret, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
// WebSocket升级器,带有Origin检查
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
allowedOrigins := []string{
"https://app.example.com",
"https://web.example.com",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
return false
},
}
// AuthMiddleware WebSocket鉴权中间件
func (am *AuthManager) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
}
if token == "" {
if cookie, err := r.Cookie("ws_token"); err == nil {
token = cookie.Value
}
}
if token == "" {
http.Error(w, `{"error":"missing_token"}`, http.StatusUnauthorized)
return
}
claims, err := am.ValidateToken(token)
if err != nil {
http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}除了鉴权之外,Origin 检查也是安全设计的重要一环。跨站 WebSocket 劫持攻击的原理是:攻击者在恶意网页中嵌入 JavaScript 代码,向受害者的 WebSocket 服务器发起连接。如果服务器不做 Origin 检查,连接就能建立,攻击者就可以以受害者的身份发送和接收消息。这种攻击和 CSRF 类似,但危害更大,因为 WebSocket 是全双工的,攻击者不仅能"发"还能"收"。
鉴权不是"加个登录页"就完事了。攻击者不会走你的登录页,他会直接连你的 WebSocket 接口。
3.2 传输加密
生产环境 WebSocket 必须走 WSS,也就是 WebSocket over TLS。但仅仅加密传输层还不够,对于特别敏感的消息(比如金融交易指令、用户隐私数据),还应该在应用层做端到端加密。这样即使 TLS 被中间人破解,或者网关服务器被入侵,攻击者也无法解密消息内容。
应用层加密用 AES-GCM 模式。GCM 模式同时提供加密和完整性校验,比 CBC 模式更安全。密钥通过密钥协商协议(如 ECDH)在客户端和服务端之间安全交换,不通过网络明文传输。
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
)
// MessageEncryptor 消息加密器
type MessageEncryptor struct {
key []byte
gcm cipher.AEAD
}
func NewMessageEncryptor(secret string) (*MessageEncryptor, error) {
hash := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(hash[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &MessageEncryptor{
key: hash[:],
gcm: gcm,
}, nil
}
// Encrypt 加密消息
func (e *MessageEncryptor) Encrypt(plaintext []byte) (string, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := e.gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt 解密消息
func (e *MessageEncryptor) Decrypt(encoded string) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
nonceSize := e.gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return e.gcm.Open(nil, nonce, ciphertext, nil)
}3.3 防攻击设计
WebSocket 网关面临的常见攻击包括四种。连接洪泛攻击,攻击者大量建立连接但不发消息,消耗服务端资源。消息轰炸攻击,攻击者建立连接后高频发送消息,打满服务端处理能力。跨站 WebSocket 劫持,前面已经讲过,通过 Origin 检查防御。消息注入攻击,攻击者在消息中注入恶意脚本或 SQL,通过输入校验和转义防御。
对于连接洪泛和消息轰炸,核心防御手段是"多维度限流"。从 IP 维度限制单 IP 的连接数和新连接频率。从用户维度限制单用户的每秒消息数。从消息维度限制单条消息的大小。三个维度叠加,形成立体防御。
package security
import (
"log"
"sync"
"time"
)
// AntiAttackGuard 防攻击守卫
type AntiAttackGuard struct {
ipLimits map[string]*IPLimiter
ipMu sync.RWMutex
userLimits map[string]*RateLimiter
userMu sync.RWMutex
connFreq map[string]*FrequencyCounter
connFreqMu sync.RWMutex
config AntiAttackConfig
}
type AntiAttackConfig struct {
MaxConnsPerIP int
MaxMsgsPerSecond int
MaxMsgSize int
MaxConnPerMinute int
IPBanDuration time.Duration
}
func DefaultAntiAttackConfig() AntiAttackConfig {
return AntiAttackConfig{
MaxConnsPerIP: 50,
MaxMsgsPerSecond: 20,
MaxMsgSize: 64 * 1024,
MaxConnPerMinute: 30,
IPBanDuration: 30 * time.Minute,
}
}
type IPLimiter struct {
connCount int
connHistory []time.Time
bannedUntil time.Time
}
type FrequencyCounter struct {
counts []time.Time
mu sync.Mutex
}
func (fc *FrequencyCounter) Check(maxCount int, window time.Duration) bool {
fc.mu.Lock()
defer fc.mu.Unlock()
now := time.Now()
cutoff := now.Add(-window)
valid := fc.counts[:0]
for _, t := range fc.counts {
if t.After(cutoff) {
valid = append(valid, t)
}
}
fc.counts = valid
if len(fc.counts) >= maxCount {
return false
}
fc.counts = append(fc.counts, now)
return true
}
// CheckConnection 检查是否允许新连接
func (g *AntiAttackGuard) CheckConnection(ip string, userID string) error {
// 1. 检查IP是否被封禁
g.ipMu.RLock()
limiter, exists := g.ipLimits[ip]
g.ipMu.RUnlock()
if exists && time.Now().Before(limiter.bannedUntil) {
return ErrIPBanned
}
// 2. 检查IP连接频率
g.connFreqMu.Lock()
fc, ok := g.connFreq[ip]
if !ok {
fc = &FrequencyCounter{}
g.connFreq[ip] = fc
}
g.connFreqMu.Unlock()
if !fc.Check(g.config.MaxConnPerMinute, time.Minute) {
g.banIP(ip)
return ErrTooManyConnections
}
// 3. 检查IP连接数
g.ipMu.Lock()
defer g.ipMu.Unlock()
if limiter == nil {
limiter = &IPLimiter{}
g.ipLimits[ip] = limiter
}
if limiter.connCount >= g.config.MaxConnsPerIP {
return ErrIPConnLimit
}
limiter.connCount++
return nil
}
// CheckMessage 检查是否允许发送消息
func (g *AntiAttackGuard) CheckMessage(userID string, msgSize int) error {
if msgSize > g.config.MaxMsgSize {
return ErrMessageTooLarge
}
g.userMu.Lock()
rl, ok := g.userLimits[userID]
if !ok {
rl = NewRateLimiter(g.config.MaxMsgsPerSecond, time.Second)
g.userLimits[userID] = rl
}
g.userMu.Unlock()
if !rl.Allow() {
return ErrRateLimited
}
return nil
}
// ReleaseConnection 释放连接计数
func (g *AntiAttackGuard) ReleaseConnection(ip string) {
g.ipMu.Lock()
defer g.ipMu.Unlock()
if limiter, ok := g.ipLimits[ip]; ok {
limiter.connCount--
if limiter.connCount < 0 {
limiter.connCount = 0
}
}
}
func (g *AntiAttackGuard) banIP(ip string) {
g.ipMu.Lock()
defer g.ipMu.Unlock()
if limiter, ok := g.ipLimits[ip]; ok {
limiter.bannedUntil = time.Now().Add(g.config.IPBanDuration)
} else {
g.ipLimits[ip] = &IPLimiter{
bannedUntil: time.Now().Add(g.config.IPBanDuration),
}
}
log.Printf("IP %s banned", ip)
}
// RateLimiter 令牌桶限流器
type RateLimiter struct {
rate int
window time.Duration
tokens int
lastRefill time.Time
mu sync.Mutex
}
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
return &RateLimiter{
rate: rate,
window: window,
tokens: rate,
lastRefill: time.Now(),
}
}
func (rl *RateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
elapsed := now.Sub(rl.lastRefill)
refill := int(elapsed / rl.window * time.Duration(rl.rate))
if refill > 0 {
rl.tokens += refill
if rl.tokens > rl.rate {
rl.tokens = rl.rate
}
rl.lastRefill = now
}
if rl.tokens > 0 {
rl.tokens--
return true
}
return false
}关于封禁策略,我建议设置自动封禁和自动解封。当某个 IP 的连接频率超过阈值时,自动封禁三十分钟。三十分钟后自动解封,允许重试。这样既能防止恶意攻击,也不会永久封禁正常用户(有些用户可能因为网络问题频繁重连被误判)。
安全设计不是"出事了再补",而是"没出事就想到"。等攻击发生时,你的系统已经在流血了。
3.4 安全检查清单
做一个完整的安全检查清单,上线前逐项过一遍。这个清单是我从多次安全审计中总结出来的,每一条都是踩过坑的:
| 检查项 | 要求 | 状态 |
|---|---|---|
| WSS加密 | 生产环境必须使用WSS,禁用明文WS | [ ] |
| Origin校验 | 检查握手请求的Origin头,拒绝非白名单来源 | [ ] |
| Token鉴权 | 握手时验证JWT,无效则拒绝升级 | [ ] |
| Token刷新 | 长连接定期刷新Token,防止Token泄露 | [ ] |
| 消息大小限制 | 单条消息不超过64KB,防止内存耗尽 | [ ] |
| 连接数限制 | 单IP、单用户连接数上限 | [ ] |
| 消息频率限制 | 单用户每秒消息数上限 | [ ] |
| 心跳超时 | 连续3次心跳未响应则断开连接 | [ ] |
| 敏感消息加密 | 应用层AES-GCM加密 | [ ] |
| 日志脱敏 | 日志中不记录消息明文 | [ ] |
| DDoS防护 | 接入云厂商DDoS防护或CDN | [ ] |
| IP黑名单 | 支持动态封禁恶意IP | [ ] |
这个清单不是一成不变的,根据业务场景可以增减。但前八项是底线,不管什么业务都必须做到。我在做安全审计时经常发现的问题包括:生产环境还在用明文 WS 没有升级 WSS;Origin 检查直接 return true 图省事;Token 永不过期;消息大小没有限制导致有人发了个十兆的 JSON 把网关内存打爆。这些问题看起来很低级,但在赶工期的团队中非常普遍。安全检查清单的价值不在于多全面,而在于每次上线前真的去逐项检查。建议把这个清单集成到 CI/CD 流程中,自动化检查能自动化部分,不能自动化的出 checklist 让人工确认。
四、监控与运维方案
4.1 监控指标体系
WebSocket 网关的监控比 HTTP 服务复杂得多,因为连接是有状态的。HTTP 服务的监控主要看 QPS、延迟、错误率就够了。WebSocket 网关要看四个维度的指标。
连接维度看的是连接的生命周期管理。当前在线连接数反映了系统的负载。每秒新建连接数和每秒断开连接数反映了系统的动态变化。连接平均存活时间反映了用户的行为模式。重连率是一个关键指标,如果重连率突然升高,说明系统可能有问题。
消息维度看的是消息的投递质量。每秒消息收发量反映了系统的吞吐能力。消息平均延迟反映了系统的响应速度。消息丢失率直接关系到用户体验。消息重试次数和待确认消息数反映了下游服务的健康状况。
资源维度看的是系统的资源消耗。CPU 和内存是常规指标。Goroutine 数量是 Go 特有的指标,如果 Goroutine 数量持续增长不下降,说明有泄漏。文件描述符使用量也很重要,每个 WebSocket 连接占用一个文件描述符,如果不监控可能撞到 ulimit 限制。
业务维度看的是业务指标的健康度。房间在线人数、活跃用户数、消息投递成功率、离线消息堆积量,这些指标直接反映用户体验。
package monitor
import (
"fmt"
"runtime"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Metrics Prometheus指标定义
type Metrics struct {
ActiveConnections prometheus.Gauge
NewConnections prometheus.Counter
Disconnections prometheus.Counter
Reconnections prometheus.Counter
MessagesSent prometheus.Counter
MessagesReceived prometheus.Counter
MessageLatency prometheus.Histogram
MessageRetries prometheus.Counter
PendingAcks prometheus.Gauge
Goroutines prometheus.Gauge
MemoryAlloc prometheus.Gauge
FDUsage prometheus.Gauge
ActiveRooms prometheus.Gauge
OfflineMessages prometheus.Gauge
}
func NewMetrics(namespace string) *Metrics {
return &Metrics{
ActiveConnections: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "active_connections",
Help: "Current active WebSocket connections",
}),
NewConnections: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "new_connections_total",
Help: "Total new connections",
}),
Disconnections: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "disconnections_total",
Help: "Total disconnections",
}),
Reconnections: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "reconnections_total",
Help: "Total reconnections",
}),
MessagesSent: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "messages_sent_total",
Help: "Total messages sent",
}),
MessagesReceived: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "messages_received_total",
Help: "Total messages received",
}),
MessageLatency: promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace, Name: "message_latency_seconds",
Help: "Message delivery latency in seconds",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5},
}),
MessageRetries: promauto.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "message_retries_total",
Help: "Total message retries",
}),
PendingAcks: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "pending_acks",
Help: "Pending ACK messages count",
}),
Goroutines: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "goroutines",
Help: "Number of goroutines",
}),
MemoryAlloc: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "memory_alloc_bytes",
Help: "Memory allocation in bytes",
}),
FDUsage: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "fd_usage",
Help: "File descriptor usage",
}),
ActiveRooms: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "active_rooms",
Help: "Active room count",
}),
OfflineMessages: promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "offline_messages",
Help: "Offline message queue size",
}),
}
}
// AlertManager 告警管理器
type AlertManager struct {
rules []*AlertRule
mu sync.Mutex
}
type AlertRule struct {
Name string
Condition func(stats *SystemStats) bool
Message string
Severity string
Cooldown time.Duration
lastAlert time.Time
}
type SystemStats struct {
ActiveConns int64
PendingAcks int64
Goroutines int
MemoryMB float64
CPUPercent float64
MsgQueueSize int64
ReconnectRate float64
}
func NewAlertManager() *AlertManager {
return &AlertManager{
rules: []*AlertRule{
{
Name: "high_connection_count",
Condition: func(s *SystemStats) bool { return s.ActiveConns > 100000 },
Message: "Active connections exceeds 100k",
Severity: "warning",
Cooldown: 5 * time.Minute,
},
{
Name: "memory_leak_suspect",
Condition: func(s *SystemStats) bool { return s.MemoryMB > 4096 },
Message: "Memory usage exceeds 4GB, possible leak",
Severity: "critical",
Cooldown: 3 * time.Minute,
},
{
Name: "goroutine_leak",
Condition: func(s *SystemStats) bool { return s.Goroutines > 50000 },
Message: "Goroutine count exceeds 50k, possible leak",
Severity: "critical",
Cooldown: 3 * time.Minute,
},
{
Name: "high_reconnect_rate",
Condition: func(s *SystemStats) bool { return s.ReconnectRate > 0.3 },
Message: "Reconnect rate exceeds 30%",
Severity: "warning",
Cooldown: 5 * time.Minute,
},
{
Name: "message_queue_backlog",
Condition: func(s *SystemStats) bool { return s.MsgQueueSize > 10000 },
Message: "Message queue backlog exceeds 10k",
Severity: "warning",
Cooldown: 2 * time.Minute,
},
},
}
}
// Check 检查告警
func (am *AlertManager) Check(stats *SystemStats) []string {
am.mu.Lock()
defer am.mu.Unlock()
var alerts []string
now := time.Now()
for _, rule := range am.rules {
if now.Sub(rule.lastAlert) < rule.Cooldown {
continue
}
if rule.Condition(stats) {
rule.lastAlert = now
alert := fmt.Sprintf("[%s] %s: %s", rule.Severity, rule.Name, rule.Message)
alerts = append(alerts, alert)
}
}
return alerts
}告警规则的设计要注意"冷却时间"。同一条告警在冷却时间内不重复触发,否则告警会刷屏,运维人员会产生告警疲劳,真正重要的告警反而被忽略。我一般把 critical 级别的冷却设为三分钟,warning 级别设为五分钟。
监控不是为了"看到"系统在跑,而是为了"预判"系统要挂。等用户投诉才发现问题,监控就是摆设。
4.2 日志规范
WebSocket 网关的日志要分级别、分维度。开发环境可以打 Debug 级别,生产环境至少 Info 以上。关键操作必须有日志,但日志中绝对不能记录消息明文,只能记录元数据(消息ID、序号、发送者、接收者、时间戳)。这是合规要求,很多数据泄露事件就是日志里存了明文密码或明文消息导致的。
日志格式建议用 JSON 结构化日志,方便 ELK 或 Loki 采集和检索。每条日志包含时间戳、级别、事件类型、用户ID、连接ID、房间ID、消息ID、耗时、错误信息等字段。不是每条日志都有所有字段,根据事件类型选择相关字段记录。比如连接建立日志主要记录用户ID、连接ID、IP、设备信息,不需要记录消息ID。消息投递日志主要记录消息ID、序号、发送者、接收者、耗时,不需要记录连接的IP。合理选择日志字段既能减少日志量,又能提高检索效率。另外日志级别要严格区分:Info 级别记录正常业务流程,Warn 级别记录可恢复的异常(如重试成功),Error 级别记录需要人工介入的异常(如消息投递失败超过重试次数)。不要把所有异常都打成 Error,那只会造成告警疲劳。
package logger
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"time"
)
// WSLogger WebSocket专用日志器
type WSLogger struct {
logger *slog.Logger
}
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Event string `json:"event"`
UserID string `json:"user_id,omitempty"`
ConnID string `json:"conn_id,omitempty"`
RoomID string `json:"room_id,omitempty"`
MsgID string `json:"msg_id,omitempty"`
Duration string `json:"duration,omitempty"`
Error string `json:"error,omitempty"`
Extra interface{} `json:"extra,omitempty"`
}
func NewWSLogger(level slog.Level) *WSLogger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return &WSLogger{logger: slog.New(handler)}
}
// LogConnection 连接事件日志
func (l *WSLogger) LogConnection(event, userID, connID, ip string, err error) {
entry := LogEntry{
Timestamp: time.Now().Format(time.RFC3339Nano),
Level: "info",
Event: "connection_" + event,
UserID: userID,
ConnID: connID,
Extra: map[string]string{"ip": ip},
}
if err != nil {
entry.Level = "error"
entry.Error = err.Error()
}
l.write(entry)
}
// LogMessage 消息事件日志
func (l *WSLogger) LogMessage(event, userID, msgID string, duration time.Duration, err error) {
entry := LogEntry{
Timestamp: time.Now().Format(time.RFC3339Nano),
Level: "info",
Event: "message_" + event,
UserID: userID,
MsgID: msgID,
}
if duration > 0 {
entry.Duration = duration.String()
}
if err != nil {
entry.Level = "error"
entry.Error = err.Error()
}
l.write(entry)
}
func (l *WSLogger) write(entry LogEntry) {
data, _ := json.Marshal(entry)
fmt.Fprintln(os.Stdout, string(data))
}4.3 运维操作手册
把日常运维操作固化成标准流程,减少人为失误。下面是我在项目中使用的运维手册核心内容。
日常巡检清单:每天至少执行一次。检查各节点连接数是否均衡,偏差不超过百分之二十说明负载均衡正常。如果偏差超过百分之二十,可能是负载均衡的权重配置不对,或者某些节点的健康检查有问题。检查待确认消息数是否异常增长,正常情况下应该在一个稳定的范围内波动。如果持续增长,说明下游服务变慢了,需要排查下游服务的健康状态。检查离线消息堆积量,如果持续增长说明消费者速度跟不上,需要扩容消费者。检查 Goroutine 数量是否稳定,如果持续增长可能有泄漏,需要做 pprof 分析。检查内存使用趋势,如果有上升趋势需要进一步排查是否有内存泄漏。检查告警历史,确保所有告警都已处理,未处理的告警要跟踪到关闭。
扩容流程:新节点部署并启动,完成健康检查。注册到服务发现,确认负载均衡已分发流量。观察新节点连接数增长曲线,确认流量在流入。观察旧节点连接数是否在下降,如果没下降说明负载均衡没生效。确认无异常后完成扩容。整个过程大约需要十到十五分钟,不需要停机。
缩容流程:从服务发现摘除目标节点。等待两分钟,确认无新流量进入。发送重定向消息给存量连接。等待连接自然关闭,最多等待五分钟。超时后强制关闭剩余连接。确认节点连接数为零后关闭进程。缩容比扩容更需要小心,因为涉及断开已有连接。
运维不是"出了问题再修",而是"通过规范化的流程让问题不发生"。
五、项目复盘与最佳实践
5.1 架构复盘
回头看整个 WebSocket 网关项目,我们的架构演进经历了三个阶段。
阶段一是单机版。一个 Go 进程跑 Gorilla WebSocket 处理连接,消息直接在内存中转发。这个版本能跑通基本功能,但存在单点故障,无法水平扩展,连接数受限于单机资源。这个阶段适合做原型验证,了解 WebSocket 的基本原理。具体来说,单机版能支撑的连接数取决于机器配置和每条消息的大小。一台四核八G的虚拟机,如果每条消息一百字节,大概能撑五万到八万连接。但如果消息更大或者业务处理更复杂,连接数会明显下降。单机版还有一个致命问题:发版必须停机,所有连接都会断开。这在产品初期用户量少的时候还能接受,用户量一大就不行了。
阶段二是集群版。引入 Redis 做消息中间件,多个网关节点组成集群,客户端通过负载均衡连接任意节点。解决了单点问题,能水平扩展。但消息可靠性不足,没有 ACK 机制,没有离线消息,网络抖动会丢消息。这个阶段适合内部测试,不能直接上生产。从单机版到集群版最难的部分不是写代码,而是处理状态一致性。单机版所有状态都在内存里,一个进程内共享就行。集群版的状态要存在 Redis 里,多个节点同时读写,就有竞态条件。比如两个节点同时给同一个房间发消息,消息序号怎么保证不重复?这就需要用 Redis 的 INCR 命令来生成序号,保证原子性。再比如一个用户连了两个设备(手机和电脑),两个设备可能连在不同的网关节点上,消息怎么同时投递给两个设备?这需要一个跨节点的消息路由机制。
阶段三是生产版。在集群版的基础上加入了连接熔断、消息 ACK、离线消息、安全防护、监控告警等全套生产级能力。这个版本能扛住真实的线上流量,处理各种异常情况。这是我们最终上线的版本。从集群版到生产版的工作量比从单机版到集群版还大,因为要处理的全是边界情况和异常场景。正常流程下消息怎么收发,集群版已经搞定了。但网络断了怎么办?Redis 挂了怎么办?磁盘满了怎么办?客户端发畸形消息怎么办?这些问题在生产环境都会遇到,每一个都需要有明确的处理策略。生产版的代码量大概是集群版的三到四倍,多出来的全是错误处理和兜底逻辑。
架构演进不是"一步到位",而是"逐步加码"。第一版能跑就行,但要知道哪里不行,提前规划好演进路径。
5.2 踩坑记录
这一路踩过的坑,拿出来分享,希望后来人少走弯路。每个坑都是真实发生过的线上事故,有的事后看来很低级,但在当时确实造成了影响。
坑一是 Goroutine 泄漏。现象是服务运行几天后 Goroutine 数量持续增长,最终 OOM。原因是连接断开后,readPump 和 writePump 两个 Goroutine 没有正确退出。writePump 阻塞在 channel 接收上,没有 close 信号通知它退出。修复方法是引入 closeOnce 机制,确保连接关闭时所有 Goroutine 都能收到退出信号。
func (c *WSConnection) Close() {
c.closeOnce.Do(func() {
close(c.closeCh) // 通知所有goroutine退出
c.conn.Close() // 关闭底层连接
close(c.sendCh) // 关闭发送channel
})
}
func (c *WSConnection) writePump() {
defer c.conn.Close()
for {
select {
case msg, ok := <-c.sendCh:
if !ok {
return // channel已关闭,退出
}
if err := c.conn.WriteMessage(msg.Type, msg.Data); err != nil {
return
}
case <-c.closeCh:
return
}
}
}这个坑的低级之处在于:开发测试时连接数少,Goroutine 泄漏不明显,跑几天才暴露。等到线上 OOM 时,已经影响了几万用户。教训是:每一个 go func() 都要想清楚它什么时候退出,不想清楚就别启动它。
每一个
go func()都要想清楚它什么时候退出。不想清楚就别启动它。
坑二是心跳设计不合理。现象是移动端在弱网环境下频繁掉线重连。原因是心跳间隔设置为六十秒,弱网环境下 TCP keepalive 检测太慢,连接已经"假死"但还在。客户端发消息时才发现连不上,此时已经过了好几十秒。修复方法是把心跳间隔缩短到十五秒,连续三次未收到心跳响应就主动断开。同时加入应用层心跳,不依赖 TCP keepalive,因为 TCP keepalive 在不同操作系统上的行为不一致。
const (
heartbeatInterval = 15 * time.Second
heartbeatTimeout = 45 * time.Second // 3次心跳周期
)
func (c *WSConnection) heartbeatLoop() {
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
lastPong := time.Now()
for {
select {
case <-ticker.C:
if time.Since(lastPong) > heartbeatTimeout {
log.Printf("Heartbeat timeout: user=%s", c.userID)
c.Close()
return
}
c.conn.WriteMessage(websocket.TextMessage, []byte("ping"))
case msg := <-c.recvCh:
if string(msg) == "pong" {
lastPong = time.Now()
}
case <-c.closeCh:
return
}
}
}坑三是消息乱序。现象是客户端收到的消息顺序不对,聊天记录看起来像时间穿越。原因是多个 Goroutine 并发写 WebSocket 连接,没有做串行化处理。Gorilla WebSocket 的 WriteMessage 方法不是线程安全的,并发写会导致数据错乱。修复方法是把所有写操作统一到 writePump 的 channel 中串行处理,绝不在其他地方直接调用 conn.WriteMessage。这个坑的隐蔽之处在于:并发写不一定会立刻出错,可能跑几千条消息才错乱一次,在测试环境很难复现。而且出错后不容易定位,因为现象是消息乱序,不会报错。所以从设计阶段就要确立原则:一个连接的写操作只能在一个 Goroutine 中执行,这个红线不能碰。
坑四是 Redis 连接池耗尽。现象是高峰期大量请求超时,日志显示 Redis 连接获取失败。原因是每条消息的处理都同步操作 Redis(去重、持久化、ACK),连接池大小设置太小。修复方法是增大连接池到两百,对非关键操作做异步化处理。去重检查改为"本地缓存加 Redis 兜底",减少 Redis 访问频次。
redisOpt := &redis.Options{
Addr: "redis-cluster:6379",
PoolSize: 200,
MinIdleConns: 20,
MaxRetries: 3,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolTimeout: 4 * time.Second,
}坑五是平滑升级失败。现象是发版时大量客户端掉线,用户投诉。原因是直接 kill -9 进程,没有做优雅关闭。修复方法是实现优雅关闭流程,配合负载均衡健康检查做平滑流量切换。这个前面已经详细讲过了。
每一个坑都是用线上事故填出来的。别人踩过的坑你还在踩,说明你没好好复盘。
5.3 最佳实践总结
把项目中验证过的最佳实践整理出来,作为后续项目的参考。
连接管理方面:单连接单 Goroutine 读写分离,通过 channel 通信。使用 sync.Once 确保连接只关闭一次。设置合理的读写缓冲区大小,四千字节起步。连接状态外置到 Redis,支持跨节点查询。
消息处理方面:全局唯一消息 ID 加会话内序号双重标识。发送侧做幂等,接收侧做去重。关键业务消息必须 ACK。非关键消息(如心跳、状态同步)可丢可不 ACK。
性能优化方面:使用对象池减少 GC 压力。消息序列化用 protobuf 替代 JSON。批量发送减少系统调用次数。写操作合并,减少锁竞争。
容错设计方面:每个依赖都要有超时和降级。Redis 不可用时降级为本地缓存。消息队列不可用时降级为直接投递。永远不要让一个依赖的故障导致整个网关不可用。
// 对象池示例
var msgPool = sync.Pool{
New: func() interface{} {
return &Message{}
},
}
func AcquireMessage() *Message {
return msgPool.Get().(*Message)
}
func ReleaseMessage(msg *Message) {
msg.MsgID = ""
msg.Seq = 0
msg.Type = ""
msg.Payload = nil
msg.SenderID = ""
msg.ReceiverID = ""
msg.RoomID = ""
msgPool.Put(msg)
}对象池在高频消息场景下效果显著。我们压测结果显示,使用对象池后 GC 停顿时间降低了百分之六十左右。但要注意,对象池不是万能的,只有在对象创建成本高(比如需要内存分配)的场景下才有意义。简单对象用对象池反而增加复杂度。另外使用对象池时要特别小心对象引用的清理。如果从池中取出对象后,里面还残留着上一次使用的字段值,可能导致数据串台。上面的 ReleaseMessage 方法中,每个字段都被显式重置为空值,这很重要。
5.4 性能基准测试数据
最后分享一组生产环境的压测数据,供参考。这些数据是在四核八G的虚拟机上测得的,使用的是 Go 1.21 版本。
单节点最大连接数达到十万,此时内存使用约三GB,Goroutine 数量约二十万(每连接两个 Goroutine)。单节点消息 QPS 达到五万,每条消息一百字节左右。消息平均延迟(P50)为三毫秒,P99 延迟为十五毫秒。消息投递成功率百分之九十九点九八(含重试)。CPU 使用率在满载时约百分之六十。
这个数据看起来不错,但要注意几个前提。第一,压测环境是纯净的,没有其他服务争抢资源。第二,消息内容是固定大小的,实际业务消息大小可能差异很大。第三,压测是均匀发消息的,实际业务有明显的波峰波谷。所以压测数据只能作为参考,实际容量要根据业务场景来规划。
性能数据不是"跑个benchmark"就有的,而是在真实流量下反复调优的结果。纸上谈兵和真刀真枪是两回事。
5.5 后续优化方向
这个项目还有不少可以继续优化的方向。
第一是协议升级。从 WebSocket 迁移到 QUIC 或 WebTransport,解决 TCP 队头阻塞问题。QUIC 是基于 UDP 的协议,在弱网环境下表现比 TCP 好得多。WebTransport 是 W3C 正在推进的新标准,有望成为 WebSocket 的继任者。不过目前 WebTransport 的浏览器支持率还不高,Safari 和 Firefox 还没有完全支持。建议持续关注,等浏览器支持率达到百分之九十以上再考虑迁移。
第二是消息压缩。对大消息做 gzip 或 zstd 压缩,降低带宽成本。特别是富文本消息和结构化数据,压缩率很高。但小消息不值得压缩,压缩开销可能比节省的带宽还大。
第三是多协议支持。统一网关同时支持 WebSocket、TCP、UDP,适配不同客户端。比如物联网设备可能用 TCP 更合适,Web 端用 WebSocket,移动端用 MQTT。网关层做协议适配,业务层不感知。
第四是智能路由。根据用户地理位置就近接入,降低延迟。用户在北京就接入北京机房的节点,用户在广州就接入广州机房的节点。需要配合 DNS 智能解析或 Anycast。
第五是灰度发布支持。网关层支持流量染色,方便新功能灰度发布。通过消息头中的标记区分灰度用户和普通用户,灰度用户的消息走新版逻辑,普通用户走旧版逻辑。这样可以在生产环境验证新功能,不影响普通用户。
写在最后
这一章把 WebSocket 网关的高可用、消息可靠性、安全设计、监控运维和项目复盘全部讲完了。从连接管理到消息投递,从容错降级到安全防护,从监控指标到运维流程,这是一个完整的 WebSocket 网关生产级方案。
回头看看整个系列,从第十章的 WebSocket 协议基础,到第十一章的网关核心实现,再到这一章的高可用与总结,一个完整的 Go WebSocket 网关项目从零到生产的过程就讲完了。如果你跟着敲完所有代码,你对 WebSocket 网关的理解已经超过了大多数 Go 开发者。
当然,技术永远在演进。WebSocket 不是终点,WebTransport、QUIC 等新协议正在崛起。但底层的设计思想是相通的:容错、可靠性、安全、可观测性。掌握了这些思想,换一个协议也能快速上手。这些设计思想不仅适用于 WebSocket 网关,也适用于任何长连接系统。比如 MQTT 网关、TCP 网关、甚至 gRPC 流式通信,都面临类似的容错、可靠性、安全问题。所以这个系列的价值不在于教你用 WebSocket,而在于教你如何设计一个生产级的长连接系统。
收藏引导:这篇文章涉及大量生产级代码示例和踩坑经验,建议收藏,在做 WebSocket 相关项目时随时翻阅。每一段代码都是经过线上验证的,不是 demo 级别的玩具。
互动引导:你在生产环境踩过哪些 WebSocket 的坑?是 Goroutine 泄漏还是消息丢失?是连接风暴还是安全漏洞?评论区聊聊,我会逐条回复。踩坑不可怕,可怕的是踩了同一个坑两次。
追更引导:下一章我们开始新的主题 -- 分布式任务调度系统。从需求分析开始,一步步用 Go 搭建一个能扛住百万级任务的调度平台。这个主题会比 WebSocket 更有挑战性,涉及分布式锁、任务分片、故障恢复等硬核话题。关注我,追更不迷路。
系列进度 12/16
下一章预告:第13章 -- 分布式任务调度系统需求分析。我们将分析一个分布式任务调度系统的完整需求,包括任务模型设计、调度策略、分布式锁方案选型、故障恢复机制等。从需求到架构,为后续的代码实现打下基础。
怕浪猫说:WebSocket 网关这个坑我踩了两年,从最初的单机版到现在的生产级集群,每一行代码都是用线上事故换来的教训。写这个系列的过程也是自我复盘的过程。很多人觉得"会用 WebSocket"就够了,但"会用"和"用好"之间隔着一个生产环境。希望这个系列能帮你少走些弯路。记住,技术深度不是看源码看出来的,是踩坑踩出来的。纸上得来终觉浅,绝知此事要躬行。工程能力是在真实的生产环境中锤炼出来的,没有任何捷径可走。下个系列见。