WebSocket 断线重连机制
WebSocket 的长连接一定会断:网络切换、网关超时、服务端重启。重连不是「断了就连」,而是要有退避和状态补发。
一、长连接必然会断
先接受一个前提:断开是常态,不是异常。常见原因:
- 网络切换 —— 手机从 Wi-Fi 切到移动网络,IP 变了,连接必然断;
- 中间设备超时 —— NAT、负载均衡、代理都有空闲超时,长时间没有数据就单方面掐断;
- 服务端重启或扩容 —— 发布、扩缩容、故障转移都会让连接失效;
- 被负载摘除 —— 实例下线,连接被强制关闭;
- 长时间空闲 —— 没有心跳的连接最容易被当成死连接清理掉。
「为什么会断」决定了重连策略要覆盖哪些场景:网络切换要能快速恢复(用户没耐心等),服务端重启要能扛住大量客户端同时重连。
二、心跳与超时判定
只监听 close 事件是不够的。存在一种静默断开:网络中断但双方都没有收到关闭帧,连接对象还在,数据也发得出去(写进缓冲区),只是永远收不到回应。这时 close 不会触发,应用以为连接还活着。
所以必须做心跳:客户端定时发一个小消息,服务端回应;超过一定时间没收到回应就判定断开,主动关闭后进入重连。
const HEARTBEAT_INTERVAL = 25000
const TIMEOUT = 60000 // 通常是心跳间隔的两倍以上
let lastPong = Date.now()
let heartbeatTimer, timeoutTimer
function startHeartbeat(ws) {
const ping = () => {
ws.send(JSON.stringify({ type: 'ping' }))
timeoutTimer = setTimeout(() => {
if (Date.now() - lastPong > TIMEOUT) ws.close(4000, 'heartbeat timeout')
}, TIMEOUT)
}
heartbeatTimer = setInterval(ping, HEARTBEAT_INTERVAL)
}
// 关键:收到任何消息(包括服务端的 pong)都要刷新 lastPong,
// 否则它一直停在初始值,每次心跳都会判定超时
ws.addEventListener('message', (e) => {
lastPong = Date.now()
handleMessage(e)
})
心跳间隔是个权衡:太短浪费流量与电量(移动端尤其明显),太长则故障发现慢。实践中 20~30 秒比较常见,超时设为间隔的两三倍。
三、退避与抖动
两个错误做法要明确反对:
立刻重试 —— 服务端重启时,所有客户端会在同一瞬间涌入,把刚起来的服务再次打垮。
固定间隔重试 —— 所有客户端保持同步,会形成周期性的重连尖峰。
正确做法是指数退避 + 随机抖动:每次等待时间翻倍,再叠加一个随机量,把重试打散。
let attempt = 0
const MAX_DELAY = 30000
function scheduleReconnect() {
const base = Math.min(MAX_DELAY, 1000 * 2 ** attempt)
const delay = base + Math.random() * 1000 // 抖动,避免同时重连
setTimeout(connect, delay)
attempt++
}
配套两条:
- 最大次数 —— 超过后就降级(比如退回轮询),不要无限重试;
- 网络恢复立即重连 —— 监听
online事件,网络一恢复立刻连,不用等退避计时。
四、重连后的状态补齐
重连成功不等于数据完整。 断开期间服务端可能已经推了若干条消息,客户端全都错过了。
标准做法是按序号增量拉取:连接时带上客户端已收到的最后序号,服务端从那里之后补发。
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'sync', lastSeq }))
}
两条纪律:
- 业务动作要幂等。 重连后客户端可能重发某个操作,服务端要能识别重复(用请求 ID 或幂等键),否则一次断线就变成两次下单;
- 不要靠重连来重试业务。 重连只负责恢复通道,业务重试走业务自己的重试机制,两者混在一起会出现诡异的重复。
移动端从后台切回来时连接往往已经断了,而这种情况不会触发任何网络事件:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return
// 回到前台时主动探一次:心跳可能还没到超时,但连接其实已经不可用
if (ws.readyState === WebSocket.OPEN) ping()
else reconnectNow() // 不等退避计时器
})
五、让用户知道发生了什么
这一节最容易被省掉,也最容易被用户投诉。
连接状态要对用户可见。 断开时至少要有明确标识(灰点、提示条),而不是让界面看起来一切正常。
重连期间要禁用提交类操作。 否则用户点了发送,以为成功了,实际上消息进了缓冲区或者直接丢了。可以做本地排队,恢复后自动发出,但必须给用户看到「待发送」状态。
{status === 'offline' && <Banner>连接已断开,正在重连…</Banner>}
<button disabled={status !== 'online'}>发送</button>
还有一个细节:移动端从后台切回前台时,连接往往已经断了。监听页面可见性变化(visibilitychange)主动检查一次连接状态,比等心跳超时更快恢复。
六、半开连接与静默超时
收到 close 事件才算断开是个危险的假设——存在静默断开,必须靠心跳与超时判定。
重连之后也不是一切照旧:断开期间的消息可能已经丢失,需要按序号增量补拉,业务动作还要做幂等。
重连期间更不能让用户继续照常操作,提交类操作应禁用或排队,并给出明确状态,否则用户以为成功了其实没发出去。
指数退避还不够,必须加随机抖动,否则所有客户端会在同一时刻同步重连,形成尖峰。
重连机制里最容易被省掉、也最容易出事的一环,是让用户知道「现在连不上」。
关闭码与握手失败的处理定义在 RFC 6455,重连策略要区分哪些码值得重试。