《互联网产品的客户端架构·内容、社交与实时互动》7.1 重连与退避:把断线当成常态

断线在移动端是常态。本节把重连拆成失败分类、指数退避、抖动与上限四个决策,给出一个可测试的纯函数退避模块,并复盘一次服务端重启后所有客户端同时重连、把网关打满的事故,最后说明网络从离线恢复时为什么要立即重连并清零计数,以及如何用确定随机源验证风暴是否真的被摊开。

本节不讨论什么

上一节讲的是实时通道不可用时产品怎么降级。降级解决「这条路径暂时不能用,用户还能做什么」;本节解决另一个更底层的问题:连接断了以后,客户端什么时候再连、等多久、哪些失败根本不该再连。

协议层的重连语义、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 次,五万台就是二十五万次连接尝试,而服务端此时可能只恢复了一半容量。连接失败得越多,重试得越密,恢复得越慢。

重连风暴有三个可验证的成因:

  1. 没有抖动。 所有客户端用同一个 baseMs,第一轮就同步。
  2. 没有上限。 重试次数无限,服务端恢复前会持续被冲击。
  3. 把不可重试错误当成可重试。 鉴权失败、协议错误也加入重试队列,制造永远不会成功的请求。

验证方法不需要真实服务端。把退避函数抽成纯函数,注入一个确定的随机源,模拟一万台客户端的第一轮延迟,统计不同延迟值的分布。没有抖动时分布只有一个点;加上抖动后,延迟应该铺满一个区间。这个测试能在本地跑,也能在 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退避重试
5xxserver退避重试,但要遵守上限
429rate-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 秒,客户端的同步重试把故障延长了四倍。

修复分三层:

  1. 客户端改成等抖动,baseMs 设为 800ms,capMs 设为 30 秒,maxAttempts 降到 8 次。第一轮重连被摊在 400–800ms 之间,第二轮被摊在 800–1600ms 之间。
  2. 网关在重启窗口返回 503 时带上 Retry-After,客户端优先遵守服务端给出的等待,而不是自己算。
  3. 监控增加 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 的集成验证里。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查