1. 先搞清楚:"短线重连"到底在解决哪种断线
1.1 短线断连的典型场景
做过网络编程的同学,大概率都遇到过这么个场景:客户端连着服务器,一切正常。结果某天网络只是抖动了三五秒——比如手机从 Wi-Fi 切到 4G、家里路由器临时重启、办公网闸道更新策略、云主机做无损升级导致连接被负载均衡回收——然后客户端和服务端之间的连接就断了。
这种断连有一个共同特征:断线时间极短,通常不超过几十秒,网络本身是健康的,只是连接通道被中间环节打断。它不是服务器宕机,也不是客户端长时间离线,更不是账号被踢。如果我们兴师动众地走一遍"清空状态、重新登录、重新拉全量数据"的流程,用户会明显感到卡顿,部分实时业务(比如 IM、行情推送、设备指令下发)还会因为恢复太慢导致一连串的连锁问题。
短线重连要解决的,就是这样一个被很多人当作"小题大做"、实际却极其影响体验的问题:当一次短促的、非致命的连接中断发生时,客户端能够在用户几乎无感知的情况下,自动把连接恢复到可用状态。
1.2 短线重连与"全量重建会话"的本质区别
很多早期客户端的写法是:
- 检测到断线 -> 清空内存里的连接状态
- 跳回登录页,或者调用登录接口重新拿 token
- 重新订阅所有业务主题
- 把服务端全量数据重新拉一遍
这个流程如果用两个字概括,就是"重建"。它的问题是:把一次网络抖动放大成了一整场事故。短线重连要做的恰恰相反,它追求的是"复用与补偿":复用原有的会话凭据、保留已确认的消息游标、只补拉断线期间漏掉的数据,而不是把所有东西推倒重来。
这背后的代价权衡非常关键。一次短线重连如果设计得好,客户端代码里甚至不需要有一个明显的"loading"状态;但如果设计成全部重建,你至少要多承担三部分开销:
- 重新鉴权的时间
- 重新建立业务上下文的 CPU/网络开销
- 全量数据拉取带来的带宽和流量费用
所以,判断一个重连逻辑是不是"短线重连"其实有个很朴素的标准:**恢复连接的成本,是否和断线时长成正比。**如果断线 3 秒,恢复动作只需要 1 次 TCP 握手 + 1 次增量同步,那这是短线重连;如果断线 3 秒,恢复动作需要重新登录 + 全量拉取,那这只能算"重连外壳,重建内核"。
1.3 一个简单的时序模型
我自己在实现短线重连时,脑子里会先画一条时间线:
t=0 网络抖动发生,连接断开,底层 socket 产生 close 事件 t=0.2 客户端捕获到断线,进入退避等待阶段(不立即重连) t=1.2 退避结束,发起新的 TCP/WebSocket 连接 t=1.4 连接建立成功,服务端下发断线期间的增量消息 t=1.5 客户端补拉完毕,恢复实时订阅这条时间线里,从 close 到恢复的时间段,就是短线重连的核心性能指标。如果这个时间能控制在 3 秒以内,绝大多数用户根本感知不到发生过断线;如果超过 10 秒,用户就会开始抱怨"消息收不到""页面卡死"。
后面所有章节讲的退避策略、状态机、心跳配合、消息补偿,本质上都是在优化这条时间线里的各个节点。
2. 重连策略设计:指数退避、抖动和上限
2.1 固定间隔为什么不行
刚入行的时候,很多人写重连就是:
function reconnect() { setTimeout(connect, 3000); }固定 3 秒重试一次。这个写法在 demo 里没问题,但放到真实环境里会有两个非常明显的问题。
第一个问题是网络抖动场景下的无效重试。假设断线的根因是路由器切换,需要 5 秒才能恢复。固定 3 秒重试意味着第 1 次重连必然失败,紧接着第 2 次重试又是在网络还差着 1 秒的情况下发起。这种情况下,客户端在恢复之前反复做无用功,每次失败还会带来额外的 DNS 查询、TCP 握手、日志上报,等于把一次抖动放大了好几倍。
第二个问题是恢复后反应太慢。如果断线发生的根因只持续了 1 秒,固定 3 秒的等待其实还算合理;但如果你的业务要求高实时性(比如交易行情、协同编辑),哪怕只多了 2 秒,用户都能感觉到"卡了一下"。
固定间隔最大的问题在于它完全不区分故障的持续时间。而现实中的网络故障有一个经验规律:**大多数断线是短时的,越重试越接近恢复;少部分断线是长期的,越重试越需要克制。**因此重试间隔应该随着失败次数增加而增长,这就是指数退避的核心思想。
2.2 指数退避 + 全抖动的计算方法
业界常用的方案是指数退避 + 抖动。在同一点上,我推荐直接用公式:
delay = min(cap, base * (2 ** attempt)) * random(0.5, 1.5)其中base是基础间隔,attempt是连续失败的次数(成功重连后归零),cap是最大间隔上限。random(0.5, 1.5)是抖动系数,也可以写成更复杂的 Full Jitter 算法,但实际用下来这个简版已经足够。
举个例子,假设base = 1s,cap = 30s,前几次重试的间隔如下:
| 连续失败次数 attempt | 理论间隔 | 加 0.5~1.5 倍抖动后的范围 |
|---|---|---|
| 0 | 1s | 0.5s ~ 1.5s |
| 1 | 2s | 1s ~ 3s |
| 2 | 4s | 2s ~ 6s |
| 3 | 8s | 4s ~ 12s |
| 4 | 16s | 8s ~ 24s |
| 5 | 30s(封顶) | 15s ~ 45s |
| >=6 | 30s | 15s ~ 45s |
这里的抖动不是可有可无的装饰。假如没有抖动,所有客户端都会严格按照同一个时间点(比如第 1s、第 2s、第 4s……)发起重连,当一次大规模网络故障恢复时,会出现成千上万个客户端在同一秒涌向服务器,这就是所谓的重连风暴。加入抖动之后,重连请求在时间轴上被摊开,服务器受到的冲击会明显平缓。
另外注意,每次重试时Math.random()都要重新取,不要复用固定的随机种子,否则所有客户端的"抖动"又会趋于一致。
2.3 其他决定重连质量的参数:上限、错误分类、成功重置
围绕这个公式,还有几个参数值得单独讲一下。
**最大重试次数。**无限重连看起来很保险,但会造成两个隐患:一是连续失败几十次后,客户端仍然在做无用功,浪费电量和流量;二是一些深层故障(比如账号被踢、服务端协议升级、token 永久失效)靠重连根本解决不了,反而会掩盖真正需要人工干预的问题。我一般会把错误分成两类:
- 可重试错误:网络不可达、连接超时、服务端主动断开且没有明确拒绝原因
- 不可重试错误:HTTP 401/403、协议版本不匹配、服务端返回明确的"该连接已永久失效"
对不可重试错误,直接停止重连并触发业务提示;对可重试错误,才走指数退避。
**成功重置。**一旦连接建立成功并进入稳定状态,attempt必须归零。否则下次断线时会带着之前的失败计数起步,导致重连间隔过大,恢复太慢。
**连接超时。**很多人只关注断线后的重试间隔,忽略了"连接建立阶段也可能挂起"。如果目标 IP 不可达,connect可能长时间不返回,客户端一直卡在 CONNECTING 状态。所以重连代码里一定要给"建立连接"这一步单独设置超时(我一般设 5~10 秒),超时后强制放弃并计入失败次数。
3. 短线重连的状态机与核心代码实现
3.1 状态机设计:把重连从 if-else 泥潭里解放出来
把重连逻辑写成一堆onclose回调里嵌套的 if-else,短期能跑,但一旦加入心跳、手动停止、手动重连、页面隐藏暂停这些需求,代码很快就会失控。
我的习惯是先定义一套简单的状态:
DISCONNECTED:初始状态,或调用 stop() 之后的静止状态 CONNECTING:正在建立连接 CONNECTED:连接已建立,正常收发数据 BACKOFF:等待退避定时器触发状态之间的流转只有几条固定路径:
connect()被调用 -> CONNECTING- 连接成功(open 事件)-> CONNECTED
- 连接关闭(close 事件)且未停止 -> BACKOFF
- 退避定时器到期 -> CONNECTING
- 连接过程中发生超时 -> BACKOFF
stop()被调用 -> DISCONNECTED,清理所有定时器
这个状态下,每条路径都只有一个"源头"。比如"发起新连接"这件事,只可能由两个动作触发:外部手动调用connect(),或退避定时器到期。不会出现"close 事件触发一次重连、error 事件又触发一次重连"的双重连接问题。
3.2 基于 WebSocket 的短线重连代码骨架
我习惯拿 TypeScript 写网络层,类型清晰,不容易踩低级错误。下面这个类是一个能直接落地的骨架,核心不依赖任何框架,Web 和 React Native 里都能用:
type ConnectionState = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'BACKOFF'; class ReconnectingSocket { private state: ConnectionState = 'DISCONNECTED'; private attempt = 0; private backoffTimer: ReturnType<typeof setTimeout> | null = null; private connectTimer: ReturnType<typeof setTimeout> | null = null; private ws: WebSocket | null = null; private stopped = false; // 可配置参数 private readonly baseDelay = 1000; // 基础间隔,单位 ms private readonly maxDelay = 30000; // 间隔上限 private readonly connectTimeout = 8000; // 连接超时 private readonly maxAttempts = 8; constructor(private readonly url: string) {} start() { this.stopped = false; this.transition('CONNECTING'); } stop() { this.stopped = true; this.clearTimers(); if (this.ws) { this.ws.onclose = null; // 防止 stop 时触发重连 this.ws.close(); this.ws = null; } this.transition('DISCONNECTED'); } private transition(next: ConnectionState) { this.state = next; if (next === 'CONNECTING') { this.openConnection(); } else if (next === 'BACKOFF') { this.scheduleReconnect(); } } private openConnection() { this.clearConnectTimer(); this.ws = new WebSocket(this.url); // 连接超时兜底 this.connectTimer = setTimeout(() => { if (this.state === 'CONNECTING') { this.ws?.close(); this.attempt++; this.transition('BACKOFF'); } }, this.connectTimeout); this.ws.onopen = () => { this.clearConnectTimer(); this.attempt = 0; // 成功重置 this.transition('CONNECTED'); this.onConnected(); }; // 注意:onerror 里不触发重连,只记录日志。 // 因为 error 之后通常还会跟一个 close 事件,如果在两边都处理,会重复调度。 this.ws.onerror = (e) => { this.onError(e); }; this.ws.onclose = () => { if (this.state === 'CONNECTING' || this.state === 'CONNECTED') { this.transition('BACKOFF'); } }; } private scheduleReconnect() { this.clearBackoffTimer(); if (this.stopped) return; if (this.attempt >= this.maxAttempts) { this.onGiveUp(); return; } const delay = this.calculateDelay(this.attempt); this.backoffTimer = setTimeout(() => { if (!this.stopped) { this.transition('CONNECTING'); } }, delay); } private calculateDelay(attempt: number): number { const base = Math.min(this.maxDelay, this.baseDelay * Math.pow(2, attempt)); const jitter = 0.5 + Math.random(); // 0.5 ~ 1.5 return Math.round(base * jitter); } private clearTimers() { this.clearBackoffTimer(); this.clearConnectTimer(); } private clearBackoffTimer() { if (this.backoffTimer) { clearTimeout(this.backoffTimer); this.backoffTimer = null; } } private clearConnectTimer() { if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } } // 子类实现:连接成功后的业务处理 protected onConnected() {} protected onError(e: unknown) {} protected onGiveUp() {} }这个骨架里有两个很容易被忽略的细节,值得展开。
**细节一:不要在 onerror 里直接重连。**WebSocket 的 error 事件之后通常还会触发 close 事件。如果你在 error 里connect()一次,再在 close 里connect()一次,就会出现两个 WebSocket 对象同时存在,旧连接的回调还会回来捣乱,日志里会出现莫名其妙的双倍重连。我见过好几个项目踩这个坑,症状是"每次断线都会出现两条连接记录"。
细节二:连接超时必须单独处理。new WebSocket(url)并不会在目标不可达时立刻失败。某些网络环境下,它可能一直处于 CONNECTING 状态,等几十秒才报错。如果不加超时兜底,你的"短线重连"会变成"长期悬挂",恢复时间完全不可控。
3.3 心跳与短线重连的组合方式
短线重连不能只依赖被动监听 close 事件,因为很多"假死连接"根本不会触发 close。想象一下这样的场景:手机锁屏 10 分钟,系统把 App 的 TCP 连接挂起,但连接没有被正常关闭;服务端因为一段时间没收到数据,已经把这边的 socket 回收了。这时候客户端的 TCP 栈还认为连接是好的,任何业务消息发出去都石沉大海,但 close 事件迟迟不来。
解决办法是应用层心跳。客户端定时发一个 ping 帧,服务端回 pong(也可以顺便带服务端最新 seq)。客户端连续 N 次(通常是 2~3 次)没收到 pong,就主动关闭当前连接并进入重连流程。
心跳周期怎么定,要看你所在网络的中间设备空闲超时阈值:
| 网络环境 | 常见空闲超时 | 建议心跳周期 |
|---|---|---|
| 云负载均衡(四层) | 60s ~ 300s | 15s ~ 30s |
| NAT 网关(UDP/TCP 会话) | 30s ~ 120s | 15s ~ 30s |
| 内网交换机 | 通常很长 | 30s ~ 60s |
| 移动网络基站侧 | 不稳定 | 10s ~ 20s |
我通常的做法是:心跳周期设为服务端空闲回收阈值的 1/3 左右。比如服务端说"空闲 60 秒回收连接",心跳就设 20 秒。然后连续 2 次心跳没收到响应,就认定连接已死,主动ws.close()触发重连。
3.4 服务端如何配合短线重连
客户端单方面重连是不够的。短线重连要真正做到"短",服务端至少要配合三件事。
第一,保留最近一段时间的会话上下文。连接断开后,服务端不要立刻清掉该连接的离线缓冲队列。我常用的做法是:客户端断线后,服务端把该连接的消息缓存保留 1~5 分钟,同时记录客户端最后确认的消息序号(ackSeq)。客户端重连成功时携带 ackSeq,服务端从 ackSeq 开始补发。
第二,下发一个重连成功事件和当前的全局游标。客户端重连成功后,需要知道"我该从哪条消息开始补",而这个信息只能由服务端给出。常见做法是重连成功后服务端立刻推送一个{ type: 'sync', cursor: 10245 }的同步帧。
第三,主动清理僵尸连接。服务端要把它认为的死连接及时关闭,否则客户端的心跳机制起不到作用。服务端清理一般用最近活动时间(lastActive)来判断:超过 2~3 个心跳周期没有任何数据,就主动断开。
4. 上线后最容易踩的坑:消息补偿、重复消费和重连风暴
4.1 重连期间的离线状态要能对账
短线重连最理想的形态是:断线只有 2 秒,用户发的一条消息在重连后立刻补发成功。但真实业务里,客户端在断线期间可能有本地操作。比如 IM 客户端里用户发出了一条消息,网络恰好断了,这条消息是留在输入框?还是进本地队列?重连成功后要不要重新发送?
我推荐的做法是:客户端维护一个发送队列 + 确认机制。消息先写到本地持久化存储,再发送;服务端收到并落盘后回一个 ack;客户端收到 ack 才从本地队列移除。重连成功后,扫描队列中未被 ack 的消息,统一重发。这里的核心不是重连代码本身,而是要让重连参与"消息可靠性闭环"。
对应地,服务端要能区分"这条消息是第一次收到"还是"客户端重连后重发的",所以客户端发的每条业务消息要带一个全局唯一的 msgId。服务端按 msgId 去重,而不是按"连接实例"去重。
4.2 立即重连与重连风暴
断线后立刻重连,听起来响应最快,但在两种情况会出事。
第一种是服务端正在进行重启或发布。比如 Kubernetes 滚动更新里,旧的 Pod 正在被销毁,新的 Pod 还没就绪。这时所有连接到旧 Pod 的客户端都会同时断开、同时重连。如果大家在断线瞬间马上发起新连接,而新的服务端实例还在启动中,就会出现"出发即失败",然后大量客户端一起进入相同节奏的退避重试,最终把新实例也打垮。
第二种是区域性网络故障。假设整个机房的网络出口闪断了 30 秒,恢复瞬间所有客户端一起重连。如果没有足够的抖动,网关和负载均衡会在第一秒内涌入全部流量,此时连接成功率反而很低,进而触发第二轮重试,形成恶性循环。
所以在这个环节,策略上要坚持两个原则:
- 断线后先退避再重连,不要立即重连。即使只是网络切换,给一个 200ms~500ms 的短退避,能跨过很多瞬时抖动。
- 抖动幅度要足够大。我在生产环境用的比例是 0.5~1.5 倍甚至 0.5~2.0 倍。幅度太小的抖动,比如 0.9~1.1,在大规模故障时本质上还是"同步重连"。
4.3 重复消息要靠幂等兜底
短线重连和消息重发机制一起工作时,必须接受一个现实:重连期间的消息极大概率会重复收到。原因很简单,客户端可能已经收到并处理了某条消息,但还没来得及把 ack 发出去,连接就断了;重连后服务端以为这条消息没被接收,于是又补发一次。
处理重复消息的经典方案是客户端维护一个最近消息ID的环状缓存(比如一个能容纳最近 500 条消息ID的 Set),收到消息后先查重:是新消息就处理并放入缓存;是重复消息就直接丢弃。如果消息量很大,可以直接用 Bloom Filter 做近似去重,但要注意有一定的误判率,业务上必须能容忍偶发丢消息才用。
另外一个容易被忽略的点:短线重连之后要做的是增量同步,不是全量同步。如果你在重连成功后无条件拉取全量状态,短时间内大量客户端同时拉取,仍然会把服务端压垮。增量同步需要服务端提供"按游标拉取"的接口,客户端传lastSeq,服务端只返回lastSeq之后的数据。
5. 从日志和指标里验证重连策略是否合理
5.1 必采集的重连指标
代码写完了,策略也定了,但怎么知道它到底有没有用?光靠"体感流畅"是不够的。我建议在重连模块里至少埋这几类指标:
- 断线原因分布:网络切换、空闲超时、心跳超时、服务端关闭、异常报错,分别占比多少
- 断线时长分布:小于 5 秒的算短线,5~60 秒算中线,超过 60 秒算长线
- 重连成功率:首重成功率、二次重试成功率、最终放弃率
- 恢复耗时:从 close 事件到 CONNECTED 状态的延迟
- 每次重连的 attempt 次数分布
- 瞬间重连速率:每秒钟全客户端发起的重连请求数
第六个指标尤其重要。它不需要像前几个那样逐个上报,而是可以在网关或接入层做一个简单的"请求速率统计",一旦发现每秒重连请求数超过正常值 3 倍以上,立刻报警。这是发现重连风暴最直接的手段。
5.2 通过日志判断连接是"短期抖动"还是"长期离线"
日志字段设计上,有一个字段最容易被忽略,那就是重连原因来源。我习惯在日志里记录下close事件的 code 和 reason:
connId=abc123 state=BACKOFF attempt=2 delay=4231ms reason=remote_close code=1006 connId=abc123 state=CONNECTING attempt=3 endpoint=wss://xxx/resource connId=abc123 state=CONNECTED attempt=0 duration=12500ms1006是 WebSocket 里最常见的异常关闭码,通常意味着底层 TCP 连接直接断掉,没有正常的关闭握手。如果你发现大量1006,基本可以判断是网络层面抖动或中间设备回收连接;如果错误码是1008、1009,说明是服务端策略问题,重连策略再优化也救不了。
有了这些日志,调参的时候就能回答"重连间隔应该调大还是调小"这类问题:如果断线时长主要集中在 2 秒内,说明你需要缩小 baseDelay,让恢复更快;如果大量断线都持续几十秒,说明退避增长太慢,中间产生的无效重试太多,需要拉大 baseDelay 或抖动幅度。
5.3 结合运营环境做调参
重连参数不是一套打天下,需要结合你的实际网络环境。我自用的几套基础参数如下,你可以直接抄作业然后微调:
| 环境类型 | baseDelay | maxDelay | 心跳周期 | 说明 |
|---|---|---|---|---|
| 内网/有线 | 500ms | 10s | 30s | 网络稳定,可以激进一点 |
| 公网/移动端 | 1s | 30s | 15~20s | 抖动大,退避要保守 |
| 弱网/IoT设备 | 2s | 60s | 10~15s | 节省流量电量,重试克制 |
| Web 前端页面 | 1s | 30s | 30s | 页面不可见时应暂停重连 |
Web 前端还有一个特殊点:当页面处于后台或标签页休眠状态时,setTimeout可能被浏览器节流。这种情况下不建议硬扛着重连,而是监听visibilitychange事件,页面重新可见时才恢复重连逻辑。否则你会发现浏览器把定时器压到 1 分钟一次,你的退避策略完全失真。
我个人在调这一类代码的时候,最深的感受是:短线重连不是一个"连上就行"的功能,而是一个需要不断跟业务指标对齐的系统。真正上线之后,你会发现一半的精力不在 connect 逻辑本身,而在消息补偿、风暴控制、日志指标这些看似边缘的细节上。先把这些细节想清楚,代码写起来会顺很多。