场景:失败之后,所有客户端在同一秒醒来
《潮汐方舟》的战斗内心跳是 5 秒,断线率是 1.4%,弱网会话占比是 14%。一次机房抖动会让一批客户端同时掉线,如果每个客户端都在固定 3 秒后重试,它们会在几乎同一时刻再次撞上服务端。失败者继续按固定间隔重试,峰值不但没有被打散,反而被训练成整齐的脉冲。
这就是重连风暴的典型形态:不是总带宽不够,而是请求在时间上过度集中。客户端能控制的不是服务端容量,而是自己的重试节奏。指数退避解决“越失败、等越久”,抖动解决“即使都失败,也不要同时醒来”。两者缺一不可。
固定间隔最危险的地方在于它把“失败次数”从节奏里抹掉了。第一次失败和第八次失败得到同样的等待,客户端在服务端已经过载时仍保持高频敲击。指数退避把失败次数变成等待时间,抖动把同一失败次数的客户端分散开,最大尝试次数则保证客户端不会无限敲下去。
四个参数:增长、封顶、上限与重置
退避策略需要四个独立参数,任何一个缺失都会留下一个具体的故障模式。
| 参数 | 作用 | 缺失后的故障 | 实验取值 |
|---|---|---|---|
| baseMs | 第一次等待的基准 | 首败立即重试,形成小脉冲 | 500ms |
| capMs | 单次等待的上限 | 等待无限增长,玩家放弃 | 20000ms |
| maxAttempts | 自动重连次数上限 | 客户端永远重试,耗电耗流量 | 8 次 |
| stableResetMs | 连接稳定多久后清零计数 | 一次偶发断线让全天重连都变慢 | 10000ms |
四个参数里最容易被忽略的是 stableResetMs。如果只按“连接成功”清零,那么玩家在网络边缘反复掉线、每次只连上两秒,退避计数会被反复重置,等待永远停在 500ms。正确的重置条件是“连接稳定超过一个窗口”,例如连续在线 10 秒后才认为这次连接有效。实验取值不是案例数据,上线前要按心跳间隔和战斗时长重新标定。
maxAttempts 也不是越大越好。超过上限后应进入“人工重试”状态:停止自动重连,给出明确按钮,并提示检查网络。继续在后台无限重试,既无法提升成功率,又会加速耗电,还会让玩家误以为客户端仍在努力。
封顶值要和产品节奏对齐。《潮汐方舟》一场战斗 8–12 分钟,如果单次等待超过 20 秒,玩家实际上已经脱离战斗;此时继续等待不如明确告知并转入离线处理。封顶不是技术常数,而是产品对“还能不能回到这一局”的判断。
增长序列与封顶:把失败次数变成等待
按实验参数计算,前八次失败的无抖动等待如下。所有数字都是 mga-net-lab 的实验设定,不是线上实测。
| 失败次数 | 原始等待 | 封顶后 | 是否触顶 |
|---|---|---|---|
| 1 | 500ms | 500ms | 否 |
| 2 | 1000ms | 1000ms | 否 |
| 3 | 2000ms | 2000ms | 否 |
| 4 | 4000ms | 4000ms | 否 |
| 5 | 8000ms | 8000ms | 否 |
| 6 | 16000ms | 16000ms | 否 |
| 7 | 32000ms | 20000ms | 是 |
| 8 | 64000ms | 20000ms | 是 |
触顶之后,继续失败不会让等待继续增长,只会消耗尝试次数。这一点常被写成“第 7 次以后都等 20 秒”,但实现上更稳妥的写法是先算出原始值,再统一取 min(raw, cap),避免在分支里漏掉边界。
增长序列还有一个隐含问题:baseMs * 2 ** (attempt - 1) 在尝试次数很大时会溢出。虽然最大尝试次数通常会先拦住它,但函数本身仍应对异常输入报错,而不是返回 Infinity 后让封顶逻辑替它收拾。模型里对非整数和小于 1 的尝试次数直接抛 RangeError,让调用错误尽早暴露。
三种抖动:full、equal 与 none
抖动不是“加一个随机数”这么简单。常见的三种策略对尾部延迟和分散效果的影响不同。
| 策略 | 计算方式 | 优点 | 代价 |
|---|---|---|---|
| none | 直接使用封顶值 | 可预测、易排查 | 同步失败时完全共振 |
| full | random * capped | 分散最彻底,尾部最短 | 可能抽到接近 0,立即重试 |
| equal | capped/2 + random * capped/2 | 保留最小等待,避免立即重试 | 分散范围只有一半 |
none 并非一无是处。内网调试、灰度验证和需要精确复现的压测里,确定性比分散更重要。生产环境默认用 full,因为它把等待压到 [0, capped),能最大程度打散同一批失败者;代价是偶尔抽到极小值,等于立即重试。若业务不能接受“刚失败就重试”,用 equal 保底一半等待。
随机源必须可注入。测试里传入固定序列,才能断言“第 1 次、随机值 0.5、封顶 500ms,最终等待 250ms”这样的具体结果;如果函数内部直接调用 Math.random(),测试只能断言范围,无法复现抖动引发的线上问题。
抖动只解决“同时重试”,不解决“重试是否值得”。如果失败原因是账号被封、协议版本不兼容,任何退避都只是延迟失败。因此重试前要先分类错误:可重试的网络错误进入退避,不可重试的业务错误直接终止并提示。
重连状态:waiting、connecting、exhausted
退避模型本身也是一个状态机,只是比连接状态机小。它只关心四件事:失败后等多久、何时发起下一次连接、连接是否已经稳定、尝试次数是否耗尽。
| 当前状态 | 事件 | 结果 |
|---|---|---|
| idle / waiting | failed | 次数加一,计算等待,进入 waiting |
| waiting | retry | 进入 connecting,清空待等待时间 |
| connecting | connected | 记录连接时间,开始稳定计时 |
| connecting | stable | 稳定窗口满足则清零回 idle,否则保持 |
| 任意 | failed 且次数已达上限 | 进入 exhausted,停止自动排程 |
exhausted 必须是一个显式状态,而不是“次数到了就什么也不做”。只有显式状态才能驱动 UI 显示“重试”按钮、驱动埋点上报“放弃自动重连”,也才能让测试断言“第 9 次失败后不再产生等待”。
connected 与 stable 也要分开。连接成功只说明传输层通了,稳定窗口才说明这次连接值得信任。把两者合并,玩家在电梯里反复进出网络时,退避计数会被每次短暂连接清零,重连节奏退化成固定高频。
可运行模型:backoff.ts
下面的模型把增长、封顶、三种抖动和四态重连机封装成纯函数。随机源作为参数传入,默认使用 Math.random(),测试可以替换成固定值或种子序列。
// file: src/net/backoff.ts
// tier: L1
// verify: node --test test/net/backoff.test.ts
/**
* 指数退避与抖动模型(mga-net-lab)。
* 所有数值都是实验参数,不是案例事实;随机源由调用方注入以便复现。
*/
export type JitterMode = "none" | "full" | "equal";
export interface BackoffOptions {
readonly baseMs: number;
readonly capMs: number;
readonly maxAttempts: number;
readonly stableResetMs: number;
}
export const DEFAULT_BACKOFF: BackoffOptions = {
baseMs: 500,
capMs: 20_000,
maxAttempts: 8,
stableResetMs: 10_000,
};
export interface BackoffStep {
readonly attempt: number;
readonly rawMs: number;
readonly cappedMs: number;
readonly delayMs: number;
readonly mode: JitterMode;
}
export type ReconnectPhase = "idle" | "waiting" | "connecting" | "exhausted";
export interface ReconnectState {
readonly attempt: number;
readonly phase: ReconnectPhase;
readonly nextDelayMs: number | null;
readonly connectedAt: number | null;
readonly history: readonly BackoffStep[];
}
export type ReconnectEvent = "failed" | "connected" | "stable" | "retry";
export interface AdvanceResult {
readonly state: ReconnectState;
readonly changed: boolean;
readonly reason: string;
}
function sample(random: () => number): number {
const value = random();
if (!(value >= 0 && value < 1)) {
throw new RangeError(`随机源必须返回 [0, 1) 区间,收到 ${value}`);
}
return value;
}
export function backoffStep(
attempt: number,
options: BackoffOptions = DEFAULT_BACKOFF,
mode: JitterMode = "full",
random: () => number = Math.random,
): BackoffStep {
if (!Number.isInteger(attempt) || attempt < 1) {
throw new RangeError(`重试序号必须是正整数,收到 ${attempt}`);
}
const rawMs = options.baseMs * 2 ** (attempt - 1);
const cappedMs = Math.min(rawMs, options.capMs);
let delayMs = cappedMs;
if (mode === "full") {
delayMs = Math.floor(sample(random) * cappedMs);
} else if (mode === "equal") {
delayMs = Math.floor(cappedMs / 2 + sample(random) * (cappedMs / 2));
}
return { attempt, rawMs, cappedMs, delayMs, mode };
}
export function createReconnect(): ReconnectState {
return {
attempt: 0,
phase: "idle",
nextDelayMs: null,
connectedAt: null,
history: [],
};
}
export function advance(
state: ReconnectState,
event: ReconnectEvent,
at: number,
options: BackoffOptions = DEFAULT_BACKOFF,
mode: JitterMode = "full",
random: () => number = Math.random,
): AdvanceResult {
if (event === "failed") {
if (state.attempt >= options.maxAttempts) {
return {
state: { ...state, phase: "exhausted", nextDelayMs: null, connectedAt: null },
changed: true,
reason: `已达到 ${options.maxAttempts} 次上限,转人工重试`,
};
}
const step = backoffStep(state.attempt + 1, options, mode, random);
return {
state: {
attempt: step.attempt,
phase: "waiting",
nextDelayMs: step.delayMs,
connectedAt: null,
history: [...state.history, step],
},
changed: true,
reason: `第 ${step.attempt} 次失败,等待 ${step.delayMs}ms`,
};
}
if (event === "connected") {
if (state.phase === "exhausted") {
return { state, changed: false, reason: "已耗尽,忽略连接事件" };
}
return {
state: { ...state, phase: "connecting", connectedAt: at, nextDelayMs: null },
changed: true,
reason: "传输层已连通,开始稳定计时",
};
}
if (event === "retry") {
if (state.phase !== "waiting") {
return { state, changed: false, reason: `阶段 ${state.phase} 不在等待窗口` };
}
return {
state: { ...state, phase: "connecting", nextDelayMs: null },
changed: true,
reason: "等待结束,发起下一次连接",
};
}
if (state.connectedAt === null) {
return { state, changed: false, reason: "尚未连接,无法判断稳定窗口" };
}
const stableMs = at - state.connectedAt;
if (stableMs < options.stableResetMs) {
return {
state,
changed: false,
reason: `稳定窗口未满足,还需 ${options.stableResetMs - stableMs}ms`,
};
}
return {
state: createReconnect(),
changed: true,
reason: `连接稳定 ${stableMs}ms,退避计数清零`,
};
}
export function formatBackoff(step: BackoffStep): string {
const labels: Record<JitterMode, string> = {
none: "无抖动",
full: "full 抖动",
equal: "equal 抖动",
};
return `第 ${step.attempt} 次:原始 ${step.rawMs}ms,封顶 ${step.cappedMs}ms,${labels[step.mode]}后 ${step.delayMs}ms`;
}
模型没有定时器,也没有网络调用。advance 只根据事件和当前时间推进状态,返回新的状态、是否发生变化和可读理由。真正的等待由运行时根据 nextDelayMs 设置定时器;定时器到点后发送 retry,而不是让模型自己睡眠。这样模型可以在一次测试里跑完八次失败,不需要真的等两分钟。
history 保留每一次的原始值、封顶值和最终延迟。它的用途不是展示,而是排查:线上如果出现“第 5 次等待异常短”,可以直接看到当次随机值和 mode,而不是只看到一个最终数字。
输出怎么读:formatBackoff
无抖动、第 3 次失败的输出是:
第 3 次:原始 2000ms,封顶 2000ms,无抖动后 2000ms
full 抖动、第 1 次、随机值 0.5 的输出是:
第 1 次:原始 500ms,封顶 500ms,full 抖动后 250ms
这两个字符串把“理论等待”和“实际等待”放在同一行。排查时最常出现的误会是“配置写的是 500ms,为什么客户端 0ms 就重试了”,看到 full 抖动后 0ms 就能立刻明白是抖动抽到了 0,而不是配置没生效。
与相邻章节的关系
3.1 的连接状态机决定“什么时候进入 failed”,本节只决定“failed 之后等多久再 retry”。两者通过 waiting 与 connecting 衔接,退避不直接操作连接阶段。3.3 处理重连成功后的状态校准:退避保证客户端能回来,校准保证回来之后状态是对的。4.1 讨论离线期间哪些操作可以排队,那些操作的重试预算应与网络重连分开计算。
如果客户端进程在断线期间被系统杀死,重连计数会随进程消失。此时应依赖本地持久化的最后状态,而不是试图恢复退避历史。相关兜底可以参考杀进程与异常退出的兜底 。
小结
- 固定间隔会把失败者训练成同步脉冲,指数退避让等待随失败次数增长。
- 四个参数缺一不可:基准、封顶、最大次数、稳定重置窗口。
- 稳定重置必须看“连接持续了多久”,不能只看“是否连上”。
full分散最彻底,equal保留最小等待,none用于可复现的调试与压测。- 随机源必须可注入,否则抖动行为无法复现、无法断言。
exhausted要显式建模,停止自动重连并转人工操作。
阅读导航:上一节:3.1 连接状态机:从连接到就绪 · 下一节:3.3 重连后的状态校准 。
常见错误与边界
- 失败后立刻重连,退避只写在配置文件里,没有任何代码路径使用它。
- 只设置最大等待,不设置最大次数,客户端在后台无限重试。
- 连接成功就清零计数,网络边缘反复掉线时退避永远停在基准值。
- 抖动加在封顶之后而不是封顶之前,导致实际等待可能超过上限。
- 用
Math.random()直接计算且不留日志,线上异常延迟无法复现。 - 把不可重试的业务错误也送进退避,玩家等完八次才看到“账号异常”。
- 第 7 次以后仍在计算
2 ** n,没有意识到原始值早已溢出。 exhausted后仍保留定时器,UI 显示已放弃但后台还在偷偷重连。- 等待时间只写进内存,切后台再回来时计数归零,退避被系统生命周期打断。
- 把重连等待与业务重试共用一套参数,战斗消息和登录消息互相拖慢。
- 压测环境使用随机抖动,无法复现同一批请求的到达顺序。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。