做个长连接项目断线重连这关早晚要过。我见过不少团队连接正常时觉得一切都很简单直到某天用户反馈“消息发不出去”“聊天记录中间断了一截”才意识到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高。处理完关闭帧和全局状态清理再放页面离开这样用户下次打开时服务端记录的连接状态才是干净的。做个长连接项目做到后来我的体会是断线重连根本不是某个函数、某个工具类的事而是从网络链路、心跳探测、重连策略、消息补偿到前后端协作的一整条链路。每层都做对稳定性才是真的稳任何一层偷懒线上迟早会以你意想不到的方式还回来。希望这篇踩坑总结能帮你在设计自己的重连方案时少走几步弯路。