做个长连接项目,断线重连这关早晚要过。我见过不少团队,连接正常时觉得一切都很简单,直到某天用户反馈“消息发不出去”“聊天记录中间断了一截”,才意识到WebSocket的稳定性问题才是整个项目的分水岭。这篇文章不打算从握手细节讲起,也不贴几十行复杂框架代码,就讲我自己在一个即时通讯类项目里,把“断线重连”从最初的无脑定时器重试,一步步改造成完整方案的经过:断开原因怎么排查、心跳怎么做、退避策略怎么定、消息缺口怎么补,以及最后收拾出来的一堆边界坑。适合正在做长连接、聊天、推送、实时协作类功能,又被“连接又断了”这件事反复折腾的读者。1. 别急着写重连代码,先搞清楚连接是怎么没的很多人一上来就写onclose回调,在里面setTimeout重连,然后跑个几分钟觉得“能重连了”。实际上,大部分断线根本轮不到onclose出场。先弄清楚连接是怎么没的,后面才不会写出南辕北辙的补救代码。1.1 断线背后,多数是网络链路和中间设备的“暗动作”先看最常见的场景。一个是网络切换:手机从Wi-Fi切到移动网络,或者从一个基站漫游到另一个,客户端的IP往往会变,原来那条TCP连接在IP地址变化的那一刻其实已经不存在了。一个是中间设备静默回收:家里路由器、办公楼网关、运营商基站,很多都会对长时间没有数据流量的TCP映射做老化清理。这个行为客户端无感,服务器也无感,只有等下一次真正传数据时才发现通道已经废了。还有一种是网关或负载均衡器的空闲超时,超过设定时间没有数据传输,连接就被回收;以及服务器发布、重启、限流导致的主动断开。移动端还要多算一个场景,App切后台之后,WebView或系统层可能直接回收网络资源,等到切回前台,连接其实已经凉了很久。对这些断开方式,单纯依赖onclose往往来不及,因为onclose只是“连接已经废了”的结果通知,而不是“连接即将失效”的预警。1.2 onclose事件只是宣告结果,不是实时告警WebSocket的关闭事件确实会触发,但它依赖底层能感知到TCP异常。比较理想的情况是:一方主动发了Close帧,或者TCP收到RST,浏览器很快触发close。但在弱网、断网、基站切换这些场景下,TCP层往往没有立刻收到任何异常报文,连接在操作系统看来依然“活着”。于是浏览器还在那挂着,直到TCP超时重传几次、最终判定超时,close事件才姗姗来迟。这个超时有多久?不同系统差异很大,短则几十秒,长则超过一分钟,有些情况下甚至要等应用层自己发现。也就是说,如果只依赖onclose,业务恢复的速度完全取决于网络协议栈的耐心,而不是你的重连策略。等它反应过来,用户早就把App关了。更高危的场景是进程被系统直接杀掉、WebView被回收,此时连close都不会触发。所以,专业的长连接方案一定会有一个比onclose更快、更可控的断线探测器,这就是后面要讲的心跳。1.3 一套实用的断线阶段模型把这些观察整理一下,我习惯把一次断线和恢复拆成四个阶段。首先是物理链路失效,这是最底层的原因,可能是网络切换、中间设备回收、服务器重启。其次是TCP层面感知异常,这里存在一个不可控的时间窗口,只能缩短、不能消灭。然后是应用层做出判定,这是心跳和超时发挥作用的地方,目标是把感知窗口压缩到几秒甚至更短。最后是重连与状态恢复,包括退避、同步、补偿,这是后面三章的内容。理清这个模型对团队协作尤其有用。以前我们出问题的时候容易互相甩锅,前端说“连接断了,后端没响应”,后端说“我没收到断开通知”。现在照着阶段模型排查,先定位是物理链路问题、感知延迟问题还是重连逻辑问题,效率高很多。你还可以给每个阶段打点记录日志,线上复现的时候一目了然。2. 判定断线:心跳、看门狗与连接状态机确认断线的手段,业界最通用的就是心跳探测。简单说,在正常数据传输的间隙,周期性发一个很小的探测包;如果对方没有回应,就认为链路有问题,主动断开并进入重连流程。2.1 为什么选择应用层心跳而不是协议层pingWebSocket协议本身提供了Ping/Pong帧,专门用来做连接健康检查。听起来很完美,但浏览器端的WebSocket API并没有把这两种帧暴露给JavaScript,也就是说,前端无法主动发送协议层Ping,也无法直接收到Pong事件。部分非浏览器环境确实可以做到,但一旦要对齐浏览器、桌面容器、移动WebView这些平台,协议层心跳的兼容性就很头疼。所以我的选择是:用业务层自定义的心跳消息来模拟,客户端定时发一条{"type":"ping"},服务端收到后回一条{"type":"pong"}。这样实现简单、跨平台一致,还能把心跳消息挂在现有消息处理器上,后面要加序列号、时间戳都很方便。代价只是每15秒多了一个几十字节的小包,对流量和带宽的影响可以忽略不计。还有一个容易踩的误区:别把心跳和业务消息混在一起。有些团队觉得“反正业务上经常有实时数据,不发心跳也没关系”,结果业务一旦进入低频期,连接就悄无声息地被中间设备回收。心跳的意义在于它是不依赖业务场景的固定探针,只要连接活着,它就一直存在。2.2 心跳发送与超时看门狗的完整实现心跳不能只是定时发ping,还要有一个“看门狗”逻辑:发出ping之后,如果在规定时间内没收到对应的pong,就判定当前连接已死,主动close,再交给重连模块处理。我贴一段核心代码,思路比代码本身更重要。注意我用seq来匹配每一次ping和pong,避免出现“上一次ping的pong晚到了,结果把新的ping当作正常响应”的误判。class Heartbeat { constructor(ws, options = {}) { this.ws = ws; this.intervalMs = options.intervalMs || 15000; this.timeoutMs = options.timeoutMs || 30000; this.pingSeq = 0; this.timer = null; this.watchdog = null; this.onDead = options.onDead || (() => {}); } start() { this.stop(); this.timer = setInterval(() => { if (this.ws.readyState !== WebSocket.OPEN) return; this.pingSeq += 1; this.ws.send(JSON.stringify({ type: 'ping', seq: this.pingSeq })); this.startWatchdog(); }, this.intervalMs); } handlePong(data) { if (data.seq === this.pingSeq) this.clearWatchdog(); } startWatchdog() { this.clearWatchdog(); this.watchdog = setTimeout(() => { this.ws.close(); this.onDead(); }, this.timeoutMs); } clearWatchdog() { if (this.watchdog) { clearTimeout(this.watchdog); this.watchdog = null; } } stop() { if (this.timer) clearInterval(this.timer); this.clearWatchdog(); } }中间还有两个细节值得展开。第一个,心跳间隔和超时时间不能太接近,至少要留出一倍的余量,否则一次网络抖动就会误杀正常连接。我们的经验是interval取15秒,timeout取30秒,弱网环境可以放宽到30秒/60秒,但整体感知窗口不要超过60秒太多。第二个,服务端也要设“多久没收到客户端任何合法消息就断开”的保护,等于给无人值守的僵尸连接兜底,这个超时要比客户端心跳间隔大一倍以上,避免正常客户端被误杀。2.3 用状态机约束“什么时候才能重连”有了心跳,能发现断线,但还有一个很常见的问题:onclose、网络错误、心跳超时,这几个入口都可能触发重连,如果每个入口都无脑发起连接,就会出现“同时重连了好几次”“重连还没完成又开始下一次重连”的乱象。我的做法是在连接管理器里维护一个状态字段,取值只有五种:空闲、连接中、已连接、等待重连、已关闭。任何重连请求进来,先检查当前状态,只有处于“已连接”或“等待重连”这两个状态时,才允许进入重连流程。连接中、已关闭的状态下会直接忽略新请求,避免重复。const ConnState = { IDLE: 'idle', CONNECTING: 'connecting', OPEN: 'open', RECONNECTING: 'reconnecting', CLOSED: 'closed' };在onclose回调里,我会先看一眼当前状态,只有OPEN状态才允许切到RECONNECTING,然后再去安排退避重连。如果本来就在CONNECTING或者RECONNECTING,说明这次关闭是主动断开或正在重试过程中产生的连带事件,直接忽略就好。这套状态机还有一个好处:UI层可以随时读取当前状态来展示“正在连接”“连接成功”“正在重连”,而不用自己猜。尤其是重连间隔很长的场景,用户看到“正在重试(第3次)”,比看到一片空白要好得多。3. 重连策略:指数退避、随机抖动与联网感知心跳确认断线之后,最考验工程能力的就是“怎么重连、什么时候重连”。很多项目在这里翻车,不是因为不会写几行重试代码,而是把简单重试当成了完整策略。3.1 定时器无脑重连,是把抖动变成事故的最快路径最朴素的做法是失败了就等1秒再连,再失败再等1秒。单看一台设备,这方案好像也能跑。但想象一个线上场景:某个区域的基站抖动,或者网关短暂异常,几千个客户端同时掉线,所有客户端都用同样的固定1秒去重连,服务器会在同一时刻收到几千个握手请求。这时候服务端本来就处于不稳定状态,结果直接被打到CPU飙升,问题从“网络抖动”变成“服务不可用”。这就是业内常说的重连风暴。固定间隔重试的另一个问题是不够快也不够稳:短暂抖动时,1秒间隔显得太慢,用户会感觉卡顿;持续性故障时,1秒间隔又太频繁,白白消耗流量和电量。一套成熟的策略应该做到:连接刚断的时候快速试探,连续失败后主动放慢,并且在合适的时机把间隔重置回初始值。3.2 指数退避的计算、上限与重置条件解决重连风暴的标准方案是指数退避加随机抖动。核心公式是:delay = min(最大间隔, 基础间隔 * 2^尝试次数) + 随机抖动基础间隔一般取1秒或2秒,最大间隔取30秒到120秒之间。随机抖动是为了让客户端之间的重连时刻错开,避免所有设备同时重试。比如基础间隔1秒、最大间隔30秒、抖动上限500毫秒,那么第1次失败后等约1.5秒,第2次约2.5秒,第3次约4.5秒,第4次约8.5秒,第5次约16.5秒,之后被30秒封顶,不再无限增长。function getBackoffDelay(attempt, baseMs = 1000, maxMs = 30000, jitterMs = 500) { const exponential = Math.min(maxMs, baseMs * Math.pow(2, attempt)); return Math.floor(exponential + Math.random() * jitterMs); }再补一个关键点:尝试次数什么时候重置。一条铁律:只有在连接真正建立成功之后才把计数清零;网络恢复事件也可以清零,但必须配合其他条件。千万不要在“发起重连”时清零,否则退避形同虚设。我自己见过一个bug,开发者在onclose里把attempt直接赋成0,结果每次断线都按最短间隔重试,退避完全没有生效,服务端该被冲垮还是被冲垮。场景基础间隔最大间隔心跳超时最大尝试次数浏览器聊天/通知1s30s30s无限次,但UI可停弱网工具类2s60s60s15次后台数据同步5s120s60s10次3.3 让网络事件和页面生命周期参与决策重连不是纯定时器的游戏,它应该是对周围环境的即时反应。浏览器给我们提供了两个重要信号,一个是在线状态事件,另一个是页面可见性变化。先说在线状态。navigator.onLine和online/offline事件能告诉我们系统层面的网络是否连通。虽然这个值在某些边界情况下不准,但它至少能让我们避免做完全无意义的尝试:网络都断了,定时器还在那傻等,纯属浪费。收到offline事件时,应该取消所有重连定时器,关掉心跳;收到online事件时,立刻重置退避计数,马上尝试重连一次。window.addEventListener('online', () => { attempts = 0; scheduleReconnect(0); }); window.addEventListener('offline', () => { clearTimeout(reconnectTimer); ws.close(); });再说页面可见性。页面切到后台之后,浏览器的定时器会被大幅节流,有的环境下心跳和重连定时器完全停摆。如果单靠定时器,页面切回前台时连接可能已经死了好久。所以我通常会加一个visibilitychange的监听,页面重新可见时,如果当前连接不是已连接状态,就立即重连,不要等下一个周期。document.addEventListener('visibilitychange', () => { if (!document.hidden && state !== ConnState.OPEN) { attempts = 0; scheduleReconnect(0); } });3.4 识别关闭码,决定“要不要重连”还有一个经常被忽略的分支:并不是所有断开都值得重连。WebSocket的CloseEvent里带有一个code字段,不同取值代表完全不同的含义。1000表示正常关闭,1001表示服务端正在重启或页面跳转,1006是异常关闭且没有close帧,1008表示违反了策略,1011表示服务端内部错误,应用层自定义的4000-4999则通常携带业务语义。正确的姿势是:网络类错误比如1006、1007、1011,直接按退避重连;服务端主动说“去重连别的时候来”这类业务码,根据语义决定;而1008这类代表客户端行为不被服务端认可的关闭码,重连多少次都没用,应该停下来给用户提示。把这张关闭码表整理进项目文档,排错效率会高很多。尤其是1008,有些团队没做判断,导致用户被反复踢下线、又反复重连提交错误请求,服务端日志里全是垃圾握手。4. 重连成功之后,先解决消息缺口与重复投递连接重新建立起来,很多人就觉得大功告成,其实真正的坑刚来。用户断线期间产生的消息、发送失败但用户以为成功的消息,都需要在这一刻对齐。4.1 用消息序号补齐断线期间的空窗最可靠的做法是消息序号。服务端给每条需要同步的消息分配一个单调递增的seq,客户端记录自己已经应用到的最新seq。重连成功后,客户端第一件事不是收消息,而是上报lastSeq,服务端把大于这个序号的消息一次性补回来。聊天场景经常这么用,实时协作、通知列表同理。拿聊天举个例子。用户本地最新消息seq是100,断线期间服务端产生了101到113共13条消息。重连之后,客户端上报{ "type": "sync", "lastSeq": 100 },服务端返回101到113这批消息,客户端逐条应用并更新本地seq到113。这样就算断线期间客户端完全离线,一条消息都没收到,只要服务端持久化了,客户端就能在重连那一刻拿到完整缺口。需要考虑海量缺口造成的瞬间流量,比如断线一小时补一万条,那一定不能直接砸给UI,要批量拉取并做游标分页。ws.onopen = () => { ws.send(JSON.stringify({ type: 'sync', lastSeq: appStore.lastMessageSeq })); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'sync_batch') { msg.items.forEach(item => { if (item.seq > appStore.lastMessageSeq) { appStore.applyMessage(item); appStore.lastMessageSeq = item.seq; } }); } };4.2 发送队列的outbox模式与服务端幂等去重除了缺消息,还有一类问题是“重复投递”。客户端点击发送之后网络恰好断了,消息其实已经送到服务器并成功入库,但客户端没有收到ack,于是重连后重发,这条消息就被复制了一份。标准解法是outbox模式加幂等键。客户端每条待发送消息先进入本地发送队列,并生成一个客户端消息ID,比如UUID;服务端在做消息持久化时,以这个ID为唯一键去重,重复收到的消息直接返回已存在。客户端只有在收到对应ack后才把这个消息从发送队列移除。这样网络断开、进程重启、重连重发,同一个ID最终只生效一次。const outbox = []; function sendWithAck(payload) { const msg = { clientMsgId: generateUUID(), ...payload }; outbox.push(msg); ws.send(JSON.stringify(msg)); } function handleAck(ack) { const idx = outbox.findIndex(m => m.clientMsgId === ack.clientMsgId); if (idx !== -1) outbox.splice(idx, 1); } function flushOutbox() { outbox.slice().forEach(msg => ws.send(JSON.stringify(msg))); }这里要特别提醒一下:flushOutbox要放在sync请求完成之后,或者至少保证消息不会乱序。如果订阅、同步、重发一起做,顺序不对会引出“补消息还没完成,重发消息先到了”的竞态问题。我的习惯是重连后的初始化动作排队执行:先鉴权、再同步seq、再恢复订阅,最后flushOutbox。4.3 订阅恢复、登录态刷新,以及服务端的配合消息补齐和发送补偿做完,还有一类容易被忽略的状态:订阅关系。比如你在实时行情页面订阅了几只股票的推送,连接断开期间服务端还在等你重新订阅,如果你不重发订阅消息,重连之后就再也收不到行情了。所以重连成功后的初始化动作里,一定要包含“重新订阅当前页面需要的频道”这一步。登录态也要一起检查。长连接往往带着鉴权信息,这个token可能在你断线的几分钟里就过期了。重连握手如果返回鉴权失败,正确的做法是走刷新token的流程,刷新成功后再建立连接,而不是拿着旧token不停死磕。服务端的配合也很重要:服务端要能识别同一个用户的旧连接和新连接,并在新连接建立后主动关闭旧连接,否则客户端网络切换时新旧两个连接同时存在,消息会重复推送,服务端资源也会被白白占用。5. 实战中容易翻车的几个细节前面几章把主流程讲完了,最后分享几个我在真实环境里踩过、或者帮别人排查过的细节。这些点不太容易从文档里学到,但线上出问题往往就出在这里。5.1 双连接:网络切换瞬间的幽灵连接前面提到过,网络切换时旧TCP连接不会立刻消失,新连接又可能被重连逻辑快速建立,这时候同一个客户端在服务端视角里有两个连接。一些服务端实现如果没判重,新消息会同时推两条,客户端就会收到重复消息,而且用户还会看到“已读状态反复横跳”这种诡异现象。我处理过的方案是给每次连接生成一个全局唯一的connectionId,新连接成立后,服务端查找同userId下的旧connectionId,主动给旧连接发关闭帧,并把它从推送集合里移除。客户端这边,重连前也先显式close掉旧实例,双保险。另外,客户端要尽快把onmessage里的旧连接处理器移除掉,不然旧连接即使没被服务端关掉,也会在本地继续回调,造成重复渲染。5.2 后台节流让定时器全部失灵浏览器对后台页面的定时器节流比想象中激进得多,有些环境甚至连setTimeout都会被拖到分钟级。如果你的心跳和重连都靠定时器驱动,用户把页面切到后台半小时再切回来,连接基本必死,而且死因很难查。我见过一个线上事故,页面切后台后心跳停摆,服务端那边还傻等客户端消息,连接其实早就被运营商回收了。用户切回来发现收不到消息,第一反应是缓存坏了。我的建议是两条腿走路:一条腿是原有的定时器逻辑,另一条腿是页面可见性事件,切回前台时主动发一次心跳探测,如果没回音,立即进入重连,不要傻等下一个心跳周期。而且要记得把心跳看门狗的超时时间适当放宽,因为后台长时间不活动后,TCP栈本身就处于半休眠状态,第一次探测可能只是把链路唤醒,不一定是真的断了。5.3 重连操作要有“全局意识”,防止多实例风暴如果你的产品同浏览器开着多个标签页,或者同一个App里多层级页面各自维护了一条长连接,断线时就会出现多实例同时重连的情况。单看每个实例似乎都正常,服务端却会收到成倍的重复握手。极端情况下,几十个标签页一起断线一起重连,服务端压力瞬间翻几倍。处理思路是让“是否正在重连”成为一个全局状态,而不是某个页面实例的状态。多标签页之间可以用同步机制选出唯一一个重连协调者,其他标签页只监听结果。业务上如果确实不需要多连接,也可以改成同一浏览器只维护一条连接,各页面共享消费,这样最省心。选择哪种方案要看业务复杂度,但如果你的产品支持多标签页,一定要把这个问题列入设计考虑,不要等线上爆了再补。5.4 关闭连接也要有“有尊严”的流程最后补一个容易被喷但确实影响体验的小事:页面跳转、刷新、关闭时,随手发一个正常关闭的Close帧,而不是让浏览器自己草率地断开。虽然不是所有环境都能保证异步发送完成,但至少onclose里不要再来一次同步重连。页面都要关了,重连毫无意义,这种“重连僵尸”会让新页面打开时误判状态。还有一个和关闭相关的细节:不要用beforeunload去发同步请求,浏览器对这类同步网络请求的限制越来越多。真要在页面关闭前通知服务端,用navigator.sendBeacon最合适,它是为这种场景设计的,异步、不阻塞页面卸载、可靠性也比普通fetch高。处理完关闭帧和全局状态清理,再放页面离开,这样用户下次打开时,服务端记录的连接状态才是干净的。做个长连接项目做到后来,我的体会是:断线重连根本不是某个函数、某个工具类的事,而是从网络链路、心跳探测、重连策略、消息补偿到前后端协作的一整条链路。每层都做对,稳定性才是真的稳;任何一层偷懒,线上迟早会以你意想不到的方式还回来。希望这篇踩坑总结能帮你在设计自己的重连方案时少走几步弯路。
做个长连接项目,断线重连这关早晚要过。我见过不少团队,连接正常时觉得一切都很简单,直到某天用户反馈“消息发不出去”“聊天记录中间断了一截”,才意识到WebSocket的稳定性问题才是整个项目的分水岭。这篇文章不打算从握手细节讲起,也不贴几十行复杂框架代码,就讲我自己在一个即时通讯类项目里,把“断线重连”从最初的无脑定时器重试,一步步改造成完整方案的经过:断开原因怎么排查、心跳怎么做、退避策略怎么定、消息缺口怎么补,以及最后收拾出来的一堆边界坑。适合正在做长连接、聊天、推送、实时协作类功能,又被“连接又断了”这件事反复折腾的读者。1. 别急着写重连代码,先搞清楚连接是怎么没的很多人一上来就写onclose回调,在里面setTimeout重连,然后跑个几分钟觉得“能重连了”。实际上,大部分断线根本轮不到onclose出场。先弄清楚连接是怎么没的,后面才不会写出南辕北辙的补救代码。1.1 断线背后,多数是网络链路和中间设备的“暗动作”先看最常见的场景。一个是网络切换:手机从Wi-Fi切到移动网络,或者从一个基站漫游到另一个,客户端的IP往往会变,原来那条TCP连接在IP地址变化的那一刻其实已经不存在了。一个是中间设备静默回收:家里路由器、办公楼网关、运营商基站,很多都会对长时间没有数据流量的TCP映射做老化清理。这个行为客户端无感,服务器也无感,只有等下一次真正传数据时才发现通道已经废了。还有一种是网关或负载均衡器的空闲超时,超过设定时间没有数据传输,连接就被回收;以及服务器发布、重启、限流导致的主动断开。移动端还要多算一个场景,App切后台之后,WebView或系统层可能直接回收网络资源,等到切回前台,连接其实已经凉了很久。对这些断开方式,单纯依赖onclose往往来不及,因为onclose只是“连接已经废了”的结果通知,而不是“连接即将失效”的预警。1.2 onclose事件只是宣告结果,不是实时告警WebSocket的关闭事件确实会触发,但它依赖底层能感知到TCP异常。比较理想的情况是:一方主动发了Close帧,或者TCP收到RST,浏览器很快触发close。但在弱网、断网、基站切换这些场景下,TCP层往往没有立刻收到任何异常报文,连接在操作系统看来依然“活着”。于是浏览器还在那挂着,直到TCP超时重传几次、最终判定超时,close事件才姗姗来迟。这个超时有多久?不同系统差异很大,短则几十秒,长则超过一分钟,有些情况下甚至要等应用层自己发现。也就是说,如果只依赖onclose,业务恢复的速度完全取决于网络协议栈的耐心,而不是你的重连策略。等它反应过来,用户早就把App关了。更高危的场景是进程被系统直接杀掉、WebView被回收,此时连close都不会触发。所以,专业的长连接方案一定会有一个比onclose更快、更可控的断线探测器,这就是后面要讲的心跳。1.3 一套实用的断线阶段模型把这些观察整理一下,我习惯把一次断线和恢复拆成四个阶段。首先是物理链路失效,这是最底层的原因,可能是网络切换、中间设备回收、服务器重启。其次是TCP层面感知异常,这里存在一个不可控的时间窗口,只能缩短、不能消灭。然后是应用层做出判定,这是心跳和超时发挥作用的地方,目标是把感知窗口压缩到几秒甚至更短。最后是重连与状态恢复,包括退避、同步、补偿,这是后面三章的内容。理清这个模型对团队协作尤其有用。以前我们出问题的时候容易互相甩锅,前端说“连接断了,后端没响应”,后端说“我没收到断开通知”。现在照着阶段模型排查,先定位是物理链路问题、感知延迟问题还是重连逻辑问题,效率高很多。你还可以给每个阶段打点记录日志,线上复现的时候一目了然。2. 判定断线:心跳、看门狗与连接状态机确认断线的手段,业界最通用的就是心跳探测。简单说,在正常数据传输的间隙,周期性发一个很小的探测包;如果对方没有回应,就认为链路有问题,主动断开并进入重连流程。2.1 为什么选择应用层心跳而不是协议层pingWebSocket协议本身提供了Ping/Pong帧,专门用来做连接健康检查。听起来很完美,但浏览器端的WebSocket API并没有把这两种帧暴露给JavaScript,也就是说,前端无法主动发送协议层Ping,也无法直接收到Pong事件。部分非浏览器环境确实可以做到,但一旦要对齐浏览器、桌面容器、移动WebView这些平台,协议层心跳的兼容性就很头疼。所以我的选择是:用业务层自定义的心跳消息来模拟,客户端定时发一条{"type":"ping"},服务端收到后回一条{"type":"pong"}。这样实现简单、跨平台一致,还能把心跳消息挂在现有消息处理器上,后面要加序列号、时间戳都很方便。代价只是每15秒多了一个几十字节的小包,对流量和带宽的影响可以忽略不计。还有一个容易踩的误区:别把心跳和业务消息混在一起。有些团队觉得“反正业务上经常有实时数据,不发心跳也没关系”,结果业务一旦进入低频期,连接就悄无声息地被中间设备回收。心跳的意义在于它是不依赖业务场景的固定探针,只要连接活着,它就一直存在。2.2 心跳发送与超时看门狗的完整实现心跳不能只是定时发ping,还要有一个“看门狗”逻辑:发出ping之后,如果在规定时间内没收到对应的pong,就判定当前连接已死,主动close,再交给重连模块处理。我贴一段核心代码,思路比代码本身更重要。注意我用seq来匹配每一次ping和pong,避免出现“上一次ping的pong晚到了,结果把新的ping当作正常响应”的误判。class Heartbeat { constructor(ws, options = {}) { this.ws = ws; this.intervalMs = options.intervalMs || 15000; this.timeoutMs = options.timeoutMs || 30000; this.pingSeq = 0; this.timer = null; this.watchdog = null; this.onDead = options.onDead || (() => {}); } start() { this.stop(); this.timer = setInterval(() => { if (this.ws.readyState !== WebSocket.OPEN) return; this.pingSeq += 1; this.ws.send(JSON.stringify({ type: 'ping', seq: this.pingSeq })); this.startWatchdog(); }, this.intervalMs); } handlePong(data) { if (data.seq === this.pingSeq) this.clearWatchdog(); } startWatchdog() { this.clearWatchdog(); this.watchdog = setTimeout(() => { this.ws.close(); this.onDead(); }, this.timeoutMs); } clearWatchdog() { if (this.watchdog) { clearTimeout(this.watchdog); this.watchdog = null; } } stop() { if (this.timer) clearInterval(this.timer); this.clearWatchdog(); } }中间还有两个细节值得展开。第一个,心跳间隔和超时时间不能太接近,至少要留出一倍的余量,否则一次网络抖动就会误杀正常连接。我们的经验是interval取15秒,timeout取30秒,弱网环境可以放宽到30秒/60秒,但整体感知窗口不要超过60秒太多。第二个,服务端也要设“多久没收到客户端任何合法消息就断开”的保护,等于给无人值守的僵尸连接兜底,这个超时要比客户端心跳间隔大一倍以上,避免正常客户端被误杀。2.3 用状态机约束“什么时候才能重连”有了心跳,能发现断线,但还有一个很常见的问题:onclose、网络错误、心跳超时,这几个入口都可能触发重连,如果每个入口都无脑发起连接,就会出现“同时重连了好几次”“重连还没完成又开始下一次重连”的乱象。我的做法是在连接管理器里维护一个状态字段,取值只有五种:空闲、连接中、已连接、等待重连、已关闭。任何重连请求进来,先检查当前状态,只有处于“已连接”或“等待重连”这两个状态时,才允许进入重连流程。连接中、已关闭的状态下会直接忽略新请求,避免重复。const ConnState = { IDLE: 'idle', CONNECTING: 'connecting', OPEN: 'open', RECONNECTING: 'reconnecting', CLOSED: 'closed' };在onclose回调里,我会先看一眼当前状态,只有OPEN状态才允许切到RECONNECTING,然后再去安排退避重连。如果本来就在CONNECTING或者RECONNECTING,说明这次关闭是主动断开或正在重试过程中产生的连带事件,直接忽略就好。这套状态机还有一个好处:UI层可以随时读取当前状态来展示“正在连接”“连接成功”“正在重连”,而不用自己猜。尤其是重连间隔很长的场景,用户看到“正在重试(第3次)”,比看到一片空白要好得多。3. 重连策略:指数退避、随机抖动与联网感知心跳确认断线之后,最考验工程能力的就是“怎么重连、什么时候重连”。很多项目在这里翻车,不是因为不会写几行重试代码,而是把简单重试当成了完整策略。3.1 定时器无脑重连,是把抖动变成事故的最快路径最朴素的做法是失败了就等1秒再连,再失败再等1秒。单看一台设备,这方案好像也能跑。但想象一个线上场景:某个区域的基站抖动,或者网关短暂异常,几千个客户端同时掉线,所有客户端都用同样的固定1秒去重连,服务器会在同一时刻收到几千个握手请求。这时候服务端本来就处于不稳定状态,结果直接被打到CPU飙升,问题从“网络抖动”变成“服务不可用”。这就是业内常说的重连风暴。固定间隔重试的另一个问题是不够快也不够稳:短暂抖动时,1秒间隔显得太慢,用户会感觉卡顿;持续性故障时,1秒间隔又太频繁,白白消耗流量和电量。一套成熟的策略应该做到:连接刚断的时候快速试探,连续失败后主动放慢,并且在合适的时机把间隔重置回初始值。3.2 指数退避的计算、上限与重置条件解决重连风暴的标准方案是指数退避加随机抖动。核心公式是:delay = min(最大间隔, 基础间隔 * 2^尝试次数) + 随机抖动基础间隔一般取1秒或2秒,最大间隔取30秒到120秒之间。随机抖动是为了让客户端之间的重连时刻错开,避免所有设备同时重试。比如基础间隔1秒、最大间隔30秒、抖动上限500毫秒,那么第1次失败后等约1.5秒,第2次约2.5秒,第3次约4.5秒,第4次约8.5秒,第5次约16.5秒,之后被30秒封顶,不再无限增长。function getBackoffDelay(attempt, baseMs = 1000, maxMs = 30000, jitterMs = 500) { const exponential = Math.min(maxMs, baseMs * Math.pow(2, attempt)); return Math.floor(exponential + Math.random() * jitterMs); }再补一个关键点:尝试次数什么时候重置。一条铁律:只有在连接真正建立成功之后才把计数清零;网络恢复事件也可以清零,但必须配合其他条件。千万不要在“发起重连”时清零,否则退避形同虚设。我自己见过一个bug,开发者在onclose里把attempt直接赋成0,结果每次断线都按最短间隔重试,退避完全没有生效,服务端该被冲垮还是被冲垮。场景基础间隔最大间隔心跳超时最大尝试次数浏览器聊天/通知1s30s30s无限次,但UI可停弱网工具类2s60s60s15次后台数据同步5s120s60s10次3.3 让网络事件和页面生命周期参与决策重连不是纯定时器的游戏,它应该是对周围环境的即时反应。浏览器给我们提供了两个重要信号,一个是在线状态事件,另一个是页面可见性变化。先说在线状态。navigator.onLine和online/offline事件能告诉我们系统层面的网络是否连通。虽然这个值在某些边界情况下不准,但它至少能让我们避免做完全无意义的尝试:网络都断了,定时器还在那傻等,纯属浪费。收到offline事件时,应该取消所有重连定时器,关掉心跳;收到online事件时,立刻重置退避计数,马上尝试重连一次。window.addEventListener('online', () => { attempts = 0; scheduleReconnect(0); }); window.addEventListener('offline', () => { clearTimeout(reconnectTimer); ws.close(); });再说页面可见性。页面切到后台之后,浏览器的定时器会被大幅节流,有的环境下心跳和重连定时器完全停摆。如果单靠定时器,页面切回前台时连接可能已经死了好久。所以我通常会加一个visibilitychange的监听,页面重新可见时,如果当前连接不是已连接状态,就立即重连,不要等下一个周期。document.addEventListener('visibilitychange', () => { if (!document.hidden && state !== ConnState.OPEN) { attempts = 0; scheduleReconnect(0); } });3.4 识别关闭码,决定“要不要重连”还有一个经常被忽略的分支:并不是所有断开都值得重连。WebSocket的CloseEvent里带有一个code字段,不同取值代表完全不同的含义。1000表示正常关闭,1001表示服务端正在重启或页面跳转,1006是异常关闭且没有close帧,1008表示违反了策略,1011表示服务端内部错误,应用层自定义的4000-4999则通常携带业务语义。正确的姿势是:网络类错误比如1006、1007、1011,直接按退避重连;服务端主动说“去重连别的时候来”这类业务码,根据语义决定;而1008这类代表客户端行为不被服务端认可的关闭码,重连多少次都没用,应该停下来给用户提示。把这张关闭码表整理进项目文档,排错效率会高很多。尤其是1008,有些团队没做判断,导致用户被反复踢下线、又反复重连提交错误请求,服务端日志里全是垃圾握手。4. 重连成功之后,先解决消息缺口与重复投递连接重新建立起来,很多人就觉得大功告成,其实真正的坑刚来。用户断线期间产生的消息、发送失败但用户以为成功的消息,都需要在这一刻对齐。4.1 用消息序号补齐断线期间的空窗最可靠的做法是消息序号。服务端给每条需要同步的消息分配一个单调递增的seq,客户端记录自己已经应用到的最新seq。重连成功后,客户端第一件事不是收消息,而是上报lastSeq,服务端把大于这个序号的消息一次性补回来。聊天场景经常这么用,实时协作、通知列表同理。拿聊天举个例子。用户本地最新消息seq是100,断线期间服务端产生了101到113共13条消息。重连之后,客户端上报{ "type": "sync", "lastSeq": 100 },服务端返回101到113这批消息,客户端逐条应用并更新本地seq到113。这样就算断线期间客户端完全离线,一条消息都没收到,只要服务端持久化了,客户端就能在重连那一刻拿到完整缺口。需要考虑海量缺口造成的瞬间流量,比如断线一小时补一万条,那一定不能直接砸给UI,要批量拉取并做游标分页。ws.onopen = () => { ws.send(JSON.stringify({ type: 'sync', lastSeq: appStore.lastMessageSeq })); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'sync_batch') { msg.items.forEach(item => { if (item.seq > appStore.lastMessageSeq) { appStore.applyMessage(item); appStore.lastMessageSeq = item.seq; } }); } };4.2 发送队列的outbox模式与服务端幂等去重除了缺消息,还有一类问题是“重复投递”。客户端点击发送之后网络恰好断了,消息其实已经送到服务器并成功入库,但客户端没有收到ack,于是重连后重发,这条消息就被复制了一份。标准解法是outbox模式加幂等键。客户端每条待发送消息先进入本地发送队列,并生成一个客户端消息ID,比如UUID;服务端在做消息持久化时,以这个ID为唯一键去重,重复收到的消息直接返回已存在。客户端只有在收到对应ack后才把这个消息从发送队列移除。这样网络断开、进程重启、重连重发,同一个ID最终只生效一次。const outbox = []; function sendWithAck(payload) { const msg = { clientMsgId: generateUUID(), ...payload }; outbox.push(msg); ws.send(JSON.stringify(msg)); } function handleAck(ack) { const idx = outbox.findIndex(m => m.clientMsgId === ack.clientMsgId); if (idx !== -1) outbox.splice(idx, 1); } function flushOutbox() { outbox.slice().forEach(msg => ws.send(JSON.stringify(msg))); }这里要特别提醒一下:flushOutbox要放在sync请求完成之后,或者至少保证消息不会乱序。如果订阅、同步、重发一起做,顺序不对会引出“补消息还没完成,重发消息先到了”的竞态问题。我的习惯是重连后的初始化动作排队执行:先鉴权、再同步seq、再恢复订阅,最后flushOutbox。4.3 订阅恢复、登录态刷新,以及服务端的配合消息补齐和发送补偿做完,还有一类容易被忽略的状态:订阅关系。比如你在实时行情页面订阅了几只股票的推送,连接断开期间服务端还在等你重新订阅,如果你不重发订阅消息,重连之后就再也收不到行情了。所以重连成功后的初始化动作里,一定要包含“重新订阅当前页面需要的频道”这一步。登录态也要一起检查。长连接往往带着鉴权信息,这个token可能在你断线的几分钟里就过期了。重连握手如果返回鉴权失败,正确的做法是走刷新token的流程,刷新成功后再建立连接,而不是拿着旧token不停死磕。服务端的配合也很重要:服务端要能识别同一个用户的旧连接和新连接,并在新连接建立后主动关闭旧连接,否则客户端网络切换时新旧两个连接同时存在,消息会重复推送,服务端资源也会被白白占用。5. 实战中容易翻车的几个细节前面几章把主流程讲完了,最后分享几个我在真实环境里踩过、或者帮别人排查过的细节。这些点不太容易从文档里学到,但线上出问题往往就出在这里。5.1 双连接:网络切换瞬间的幽灵连接前面提到过,网络切换时旧TCP连接不会立刻消失,新连接又可能被重连逻辑快速建立,这时候同一个客户端在服务端视角里有两个连接。一些服务端实现如果没判重,新消息会同时推两条,客户端就会收到重复消息,而且用户还会看到“已读状态反复横跳”这种诡异现象。我处理过的方案是给每次连接生成一个全局唯一的connectionId,新连接成立后,服务端查找同userId下的旧connectionId,主动给旧连接发关闭帧,并把它从推送集合里移除。客户端这边,重连前也先显式close掉旧实例,双保险。另外,客户端要尽快把onmessage里的旧连接处理器移除掉,不然旧连接即使没被服务端关掉,也会在本地继续回调,造成重复渲染。5.2 后台节流让定时器全部失灵浏览器对后台页面的定时器节流比想象中激进得多,有些环境甚至连setTimeout都会被拖到分钟级。如果你的心跳和重连都靠定时器驱动,用户把页面切到后台半小时再切回来,连接基本必死,而且死因很难查。我见过一个线上事故,页面切后台后心跳停摆,服务端那边还傻等客户端消息,连接其实早就被运营商回收了。用户切回来发现收不到消息,第一反应是缓存坏了。我的建议是两条腿走路:一条腿是原有的定时器逻辑,另一条腿是页面可见性事件,切回前台时主动发一次心跳探测,如果没回音,立即进入重连,不要傻等下一个心跳周期。而且要记得把心跳看门狗的超时时间适当放宽,因为后台长时间不活动后,TCP栈本身就处于半休眠状态,第一次探测可能只是把链路唤醒,不一定是真的断了。5.3 重连操作要有“全局意识”,防止多实例风暴如果你的产品同浏览器开着多个标签页,或者同一个App里多层级页面各自维护了一条长连接,断线时就会出现多实例同时重连的情况。单看每个实例似乎都正常,服务端却会收到成倍的重复握手。极端情况下,几十个标签页一起断线一起重连,服务端压力瞬间翻几倍。处理思路是让“是否正在重连”成为一个全局状态,而不是某个页面实例的状态。多标签页之间可以用同步机制选出唯一一个重连协调者,其他标签页只监听结果。业务上如果确实不需要多连接,也可以改成同一浏览器只维护一条连接,各页面共享消费,这样最省心。选择哪种方案要看业务复杂度,但如果你的产品支持多标签页,一定要把这个问题列入设计考虑,不要等线上爆了再补。5.4 关闭连接也要有“有尊严”的流程最后补一个容易被喷但确实影响体验的小事:页面跳转、刷新、关闭时,随手发一个正常关闭的Close帧,而不是让浏览器自己草率地断开。虽然不是所有环境都能保证异步发送完成,但至少onclose里不要再来一次同步重连。页面都要关了,重连毫无意义,这种“重连僵尸”会让新页面打开时误判状态。还有一个和关闭相关的细节:不要用beforeunload去发同步请求,浏览器对这类同步网络请求的限制越来越多。真要在页面关闭前通知服务端,用navigator.sendBeacon最合适,它是为这种场景设计的,异步、不阻塞页面卸载、可靠性也比普通fetch高。处理完关闭帧和全局状态清理,再放页面离开,这样用户下次打开时,服务端记录的连接状态才是干净的。做个长连接项目做到后来,我的体会是:断线重连根本不是某个函数、某个工具类的事,而是从网络链路、心跳探测、重连策略、消息补偿到前后端协作的一整条链路。每层都做对,稳定性才是真的稳;任何一层偷懒,线上迟早会以你意想不到的方式还回来。希望这篇踩坑总结能帮你在设计自己的重连方案时少走几步弯路。