接手过一个实时通知服务,业务上要求订单状态变更、客服会话、后台告警都要在秒级内推到用户端。最开始图省事用HTTP轮询,连接一多业务一复杂就明显顶不住——服务端压力大、客户端拿到的数据也不是实时的,高峰时段光无效请求就能把接口拖垮。后来整体换成了WebSocket消息推送方案,服务端主动推、客户端一个长连接跑到底,实测连接数从几百涨到上万,服务端CPU和内存都还算稳定。这篇文章不聊PPT,把我从协议设计、服务端实现到生产落地的完整过程拆开讲一遍,包括踩过的坑和排查思路,给想上或者正在做消息推送的同行一个参考。
1. 为什么选WebSocket,而不是轮询或SSE
很多刚接触实时功能的人第一反应是"前端定时调接口不就行了"。确实,数据量小、实时性要求不高的时候,轮询是最省事的。但一旦聊到消息推送,轮询方案的所有问题都会放大,这个需要先想清楚。
1.1 HTTP轮询的尴尬
短轮询的逻辑很简单:前端每隔N秒发一次HTTP请求,问服务端"有没有新消息",服务端没有就返回空。问题是,哪怕没有任何消息,请求也会按时打过来。我做过一次粗算:1000个在线用户,按3秒轮询一次算,每秒差不多333个请求,其中绝大多数是无效请求;如果轮询频率提高到1秒,每秒就是1000个请求。这些请求全都要走HTTP握手、鉴权、序列化、响应,服务端一半以上的资源都在处理"没有新消息"这件事。
长轮询稍微好一点:请求发出去之后,服务端先挂起,等有消息了再响应,客户端收到后再发下一个请求。但长轮询有一个非常难受的地方:服务端要维持大量挂起的请求,每次响应结束连接就断了,下一条消息要等下一次请求建立连接后才能推。频繁建立TCP连接、频繁解析HTTP头,在高并发下其实省不了太多资源,还会引入超时重连、消息顺序错乱这些额外复杂度。
我把两个方案都试过之后,结论是:如果只是做一个每天几百人用的轻量应用,轮询能撑住;一旦用户量起来、实时性要求到秒级,HTTP这套请求响应模型再优化都是事倍功半。方向错了,怎么调都是错的。
1.2 WebSocket的核心优势在哪里
WebSocket和HTTP最大的区别在于:它一次握手之后,连接就变成了一个双向、全双工的长连接,服务端可以随时主动把消息塞给客户端,不需要等客户端来拉。这个"主动推"的能力才是消息推送的核心需求。
我打个比方:HTTP就像打电话,每次都要先拨号、接通、说事、挂断,下一条消息再重复一遍;WebSocket更像微信语音通话,接通之后两边随时都能说话,不用反复拨号。这个连接一旦建立,服务端发消息的成本就极低——一个TCP连接上直接写数据帧,少了HTTP头、少了请求响应配对,协议层面的开销小很多。另外WebSocket还带了Ping/Pong控制帧,天然支持心跳检测,连接是不是还活着可以主动探测,这是HTTP做不到的。
实测下来,我那个服务用WebSocket之后,单台机器轻松扛住了上万连接,每秒钟推送几千条消息,CPU占用基本平稳。对比之前的轮询方案,响应延迟从"最坏3秒、平均1.5秒"降到了"秒内到达",服务端无效请求直接归零。
1.3 什么场景不适合硬上WebSocket
WebSocket不是万能药。如果是低频通知、服务端本来就无状态、又不想维护大量长连接,SSE(Server-Sent Events)可能更合适。SSE基于HTTP,走普通的GET请求,服务端单向推文本,前端用EventSource接口就能接,断线自动重连、自带事件ID,很多场景够用了。缺点就是单向、二进制支持弱,交互性强的场景不适合。
还有一些场景我会刻意避开WebSocket:业务量极小、开发资源紧张的项目;客户端处于不可控的弱网环境、但又没有精力做重连和消息补偿的团队;以及后端完全无状态化、连Redis都不想引入的架构。做技术选型的时候,先想清楚"我到底要不要双向通信",不要听到实时两个字就上WebSocket。实时推送是需求,WebSocket只是众多实现手段之一。
2. 整体架构与协议设计
确定用WebSocket之后,第一件事不是写代码,而是把连接模型、消息协议、心跳机制定下来。这块设计得好,后面实现和排查都会顺很多;设计得随意,上线之后全是坑。
2.1 连接生命周期与状态机
一个WebSocket连接从建立到关闭,要经过握手、OPEN、CLOSING、CLOSED这几个阶段。服务端不只要管状态,还得把"连接"和"业务用户"对应起来——这才是消息推送能"按人推"的基础。
我在设计连接管理时,建了一张全局的表,大致是这样的映射:connectionId(每次连接的唯一ID)、userId(谁连上来的)、conn(底层WebSocket对象)、sendChannel(给这个连接发消息的管道)、connectTime、lastActiveTime。每个客户端连上来,服务端分配给一个独立的连接对象,注册到连接管理器里;断开时反注册、清理资源。这个注册/反注册的动作看似简单,但一定要做成线程安全的,不然并发断开和推送同时发生时会出现竞态。
还需要考虑一个用户多端在线的情况。同一个账号在PC、App、小程序上都保持连接,这时候一个userId会对应多个connectionId。推送时不能只推一条连接,要把这个用户的所有在线连接的sendChannel都拿到,逐个发。消息往哪个连接发、发完如何确认,全都要在设计阶段定清楚。
2.2 消息协议格式设计
很多新手犯的错是:服务端直接往连接里写一串裸字符串,也不管是什么类型、有没有消息ID。短时间跑通没问题,等到要排查"为什么客户端把一条告警当成聊天消息处理"的时候,就会后悔没做消息协议。
我用的消息格式是JSON,但JSON只是载体,结构一定要统一。大致长这样:
{ "type": "notification", "msgId": "202405121530001234", "timestamp": 1715506200123, "channel": "user:1001", "data": { "event": "order.shipped", "title": "订单已发货", "content": "您购买的XX已从仓库发出" } }type表示消息类型,通知、聊天、系统命令、心跳、ACK回复都算不同的type,客户端按type分流处理;msgId是全链路唯一的消息ID,用于去重和消息确认,客户端拿到之后按msgId缓存一下就能做幂等;channel表示这条消息的路由维度,user:1001就是推给用户1001,room:2001就是推给房间2001,方便在服务端和客户端两边都做路由判断。
协议一定要带版本。我建议在最外层加一个ver字段,哪怕现在是第一版。后面接口升级、字段调整的时候,老客户端还能根据版本做兼容,不至于一改协议线上就全崩。
2.3 单发、广播与房间模型的实现
消息推送的路由模型,我总结下来就三种:单发给某个用户、广播给全部在线用户、定向推给一个房间或群组。大多数业务都是这三种的组合。
在实现上,单发最简单:按userId找到对应所有连接,往sendChannel里投递即可。广播也不难:遍历连接管理器里所有的连接,逐个投递。重点说一下房间模型,这个稍微复杂一点。比如客服系统里,一个用户进来,要推给他的客服、客服组长,可能还要推给整个工单组,这时候就需要一个"房间订阅表":房间ID -> 成员用户ID集合,或者用户在哪个房间。我在项目里维护了两张map:room->members、member->rooms,推送时先从room拿members,再按userId路由到连接。这样用户切换页面、加入新的会话时,只要维护订阅关系,推送侧完全不用改。
有个容易被忽略的点:房间推送时要处理"用户不在线"的情况。直接丢弃会让业务有丢失感,塞进离线队列又可能积压。我的做法是:重要的消息先进离线存储(比如Redis list),在线用户走实时通道,这样用户下次上线还能捞到离线消息,业务上更完整。
2.4 心跳保活机制:关键中的关键
长连接最大的天敌是"假死"——TCP连接看起来还在,但网络链路早就断了,双方都不知道。尤其WebSocket中间经过了Nginx、负载均衡、云防火墙,这些组件默认都有空闲超时,连接没数据流动就会被主动断掉。
心跳的作用就是让连接持续有数据流动,并借机探测对方是否还活着。我用的策略是:服务端每30秒发一个Ping控制帧,客户端正常情况下会自动回Pong,服务端收到Pong就认为连接健康;超过90秒(即连续3次Ping没有Pong)就判定假死,主动关闭连接。客户端这边也要有兜底:每20秒主动发一个业务层的ping消息,服务端收到后回pong。为什么客户端要单独发?因为浏览器端的原生WebSocket API不开放手动发Ping/Pong控制帧的能力,只能通过业务消息来实现类似效果。
这里有一个让我印象很深的坑:心跳间隔一定要比中间设备的空闲超时短。Nginx的proxy_read_timeout默认是60秒,如果你心跳间隔60秒,刚好卡在超时边界,一个波动就把连接掐了。我最后把Nginx的read/send timeout调到了3600秒,心跳设到30秒,中间留了足够的余量,断连率一下子降了很多。
3. 服务端核心实现:从单机到多机
架构和协议定完,就可以动手写服务端了。我会用Go作为示例语言,因为它的并发模型非常适合做长连接系统,实现起来也直观好懂。Node.js和Java的实现思路是一样的,代码结构可以对应着迁移。
3.1 协议库的选型与读写模型
服务端编码最基础的一步是选WebSocket库。Go语言里之前最常用的是gorilla/websocket,现在官方生态也在变迁,我当前项目用的库提供了一组简洁的API:Upgrade把HTTP请求升级成WebSocket连接,ReadMessage/WriteMessage收发消息,支持TextMessage、BinaryMessage、PingMessage、PongMessage等消息类型。
真正写代码之前,先理解一个关键模型:每条WebSocket连接上,读和写不能在同一个goroutine里串行做。为什么?因为网络是并发双向的,服务端在等待客户端消息的同时,还要能主动推送消息。如果读写串行,一旦客户端不发消息,服务端的推送就卡死了。所以必须为每条连接分配两个独立的循环:一个readPump负责从连接上读消息,一个writePump负责通过sendChannel向外写消息。
3.2 连接接入与鉴权绑定
握手时先鉴权。我的做法是客户端连接时在URL上带一个token参数,服务端在Upgrade之前先校验token,校验失败就直接返回401,根本不给它升级连接。token本身是通过另一个HTTP接口动态签发的,短期有效,避免把长期登录态直接暴露在连接URL里。真实场景中如果担心URL参数会被Nginx日志、代理日志记录,可以把token放在Sec-WebSocket-Protocol这个握手头里传递,服务端从Request的Header里取,安全性更好。
核心接入代码大致是这样的:
func handleWS(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") userID := validateToken(token) if userID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("upgrade failed: %v", err) return } client := &Client{ ID: generateConnectionID(), UserID: userID, Conn: conn, Send: make(chan []byte, 256), } manager.Register(client) go client.writePump() go client.readPump() }这里有几个细节我要特别说明:sendChannel的缓冲区一定要有大小限制,不能无限扩容。我线下压测的时候发现,如果某个客户端消费速度跟不上,写侧一直往channel里塞消息,缓冲区越积越大,内存就会持续上涨。设一个上限(比如256),写满了就不再加新消息,直接断开这个慢客户端,让上层重连,这个策略在业界叫backpressure(背压)——宁可丢一个连接,也不能让整个进程内存被拖垮。
3.3 读循环、写循环与消息处理
readPump的核心代码我写得比较保守,每一处都有防御:
func (c *Client) readPump() { defer func() { manager.Unregister(c) c.Conn.Close() }() c.Conn.SetReadLimit(4096) c.Conn.SetReadDeadline(time.Now().Add(pongWait)) c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) for { _, msg, err := c.Conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { log.Printf("client closed abnormal: %v", err) } break } c.handleMessage(msg) } }读循环里做的事:限制单条消息大小,防止有人故意发超大帧;设置读超时,服务端Ping之后如果客户端一直不回复,读超时会触发,循环退出,连接自动清理。SetPongHandler这个写法很有意思——库会自动把Pong帧回调到这里,只要客户端还在,Pong就会周期性触发,每次触发都把读超时往后延,这样健康的连接永远不会因为读超时而断开。
writePump这边通过channel收消息,同时用定时器定期发服务端Ping:
func (c *Client) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.Conn.Close() }() for { select { case msg, ok := <-c.Send: c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err := c.Conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-ticker.C: c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }有件事必须在开始压测前就意识到:这两个循环退出的时候,必须保证同时清理连接管理器里的注册信息和底层连接,否则每断开一个连接就泄漏一个对象。所以我defer里既调用了manager.Unregister又调用了c.Conn.Close()。这不是代码洁癖,是在压测里实打实踩过内存泄漏的坑才发现必须这么干。
3.4 业务消息的路由分发
客户端发上来的业务消息,readPump会交给handleMessage处理。我在这里做了一层轻量的路由,按消息类型dispatch到对应的处理器:ping类型直接回pong;ack类型更新离线重发队列;subscribe类型把当前连接加进指定房间;业务推送类型则走统一的推送入口。
推送入口是核心,我定义了三个方法,语义清晰:
func (h *Hub) SendToUser(ctx context.Context, userID string, data []byte) { conns := h.GetConnectionsByUser(userID) for _, c := range conns { select { case c.Send <- data: default: // 发送缓冲区已满,说明该连接消费速度跟不上,标记为慢客户端 h.CloseSlowClient(c) } } } func (h *Hub) SendToRoom(ctx context.Context, roomID string, data []byte) { members := h.GetRoomMembers(roomID) for _, uid := range members { h.SendToUser(ctx, uid, data) } } func (h *Hub) Broadcast(ctx context.Context, data []byte) { conns := h.GetAllConnections() for _, c := range conns { select { case c.Send <- data: default: h.CloseSlowClient(c) } } }这里我用select加default做非阻塞发送,sendChannel满了就直接关闭慢连接。这样做是为了防止一个慢客户端阻塞整个推送链路。写到一个channel上的所有消息是串行的,如果有一台设备网络极差,消息堆积在缓冲区,不处理的话其他正常用户也会被带慢。
3.5 单机到多机:Redis Pub/Sub撑起横向扩展
单机WebSocket连接撑个上万没问题,但用户量继续涨,单机的文件描述符、内存、CPU总有上限。要横向扩展,就遇到一个问题:用户A连在节点1上,节点2的业务代码要推送消息给A,节点2怎么找到A的连接?
解决方案是引入一个中间层做消息的路由转发。我用的Redis Pub/Sub,每个节点启动时subscribe几个固定的频道:user:{userId}、room:{roomId}、broadcast。业务侧要推送给某个人,就往这个用户对应的频道publish一条消息;所有节点都订阅了它,收到后各自的Hub只负责发给本机上的相关连接。
// 发布消息 func (s *Hub) PublishToUser(ctx context.Context, userID string, payload []byte) { s.rdb.Publish(ctx, "ws:user:"+userID, payload) } // 订阅消息循环:每个服务实例启动时分别执行 func (s *Hub) SubscribeUser(ctx context.Context, userID string) { sub := s.rdb.Subscribe(ctx, "ws:user:"+userID) defer sub.Close() ch := sub.Channel() for msg := range ch { // 只推给本机连接,本机没有就忽略 s.SendToUser(ctx, userID, []byte(msg.Payload)) } }订阅这块有个细节:不能为每个用户各起一个订阅循环,否则用户量大时Redis连接数和goroutine数会爆炸。我的做法是:所有节点的订阅集中在通道上,业务层通过订阅路由判断消息归谁处理。用Redis Pub/Sub只是个简便方案,要求更高的场景可以换消息队列,但本质是一样的——把"谁在哪个节点"这件事从单机内存搬到分布式中间件上。
4. 客户端接入与断线重连
服务端做得再稳,客户端一断就什么都不说了。移动端的WebSocket在弱网环境下尤其容易断,客户端的连接管理能力直接决定用户体感。我在这部分踩过的坑不少于服务端。
4.1 封装一个靠谱的WebSocket客户端
现在主流浏览器、微信小程序、React Native环境都支持原生WebSocket API,不需要额外引库。但直接裸用API有一个问题:到处new WebSocket、到处写onclose逻辑,后期改个重连策略就要全局搜代码。所以我建议封装一个WSClient类,把连接、心跳、重连、消息回调全部收敛在一起。
class WSClient { constructor(url, options = {}) { this.url = url; this.heartbeatInterval = options.heartbeatInterval || 20000; this.reconnectBase = options.reconnectBase || 1000; this.reconnectMax = options.reconnectMax || 30000; this.reconnectAttempt = 0; this.ws = null; this.heartbeatTimer = null; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => this.handleOpen(); this.ws.onmessage = (e) => this.handleMessage(e); this.ws.onclose = () => this.handleClose(); this.ws.onerror = (e) => this.handleError(e); } handleOpen() { this.reconnectAttempt = 0; this.startHeartbeat(); // 重连成功后重新订阅房间、重发当前状态 this.resubscribe(); } handleClose() { this.stopHeartbeat(); this.scheduleReconnect(); } scheduleReconnect() { const delay = Math.min( this.reconnectBase * 2 ** this.reconnectAttempt, this.reconnectMax ); this.reconnectAttempt++; setTimeout(() => this.connect(), delay); } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { this.send({ type: 'ping', ts: Date.now() }); }, this.heartbeatInterval); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } }这个封装的几个关键点:onclose事件是监听断线的主要入口,特别要注意onerror后面通常紧跟着onclose,不要在onerror里写重连逻辑,否则会重连两次。heartbeatTimer在onclose时一定要清理,不然断线状态下定时器还在跑,会发送失败消息或者重复触发。
4.2 客户端心跳与服务端如何配合
客户端心跳的目的是配合服务端检测"假死连接"。我设定客户端每20秒发一个业务ping,服务端收到后立即回一个pong,客户端持续收到任何消息就认为连接活跃。如果超过40秒没收到任何服务端消息,客户端就主动调ws.close()强制断开,走重连流程。
这里有个容易误判的地方:客户端本地网络卡顿,但并不代表服务端已经断了,这时候主动断开再重连可能会让状态更混乱。所以我的策略是"先本地超时,再依赖服务端stale判断":客户端把"长时间收不到消息"作为自检条件,但把最终判定权还是交给服务端的Ping/Pong。客户端自检只是为了避免在已经断掉的连接上继续发送无效请求,真正断开连接的动作还是由服务端或底层网络触发。
还有一个小技巧:在页面切后台的时候,浏览器会暂停定时器和网络请求,恢复前台时连接可能早已断了。我监听了document的visibilitychange事件——切后台时延长心跳定时器,切回前台时立即检查连接状态并主动ping,如果发现未恢复就触发重连。这样既省电又能快速恢复连接,实测用户体验好了很多。
4.3 指数退避重连,别把服务端打死
断线重连最忌讳的就是:客户端断线之后立刻猛重连,尤其在服务端重启或者线上发版的时候,几千个客户端同时重连,服务端瞬间被冲垮,这就是著名的"重连风暴"。
我的重连策略是指数退避加随机抖动:第一次重连延迟1秒,第二次2秒,第三次4秒,第四次8秒,最大到30秒封顶。每次延迟加一个0到1000毫秒的随机值,避免所有客户端在同一时刻发起重连。这个策略不复杂,但非常有效。代码里handleClose里的scheduleReconnect就是用reconnectAttempt做指数计算,reconnectAttempt每次成功连接后才归零。
还有一个点:重连成功后,之前的业务状态不一定还在。比如用户在客服会话页面,中间断了几秒,重连后服务端的房间订阅关系可能已经因为旧连接断开而被清理。所以handleOpen里要调resubscribe,把当前页面的订阅关系重新上报。我最初没做这一步,断线重连后消息收不到,排查了半天才发现是订阅关系丢了。
5. 生产环境实战问题排查
方案上线只是开始,真正的考验在生产环境。这一节我把踩得最深的几个坑和排查思路整理出来,每个都是真实场景,不一定全覆盖,但遇到类似问题时可以参考排查方向。
5.1 连接为什么总是被断开
现象:客户端日志里频繁出现onclose,服务端日志显示连接被正常关闭,且间隔时间非常规律。通常每隔60秒或75秒断一次,基本可以断定是中间设备超时。
最常见的原因是Nginx配置问题。默认的proxy_read_timeout是60秒,如果服务端心跳周期接近60秒,连接刚好在心跳的间隙被Nginx判定为空闲而掐掉。解决办法是把读写超时调大,并确保Nginx正确配置了Upgrade头:
location /ws/ { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }排查这类问题的思路是:先看断连时间是否规律,规律多半是中间设备超时;再看服务端日志有没有记录到TCP层异常,比如read: connection reset by peer。用tcpdump或抓包工具在客户端和服务端分别抓包,能清楚看到FIN或RST包来自哪个IP——判断是客户端发起、服务端发起还是中间路由发起。还有一种是云厂商负载均衡自带的空闲连接超时,配置项通常叫idle timeout,同样需要调大到大于心跳间隔。
5.2 内存和文件描述符一直在涨
现象:进程运行几天后内存占用逐渐上升,连接数没有明显增长但fd数一直在涨,最终导致"too many open files"。这个几乎可以断定是连接没有正确释放。
排查我一般分三步走:第一步看日志,有没有"connection closed"和"connection open"数量对不上;第二步查系统层面的fd统计,用ss -s看当前socket数量;第三步用内存在线分析工具(比如Go的pprof)抓goroutine栈,看哪些goroutine停留在等待状态迟迟不退。
代码层面的原因通常有两个:一是readPump和writePump的退出条件触发不完整,比如只Close了连接但没有从manager里Unregister,导致连接对象还在map里被引用;二是sendChannel缓冲区一直在等待消费,写循环已经退出但channel没有关闭,发送方永远阻塞。解决办法就是严格在两个pump的defer里做对称清理,并且对sendChannel做关闭保护——用一个sync.Once确保只关闭一次。
一个血的教训:不要在业务代码里直接close客户端的sendChannel。因为可能有多个goroutine同时往这个channel送礼,别人正在往里写的时候你关了channel,会panic。正确做法是关闭底层连接后让写循环自己退出,由它来关闭channel,其他发送方只做select的default判断。
5.3 消息到达率与离线补偿
WebSocket底层是TCP,消息在连接存活期内一般是可靠的,但连接断了之后,服务端往这条连接推的消息就会丢失。业务上如果丢一条通知无所谓,但如果是订单提醒、验证码、支付结果,丢了就要出事故。
我实现的补偿机制是"消息队列加ACK"两层。服务端为每个userId缓存一个待确认消息队列,推送消息时带msgId;客户端收到后回一条ack消息,服务端收到ack才从待确认队列里移除。如果连接断开后重连,客户端先把本地未确认的msgId列表上报,服务端把未确认的消息重新推一遍。同时所有离线消息会按用户维度暂存到Redis list里,用户下次上线时先拉离线消息,再开始收实时消息。
这个方案的代价是消息处理有延迟,但换来的是可送达性。注意做幂等:客户端要按msgId去重,不然一条消息被重发两次,用户看到两遍就会投诉。我在客户端维护了一个最近接收消息ID的缓存Set,重复的直接丢弃。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 连接每隔60/75秒规律断开 | Nginx或负载均衡空闲超时 | 查看断连时间规律,抓包看FIN来源 | 调大proxy_read_timeout,确认Upgrade头配置 |
| 推送经常延迟但连接正常 | 推送链路有慢消费者,sendChannel堆积 | 查看写循环耗时、channel长度 | 设置channel缓冲区上限,识别并断开慢客户端 |
| 在线状态不准确 | 心跳机制不健全,假死连接未清理 | 查看服务端心跳超时日志 | 服务端Ping+Pong定期检测,连续未响应主动断开 |
| 重连成功后收不到消息 | 断线期间订阅关系丢失 | 客户端日志上报resubscribe情况 | 连接成功后重新上报房间/频道订阅 |
| 上线时服务端CPU飙升 | 大量客户端同时重连 | 观察重连时间分布 | 客户端指数退避加随机抖动,服务端限流 |
| 内存或fd持续增长 | 连接对象未释放,通道泄漏 | pprof分析goroutine,ss查socket | 对称清理逻辑,关闭sendChannel加保护 |
这个表格基本覆盖了我在生产环境遇到过的绝大多数问题。不是说照着查就能100%解决,但排查方向对了,问题就解决了一半。
6. 几个补充的经验细节
除了上面那些硬核问题,还有一些小经验,虽然不能写进架构文档,但对实际运维很有用。我单独放在这里。
第一,连接要打标签。我给每条连接保存一个meta信息,里面包含客户端版本、登录渠道、设备类型、页面来源。连接出问题的时候,可以根据版本号快速定位是不是某个旧版本的兼容问题,而不是一头扎进全量日志里捞。很多线上问题排查,其实就是靠这些看似没用的标签把范围缩小的。
第二,监控比写代码更重要。我上线直播前给服务端加了三个核心指标:当前连接数、每秒消息收发量、每连接心跳超时率。连接数猛增或猛降,消息量异常波动,心跳超时率明显上升,都是故障的前兆。配合告警,很多时候问题还没影响到业务就已经被发现了。
第三,发版的时候要做连接优雅迁移。直接重启服务会瞬间断掉所有在线连接,用户会感觉到明显卡顿。我用的是先摘流量、再等存量连接自然退出、超时强杀的方式:服务进程收到退出信号后,先停止接受新握手,给每个客户端发一条closing消息,等待若干秒让客户端主动重连到新节点,然后才真正退出。这个细节对用户体验的影响很大。
第四,不要忽略客户端弱网场景的适配。移动端4G切WiFi、电梯里没信号、地铁隧道断网,这类环境WebSocket连接几乎必断,断就断,关键是重连要快、要稳,而且不能打扰用户。我经常说,消息推送系统能不能打,一半在看服务端并发能力,另一半在看客户端网络自适应能力。两者是相辅相成的。
作为一个在实时推送领域折腾过不少项目的从业者,我的体会是:WebSocket本身并不难,难的是围绕它搭建的整套连接治理体系。心跳、重连、鉴权、路由、多机扩展、监控告警,这些才是真正决定线上稳定性的东西。技术方案选型可以有很多种,但"连接是状态,状态需要管理"这个意识,是每个做消息推送的人必须提前建立的。