☰
WebSocket断线重连实战:从心跳机制到指数退避的完整方案
2026/10/20 11:11:50 网站建设 项目流程

websocket断线重连这个事儿,看着不起眼,真到了线上出问题的时候,能把人折腾到怀疑人生。我印象很深的一次是某个消息推送服务上线后,用户反馈“用着用着就收不到通知了,要刷新页面才好”,查了半天发现根本不是业务逻辑的锅,就是连接悄悄断了之后没有任何恢复机制。从那以后我就养成了一个习惯:但凡项目里要用websocket,第一件事不是写业务收发消息,而是先把断线重连这套骨架搭好。这篇东西就把我这些年踩过的坑、沉淀下来的方案和排查思路整理出来,希望对正在被“连接不稳定”困扰的朋友有点帮助。

要说清楚断线重连,先得搞清楚一个事实:websocket连接“断掉”这件事,远比你想的频繁,而且大部分时候并不是服务器主动关的,也不是代码写错了,而是网络环境本身在背后做了一个又一个“隐形操作”。理解不了这一点,后面写重连逻辑就会处处被动。

1. 先搞清楚连接是怎么断的

1.1 正常关闭与会话终结

正常情况下,一条websocket连接结束走的是协议规定的关闭握手:一端发Close帧,另一端回Close帧,然后TCP连接才真正拆除。这种关闭是“礼貌”的,双方都知道连接没了,资源可以立刻释放。

但在真实场景里,大量断开根本走不到这一步。最常见的是客户端直接断网,比如手机从WiFi切到移动网络、进电梯、过隧道,TCP连接在底层已经死了,可是应用层一无所知——因为TCP是个“假装可靠”的协议,它只有在发送数据收不到ACK时才会意识到链路出问题,如果两端一直不发数据,它会一直傻等下去。这就是很多长连接“看起来活着,实际上已经死了”的根源。

另一个常见情况是服务端主动断开但不发Close帧,比如进程崩溃、被kill -9、机房网络抖动导致负载均衡器把连接重置。客户端这边通常只会收到一个TCP RST,表现就是onerror之后紧跟onclose,而且close事件的code往往是1006(Abnormal Closure)。这个1006非常关键,它意味着连接是非正常关闭的,基本可以断定底层链路出了问题,是触发重连的首要信号。

1.2 隐形杀手:NAT超时与空闲回收

如果说上面那些还算是“看得见”的断开,那NAT超时就是典型的“看不见的杀手”。

绝大多数移动端和家庭宽带用户上网都要经过NAT网关,设备出去的公网IP和端口是网关临时映射的。NAT网关会维护一张映射表,但它不会傻傻地一直记住你,每条映射都有一个超时时间。TCP映射的超时通常2到5分钟,UDP更短,有的只有30秒。一旦在超时时间内这条连接上没有任何数据包经过,网关就会默默把映射删掉。对于位于NAT后面的客户端来说,它对这件事完全无感,它还觉得连接好端端的,但外部主动推消息进来时已经找不到路,直接被网关丢弃。

这就解释了一个非常典型的线上故障:用户挂着页面不操作,过了一段时间,服务端发消息用户收不到,但页面不刷新的话,页面里的WebSocket状态依然是“open”,一点异常都看不出来。直到用户手动点击某个按钮触发了websocket.send,客户端才在超时后收到错误,这时候onclose才姗姗来迟地触发。

明白这个原理,就能理解为什么“心跳机制”不是可选项而是必选项——它不是用来打扰服务器,而是用来告诉链路中的所有设备“这条连接还活着,别删我的映射”。

1.3 服务端异常与网络瞬断的判定难点

还有一种情况很磨人:网络只是瞬断了几百毫秒,TCP连接实际上没有完全断开,但某些中间设备(比如不规范的代理、负载均衡器)已经把这个连接标记为失效。等网络恢复,客户端如果继续往这条旧连接上发数据,轻则消息丢失,重则直接触发RST断开。

这就带来一个判定难点:客户端怎么知道连接“需要重连”还是“可以继续用”?很多人只看onclose事件,只要没触发就认为连接健康。这是不对的,因为TCP的死亡通知延迟非常高,甚至可能永远不通知。真正可靠的办法是结合心跳超时来做“应用层探活”,服务端超过N秒没回心跳响应,就主动判定连接失效,走重连流程。

我个人的经验是:把“连接状态”和“可用状态”区分开。连接对象存在不意味着可用,只有“握手成功且最近一次心跳响应在超时窗口内”才算真正可用。这个思路能让很多边界情况变得异常清晰。

2. 重连方案的整体设计:先画边界再写代码

2.1 状态机是重连逻辑的地基

不少同学写重连,就是onclose里面setTimeout再new WebSocket,看起来能跑,但真到复杂场景就乱了:重连正在进行时用户主动关闭页面怎么办?服务端在重连期间返回了500怎么办?多个重连定时器叠加怎么办?

所以我强烈建议,动手前先把连接状态机画出来。状态至少应该包括:

  • DISCONNECTED:初始状态,连接从未建立或已彻底放弃
  • CONNECTING:正在建立连接
  • CONNECTED:已连接且心跳正常
  • RECONNECTING:正在等待退避计时器触发下一次重连
  • CLOSED_BY_USER:用户主动关闭,任何情况下都不得自动重连

所有状态迁移必须是单向且明确的。尤其要警惕两个坑:一是RECONNECTING状态下计时器还没到,结果页面又触发了新的重连调用,产生两个并发连接;二是用户主动关闭的意图被淹没在异常关闭里,导致关了页面还在后台无限重连。

解决并发重连的办法很笨但很有效:在状态机里加一个原子判断,只有当前状态是DISCONNECTED或RECONNECTING时才允许发起新连接,否则直接return。另外用token校验法,每次打开新连接时生成一个自增id,连接回调触发时先校验id是不是当前最新的,不是就直接忽略,这个办法可以防住“旧连接的异步事件干扰新连接状态”的经典bug。

2.2 四项关键参数:间隔、倍率、抖动与上限

重连策略不是拍脑袋定个“3秒一次”,这四个参数是需要根据业务场景认真算的:

  • 基础间隔(baseDelay):第一次重连前的等待时间。实时性要求高的场景可以设500ms到1s,要求不高的设2s到5s。太短会放大抖动,太长会让用户明显感到“失联”。
  • 倍率(multiplier):每次重连失败后,间隔乘以多少。常见的是2倍,也就是1s、2s、4s、8s这样翻。翻得太快会让恢复窗口变长,翻得太慢又会在网络长时间不可用时产生大量无效请求。
  • 抖动(jitter):给间隔加一个随机扰动。网络重连最怕“共振”,比如服务端重启后,几万个客户端同时重连,如果间隔都一样,会在同一秒打出几万个连接请求。加抖动,就是在基础间隔上随机增减一部分,让请求分散开。
  • 最大间隔(maxDelay):间隔不能无限翻倍,超过比如30s或60s就封顶,保持恒定频率继续尝试。

这里给一个我常用的校准公式:

delay = min(maxDelay, baseDelay * Math.pow(multiplier, retryCount)) jitteredDelay = delay / 2 + Math.random() * delay / 2

用jitteredDelay作为实际等待时间。注意抖动范围是“一半到全部”,不是“0到全部”,这样既能分散请求,又不会出现某个重连间隔短到0导致瞬间重试。

还有一个容易忘的参数是最大重试次数的处理逻辑。断网时间长了,无限重连不仅耗电、耗流量,还会给服务端持续的连接压力。我的做法是:连续失败达到上限(比如10次)后,不再自动重连,而是把状态置为DISCONNECTED并通知业务层显示“连接已断开,点击重试”之类的交互。如果是移动端App,还可以利用系统网络状态监听(比如网络从无信号恢复时)触发一次即时重连,比傻等定时器体验好得多。

2.3 业务逻辑与连接逻辑必须解耦

这是我见过最多项目踩坑的地方:把业务消息发送直接写死在websocket的onopen回调里,或者把UI更新逻辑直接塞进onmessage里。结果是连接一断,业务全乱。

正确的做法是把连接层封装成一个独立的Transport模块,对外只暴露几个语义清晰的方法:connect()、disconnect()、send(msg)、onMessage(callback)、onStatusChange(callback)。内部怎么重连、怎么心跳、怎么退避,业务层完全不用关心。业务层只关心三件事:连接是否可用(状态回调),消息能不能发(send返回布尔值),来了消息怎么处理(onMessage)。

这个边界划清楚之后,很多问题会自然消解。比如页面切后台再切回来,业务层只需要调用transport.checkHealth(),由连接层决定是复用现有连接还是走重连,业务层不需要知道自己到底重连了几次。

3. 核心实现:心跳、退避与消息补偿

3.1 心跳机制怎么设计才算“有效”

心跳是断线重连里最核心的一环,但设计不好就是纯摆设。我见过不少人每15秒发一个ping,服务端根本不做处理,这种心跳只能算“安慰剂”。

有效的心跳要满足三个要求:

第一,能验证双向通路。客户端给服务端发ping,服务端必须原样回复pong,客户端收到pong才算一次成功。只发不收等于只验证了“客户端能发数据”,服务端到客户端的下行通路是不是通的依然不知道。

第二,超时判定要独立于网络请求。心跳超时计时器应该在发出ping的时候启动,收到pong时清除。如果用了浏览器的setInterval,要注意长时间运行后可能出现的timer drift,更稳妥的是每次发送后单独设置一次性的setTimeout,收到响应就清掉。

第三,心跳要能区分“真死”和“慢”。网络差的时候,心跳延迟可能超过正常阈值,但连接还没死。所以我一般不把心跳超时直接当成断线,而是采用“容忍N次超时”的策略。比如每15秒发一次ping,若连续2次没收到pong(也就是30秒没有任何下行数据),才判定连接死亡,触发重连。这样既不会在弱网下频繁误判,又能在真死的时候及时感知。

服务端的心跳处理也顺手提一句:收到ping就回pong,同时把该连接的最后活跃时间更新。服务端的清理线程定期扫表,把超过“1.5倍心跳间隔 + 容忍次数×心跳间隔”时间的连接直接关闭。比如客户端15秒心跳、容忍2次,那服务端清理阈值就是15 + 2×15 = 45秒,超过45秒没任何消息的连接直接掐掉。

3.2 指数退避重连完整实现

下面给一个完整的客户端重连实现,我注释写得比较详细,可以直接参考。语言用的是TypeScript风格的JavaScript,在浏览器或者Node环境都能跑:

class WebSocketTransport { constructor(url, options = {}) { this.url = url; this.baseDelay = options.baseDelay || 1000; this.multiplier = options.multiplier || 2; this.maxDelay = options.maxDelay || 30000; this.maxRetry = options.maxRetry || 10; this.heartbeatInterval = options.heartbeatInterval || 15000; this.heartbeatTimeout = options.heartbeatTimeout || 10000; this.missedHeartbeatLimit = options.missedHeartbeatLimit || 2; this.status = 'DISCONNECTED'; this.retryCount = 0; this.heartbeatTimer = null; this.heartbeatTimeoutTimer = null; this.reconnectTimer = null; this.missedHeartbeat = 0; this.ws = null; this.connToken = 0; this.messageHandlers = []; this.statusHandlers = []; } connect(manual = false) { if (manual) { this.retryCount = 0; } if (this.status === 'CONNECTING' || this.status === 'CONNECTED') { return; } this.setStatus('CONNECTING'); const token = ++this.connToken; this.ws = new WebSocket(this.url); this.ws.onopen = () => { if (token !== this.connToken) return; this.setStatus('CONNECTED'); this.retryCount = 0; this.missedHeartbeat = 0; this.startHeartbeat(); }; this.ws.onmessage = (evt) => { if (token !== this.connToken) return; this.missedHeartbeat = 0; // 如果收到的是pong,单独识别,其余交给业务handler if (evt.data === '--pong--') { this.clearHeartbeatTimeout(); return; } this.messageHandlers.forEach(handler => handler(evt.data)); }; this.ws.onclose = (evt) => { if (token !== this.connToken) return; this.cleanup(); const isUserClosed = this.closedByUser; if (isUserClosed) { this.setStatus('DISCONNECTED'); return; } // 1006是非正常关闭,1000是正常关闭 // 正常关闭且不是服务端要求的重连,就不自动重连 if (evt.code === 1000 && !this.serverRequireReconnect) { this.setStatus('DISCONNECTED'); return; } this.scheduleReconnect(); }; this.ws.onerror = () => { if (token !== this.connToken) return; // error之后浏览器通常会紧接着触发close // 这里不主动重连,等close统一处理 }; } scheduleReconnect() { if (this.retryCount >= this.maxRetry) { this.setStatus('DISCONNECTED'); return; } this.setStatus('RECONNECTING'); this.retryCount++; const delay = this.calcDelay(); this.reconnectTimer = setTimeout(() => { this.connect(); }, delay); } calcDelay() { const exp = Math.min(this.maxDelay, this.baseDelay * Math.pow(this.multiplier, this.retryCount - 1)); return exp / 2 + Math.random() * exp / 2; } startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.status !== 'CONNECTED') return; this.sendRaw('--ping--'); this.clearHeartbeatTimeout(); this.setHeartbeatTimeout(); }, this.heartbeatInterval); } setHeartbeatTimeout() { this.heartbeatTimeoutTimer = setTimeout(() => { this.missedHeartbeat++; if (this.missedHeartbeat >= this.missedHeartbeatLimit) { this.ws.close(); } else { // 再试一次心跳 this.sendRaw('--ping--'); this.setHeartbeatTimeout(); } }, this.heartbeatTimeout); } clearHeartbeatTimeout() { if (this.heartbeatTimeoutTimer) { clearTimeout(this.heartbeatTimeoutTimer); this.heartbeatTimeoutTimer = null; } } sendRaw(data) { if (this.ws && this.status === 'CONNECTED') { this.ws.send(data); } } send(data) { if (this.status !== 'CONNECTED' || !this.ws) { // 可以在断线时返回false,或者进入待发送队列 return false; } this.ws.send(data); return true; } cleanup() { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; this.clearHeartbeatTimeout(); if (this.ws) { // 防止重复清理触发onclose,先置空 const ws = this.ws; this.ws = null; ws.onclose = null; ws.onmessage = null; ws.onerror = null; ws.close(); } } setStatus(status) { this.status = status; this.statusHandlers.forEach(handler => handler(status)); } onMessage(handler) { this.messageHandlers.push(handler); } onStatusChange(handler) { this.statusHandlers.push(handler); } closeByUser() { this.closedByUser = true; clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.cleanup(); this.setStatus('DISCONNECTED'); } }

这段代码的几个设计点值得说清楚:

  • connToken的存在是为了防止“旧连接的close事件把新连接的状态给覆盖掉”。因为没有token校验的话,如果重连过程中第一个连接迟迟没触发close,第二个连接已经onopen了,这时候第一个连接的close事件到达,会把状态误改为RECONNECTING。
  • 心跳超时的处理是“先关连接再依赖close事件重连”。手动调ws.close()会触发onclose,但close事件的code会是1005或1000,如果是1000会被误判为正常关闭而不重连,所以这里有个小坑:如果心跳超时触发了主动关闭,要设置一个标志位强制走重连分支。上面代码简化了这个问题,实际项目里建议加一个reason字段区分关闭来源。
  • 服务端要求客户端重连的典型场景是“服务端准备发版”。服务端在关闭前给客户端发一条特殊消息告诉客户端“我要关了,你10秒后重连”,客户端收到后设置serverRequireReconnect = true,主动close并立即安排重连。这个场景不用协议标准里的1001(Going Away)去判断,因为有的浏览器对1001之后的自动重连表现不一致。

3.3 重连期间的消息缓冲与幂等处理

重连期间业务消息怎么办?这是另一个高频问题。如果只是简单地在send失败时返回false,让业务自己处理,那业务会很痛苦。我的经验是在Transport层增加一个可选的“待发送队列”,连接恢复后自动补发。

队列设计要注意三点:

一是队列大小必须限制。移动弱网环境下,断网半小时可能堆积上千条消息,全塞内存里不现实。我的做法是默认只保留最近50条,先进先出,超过就丢弃最旧的。

二是补发时机要在心跳验证通过之后。onopen只代表TCP握手完成,不代表业务可用,最好等到第一次pong收到后再补发,否则可能消息一发出就断,还得重排。

三是补发不等于业务成功。客户端补发了消息,但不代表服务端一定收到了,所以业务层需要配合“消息确认”机制。简单做法是每条消息带一个唯一id,服务端处理完回一个ack,客户端在超时未收到ack时重新补发。配合消息id,客户端在断线期间的补发才能做到幂等——服务端根据id去重,保证重复发送不产生重复业务操作。

这一段代码就不贴了,原理比代码重要:消息id + ack确认 + 去重表,三件套缺一不可。

4. 常见问题与排查技巧实录

4.1 服务端僵尸连接堆积怎么清

重连逻辑写得再好,服务端也得配合清理。我见过一个比较极端的现网事故:客户端开了重连,但服务端没有心跳清理逻辑,NAT超时把链路掐断后,TCP连接处于半开状态,服务端认为客户端还在,实际上数据已经发不到了。积攒的连接越来越多,最后服务端连接数打满,新用户连不上,老用户消息收不到,整个推送服务瘫痪。

服务端清理的标准姿势就是“读超时扫描”:维护每个连接的最后活跃时间,定时任务每分钟跑一次,把超过阈值的连接主动close。阈值要大于客户端心跳周期,一般取“客户端心跳间隔 × 2 + 30秒”比较稳。比如客户端15秒心跳,服务端阈值设60秒,既不会误杀正常连接,又能及时清掉僵尸。

另外服务端别只依赖TCP本身的keepalive,操作系统的TCP keepalive默认需要两个小时才探测一次,对websocket这种场景来说太慢了,必须在应用层做。

4.2 防止重连风暴:并发场景的保护

前面提到用jitter避免客户端同步重连,但还有一个更容易踩的坑:服务端重启后,如果客户端退避重连间隔已经翻到了很长(比如30秒),服务端恢复后客户端可能还在傻等下一次重连,导致恢复时间被拖长。

针对这种情况有两套优化方案。方案一是“服务端广播重连信号”:服务端启动完成后,通过另一个轻量通道(比如轮询接口或推送)向所有客户端广播“我活过来了,快来重连”,客户端收到信号立即重新连,不用等退避计时器。方案二是“客户端网络状态监听”:移动端检测到网络从断开变恢复时立即触发重连,Web端监听online事件,PC端监听网络状态变化事件。

这里特别提醒一下,绝对不要在网络恢复时用“所有客户端同一个固定时间点重连”,那等于自己制造一次重连风暴。如果要广播重连信号,可以让服务端在响应里带一个随机delay,或者客户端收到信号后自己再加一个随机化的短延迟。

4.3 浏览器端的兼容与页面生命周期

浏览器环境有几个特殊问题值得单独拎出来说。

一是WebSocket在页面关闭时并不会触发onclose,因为页面销毁了,代码都没机会执行。所以做埋点统计时要依托服务端主动断开来感知客户端离线,不能依赖客户端上报的close事件。

二是页面切后台时,浏览器为了省电会冻结定时器,尤其是移动端Safari和Chrome的后台冻结机制,setInterval在后台可能被大幅延迟甚至暂停。这会导致两个后果:心跳停发、重连计时器不准。后者更隐蔽——页面从后台切回来,之前设定好的重连timer可能已经被冻结了半天,但它一恢复就会立刻执行,在某些实现下会出现“页面一切回来就涌出好几个重连请求”的情况。

对策是监听visibilitychange事件,页面可见时主动检查连接健康状态,该重连就立即重连,不可见时暂停自动重连。这样既省电,也避免后台频繁重连的无效消耗。

4.4 一次“重连不上”的排查过程复盘

最后分享一个我实际排查过的案例,现象是“客户端在网络恢复后偶尔重连不上,要手动刷新页面才正常”,而且只在某个办公网络下必现。

排查过程大概是这样:

先看客户端日志,发现重连请求发出去后,onclose事件立刻触发,code是1006。这说明握手都没成功,TCP层就被断了。继续看服务端日志,发现服务端根本没收到这些连接请求(没有accept日志),说明请求在中间链路就被拦截了。

然后怀疑是某个安全设备做了“新连接速率限制”。因为办公网出口有防火墙,检测到同一客户端快速重连时,会认为这是异常扫描行为,在一定时间窗口内直接丢弃后续的TCP SYN包。而我当时的退避间隔最长只到3秒,连续重试没有封顶,短时间内发出了多个SYN包,触发了防火墙限流。

解决办法是把最大重试次数降下来(10次改为5次),同时把最大间隔提高到15秒以上,还加了随机抖动,让SYN包时间分布更“自然”。改完再测,问题消失。

这个案例给了一个通用启发:所有“重连不上”的问题,不要只盯应用层,要按“客户端代码 → 服务端日志 → 中间链路”的顺序逐层排查。尤其当客户端报1006但服务端无日志时,基本可以断定问题出在中间链路,这时候再怎么写重连代码都是白搭,得从连接频率、分发模式上找原因。

还有个小技巧,排查断线重连问题时,建议在客户端记录一个“连接时间线日志”,把onopen、onclose、心跳超时、重连调度全部打点,带时间戳。这样出问题后看日志,能很快还原出“到底在哪一步断了,重连卡在了哪里”,比瞎猜高效得多。

我自己现在写websocket重连,已经固定成一套模板了:先定义状态机,再写心跳和退避,然后封装Transport层,最后配合服务端做清理和幂等。这个套路在好几个项目里验证过,基本能覆盖掉95%以上的断线场景。剩下那5%,往往不是技术问题,而是业务上压根没想清楚“断线之后到底应该怎么办”——是先排队等恢复,还是直接提示用户手动重连?这个决策,只有业务方自己知道答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询