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()——不然重连后同一条订单在列表里出现两回。
标签页切后台,定时器会被降频
浏览器为了省电,标签页切到后台后会大幅降低 setInterval、setTimeout 的触发频率,移动端甚至直接冻结页面。心跳和重连都会跟着变慢甚至停摆。用户切走一会儿再回来,很可能面对的就是一条早已死掉、却因为心跳被降频而没被发现的连接。
比起纠结后台期间的定时器,更可靠的是在页面重新可见时主动校验一遍:
1document.addEventListener('visibilitychange', () => { 2 if (document.visibilityState === 'visible') { 3 ensureConnected() // 连接不在就立刻重连 4 refreshSnapshot() // 拉一次最新快照,补上后台期间的空白 5 } 6})
visibilitychange 比 focus/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;重连有没有指数退避、随机抖动和次数上限;主动关闭和页面卸载会不会误触发重连;重连成功后能不能续传或至少刷一次快照;重复消息在前后端有没有去重和幂等;页面重新可见时会不会校验连接并补数据;鉴权失败能不能干净地中止整套重连。
说到底,实时系统的可靠性不在“永远连着”,而在断了能被发现、发现了能恢复、恢复之后数据仍然可信这三步。连接还在从来不等于链路可用,这条区别想清楚了,上面那些心跳、退避、续传才有落点。