从 onclose 重连到 OOM:WebSocket 长连接上生产的六道坎
团队合入过一个很轻量的 PR:浏览器端只写 new WebSocket(url),服务端监听连接事件,收到广播数据后调用 ws.send()。两个浏览器标签页互发消息,左边敲完回车,右边立即弹出。本地链路顺畅,代码顺利合入主干。
推到公网后,问题很快出现:
- 同事进电梯断网十几秒,重连后,电梯里别人发出的三十来条消息全部漏掉。界面没有报错提示,两端本地状态悄无声息地分叉。
- 周二下午常规发版,后端服务进程重启,几十万在线客户端在同一秒全部重试,刚拉起的网关瞬间被请求冲垮。
- 某台网关内存一路飙升,触发操作系统 OOM 被强杀,连带上面两万多个正常连接一起崩掉。
如果只在本地写 Demo,很容易把长连接想简单:客户端握手成功、两端能收发数据帧就算完成。但 RFC 6455 定义的内容,只是生产系统最表面的 5%。剩下 95% 的脏活和暗坑,协议从一开始就不打算解决。
第一道坎:断线重连不能只写 onclose = () => connect()
在移动端和公网环境里,长连接随时断开是常态。手机从 Wi-Fi 切到 5G,电脑休眠合盖,公司内网代理网关设置 60 秒空闲超时截断,服务端做常规滚动重启,任何一个日常动作都可能掐断连接。
不少前端写第一版时,习惯给 onclose 挂一个直接重连的回调。本地测试没问题,生产环境会触发重连惊群。
假设长连接网关挂着 4 万个在线连接,后端做一次正常滚动发布。服务进程一停,这 4 万个连接在 100 毫秒内全部收到断开信号。如果每个客户端立刻重试,刚重启的网关等不到平缓回流,迎面撞上的是一场由自己客户端发起的全量 DDOS。网关 CPU 瞬间被 TLS 握手打满,新进程被堵死,超时再次导致全员断开,形成恶性循环。
工业界解决这个问题的方式是指数退避加随机抖动,也就是 Full Jitter。
class ReconnectingSocket {
constructor(url) {
this.url = url;
this.attempt = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempt = 0;
this.startHeartbeat();
};
this.ws.onclose = () => {
clearInterval(this.heartbeat);
// 这里必须加上指数退避和随机打散,把几万个同时重连的请求错开,免得把刚重启的网关当场冲垮
const cap = Math.min(30000, 1000 * 2 ** this.attempt);
const delay = Math.random() * cap;
this.attempt++;
setTimeout(() => this.connect(), delay);
};
}
startHeartbeat() {
// 探测 TCP 半开连接:如果连续两次心跳没回包,直接在前端主动掐死
this.missed = 0;
this.heartbeat = setInterval(() => {
if (this.missed >= 2) {
this.ws.close();
return;
}
this.missed++;
this.ws.send(JSON.stringify({ type: 'ping' }));
}, 15000);
}
}
把重连间隔打散还不够,客户端还必须在应用层显式补上 Ping/Pong 心跳。
TCP 协议本身不会主动告诉应用层对端什么时候断掉。手机进地下车库或突然切断 Wi-Fi 时,底层 TCP 会进入半开连接状态,双方操作系统都认为连接仍然正常。没有应用层心跳超时机制,服务端连接表会一直把这个死连接当成正常用户留着,几分钟内都不知道对方已经退网。浏览器没有把原生 WebSocket Ping 帧暴露给 JavaScript API,这套心跳检测只能在应用层用自己的数据结构手写。
第二道坎:断线十六秒,凭空蒸发的消息谁来赔
解决重连后,业务灾难才开场。
用户拿着手机进电梯,连接在 14:03:10 断开,走出电梯后在 14:03:26 重新连上服务器。中间过去 16 秒,业务频道里广播了 30 条新消息。
如果用的是裸 WebSocket,这 30 条消息在客户端掉线瞬间就已经消失。客户端连上后直接接入最新实时数据流,控制台没有报错,但聊天记录中间空了一块,看板跳过关键状态流转,协同文档的数据状态在本地和远端产生不可逆分叉。
用户不会提工单说“系统丢了第 4182 号消息”,他们只会觉得软件经常抽风,几个月后默默流失。
解决断线补发,系统必须建立三件套机制:
- 每个频道发出的消息都带一个由单点服务分配的单调递增序号(Sequence ID)。客户端每次收到消息都记住自己最后处理到哪一个号,也就是自己的游标 Cursor。
- 服务端针对每个频道维护一个滑动回放窗口(Replay Buffer),可以用 Redis Stream 或内存环形缓冲区保存最近一段时间或最近 N 条消息。
- 重连发生时,客户端把自己的游标带上去。服务端需要先在缓冲区里找出游标之后的所有增量消息回放给客户端,回放完毕后,才能把客户端平滑缝合进实时流。
缝合点最容易翻车。回放历史消息和接收实时广播容易产生时序交错,处理不慎就会导致同一条消息下发两次。一旦引入补发机制,系统的交付语义就从理想中的精确一次变成至少一次,客户端接收端必须强制基于 Sequence ID 做幂等去重。
服务端的历史回放缓冲区保留多久,也是现实问题。
无论设置 2 分钟还是 10 分钟,总有用户的笔记本周五晚上合盖,周一早晨才重新打开。断网两天的客户端,不可能指望单靠内存里两三分钟的短缓存补发消息。服务端也不可能为了少数几个掉线太久的用户,把频道所有历史消息无限期存在内存里,资源消耗账本算不过来。
协议设计本身要留退路。当客户端重新连上、带来的游标在服务端缓存里已经找不到时,服务端必须明确返回一个特定状态码,告诉它不要继续等增量回放。客户端应该主动退回普通 HTTP 接口,先完整拉取一份最新业务数据快照,把底色铺满,再重新接入实时增量流。这样整体状态才不会乱套。
第三道坎:多节点集群下的时序错乱
单台服务器上跑长连接,消息先后顺序一般不容易乱。整个进程跑在同一个事件循环里,谁先发谁后发就是客观的物理先后。
线上业务稍微上规模,前面挂负载均衡,后端多台网关并行跑起来后,事情就微妙了。
客户端断线重连后,极大概率被调度到集群里另一台完全不同的机器。如果每台机器都根据本地系统时间戳或各自内存里的计数器给消息标序号,同一个房间里两个用户看到的消息顺序可能完全相反。刚连进来的客户端甚至可能先收到后发出来的 4207 号消息,过半秒后才收到 4206 号,前端界面数据当场错乱。
不要迷信物理服务器之间的 NTP 时间同步。服务器之间的物理时钟有毫秒级偏差很正常,在高频推送场景下,十几毫秒的差距足够把一大批消息的先后顺序颠倒。
要让多台机器上的消息顺序保持一致,必须把定序工作收拢到一个单独的地方。在很多实际项目里,最简易的做法是借助 Redis 的原子自增指令 INCR,给某个频道里的每一条消息发一个单调递增序号,整套集群都以这个数字为准。
这么做有代价。原本完全可以在单机内存里光速转发的消息,现在每发一条都要多走一次网络往返,去集中协调点打卡。定序服务本身的可用性和性能,又成了整个系统新的承重墙。
第四道坎:“显示谁在线”为什么容易翻车
很多产品需求里加一个“看看当前谁在线”或“显示频道在线人数”,排期时看着很轻,总觉得顺手写两行代码就能搞定。
真正做过这块的人知道,在线状态是长连接里特别折磨人的分布式状态难题。公网里到处都是可能假死的网络连接,单纯监听“连上了”和“断开了”两个事件,根本没办法准确判断一个人是否还活着。
如果代码只是连上来就在内存 Map 里加个名字,连接一断开就随手删掉,推到生产环境立刻露馅。
移动网络不好的用户,手机一走一停,几秒钟断一次又连一次,频道其他人会被没完没了的“某某加入了”“某某退出了”广播刷屏。同一个用户还可能同时在电脑网页、手机 App 和平板三端挂着,产品界面上不能把他算成三个人,服务端必须按用户维度做多端会话引用计数。
更棘手的是,某台网关服务器因为机房断电或内核问题突然挂掉,上面成千上万个连接来不及触发正常断开事件,在线名单里会凭空多出一大堆永远不会退出的“幽灵用户”。
在严谨的生产环境里,在线状态通常不敢赌断开事件,而是改用带 TTL 的集中缓存维持。客户端每次发上来的心跳包,除了确认 TCP 还通着,也是在给自己的在线租约续命。机器突然挂了或客户端真掉线,超时时间一到,缓存自然过期清空。广播上下线消息时,服务端最好也卡上两三秒防抖延迟,免得网络稍微抖动就疯狂向外通报。
第五道坎:广播扇出的乘法雪崩
前面几点都还只是在一个客户端身上折腾。长连接系统真正在架构上最容易被瞬间击穿的瓶颈,是一道小学乘法题。
服务端实际出口消息吞吐量,等于频道里发消息的频率乘以当前订阅人数。
平时这笔账不大。一个普通监控看板,一秒钟更新 50 次,只有十来个人看,每秒推几百条数据,随便找一台配置普通的单机跑起来都轻松,看不出压力。
碰上全员大会或爆款大促,同一个频道里一口气挤进两万名在线用户。50 乘以 20000,每秒实际出口下发量瞬间飙到 100 万条。
单进程 Node.js 处理几千个长连接广播时可能还能顶住。到了每秒几十万上百万次下发的级别,CPU 光是用来在内存里反复遍历几万个 Socket 数组、挨个把 JSON 序列化成二进制数据帧,就足以把单线程事件循环卡死到几千毫秒以上延迟。
长连接规模稍微做大,最后都得把架构拆成两层扇出集群:前台放多台只负责挂住客户端长连接的轻量网关节点,后台挂一套 Redis Pub/Sub 或专用消息总线。业务端只要把消息推给总线一次,由总线负责分发给各个网关节点,每台网关再各自推给连在自己身上的那部分客户端。
这种集群架构对发版运维也是考验。普通 Web API 发版,直接把旧进程杀掉重新拉起即可。长连接网关如果直接强杀,等于瞬间人为制造一场全网断线惊群风暴。网关发版前必须先做优雅排水(Drain):先拒绝接入新连接,同时通知老连接分批去重连其他健康节点,直到自己身上的连接数平缓掉到零,才能停机更新。
第六道坎:一个慢客户端如何杀死健康的整台服务器
除了广播吞吐量,线上事故里还有一种隐蔽得多的故障。
一台承载着两万个活跃长连接的网关,在没有大促也没有流量高峰的普通下午,内存占用突然以肉眼可见的速度飙升,几分钟就把系统物理内存吃满。Linux 的 OOM 机制直接把整个进程强杀,这台机器上两万多个正常用户全部被踢下线。
事后排查日志,事故源头只是一个用户拿着手机进入没有信号的地下车库。
在 Node.js 中,调用 ws.send() 发送数据时,数据不会直接送达对方网卡,而是先压进操作系统的 TCP 发送缓冲区。如果对方处于极端弱网环境,TCP 接收窗口被塞满无法确认,操作系统缓冲区很快会被填满。
这时 Node.js 底层会把后续所有待发数据悄悄堆积在当前进程自己的 V8 堆内存里。
如果这是一个每秒下发 50 条行情变动的数据频道,一个彻底卡住不读数据的慢客户端,会让服务端为它一个人在内存里堆积几万条未发送消息。这样的弱网连接稍微多上几十个,Node.js 堆内存就会被顶爆。
生产环境的服务端在调用发送前,必须严格检查底层积压水位:
const MAX_BUFFERED = 1 * 1024 * 1024; // 单个连接最多允许积压 1MB
function deliver(client, message) {
if (client.ws.bufferedAmount > MAX_BUFFERED) {
// 积压超过阈值,说明该客户端消费能力严重掉队,坚决不能让它吃空堆内存
if (client.mode === 'state') {
// 策略一:针对像光标位置、仪表盘这种只要最新值的数据,做覆盖合并
client.pending.set(message.key, message);
} else {
// 策略二:对于重要事件流,宁可直接掐断连接,让它去走重连拉回放
client.ws.close(1013, 'slow consumer');
}
return;
}
client.ws.send(message.encoded);
}
面对慢客户端,最忌讳在内存里由着它无限排队。像光标位置或股票价格这类只要最新值的数据,把中间历史数据统统扔掉,只保留最新一条,等它网络恢复再下发,也就是 Conflation。遇到聊天记录这种每一条都不能丢的事件流,最干脆的做法反而是主动把慢连接掐掉,让它回头走断线重连去拉历史回放。不能让一两个弱网用户把整台服务器内存拖死。
什么时候其实没必要上 WebSocket
把这些问题捋一遍,大概就能明白为什么自己搞一套长连接会这么费劲。
防止慢连接吃空内存,就得敢主动掐断它。掐断之后为了不丢消息,又必须回头做游标回放。要做游标回放,集群多台机器之间又得统一定序。整条链路只要少考虑一个小细节,上线之后就总有一个地方在悄悄漏水。
技术评审里看到长连接方案,先别急着写代码,多琢磨功能到底值不值得上 WebSocket。
如果只是服务端往客户端单向推数据,比如给大模型做打字机式流式输出,或者仅推订单支付状态和后台任务进度,优先考虑 Server-Sent Events(SSE)。它底层走普通 HTTP 请求,公司内网各种反向代理和网关一般不会拦截。现代浏览器原生自带断线自动重连,还能自动带上 Last-Event-ID 把游标续传做好,很多协议层面的脏活浏览器已经替你解决。
如果只是做运营后台看板或大屏数据汇总,业务上哪怕有三五秒延迟也能接受,更用不着上长连接折腾自己。写普通 HTTP 接口,让前端定时短轮询,既能享受 CDN 缓存,抓包排错用 curl 一眼就能看明白,大半夜也不用担心连接池内存泄漏把报警打到手机上。
实时长连接这套沉重的工程复杂度,只有在确实需要多人同时在线协同、做极低延迟高频双向交互时,花下去的精力和成本才算物有所值。真要落地,也不建议硬拿 Node.js 从零手搓一整套分布式集群。像用 Go 写的 Centrifugo 这种成熟的独立网关,已经封装好连接心跳保活、序号增量重放、在线状态和总线广播,可以直接当现成基础设施来用,把省下来的力气放到核心业务上。