本节不讨论什么
上一节讲的是实时通道不可用时产品怎么降级。降级解决「这条路径暂时不能用,用户还能做什么」;本节解决另一个更底层的问题:连接断了以后,客户端什么时候再连、等多久、哪些失败根本不该再连。
协议层的重连语义、Last-Event-ID、心跳间隔与关闭码,附录 A 已经逐项列过,这里不重复。消息到达之后的去重与排序在 7.2,断线期间缺了哪些消息、怎么补在 7.3。本节只做一件事:把「重连」从一个随手写的 setTimeout 变成一个可以测试、可以观测、不会在服务端故障时反噬的调度器。
断线不是异常,是运行时的一部分
在桌面宽带里,一条长连接可以活几个小时;在移动端,它可能活不过一次电梯。至少五类事件会让连接消失:
| 事件 | 端上看到的现象 | 是否可立刻重连 |
|---|---|---|
| 网络切换(Wi-Fi 与蜂窝) | 旧连接无响应,新网络已可用 | 可以,但要等网络就绪 |
| 应用进入后台再回来 | 连接被系统挂起,回来时已失效 | 可以,先探活 |
| 代理或 NAT 超时 | 没有关闭帧,读操作长时间无数据 | 可以,但先确认不是半开 |
| 服务端发布或扩容 | 收到关闭帧,或连接被重置 | 可以,但要避开重启窗口 |
| 鉴权过期 | 服务端主动关闭,或首帧返回未授权 | 不可以直接重连,先刷新凭证 |
把断线当成异常,代码会长成「正常路径 + 一个兜底 onerror」。把断线当成常态,代码会长成一个状态机:idle → connecting → open → closed → waiting → connecting。两者的差别不在代码量,而在故障时的行为:前者会在服务端重启时把所有客户端同时放出去,后者会先等一个被抖动打散的延迟。
状态机里有一个容易被忽略的状态:waiting。它不是「连接中」,而是「已经决定要重连,但还没到时间」。UI 不应该在这个状态显示「连接中」的转圈,因为用户会以为马上就好;更准确的说法是「网络不稳定,正在重试」,并给出一个手动重试入口。状态与文案必须对齐,否则退避越正确,用户越困惑。
重连风暴是怎么形成的
服务端发布时,五万台客户端在同一秒断开。如果每台都固定等 1 秒再连,第二轮重连仍然发生在同一秒;如果第一次重连失败,固定等 2 秒,第三轮还是同一秒。退避只改变了间隔,没有改变「同一批客户端仍然同步」这个事实。
更糟的是重试放大。网关在重启窗口里返回 503,客户端把 503 当成可重试错误,于是每一轮失败都生成新一轮请求。假设每台客户端重试 5 次,五万台就是二十五万次连接尝试,而服务端此时可能只恢复了一半容量。连接失败得越多,重试得越密,恢复得越慢。
重连风暴有三个可验证的成因:
- 没有抖动。 所有客户端用同一个
baseMs,第一轮就同步。 - 没有上限。 重试次数无限,服务端恢复前会持续被冲击。
- 把不可重试错误当成可重试。 鉴权失败、协议错误也加入重试队列,制造永远不会成功的请求。
验证方法不需要真实服务端。把退避函数抽成纯函数,注入一个确定的随机源,模拟一万台客户端的第一轮延迟,统计不同延迟值的分布。没有抖动时分布只有一个点;加上抖动后,延迟应该铺满一个区间。这个测试能在本地跑,也能在 CI 里阻止「有人把抖动删掉」。
指数退避的四个参数
一个可用的退避策略只需要四个参数:
| 参数 | 含义 | 典型取值 | 取值不当的后果 |
|---|---|---|---|
baseMs | 第一次重连的基准等待 | 500–1000ms | 太小会立刻冲击,太大用户感知明显 |
capMs | 单次等待的上限 | 20–60s | 没有上限会等到用户放弃 |
maxAttempts | 最多重试几次 | 6–10 次 | 无限重试等于把故障期无限拉长 |
jitter | 抖动模式 | equal 或 full | 没有抖动会形成同步波峰 |
第 n 次重连的等待是 min(capMs, baseMs × 2^(n-1))。这个式子本身没有争议,争议通常在边界:baseMs 从 0 还是从 1 开始算、达到上限后是继续重试还是停止、maxAttempts 到了以后 UI 显示什么。
本节的约定是:attempt 从 1 开始,第 1 次等待 baseMs;超过 maxAttempts 后返回 retry: false,由上层决定是提示用户还是转入降级路径。退避模块不负责「永远连下去」,它只负责在允许的次数内给出下一次等待。 这个边界让它可以被纯函数测试,也避免它偷偷吞掉「已经放弃」这个产品状态。
上限也不能只有次数。一个持续失败的长连接,如果每次都等 30 秒、重试 8 次,用户要等四分钟才看到失败提示。次数上限与单次等待上限必须同时存在,前者控制总量,后者控制单次体感。
抖动不是可选装饰
抖动的作用是把「同一时刻失败的一批客户端」摊开。两种常见做法:
- 全抖动(full):在
[0, ceiling]内均匀取值。分布最散,但可能取到接近 0 的延迟,导致个别客户端立刻重连。 - 等抖动(equal):保留一半固定延迟,另一半随机。最小等待是
ceiling / 2,既摊开了波峰,又不会出现「几乎没等」的客户端。
本节的默认值是等抖动。它牺牲了一部分分散度,换来了一个可解释的下限:即使随机数取到 0,客户端也至少等半个周期。对用户来说,这比「有时 1ms 就重连、有时等 30 秒」更稳定。
抖动必须可注入。生产代码用 Math.random,测试代码传一个确定序列。这样「同一批客户端的首次重连被摊开」可以写成断言,而不是靠人眼看日志。随机性一旦不可注入,退避逻辑就只能靠集成测试碰运气。
哪些失败不该重试
不是所有失败都值得等。分类错了,退避参数再漂亮也没用:
| 失败 | 判定 | 端上动作 |
|---|---|---|
| 连接重置、DNS 失败、无关闭帧 | transport | 退避重试 |
| 请求或握手超时 | timeout | 退避重试 |
| 5xx | server | 退避重试,但要遵守上限 |
| 429 | rate-limit | 退避重试,服务端给出等待时间时优先遵守 |
| 401 / 403 / 凭证过期 | auth | 不重连,先刷新凭证 |
| 400 / 422 / 协议帧不合法 | protocol | 不重连,记录并上报 |
后两类错误的共同点是「等待不会改变结果」。鉴权失败时,客户端手里的凭证已经无效,再连一百次也是 401;协议错误通常意味着客户端与服务端版本不兼容,重连只会重复触发同一个错误。把它们放进重试队列,等于用用户的时间和服务器资源换一个确定不会成功的结局。
auth 与 protocol 也不是「什么都不做」。auth 应该交给令牌刷新流程,刷新成功后用新的 attempt = 1 重新开始;protocol 应该上报版本与帧样本,并提示用户升级。「不重试」不等于「不处理」,而是「不在这一层处理」。
网络从离线恢复:不要等计时器
移动端有一个特殊信号:操作系统告诉应用「网络从离线变成在线」。这时不应该继续等退避计时器,而应该立即发起重连,并把 attempt 清零。
原因有两个。第一,退避计时器是在「网络不可用」的前提下设置的,网络恢复后这个前提消失了,继续等只是浪费用户时间。第二,离线期间可能已经积累了很多次失败的尝试,如果不清零,恢复后的第一次失败会直接跳到很高的等待,用户会以为应用卡死。
但「立即重连」不能由「网络类型变化」触发。Wi-Fi 切到蜂窝、蜂窝切到 Wi-Fi,网络一直是可用的,只是路径变了;这时立即重连是合理的,但不应该重置尝试计数。真正需要区分的是 offline → online 与 online → online。所以调度器接收的是网络状态迁移,而不是「网络类型变了」这个事件。
系统事件可能重复投递:同一次恢复收到两次 online。状态迁移函数必须是幂等的——只有 previous === "offline" 时才返回 reconnectNow: true,第二次调用看到 previous === "online",什么也不做。
一个可测试的退避模块
下面的模块属于 L1:没有定时器、没有网络、没有全局状态。时间只以毫秒数的形式出现,随机源由调用方注入,因此每条分支都能在毫秒内验证。
// file: src/reliability/backoff.ts
// tier: L1
// verify: node --test test/reliability/backoff.test.ts
export type FailureKind = "transport" | "timeout" | "server" | "rate-limit" | "auth" | "protocol";
export type JitterMode = "none" | "full" | "equal";
export interface BackoffOptions {
baseMs: number;
capMs: number;
maxAttempts: number;
jitter: JitterMode;
}
export interface RetryDecision {
retry: boolean;
delayMs: number;
kind: FailureKind;
reason: string;
}
export type NetworkState = "online" | "offline";
export interface RecoverySignal {
reconnectNow: boolean;
resetAttempt: boolean;
reason: string;
}
export type RandomSource = () => number;
export const DEFAULT_BACKOFF: BackoffOptions = {
baseMs: 500,
capMs: 30_000,
maxAttempts: 8,
jitter: "equal",
};
export function classifyFailure(input: { status?: number; code?: string }): FailureKind {
if (input.code === "AUTH_EXPIRED" || input.status === 401 || input.status === 403) return "auth";
if (input.code === "PROTOCOL" || input.status === 400 || input.status === 422) return "protocol";
if (input.status === 429) return "rate-limit";
if (input.status !== undefined && input.status >= 500) return "server";
if (input.code === "TIMEOUT") return "timeout";
return "transport";
}
export function isRetryable(kind: FailureKind): boolean {
// 鉴权失败与协议错误不会因为等待而自愈,重试只会放大服务端压力。
return kind !== "auth" && kind !== "protocol";
}
export function exponentialDelay(attempt: number, options: BackoffOptions): number {
assertOptions(options);
if (!Number.isInteger(attempt) || attempt < 1) throw new Error("attempt 必须是正整数");
// 先封顶再乘,避免大 attempt 时出现 Infinity。
const raw = options.baseMs * 2 ** (attempt - 1);
return Math.min(options.capMs, raw);
}
export function applyJitter(delayMs: number, mode: JitterMode, random: RandomSource = Math.random): number {
if (!Number.isInteger(delayMs) || delayMs < 1) throw new Error("delayMs 必须是正整数");
if (mode === "none") return delayMs;
const sample = Math.min(0.999999, Math.max(0, random()));
if (mode === "full") {
// 全抖动:从 0 到上限之间取值;保留 1ms 下限,避免同一瞬间再次重连。
return Math.max(1, Math.floor(sample * delayMs));
}
const half = Math.floor(delayMs / 2);
return half + Math.floor(sample * (delayMs - half));
}
export function nextReconnect(
attempt: number,
kind: FailureKind,
options: BackoffOptions = DEFAULT_BACKOFF,
random: RandomSource = Math.random,
): RetryDecision {
assertOptions(options);
if (!Number.isInteger(attempt) || attempt < 1) throw new Error("attempt 必须是正整数");
if (!isRetryable(kind)) {
return { retry: false, delayMs: 0, kind, reason: `${kind} 错误不重试,交给上层处理` };
}
if (attempt > options.maxAttempts) {
return { retry: false, delayMs: 0, kind, reason: `已达到 ${options.maxAttempts} 次重试上限` };
}
const ceiling = exponentialDelay(attempt, options);
const delayMs = applyJitter(ceiling, options.jitter, random);
return { retry: true, delayMs, kind, reason: `第 ${attempt} 次重连等待 ${delayMs}ms` };
}
export function networkTransition(previous: NetworkState, next: NetworkState): RecoverySignal {
if (previous === "offline" && next === "online") {
// 网络恢复不需要等退避计时器,但必须把尝试计数清零,否则下一次失败会直接跳到上限。
return { reconnectNow: true, resetAttempt: true, reason: "网络从离线恢复" };
}
if (previous === "online" && next === "offline") {
return { reconnectNow: false, resetAttempt: false, reason: "进入离线,等待网络信号" };
}
return { reconnectNow: false, resetAttempt: false, reason: "网络状态无变化" };
}
function assertOptions(options: BackoffOptions): void {
if (!Number.isInteger(options.baseMs) || options.baseMs < 1) throw new Error("baseMs 必须是正整数");
if (!Number.isInteger(options.capMs) || options.capMs < options.baseMs) {
throw new Error("capMs 必须是不小于 baseMs 的整数");
}
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
throw new Error("maxAttempts 必须是正整数");
}
}
实现里的三个决定
先封顶再乘
exponentialDelay 用 Math.min(capMs, baseMs * 2 ** (attempt - 1)),而不是先乘再判断。两者在正常范围内结果相同,但在 attempt 很大时,后者会先得到一个 Infinity 再被 Math.min 拉回来。虽然 JavaScript 能处理 Infinity,但这个中间值会让日志和调试器出现难以解释的数字,也让「上限是否真的生效」变得不直观。
随机源是参数,不是全局
applyJitter 的第三个参数默认是 Math.random,测试时传入确定序列。这样 equal 抖动在随机数为 0、0.5、0.999999 时的边界都能精确断言。生产代码不需要为可测试性做额外抽象,测试也不需要 monkey-patch 全局对象。
reason 是给日志和 UI 的,不是装饰
RetryDecision 同时返回 retry 与 reason。调用方不需要根据 delayMs === 0 反推「是不该重试,还是次数用完了」。日志可以直接记录 kind 与 reason,UI 也可以据此区分「正在重连」与「已停止重试」。一个只返回布尔值的退避函数,最终会让每个调用方各写一套判断。
测试与真实输出
本机运行结果如下:
$ node --test test/reliability/backoff.test.ts
✔ 无抖动时按 2 的幂增长并在上限封顶 (0.915208ms)
✔ equal 抖动落在后一半区间且可复现 (0.142375ms)
✔ full 抖动落在 1 到上限之间 (0.080708ms)
✔ 鉴权与协议错误不重试,传输错误重试 (0.096875ms)
✔ 超过最大次数后停止重试 (0.160375ms)
✔ 网络从离线恢复时立即重连并清零尝试计数 (0.896291ms)
✔ 无抖动时同一批客户端在同一毫秒重连 (0.117208ms)
✔ 加抖动后同一批客户端的首次重连被摊开 (0.093417ms)
✔ 非法退避参数被拒绝 (0.301625ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 149.967084
九条用例覆盖了增长、封顶、两种抖动、失败分类、次数上限、网络迁移、风暴模拟与非法参数。它们没有覆盖真实定时器与真实网络,因为本节验证的是「给定失败类型和尝试次数,应该等多久」,不是「setTimeout 是否准时」。后者属于运行时行为,用假时钟测不出浏览器后台节流的差异。
失败复盘:服务端重启后的三分钟
「回声」的一次夜间发布把实时网关重启了。发布窗口预计 40 秒,实际影响持续了将近三分钟。事后日志里有三个数字(示例值,来自当时的监控面板):连接失败率在重启后第 5 秒达到峰值;重连请求量是平时的 6 倍;第二轮失败中有 70% 是网关自己返回的 503。
根因不是发布,而是客户端的重连策略:所有客户端使用固定 1 秒间隔,没有抖动,maxAttempts 设为 20。服务端刚恢复时,五万台客户端同时到达;网关扛不住,返回 503;客户端把这批 503 当成可重试错误,1 秒后再来一轮。发布只中断了 40 秒,客户端的同步重试把故障延长了四倍。
修复分三层:
- 客户端改成等抖动,
baseMs设为 800ms,capMs设为 30 秒,maxAttempts降到 8 次。第一轮重连被摊在 400–800ms 之间,第二轮被摊在 800–1600ms 之间。 - 网关在重启窗口返回 503 时带上
Retry-After,客户端优先遵守服务端给出的等待,而不是自己算。 - 监控增加
reconnect_attempt_total{reason}与「同一秒内重连请求的基尼系数」。后者不是为了好看,而是为了在下次发布前能回答「这一批客户端是不是又同步了」。
修复后的验证没有等下一次发布。测试里用确定随机源模拟 10,000 台客户端,断言第一轮延迟的去重值数量大于 1000;再把抖动关掉,断言去重值数量等于 1。这条用例直接对应事故成因,比「代码看起来有 jitter」可靠。
重连成功不等于消息完整
退避解决的是「什么时候再连」,不解决「连上之后从哪里继续」。一次成功的重连只恢复了一条管道,断线期间的消息仍然不在端上;服务端也可能因为重连而重发一部分已经收到的消息。前者需要空洞检测与补拉,后者需要去重。
所以重连状态机在 open 之后不能直接回到「一切正常」。它应该先进入一个短暂的 syncing 状态:确认序列号、补齐缺口、丢弃重复,然后再把 UI 切回在线。这个状态的存在,是下一节与 7.3 的接口。
小结
重连是一个调度问题,不是异常处理。失败要先分类,鉴权与协议错误不进入重试队列;等待要指数增长、有单次上限、有总次数上限;抖动要把同时失败的客户端摊开;网络从离线恢复时立即重连并清零计数。退避模块只负责给出下一次等待,不负责替产品决定「什么时候放弃」。
重连把管道接回来,但管道里可能重复、也可能缺消息。下一节先处理重复与乱序:端上用消息 id 与序列号,到底能保证什么、不能保证什么。
阅读导航:上一节:6.3 降级路径:实时不可用时产品还能做什么 · 下一节:7.2 消息去重与有序:端上能保证什么 。
常见错误与边界
- 用固定间隔重连,没有指数增长。服务端故障期间请求量不会下降。
- 加了指数退避但没有抖动。所有客户端仍然在同一毫秒进入下一轮。
- 只限制单次等待,不限制总次数。用户可能等几分钟才看到失败提示。
- 把 401、403 或协议错误放进重试队列。等待不会让凭证或版本兼容性自愈。
- 网络恢复后继续等退避计时器。用户明明已经联网,应用却还在倒计时。
- 网络类型变化就重置尝试计数。Wi-Fi 切蜂窝会导致计数被反复清零,退避形同虚设。
- 连接成功就把 UI 切回「在线」。此时消息可能还没补齐,用户会看到一段空白。
- 认为退避测试必须用真实定时器。延迟计算是纯函数,真实定时器只应出现在 L2/L3 的集成验证里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。