最近在做一个用于展示实时构建日志的小工具。最开始我图省事,浏览器端只要 socket.onclose = () => reconnect() 就算完成。结果上线到测试环境后,问题很快出现了:办公室网络偶发抖动、电脑休眠后恢复、切换 VPN、浏览器标签页长时间后台挂起,都会把长连接带到一个半死不活的状态。页面上还能看到“已连接”,但日志已经不再滚动。
这次复盘让我意识到,WebSocket 的困难从来不在“连上”,而在“如何知道它已经坏了,以及坏了之后怎样恢复得足够稳”。
1. 先区分几类失败
我先把现场抓到的失败类型做了分类:
- 服务端主动关闭。通常会有 close code,可以明确判断是鉴权失败、版本不兼容,还是服务端发布导致断开。
- 网络瞬断。客户端有时能收到
close,有时什么事件都没有,只是消息不再到达。 - 浏览器休眠或后台节流。定时器和心跳都可能被延后,导致误判。
- 连接恢复但状态丢失。比如服务端增量推送的游标没有同步,重连后会漏消息。
把这些情况分开看之后,重连策略就清晰很多:不是所有断开都该“立刻重连”,不是所有重连成功都代表业务恢复。
2. 最小可用方案:心跳 + 指数退避
我给客户端补了两层最基础的保护。
第一层是应用级心跳。浏览器原生 WebSocket API 并不直接暴露 ping/pong,所以我在业务协议里加了一个 type = heartbeat 的消息,每 20 秒发送一次。服务端原样返回即可。
第二层是指数退避。以前我每次断开就 1 秒后重试,结果网络稍差时会疯狂重连,把日志刷满。改成 1s、2s、4s、8s、16s,上限 30s 后,客户端和服务端都平稳很多。为了避免所有客户端同时重连,我又加了一个 0 到 800ms 的随机抖动。
这里有个细节:心跳超时不能只看“多久没收到任何消息”,还要看当前页面是否处于后台。如果标签页被浏览器严重节流,原本 20 秒的心跳定时器可能推迟到一分钟以后才执行。我的做法是,在 visibilitychange 里记录页面状态,后台时只维持弱监测,回到前台后立即做一次快速探活。
3. 真正麻烦的是“假活着”
最难排查的不是彻底断开,而是 TCP 连接还没完全释放、前端实例也没报错,但实际上消息链路已经断了。这种情况下,readyState 仍然可能是 OPEN。
我最后采用的判断标准是:
- 最近一次收到任意服务端消息的时间。
- 最近一次收到心跳回包的时间。
- 当前是否存在连续多次发送失败或解析异常。
当这三项组合起来超过阈值时,我会主动 close() 当前连接,并进入受控重连,而不是继续相信这个“看似在线”的 socket。
这个策略的好处是主动。与其等待浏览器底层某个时刻终于意识到连接坏了,不如业务层自己做出“此连接不可信”的判定。
4. 重连不等于恢复
我之前另一个误区是,只要重新连上就把 UI 改成绿色“已连接”。后来发现这会掩盖更深层的问题:实时系统真正要恢复的是“数据连续性”。
所以我把连接状态拆成了三层:
- Transport Connected:底层 WebSocket 已建立。
- Session Ready:鉴权、订阅、房间加入等初始化动作完成。
- Stream Healthy:消息序列号连续,业务流恢复正常。
只有第三层成立,我才让页面展示“实时同步中”。如果只是第一层成功,页面会显示“连接已恢复,正在补齐数据”。
这套状态分层非常值钱,因为它把技术状态翻译成了用户能理解的提示,也方便我在日志里精确定位问题卡在哪一层。
5. 消息补偿是最后一块拼图
为了避免重连期间丢消息,我给每条服务端消息增加了递增序列号,同时客户端保存最近一次成功消费的序号。重连后,客户端会把这个序号带给服务端,请求补发缺失区间。
如果服务端无法补全,比如缓存窗口已经过期,我会触发一次全量刷新。虽然体验比增量恢复差,但至少不会让界面默默停在错误数据上。
这个思路和消息队列里的 offset 很像。实时系统常常看起来是“推”的,真正稳定以后,背后一定要有某种“拉回来校验”的能力。
6. 我最后沉淀下来的工程原则
这次改完之后,我给自己记了几条很朴素但实用的原则:
- 不要把
onopen当成连接成功的终点,它只是开始。 - 不要把
readyState === OPEN当作健康状态的充分条件。 - 重连必须带退避和抖动,否则故障期间会放大系统压力。
- 心跳只能证明“最近还能说话”,不能证明“数据一定完整”。
- 真正重要的是恢复路径是否可验证,而不是表面上连没连上。
现在回头看,这个功能并不复杂,难的是一开始没有从“坏掉以后怎么办”的角度去设计。很多实时系统的可靠性,其实就是把这些边缘情况一条条补成默认能力。