WebSocket 心跳与重连:连接还在不等于链路可用

同一段建连代码,在两个环境里表现完全不一样。

1const ws = new WebSocket('wss://example.com/socket')
2ws.onmessage = (event) => console.log(event.data)
3ws.onclose = () => console.log('closed')

在我本地开发机上,拔网线、断 WiFi,onclose 几乎立刻打印。可同样这段代码上到客户内网,连接明明已经收不到任何消息了,onclose 却迟迟不响,页面右上角还稳稳地写着“在线”。ws.readyState 读出来是 OPEN,一切看着都正常,唯独告警不再刷新。

这就是实时前端最容易被忽略的一件事:连接对象还在、readyState 还是 OPEN,不代表这条链路真的能收发数据。 实时不等于一直在线。TCP 连接可能早就被中间的代理、NAT 设备或者对端悄悄丢掉了,但两端谁都没发 FIN,浏览器自然不会触发 close 事件,这种状态叫半开连接(half-open)。你以为自己连着,其实对面早就不在了。

我第一次撞上它,是给运营做的一个告警大屏。第一版只监听 onmessage,本地跑一晚上稳如老狗,到了客户现场,机房内网的代理十几分钟没有数据流过就默默回收了连接。大屏还亮着“在线”,值班的人盯着一屏不动的数字以为风平浪静,实际上后台的告警一条都没进来。从那以后我才认真去补心跳和重连这一整套东西。

为什么 onclose 靠不住

理解半开连接,得先接受一个事实:WebSocket 的 close 事件只在“本端明确知道连接结束”时才触发——要么本地调了 close(),要么收到了对端的关闭帧,要么 TCP 层报了错。如果是中间设备单方面丢弃连接,两端的 socket 都还“以为”连着,浏览器没有任何信号可以依据,onclose 就不会来。

移动网络、企业代理、电脑从休眠恢复,这几种场景最容易制造半开连接。休眠尤其典型:合上盖子再打开,ws 对象还在,readyState 还是 OPEN,但底层连接早在休眠期间就断了。你不主动去探一下,根本发现不了。

所以第一条结论是:不能把 readyState 当成链路健康的判据,得靠应用层自己发探针去确认。 这就是心跳存在的理由——它不是为了保活,是为了主动暴露那些浏览器不告诉你的死连接。

用心跳把半开连接逼出来

思路很直接:定时发一个 ping,约定服务端回一个 pong;发出去以后起一个计时器,超时还没等到 pong,就判定链路已死,主动 close,交给重连逻辑接管。

1let heartbeatTimer = null
2let pongTimer = null
3
4function startHeartbeat(ws) {
5  heartbeatTimer = setInterval(() => {
6    if (ws.readyState !== WebSocket.OPEN) return
7    ws.send(JSON.stringify({ type: 'ping' }))
8
9    pongTimer = setTimeout(() => {
10      // 超时没收到 pong,认定链路已死,主动断开走重连
11      ws.close()
12    }, 5000)
13  }, 30000)
14}
15
16function handleMessage(message) {
17  if (message.type === 'pong') {
18    clearTimeout(pongTimer)
19    return
20  }
21  // 其余业务消息……
22}
23
24function stopHeartbeat() {
25  clearInterval(heartbeatTimer)
26  clearTimeout(pongTimer)
27}

关键是那个主动 close()。半开连接自己不会断,你得替它宣判死亡,否则重连逻辑永远等不到 onclose,也就永远不会启动。

心跳间隔不是越短越好。太密会白白耗电和占服务端资源,移动端尤其明显;太疏则发现死连接太慢,告警场景可能十几秒都感知不到掉线。我一般把间隔和业务能忍受的“最长失联时间”挂钩:告警大屏这种实时性强的,间隔压到 15 到 20 秒;后台通知这类不那么急的,30 秒甚至更长都行。

还有个容易忽略的点:ping 最好用应用层的 JSON 消息,而不是 WebSocket 协议自带的 ping/pong 控制帧。因为浏览器端的 WebSocket API 根本不暴露发送控制帧的接口,也收不到控制帧的回调——协议帧那套是给服务端和网关用的,浏览器里只能靠自己在 payload 里约定 type: 'ping'

连接状态要显式建模

发现了掉线,还得让整个应用知道现在处在什么状态。只有一个 readyState 是不够的,业务上至少要有一套自己的状态:

1connecting     连接中
2connected      已连接
3reconnecting   重连中
4disconnected   已断开
5failed         重连耗尽,放弃

这套状态直接驱动界面。大屏右上角不该只有“在线/离线”两档,而应该能表达“正在重连(第 3 次)”“已断开,请检查网络”。否则用户盯着一屏旧数据,还以为系统一切正常——这恰恰是半开连接最坑人的地方,视觉上没有任何异常。

我习惯把这套状态收进一个小状态机里,所有 UI 只读这个状态,不去直接摸 ws.readyState

1let state = 'connecting'
2
3function setState(next) {
4  state = next
5  render(next) // 更新右上角指示灯与文案
6}

发送也会失败:readyState 和 bufferedAmount

半开连接不光影响收,也影响发。连接还没真正建立、或者已经进入 CLOSING/CLOSED,直接 ws.send() 会抛异常。所以发送前该确认状态:

1function safeSend(ws, data) {
2  if (ws.readyState === WebSocket.OPEN) {
3    ws.send(data)
4    return true
5  }
6  // 没连上,先缓存,等重连成功后补发
7  pendingQueue.push(data)
8  return false
9}

readyState 有四个值:CONNECTING(0)OPEN(1)CLOSING(2)CLOSED(3)。只有 OPEN 才能安全发送。重连成功后,把 pendingQueue 里积压的消息按序发出去,是很自然的补偿。

还有个容易被忽略的信号是 ws.bufferedAmount——它表示已经调 send() 但还没真正发出去、堆在缓冲区里的字节数。如果这个值持续上涨,说明发送速度超过了网络吞吐,链路可能正在恶化。发送高频数据(比如实时坐标、协同光标)时,可以拿它做背压:缓冲超过阈值就先丢掉旧的、只留最新,别把内存和网络一起拖垮。

重连要退避,别形成风暴

断线后立刻重连一次是合理的,但绝不能失败后每 100ms 硬连一次。服务端重启或网络大面积故障时,成千上万个客户端一起密集重连,会把刚起来的服务端再打垮一次,形成重连风暴。

稳妥的做法是指数退避,再叠一层随机抖动:

1function getReconnectDelay(attempt) {
2  const base = Math.min(1000 * 2 ** attempt, 30000)
3  const jitter = Math.random() * 1000
4  return base + jitter
5}

抖动(jitter)这一步很关键。如果所有客户端都用同一条退避曲线,它们会在同一时刻集体苏醒、同时重连,退避就白做了。加一点随机量,把重连时刻打散开,服务端承受的才是平缓的爬坡而不是尖峰。

退避之外还得有上限。连了十几次都失败,多半是服务真挂了或者鉴权出了问题,这时应该切到 failed 状态、给用户明确提示,而不是无休止地重连下去把日志刷爆。

区分意外断开和主动关闭

重连逻辑挂在 onclose 上,就必然要回答一个问题:用户主动退出、页面卸载导致的 close,也会触发 onclose,这时候绝不能再去重连。得给主动关闭打个标记:

1let manualClose = false
2
3function close() {
4  manualClose = true
5  stopHeartbeat()
6  ws.close()
7}
8
9ws.onclose = () => {
10  stopHeartbeat()
11  if (!manualClose) scheduleReconnect()
12}

页面卸载也要顺手关一下,避免 beforeunload 时残留一个正在重连的定时器:

1window.addEventListener('beforeunload', () => {
2  manualClose = true
3  ws.close()
4})

断线期间的消息,会丢也会重

链路一旦中断,这段窗口里服务端推的消息,客户端是收不到的。重连成功不代表补上了这段空白。所以除了“连回来”,还得回答“断的这段时间我漏了什么”。

对重要消息,让服务端带上单调递增的序号:

1{
2  "seq": 1024,
3  "type": "order.updated",
4  "payload": {}
5}

客户端记住最后处理的 seq,重连后把它带上去请求续传:

1{
2  "type": "resume",
3  "lastSeq": 1024
4}

服务端根据 lastSeq 补发缺失的消息,或者干脆告诉客户端“差得太多了,重新拉一次全量吧”。

如果后端没有续传能力,前端也不能假装无事发生。最低限度:重连成功后主动重新拉一次关键数据的快照,把断线窗口里可能发生的变化补回来。 不要相信“断线期间什么都没发生”。

重复消息同样要防。网络重试、服务端补发都可能让同一条消息到两遍。关键写操作在后端要做幂等(idempotency),前端渲染则按业务 id 去合并更新,而不是无脑 list.push()——不然重连后同一条订单在列表里出现两回。

标签页切后台,定时器会被降频

浏览器为了省电,标签页切到后台后会大幅降低 setIntervalsetTimeout 的触发频率,移动端甚至直接冻结页面。心跳和重连都会跟着变慢甚至停摆。用户切走一会儿再回来,很可能面对的就是一条早已死掉、却因为心跳被降频而没被发现的连接。

比起纠结后台期间的定时器,更可靠的是在页面重新可见时主动校验一遍:

1document.addEventListener('visibilitychange', () => {
2  if (document.visibilityState === 'visible') {
3    ensureConnected() // 连接不在就立刻重连
4    refreshSnapshot() // 拉一次最新快照,补上后台期间的空白
5  }
6})

visibilitychangefocus/blur 更贴合“页面是否真正可见”这件事,移动端切换、锁屏都能覆盖到。回到页面就当作“可能已经掉线过”来处理,反而最稳。

这里的 ensureConnected() 也别简单地无脑重连——先按前面那套心跳发一个 ping 探一下,真死了再重建连接,避免明明还连着却白白断一次、把正在收的消息也打断。一个稳妥的次序是:可见时先探活,探活失败才走重连,重连成功再拉快照补数据。三步都独立可测,出问题也好定位是哪一环。

鉴权过期必须能中止重连

WebSocket 也逃不开鉴权。token 过期时,服务端通常会关掉连接,或先推一条错误消息:

1{ "type": "error", "code": "UNAUTHORIZED" }

这时如果重连逻辑照常运转,就会陷入“连上就被踢、被踢又重连”的死循环,服务端日志被刷满,用户界面却一直卡在“重连中”。正确的做法是让鉴权错误也进状态机,命中就停重连、去刷新 token 或引导登录:

1function handleMessage(message) {
2  if (message.type === 'error' && message.code === 'UNAUTHORIZED') {
3    manualClose = true // 阻止 onclose 触发重连
4    stopReconnect()
5    redirectToLogin()
6    return
7  }
8}

重连不是无条件的循环,它必须能被鉴权失败、被达到最大次数这些出口打断。

先想清楚要不要用 WebSocket

把上面这套心跳、退避、续传都实现一遍,成本不低。所以真正动手前,值得先问一句:这个需求非 WebSocket 不可吗?

WebSocket 的强项是双向、低延迟——聊天、协同编辑、实时对战这类客户端也要频繁往上推数据的场景。但如果只是服务端单向往下推(告警、进度、通知、行情),SSE(Server-Sent Events)往往更省事。它基于普通 HTTP,浏览器的 EventSource 自带断线重连,连退避都替你做了一部分:

1const es = new EventSource('/api/alerts')
2es.onmessage = (e) => render(JSON.parse(e.data))
3es.onerror = () => {
4  // EventSource 会自动重连,这里通常只需更新一下 UI 状态
5}

我那个告警大屏其实是单向推送,事后复盘时想过:如果一开始用 SSE,浏览器原生的自动重连能省掉一大半手写逻辑。最终还是留在 WebSocket,是因为后来加了双向的确认回执。选型这一步定错,后面再稳的重连代码也是在给一个本可以更简单的方案打补丁。 需要双向就 WebSocket 并接受这套复杂度;只是单向下推,先考虑 SSE。

上线前的自检

把一个实时功能推上线前,我会照着这几条过一遍:

有没有独立于 readyState 的应用层状态,界面能不能如实反映;有没有心跳去主动探半开连接,超时能不能触发主动 close;重连有没有指数退避、随机抖动和次数上限;主动关闭和页面卸载会不会误触发重连;重连成功后能不能续传或至少刷一次快照;重复消息在前后端有没有去重和幂等;页面重新可见时会不会校验连接并补数据;鉴权失败能不能干净地中止整套重连。

说到底,实时系统的可靠性不在“永远连着”,而在断了能被发现、发现了能恢复、恢复之后数据仍然可信这三步。连接还在从来不等于链路可用,这条区别想清楚了,上面那些心跳、退避、续传才有落点。