
接手过一个实时通知服务业务上要求订单状态变更、客服会话、后台告警都要在秒级内推到用户端。最开始图省事用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 什么场景不适合硬上WebSocketWebSocket不是万能药。如果是低频通知、服务端本来就无状态、又不想维护大量长连接SSEServer-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就是推给用户1001room:2001就是推给房间2001方便在服务端和客户端两边都做路由判断。协议一定要带版本。我建议在最外层加一个ver字段哪怕现在是第一版。后面接口升级、字段调整的时候老客户端还能根据版本做兼容不至于一改协议线上就全崩。2.3 单发、广播与房间模型的实现消息推送的路由模型我总结下来就三种单发给某个用户、广播给全部在线用户、定向推给一个房间或群组。大多数业务都是这三种的组合。在实现上单发最简单按userId找到对应所有连接往sendChannel里投递即可。广播也不难遍历连接管理器里所有的连接逐个投递。重点说一下房间模型这个稍微复杂一点。比如客服系统里一个用户进来要推给他的客服、客服组长可能还要推给整个工单组这时候就需要一个房间订阅表房间ID - 成员用户ID集合或者用户在哪个房间。我在项目里维护了两张maproom-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现在官方生态也在变迁我当前项目用的库提供了一组简洁的APIUpgrade把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收消息同时用定时器定期发服务端Pingfunc (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类型直接回pongack类型更新离线重发队列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缓冲区上限识别并断开慢客户端在线状态不准确心跳机制不健全假死连接未清理查看服务端心跳超时日志服务端PingPong定期检测连续未响应主动断开重连成功后收不到消息断线期间订阅关系丢失客户端日志上报resubscribe情况连接成功后重新上报房间/频道订阅上线时服务端CPU飙升大量客户端同时重连观察重连时间分布客户端指数退避加随机抖动服务端限流内存或fd持续增长连接对象未释放通道泄漏pprof分析goroutiness查socket对称清理逻辑关闭sendChannel加保护这个表格基本覆盖了我在生产环境遇到过的绝大多数问题。不是说照着查就能100%解决但排查方向对了问题就解决了一半。6. 几个补充的经验细节除了上面那些硬核问题还有一些小经验虽然不能写进架构文档但对实际运维很有用。我单独放在这里。第一连接要打标签。我给每条连接保存一个meta信息里面包含客户端版本、登录渠道、设备类型、页面来源。连接出问题的时候可以根据版本号快速定位是不是某个旧版本的兼容问题而不是一头扎进全量日志里捞。很多线上问题排查其实就是靠这些看似没用的标签把范围缩小的。第二监控比写代码更重要。我上线直播前给服务端加了三个核心指标当前连接数、每秒消息收发量、每连接心跳超时率。连接数猛增或猛降消息量异常波动心跳超时率明显上升都是故障的前兆。配合告警很多时候问题还没影响到业务就已经被发现了。第三发版的时候要做连接优雅迁移。直接重启服务会瞬间断掉所有在线连接用户会感觉到明显卡顿。我用的是先摘流量、再等存量连接自然退出、超时强杀的方式服务进程收到退出信号后先停止接受新握手给每个客户端发一条closing消息等待若干秒让客户端主动重连到新节点然后才真正退出。这个细节对用户体验的影响很大。第四不要忽略客户端弱网场景的适配。移动端4G切WiFi、电梯里没信号、地铁隧道断网这类环境WebSocket连接几乎必断断就断关键是重连要快、要稳而且不能打扰用户。我经常说消息推送系统能不能打一半在看服务端并发能力另一半在看客户端网络自适应能力。两者是相辅相成的。作为一个在实时推送领域折腾过不少项目的从业者我的体会是WebSocket本身并不难难的是围绕它搭建的整套连接治理体系。心跳、重连、鉴权、路由、多机扩展、监控告警这些才是真正决定线上稳定性的东西。技术方案选型可以有很多种但连接是状态状态需要管理这个意识是每个做消息推送的人必须提前建立的。