《移动游戏产品的架构·网络、资源与热更新》3.2 指数退避与抖动:重连节奏怎么定

重连风暴不是带宽问题,而是所有客户端在同一时刻做同一件事。《潮汐方舟》14% 的会话处于弱网,断线率 1.4%,固定间隔重试会让失败者在同一秒再次碰撞。本节用指数增长、封顶、最大尝试次数与稳定重置四个参数,加上 full、equal、none 三种抖动,把重连节奏写成可复现的纯函数模型。

场景:失败之后,所有客户端在同一秒醒来

《潮汐方舟》的战斗内心跳是 5 秒,断线率是 1.4%,弱网会话占比是 14%。一次机房抖动会让一批客户端同时掉线,如果每个客户端都在固定 3 秒后重试,它们会在几乎同一时刻再次撞上服务端。失败者继续按固定间隔重试,峰值不但没有被打散,反而被训练成整齐的脉冲。

这就是重连风暴的典型形态:不是总带宽不够,而是请求在时间上过度集中。客户端能控制的不是服务端容量,而是自己的重试节奏。指数退避解决“越失败、等越久”,抖动解决“即使都失败,也不要同时醒来”。两者缺一不可。

固定间隔最危险的地方在于它把“失败次数”从节奏里抹掉了。第一次失败和第八次失败得到同样的等待,客户端在服务端已经过载时仍保持高频敲击。指数退避把失败次数变成等待时间,抖动把同一失败次数的客户端分散开,最大尝试次数则保证客户端不会无限敲下去。

四个参数:增长、封顶、上限与重置

退避策略需要四个独立参数,任何一个缺失都会留下一个具体的故障模式。

参数作用缺失后的故障实验取值
baseMs第一次等待的基准首败立即重试,形成小脉冲500ms
capMs单次等待的上限等待无限增长,玩家放弃20000ms
maxAttempts自动重连次数上限客户端永远重试,耗电耗流量8 次
stableResetMs连接稳定多久后清零计数一次偶发断线让全天重连都变慢10000ms

四个参数里最容易被忽略的是 stableResetMs。如果只按“连接成功”清零,那么玩家在网络边缘反复掉线、每次只连上两秒,退避计数会被反复重置,等待永远停在 500ms。正确的重置条件是“连接稳定超过一个窗口”,例如连续在线 10 秒后才认为这次连接有效。实验取值不是案例数据,上线前要按心跳间隔和战斗时长重新标定。

maxAttempts 也不是越大越好。超过上限后应进入“人工重试”状态:停止自动重连,给出明确按钮,并提示检查网络。继续在后台无限重试,既无法提升成功率,又会加速耗电,还会让玩家误以为客户端仍在努力。

封顶值要和产品节奏对齐。《潮汐方舟》一场战斗 8–12 分钟,如果单次等待超过 20 秒,玩家实际上已经脱离战斗;此时继续等待不如明确告知并转入离线处理。封顶不是技术常数,而是产品对“还能不能回到这一局”的判断。

增长序列与封顶:把失败次数变成等待

按实验参数计算,前八次失败的无抖动等待如下。所有数字都是 mga-net-lab 的实验设定,不是线上实测。

失败次数原始等待封顶后是否触顶
1500ms500ms否
21000ms1000ms否
32000ms2000ms否
44000ms4000ms否
58000ms8000ms否
616000ms16000ms否
732000ms20000ms是
864000ms20000ms是

触顶之后,继续失败不会让等待继续增长,只会消耗尝试次数。这一点常被写成“第 7 次以后都等 20 秒”,但实现上更稳妥的写法是先算出原始值,再统一取 min(raw, cap),避免在分支里漏掉边界。

增长序列还有一个隐含问题:baseMs * 2 ** (attempt - 1) 在尝试次数很大时会溢出。虽然最大尝试次数通常会先拦住它,但函数本身仍应对异常输入报错,而不是返回 Infinity 后让封顶逻辑替它收拾。模型里对非整数和小于 1 的尝试次数直接抛 RangeError,让调用错误尽早暴露。

三种抖动:full、equal 与 none

抖动不是“加一个随机数”这么简单。常见的三种策略对尾部延迟和分散效果的影响不同。

策略计算方式优点代价
none直接使用封顶值可预测、易排查同步失败时完全共振
fullrandom * capped分散最彻底,尾部最短可能抽到接近 0,立即重试
equalcapped/2 + random * capped/2保留最小等待,避免立即重试分散范围只有一半

none 并非一无是处。内网调试、灰度验证和需要精确复现的压测里,确定性比分散更重要。生产环境默认用 full,因为它把等待压到 [0, capped),能最大程度打散同一批失败者;代价是偶尔抽到极小值,等于立即重试。若业务不能接受“刚失败就重试”,用 equal 保底一半等待。

随机源必须可注入。测试里传入固定序列,才能断言“第 1 次、随机值 0.5、封顶 500ms,最终等待 250ms”这样的具体结果;如果函数内部直接调用 Math.random(),测试只能断言范围,无法复现抖动引发的线上问题。

抖动只解决“同时重试”,不解决“重试是否值得”。如果失败原因是账号被封、协议版本不兼容,任何退避都只是延迟失败。因此重试前要先分类错误:可重试的网络错误进入退避,不可重试的业务错误直接终止并提示。

重连状态:waiting、connecting、exhausted

退避模型本身也是一个状态机,只是比连接状态机小。它只关心四件事:失败后等多久、何时发起下一次连接、连接是否已经稳定、尝试次数是否耗尽。

当前状态事件结果
idle / waitingfailed次数加一,计算等待,进入 waiting
waitingretry进入 connecting,清空待等待时间
connectingconnected记录连接时间,开始稳定计时
connectingstable稳定窗口满足则清零回 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 重连后的状态校准 。

常见错误与边界

  1. 失败后立刻重连,退避只写在配置文件里,没有任何代码路径使用它。
  2. 只设置最大等待,不设置最大次数,客户端在后台无限重试。
  3. 连接成功就清零计数,网络边缘反复掉线时退避永远停在基准值。
  4. 抖动加在封顶之后而不是封顶之前,导致实际等待可能超过上限。
  5. 用 Math.random() 直接计算且不留日志,线上异常延迟无法复现。
  6. 把不可重试的业务错误也送进退避,玩家等完八次才看到“账号异常”。
  7. 第 7 次以后仍在计算 2 ** n,没有意识到原始值早已溢出。
  8. exhausted 后仍保留定时器,UI 显示已放弃但后台还在偷偷重连。
  9. 等待时间只写进内存,切后台再回来时计数归零,退避被系统生命周期打断。
  10. 把重连等待与业务重试共用一套参数,战斗消息和登录消息互相拖慢。
  11. 压测环境使用随机抖动,无法复现同一批请求的到达顺序。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查