WebSocket 重连策略实践:把“看起来在线”变成“真正可恢复”

最近在做一个用于展示实时构建日志的小工具。最开始我图省事,浏览器端只要 socket.onclose = () => reconnect() 就算完成。结果上线到测试环境后,问题很快出现了:办公室网络偶发抖动、电脑休眠后恢复、切换 VPN、浏览器标签页长时间后台挂起,都会把长连接带到一个半死不活的状态。页面上还能看到“已连接”,但日志已经不再滚动。

这次复盘让我意识到,WebSocket 的困难从来不在“连上”,而在“如何知道它已经坏了,以及坏了之后怎样恢复得足够稳”。

1. 先区分几类失败

我先把现场抓到的失败类型做了分类:

  1. 服务端主动关闭。通常会有 close code,可以明确判断是鉴权失败、版本不兼容,还是服务端发布导致断开。
  2. 网络瞬断。客户端有时能收到 close,有时什么事件都没有,只是消息不再到达。
  3. 浏览器休眠或后台节流。定时器和心跳都可能被延后,导致误判。
  4. 连接恢复但状态丢失。比如服务端增量推送的游标没有同步,重连后会漏消息。

把这些情况分开看之后,重连策略就清晰很多:不是所有断开都该“立刻重连”,不是所有重连成功都代表业务恢复。

2. 最小可用方案:心跳 + 指数退避

我给客户端补了两层最基础的保护。

第一层是应用级心跳。浏览器原生 WebSocket API 并不直接暴露 ping/pong,所以我在业务协议里加了一个 type = heartbeat 的消息,每 20 秒发送一次。服务端原样返回即可。

第二层是指数退避。以前我每次断开就 1 秒后重试,结果网络稍差时会疯狂重连,把日志刷满。改成 1s、2s、4s、8s、16s,上限 30s 后,客户端和服务端都平稳很多。为了避免所有客户端同时重连,我又加了一个 0 到 800ms 的随机抖动。

这里有个细节:心跳超时不能只看“多久没收到任何消息”,还要看当前页面是否处于后台。如果标签页被浏览器严重节流,原本 20 秒的心跳定时器可能推迟到一分钟以后才执行。我的做法是,在 visibilitychange 里记录页面状态,后台时只维持弱监测,回到前台后立即做一次快速探活。

3. 真正麻烦的是“假活着”

最难排查的不是彻底断开,而是 TCP 连接还没完全释放、前端实例也没报错,但实际上消息链路已经断了。这种情况下,readyState 仍然可能是 OPEN

我最后采用的判断标准是:

  1. 最近一次收到任意服务端消息的时间。
  2. 最近一次收到心跳回包的时间。
  3. 当前是否存在连续多次发送失败或解析异常。

当这三项组合起来超过阈值时,我会主动 close() 当前连接,并进入受控重连,而不是继续相信这个“看似在线”的 socket。

这个策略的好处是主动。与其等待浏览器底层某个时刻终于意识到连接坏了,不如业务层自己做出“此连接不可信”的判定。

4. 重连不等于恢复

我之前另一个误区是,只要重新连上就把 UI 改成绿色“已连接”。后来发现这会掩盖更深层的问题:实时系统真正要恢复的是“数据连续性”。

所以我把连接状态拆成了三层:

  1. Transport Connected:底层 WebSocket 已建立。
  2. Session Ready:鉴权、订阅、房间加入等初始化动作完成。
  3. Stream Healthy:消息序列号连续,业务流恢复正常。

只有第三层成立,我才让页面展示“实时同步中”。如果只是第一层成功,页面会显示“连接已恢复,正在补齐数据”。

这套状态分层非常值钱,因为它把技术状态翻译成了用户能理解的提示,也方便我在日志里精确定位问题卡在哪一层。

5. 消息补偿是最后一块拼图

为了避免重连期间丢消息,我给每条服务端消息增加了递增序列号,同时客户端保存最近一次成功消费的序号。重连后,客户端会把这个序号带给服务端,请求补发缺失区间。

如果服务端无法补全,比如缓存窗口已经过期,我会触发一次全量刷新。虽然体验比增量恢复差,但至少不会让界面默默停在错误数据上。

这个思路和消息队列里的 offset 很像。实时系统常常看起来是“推”的,真正稳定以后,背后一定要有某种“拉回来校验”的能力。

6. 我最后沉淀下来的工程原则

这次改完之后,我给自己记了几条很朴素但实用的原则:

  1. 不要把 onopen 当成连接成功的终点,它只是开始。
  2. 不要把 readyState === OPEN 当作健康状态的充分条件。
  3. 重连必须带退避和抖动,否则故障期间会放大系统压力。
  4. 心跳只能证明“最近还能说话”,不能证明“数据一定完整”。
  5. 真正重要的是恢复路径是否可验证,而不是表面上连没连上。

现在回头看,这个功能并不复杂,难的是一开始没有从“坏掉以后怎么办”的角度去设计。很多实时系统的可靠性,其实就是把这些边缘情况一条条补成默认能力。