《互联网产品的客户端架构·内容、社交与实时互动》13.2 故障注入:断网、慢网与断线重连

用可控网络适配器与假时钟注入断网、慢网、乱序和断线,验证离线排队不产生副作用、重连后按高水位补拉、补拉与实时消息去重、部分响应保留剩余空洞,并复盘一次离线连点把点赞翻回原状的事故,说明每个故障场景的断言,以及为什么 L2 适配器不能替代真实 socket 与真机弱网。

故障注入不是造假

13.2 不搭建混沌工程平台,也不模拟真实丢包率。它只做一件事:把网络变成一个可控适配器,让断网、慢网、乱序与断线在进程内按脚本发生,然后用假时钟推进时间,断言端上的状态。

它不重复 7.1 的退避公式,也不重复 7.3 的区间算法。这里关心的是组合:离线写入会不会丢、恢复后补拉会不会重、补拉与实时推送会不会双计、部分响应会不会被当成完整。验证级别是 L2,没有真实端口,也没有浏览器后台策略。

三类故障与三条断言

故障注入方式期望行为断言
断网mode = "offline",请求立即拒绝写入留在队列,服务端计数不变pending 不减少,MutationLog 全零
慢网mode = "slow",latencyMs 生效请求在 advance 前不落地推进 99ms 仍为零,补 1ms 才生效
乱序reorder = true,到期请求反序交付幂等写入最终一致交付顺序反转,两个条目的计数都正确
断线水位停住,恢复后补拉按高水位补齐缺口并去重remainingGaps 为空,重复消息为 duplicate

四条故障里有两条是“时间”问题,两条是“顺序”问题。它们都不需要真实网络,但都需要一个明确的时间源与一个可观察的请求队列。

可控网络:时间是一个计数器

ControlledNetwork 只有三种模式:在线、慢网、离线。慢网请求进入 #pending,记录 due = now + latency;advance(ms) 推进计数器,把到期请求按顺序或反序 resolve。整个过程不调用 setTimeout,因此不会受 CI 调度影响。

request 还接受一个 label,交付时记入 deliveryOrder()。这让“乱序”可以被断言,而不是靠日志猜测。

一个容易忽略的点:离线时 request 返回 rejected Promise,而不是抛同步异常。队列用 Promise.allSettled 批量接收,失败项原样留在队列里。这样“离线”与“服务端拒绝”可以走同一条恢复路径。

离线队列与幂等日志

Outbox 保存待发送的写入,enqueue 按幂等键去重,flush 一次把整批请求发出去。失败项留在队列,成功项交给 MutationLog。

MutationLog 只认幂等键。同一个键第二次到达时返回 applied: false,计数不变。点赞写入携带的是目标状态 active,不是“翻转”动作;因此重复投递、超时重试、乱序交付都不会把计数翻回去。这条约定来自 5.1,本节只是把它放进故障场景。

flush 用 Promise.allSettled 而不是串行 await,是为了让一批写入共享同一个时间窗口。串行发送会让第二个请求的 due 依赖第一个请求的完成时刻,慢网测试就不再是“同一批请求等同一个延迟”。

重连补拉:高水位而不是最新一条

RealtimeSync 包装 7.3 的 GapTracker 与 CatchUpSession。实时消息与补拉响应都进入同一个 tracker:实时消息按 ingest 排队,补拉响应也按 ingest 合并。重复消息按 id 判 duplicate,超前消息先 buffered,缺口补齐后连续应用。

补拉的起点是连续高水位,不是收到过的最大序列号。CatchUpSession 返回的 remainingGaps 表达“还没补完”;调用方不能只看 Promise 是否 resolve。补拉期间再次触发会被 #running 拒绝,避免多个响应交错。

可运行代码

// file: src/summary/fault-injection.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/summary/fault-injection.test.ts
import { CatchUpSession, GapTracker } from "../reliability/gap.ts";
import type {
  CatchUpReport,
  GapIngestResult,
  PullSource,
  SequencedMessage,
} from "../reliability/gap.ts";

export type NetworkMode = "online" | "slow" | "offline";

export class OfflineError extends Error {
  constructor() {
    super("网络离线");
    this.name = "OfflineError";
  }
}

// 时间只以计数器形式存在,advance 负责推进;不依赖真实 setTimeout。
export class ControlledNetwork {
  #mode: NetworkMode;
  #latency: number;
  #reorder: boolean;
  #now = 0;
  #order = 0;
  #pending: Array<{
    due: number;
    order: number;
    label: string;
    value: unknown;
    resolve: (value: unknown) => void;
  }> = [];
  #delivered: string[] = [];

  constructor(options: { mode?: NetworkMode; latencyMs?: number; reorder?: boolean } = {}) {
    this.#mode = options.mode ?? "online";
    this.#latency = options.latencyMs ?? 0;
    this.#reorder = options.reorder ?? false;
    assertLatency(this.#latency);
  }

  get now(): number {
    return this.#now;
  }

  get pending(): number {
    return this.#pending.length;
  }

  setMode(mode: NetworkMode): void {
    this.#mode = mode;
  }

  deliveryOrder(): string[] {
    return [...this.#delivered];
  }

  request<T>(value: T, label = ""): Promise<T> {
    if (this.#mode === "offline") return Promise.reject(new OfflineError());
    if (this.#mode === "online" || this.#latency === 0) {
      this.#delivered.push(label);
      return Promise.resolve(value);
    }
    this.#order += 1;
    return new Promise<T>((resolve) => {
      this.#pending.push({
        due: this.#now + this.#latency,
        order: this.#order,
        label,
        value,
        resolve: resolve as (value: unknown) => void,
      });
    });
  }

  // 一次推进最多交付一批;需要连续交付时由调用方继续 advance。
  async advance(ms: number): Promise<void> {
    if (!Number.isInteger(ms) || ms < 0) throw new RangeError("advance 必须是非负整数");
    this.#now += ms;
    const due = this.#pending.filter((delivery) => delivery.due <= this.#now);
    this.#pending = this.#pending.filter((delivery) => delivery.due > this.#now);
    due.sort((a, b) => (this.#reorder ? b.order - a.order : a.order - b.order));
    for (const delivery of due) {
      this.#delivered.push(delivery.label);
      delivery.resolve(delivery.value);
    }
    await Promise.resolve();
    await Promise.resolve();
  }
}

export interface Mutation {
  key: string;
  kind: "like" | "comment";
  itemId: string;
  active?: boolean;
  text?: string;
}

export interface MutationState {
  likeCount: number;
  commentCount: number;
}

export interface FlushReport {
  sent: number;
  failed: number;
  deduped: number;
  pending: number;
}

// 服务端按幂等键去重;重复投递只返回当前状态,不再改计数。
export class MutationLog {
  #applied = new Set<string>();
  #liked = new Set<string>();
  #likes = new Map<string, number>();
  #comments = new Map<string, number>();
  #texts: string[] = [];

  apply(mutation: Mutation): MutationState & { applied: boolean } {
    assertMutation(mutation);
    const state = this.state(mutation.itemId);
    if (this.#applied.has(mutation.key)) return { applied: false, ...state };
    this.#applied.add(mutation.key);
    if (mutation.kind === "like") {
      const next = mutation.active ?? false;
      if (this.#liked.has(mutation.itemId) !== next) {
        this.#likes.set(mutation.itemId, Math.max(0, state.likeCount + (next ? 1 : -1)));
        if (next) this.#liked.add(mutation.itemId);
        else this.#liked.delete(mutation.itemId);
      }
    } else {
      this.#comments.set(mutation.itemId, state.commentCount + 1);
      this.#texts.push(mutation.text ?? "");
    }
    return { applied: true, ...this.state(mutation.itemId) };
  }

  state(itemId: string): MutationState {
    return {
      likeCount: this.#likes.get(itemId) ?? 0,
      commentCount: this.#comments.get(itemId) ?? 0,
    };
  }

  get appliedCount(): number {
    return this.#applied.size;
  }

  get texts(): string[] {
    return [...this.#texts];
  }
}

// 离线时写入留在队列;恢复后整批重放,服务端用幂等键兜底。
export class Outbox {
  #queue: Mutation[] = [];

  enqueue(mutation: Mutation): boolean {
    assertMutation(mutation);
    if (this.#queue.some((entry) => entry.key === mutation.key)) return false;
    this.#queue.push({ ...mutation });
    return true;
  }

  get pending(): number {
    return this.#queue.length;
  }

  async flush(network: ControlledNetwork, log: MutationLog): Promise<FlushReport> {
    const batch = [...this.#queue];
    if (batch.length === 0) return { sent: 0, failed: 0, deduped: 0, pending: 0 };
    const settled = await Promise.allSettled(
      batch.map((mutation) => network.request(mutation, mutation.key)),
    );
    let sent = 0;
    let failed = 0;
    let deduped = 0;
    const remaining: Mutation[] = [];
    settled.forEach((result, index) => {
      const mutation = batch[index];
      if (result.status === "rejected") {
        failed += 1;
        remaining.push(mutation);
        return;
      }
      sent += 1;
      if (!log.apply(result.value).applied) deduped += 1;
    });
    this.#queue = remaining;
    return { sent, failed, deduped, pending: remaining.length };
  }
}

// 包装 7.3 的 GapTracker 与 CatchUpSession:实时消息和补拉响应进入同一个 tracker。
export class RealtimeSync {
  #tracker: GapTracker;
  #running = false;

  constructor(startSeq = 0) {
    this.#tracker = new GapTracker({ startSeq });
  }

  get highWater(): number {
    return this.#tracker.highWater;
  }

  get pendingCount(): number {
    return this.#tracker.pendingCount;
  }

  get running(): boolean {
    return this.#running;
  }

  appliedSeqs(): number[] {
    return this.#tracker.appliedSeqs();
  }

  receive(message: SequencedMessage): GapIngestResult {
    return this.#tracker.ingest(message);
  }

  async catchUp(source: PullSource): Promise<CatchUpReport> {
    if (this.#running) throw new Error("补拉已在进行中");
    this.#running = true;
    try {
      return await new CatchUpSession(this.#tracker, source, () => 0).catchUp();
    } finally {
      this.#running = false;
    }
  }
}

function assertLatency(ms: number): void {
  if (!Number.isInteger(ms) || ms < 0) throw new RangeError("latencyMs 必须是非负整数");
}

function assertMutation(mutation: Mutation): void {
  if (typeof mutation.key !== "string" || mutation.key.length === 0) {
    throw new Error("幂等键不能为空");
  }
  if (mutation.kind !== "like" && mutation.kind !== "comment") {
    throw new Error(`未知写入类型: ${mutation.kind}`);
  }
  if (typeof mutation.itemId !== "string" || mutation.itemId.length === 0) {
    throw new Error("itemId 不能为空");
  }
  if (mutation.kind === "like" && typeof mutation.active !== "boolean") {
    throw new Error("点赞必须给出目标状态");
  }
  if (mutation.kind === "comment" && (mutation.text ?? "").trim() === "") {
    throw new Error("评论内容不能为空");
  }
}

代码里没有真实定时器,也没有真实 socket。ControlledNetwork.advance 是唯一的时间入口;MutationLog 是唯一的服务端事实;RealtimeSync.catchUp 是唯一的补拉入口。三者都可以在毫秒内复现。

测试与真实输出

八条用例覆盖离线、幂等键、慢网、乱序、断线补拉、部分响应、补拉失败重试与实时乱序。本机 Node 26.5.0 输出如下:

$ node --test --test-concurrency=1 test/summary/fault-injection.test.ts
✔ 离线时写入只进队列,不产生服务端副作用 (1.067ms)
✔ 同一幂等键重复入队只保留一条,重放不重复计数 (0.286334ms)
✔ 慢网下请求在假时钟推进前不落地 (0.228834ms)
✔ 乱序交付不改变幂等写入的最终状态 (0.138958ms)
✔ 断线重连后按高水位补拉缺失区间,并与实时消息去重 (0.470459ms)
✔ 补拉只返回一部分时保留剩余空洞,下一次继续补齐 (0.152ms)
✔ 补拉期间离线会失败且水位不前进,恢复后可以重试 (0.336042ms)
✔ 乱序实时消息先缓冲,缺口补齐后连续应用 (0.123584ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 198.857667

这些用例证明的是“给定故障脚本,端上状态如何收敛”,不是“真实网络一定这样”。比如慢网用例只推进一个计数器,不模拟 TCP 重传、DNS 超时或运营商 NAT。

失败复盘:离线连点把点赞翻回原状

「回声」早期用 POST /like/toggle,离线时把三次点击都排进队列。恢复后队列按顺序重放:第一次点赞、第二次取消、第三次再点赞。服务端最终是“已点赞”,但中间给三个请求都回了成功;如果其中一次超时重试,队列里就会多出一次翻转,最终状态取决于重试次数,而不是用户意图。

更糟的是弱网下的响应乱序。第一次点赞的响应后到,把界面改成已点赞;第二次取消的响应先到,又把界面改成未点赞。用户看到图标闪两次,计数也跟着跳。

修复不是“离线时禁止点赞”,而是两条协议约束:写入携带目标状态,服务端把“设为 true/false”做成幂等;队列按幂等键去重,重放多少次都只产生一次副作用。修复后同样的离线连点,最终状态由最后一次用户意图决定,不由重试次数决定。

第二个事故来自补拉。重连后只拉“最新一条”,把本地游标直接推到最新,中间的 101–129 永远不会被请求。修复是维护连续高水位,按区间补拉,并在报告里保留 remainingGaps。这两条修复分别对应本节的两个模块。

故障注入证明了什么,没证明什么

它证明了:离线写入不会产生服务端副作用;恢复后队列可以重放;幂等键能吸收重复;慢网请求在时间推进前不落地;乱序交付不破坏最终状态;补拉按高水位补齐缺口,部分响应会留下剩余空洞;补拉失败后水位不前进,可以重试。

它没有证明:真实 socket 的断开与重连时序、真实丢包与重传、浏览器后台节流、服务端历史保留策略、多端并发写入的冲突解决。这些需要 L3 端口、真机或服务端联调,不能用一个进程内适配器替代。

小结

故障注入的价值不是“制造错误”,而是把错误变成可断言的输入。断网断言队列不丢,慢网断言时间可控,乱序断言幂等收敛,断线断言高水位补拉。四条断言合起来,才是“最终一致”而不是“看起来恢复了”。

网络恢复之后,还有一类状态没有回答:用户看到的“在线”“正在输入”到底意味着什么。下一节先把卷二验证到的地方与没验证的地方写成质量场景,再交接到卷三。

阅读导航:上一节:13.1 端到端场景:发内容、互动、实时回复 · 下一节:13.3 质量场景与风险清单:进入卷三 。

常见错误与边界

  • 用真实 setTimeout 测慢网,CI 抖动让 99ms 与 100ms 的边界无法稳定复现。
  • 离线时直接丢弃写入,恢复后用户发现点赞和评论都没了。
  • 队列不去重,重连后同一批写入被重放两次。
  • 点赞接口用 toggle,重试把用户意图翻回相反状态。
  • 补拉从“收到过的最大序列号”开始,乱序时把中间空洞永久跳过。
  • 把补拉 Promise 的 resolve 当成“补完了”,忽略 remainingGaps。
  • 补拉与实时推送使用两个容器,同一消息被应用两次。
  • 用 L2 故障注入的结果宣称真实弱网或真机后台行为。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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