《互联网产品的客户端架构·交易、增长与平台化》13.2 故障注入:重复提交、支付超时、回调乱序

正常主线只能证明系统会走路,故障注入才能证明它不会在摔倒时抓错东西。本节在进程内注入重复提交、支付超时、回调先成功后失败、重复通知与乱序通知,用假时钟推进查询,断言只产生一笔订单、支付状态不被弱事件改写、未确认支付绝不退款,并复盘一次三类故障叠加的事故。

正常主线证明不了的事

13.1 的主线是「一切按顺序发生」:下单成功、支付成功、退款成功。它证明系统会走路,但不能证明摔倒时不会抓错东西。

交易端的线上事故,绝大多数不是主流程写错,而是异常叠加:

  • 用户手抖点了两次,端上超时后又自动重试一次。
  • 支付渠道扣款成功,但回调晚了四十秒。
  • 回调先到一条失败,再到一条成功;或者同一条成功被重发三次。
  • 用户从渠道回跳,端上开始轮询,回调同时到达。

这些场景的共同点是同一个事实被多个来源、多个时刻描述。系统必须能判断:哪条是新的、哪条是重复的、哪条是过期的、哪条需要查询确认。

三类故障,三种错误诱惑

故障注入方式错误诱惑正确断言
重复提交同一幂等键调用两次再建一张订单订单数仍为一,订单号相同
支付超时支付返回未知,推进假时钟当成失败去退款只查询,不退款,不重复支付
回调乱序失败先到、成功后到用后到的成功覆盖终态过期通知忽略,歧义通知触发查询

注入点放在哪里

注入不修改被测逻辑,只控制端口返回值与事件到达顺序:

  • 重复提交:同一个幂等键调用两次 submit,观察键到订单的映射是否稳定。
  • 支付超时:pay 返回 unknown,推进假时钟后调用 query,查询结果由脚本决定。
  • 回调乱序:直接调用 notify,序号由测试指定,允许 2 先于 1 到达。

故障注入代码

下面的模块把订单、回调、查询、退款放进同一个进程内世界。回调按序号去重,成功是吸收态,失败不能覆盖已支付,退款用独立幂等键。

// file: src/summary/fault-injection.ts
// tier: L2
// verify: node --test test/summary/fault-injection.test.ts

export type FaultOrderState =
  | "pending_payment" | "paid" | "failed" | "cancelled" | "refunding" | "refunded";

export type FaultKind =
  | "duplicate_submit" | "pay_timeout_success" | "pay_timeout_failure"
  | "callback_success_then_fail" | "callback_duplicate" | "callback_out_of_order";

export interface FaultClock { now(): number; advance(ms: number): number }

export interface Notification { seq: number; result: "success" | "fail" }

export interface FaultOrder {
  orderId: string; submitKey: string; payableCents: number; paidCents: number;
  refundedCents: number; state: FaultOrderState; lastNotifySeq: number;
  notifications: string[];
}

export interface FaultLog { at: number; event: string; detail: string }

export interface FaultReport {
  kind: FaultKind; orders: number; finalState: FaultOrderState | null;
  payableCents: number; paidCents: number; refundedCents: number;
  refundCount: number; queryRequired: boolean; problems: string[]; log: FaultLog[];
}

interface RefundRecord {
  refundId: string; orderId: string; key: string; amountCents: number;
}

// 进程内世界:订单、回调、查询、退款都在一个对象里,时间由外部时钟推进。
export class FaultWorld {
  clock: FaultClock;
  orders: FaultOrder[];
  byKey: Map<string, FaultOrder>;
  refunds: RefundRecord[];
  refundKeys: Map<string, RefundRecord>;
  logRows: FaultLog[];
  queryRequired: boolean;

  constructor(clock: FaultClock) {
    this.clock = clock;
    this.orders = [];
    this.byKey = new Map();
    this.refunds = [];
    this.refundKeys = new Map();
    this.logRows = [];
    this.queryRequired = false;
  }

  note(event: string, detail: string): void {
    this.logRows.push({ at: this.clock.now(), event, detail });
  }

  // 重复提交的唯一判据是幂等键,不是按钮是否被点过。
  submit(key: string, payableCents: number): FaultOrder {
    const existing = this.byKey.get(key);
    if (existing !== undefined) {
      this.note("submit_reused", `${key} -> ${existing.orderId}`);
      return existing;
    }
    if (key.trim() === "") throw new Error("幂等键不能为空");
    if (!Number.isSafeInteger(payableCents) || payableCents <= 0) {
      throw new Error("实付金额必须是正整数分");
    }
    const order: FaultOrder = {
      orderId: `o-${this.orders.length + 1}`, submitKey: key, payableCents,
      paidCents: 0, refundedCents: 0, state: "pending_payment",
      lastNotifySeq: 0, notifications: [],
    };
    this.orders.push(order);
    this.byKey.set(key, order);
    this.note("submit_created", order.orderId);
    return order;
  }

  // 回调只携带线索:成功通知不会自动退款,失败通知也不能把已支付改回去。
  notify(orderId: string, notice: Notification): boolean {
    const order = this.orders.find((item) => item.orderId === orderId);
    if (order === undefined) throw new Error(`订单不存在:${orderId}`);
    if (!Number.isInteger(notice.seq) || notice.seq < 1) {
      throw new Error("回调序号必须是正整数");
    }
    if (notice.seq <= order.lastNotifySeq) {
      this.note("notify_stale", `${orderId}#${notice.seq} <= ${order.lastNotifySeq}`);
      return false;
    }
    order.lastNotifySeq = notice.seq;
    order.notifications.push(`${notice.seq}:${notice.result}`);
    const settled =
      order.state === "paid" || order.state === "refunding" || order.state === "refunded";
    if (notice.result === "success") {
      if (settled) {
        this.note("notify_duplicate_success", `${orderId}#${notice.seq}`);
        return false;
      }
      if (order.state === "failed" || order.state === "cancelled") {
        this.queryRequired = true;
        this.note("notify_late_success_query", `${orderId}#${notice.seq}`);
        return false;
      }
      order.paidCents = order.payableCents;
      order.state = "paid";
      this.note("notify_paid", `${orderId}#${notice.seq}`);
      return true;
    }
    if (settled) {
      this.note("notify_late_fail_ignored", `${orderId}#${notice.seq}`);
      return false;
    }
    if (order.state === "failed") {
      this.note("notify_duplicate_fail", `${orderId}#${notice.seq}`);
      return false;
    }
    order.state = "failed";
    this.note("notify_failed", `${orderId}#${notice.seq}`);
    return true;
  }

  // 超时后的唯一动作是查询;查询结果是终态,不能被更弱的回调覆盖。
  query(orderId: string, status: "paid" | "failed"): FaultOrder {
    const order = this.orders.find((item) => item.orderId === orderId);
    if (order === undefined) throw new Error(`订单不存在:${orderId}`);
    if (order.state === "paid" || order.state === "refunding" || order.state === "refunded") {
      this.note("query_ignored_terminal", orderId);
      return order;
    }
    if (status === "paid") {
      order.paidCents = order.payableCents;
      order.state = "paid";
    } else {
      order.state = "failed";
    }
    this.note(`query_${status}`, orderId);
    return order;
  }

  // 退款键独立于订单号:同一个退款请求重放不会退第二次。
  requestRefund(orderId: string, key: string, amountCents: number): RefundRecord {
    const existing = this.refundKeys.get(key);
    if (existing !== undefined) {
      this.note("refund_reused", `${key} -> ${existing.refundId}`);
      return existing;
    }
    const order = this.orders.find((item) => item.orderId === orderId);
    if (order === undefined) throw new Error(`订单不存在:${orderId}`);
    if (order.state !== "paid" && order.state !== "refunding") {
      throw new Error(`订单状态 ${order.state} 不允许退款`);
    }
    if (!Number.isSafeInteger(amountCents) || amountCents <= 0) {
      throw new Error("退款金额必须是正整数分");
    }
    if (amountCents > order.paidCents - order.refundedCents) {
      throw new Error("退款金额超过可退额度");
    }
    const record: RefundRecord = {
      refundId: `rf-${orderId}-${this.refunds.length + 1}`,
      orderId, key, amountCents,
    };
    this.refunds.push(record);
    this.refundKeys.set(key, record);
    order.refundedCents += amountCents;
    order.state = order.refundedCents === order.paidCents ? "refunded" : "refunding";
    this.note("refund_accepted", record.refundId);
    return record;
  }

  audit(order: FaultOrder | null): string[] {
    const problems: string[] = [];
    if (this.orders.length > 1) problems.push(`产生了 ${this.orders.length} 笔订单`);
    if (order === null) return problems;
    if (order.paidCents > order.payableCents) problems.push("支付金额超过订单金额");
    if (order.refundedCents > order.paidCents) problems.push("退款超过实付");
    if (order.state === "failed" && order.refundedCents > 0) {
      problems.push("支付失败却发生了退款");
    }
    if (this.queryRequired && order.state === "paid") {
      problems.push("乱序回调后未查询就确认了支付");
    }
    return problems;
  }
}

function report(
  kind: FaultKind, world: FaultWorld, order: FaultOrder | null,
): FaultReport {
  return {
    kind, orders: world.orders.length,
    finalState: order === null ? null : order.state,
    payableCents: order === null ? 0 : order.payableCents,
    paidCents: order === null ? 0 : order.paidCents,
    refundedCents: order === null ? 0 : order.refundedCents,
    refundCount: world.refunds.length,
    queryRequired: world.queryRequired,
    problems: world.audit(order),
    log: [...world.logRows],
  };
}

export function runFault(kind: FaultKind, clock: FaultClock): FaultReport {
  const world = new FaultWorld(clock);
  switch (kind) {
    case "duplicate_submit": {
      const first = world.submit("sub-draft-1-abcd1234", 4_398);
      const second = world.submit("sub-draft-1-abcd1234", 4_398);
      if (first.orderId !== second.orderId) world.note("duplicate_order", first.orderId);
      return report(kind, world, first);
    }
    case "pay_timeout_success": {
      const order = world.submit("sub-draft-1-abcd1234", 4_398);
      clock.advance(8_000);
      world.note("pay_timeout", order.orderId);
      world.query(order.orderId, "paid");
      world.requestRefund(order.orderId, "rf-key-1", 4_398);
      return report(kind, world, order);
    }
    case "pay_timeout_failure": {
      const order = world.submit("sub-draft-1-abcd1234", 4_398);
      clock.advance(8_000);
      world.note("pay_timeout", order.orderId);
      world.query(order.orderId, "failed");
      try {
        world.requestRefund(order.orderId, "rf-key-1", 4_398);
      } catch (error) {
        world.note("refund_rejected", String((error as Error).message));
      }
      return report(kind, world, order);
    }
    case "callback_success_then_fail": {
      const order = world.submit("sub-draft-1-abcd1234", 4_398);
      world.notify(order.orderId, { seq: 1, result: "success" });
      world.notify(order.orderId, { seq: 2, result: "fail" });
      world.requestRefund(order.orderId, "rf-key-1", 4_398);
      return report(kind, world, order);
    }
    case "callback_duplicate": {
      const order = world.submit("sub-draft-1-abcd1234", 4_398);
      world.notify(order.orderId, { seq: 1, result: "success" });
      world.notify(order.orderId, { seq: 1, result: "success" });
      world.notify(order.orderId, { seq: 2, result: "success" });
      world.requestRefund(order.orderId, "rf-key-1", 4_398);
      world.requestRefund(order.orderId, "rf-key-1", 4_398);
      return report(kind, world, order);
    }
    case "callback_out_of_order": {
      const order = world.submit("sub-draft-1-abcd1234", 4_398);
      world.notify(order.orderId, { seq: 2, result: "fail" });
      world.notify(order.orderId, { seq: 1, result: "success" });
      world.notify(order.orderId, { seq: 3, result: "success" });
      return report(kind, world, order);
    }
    default:
      throw new Error(`未知故障类型:${String(kind)}`);
  }
}

export function logEvents(report: FaultReport): string[] {
  return report.log.map((row) => row.event);
}

三个实现细节决定了断言是否可靠。

序号只用于去重,不用于判断真假。 序号 2 的失败回调先到,序号 1 的成功回调后到,后者被判为过期而不是「更早所以更真」。真实系统里序号由渠道生成,端上只能比较,不能重排。

成功是吸收态,但乱序成功要查询。 已支付后收到的失败通知直接忽略;已失败后收到的成功通知不能直接改状态,而是置 queryRequired,要求走一次权威查询。前者防止误退款,后者防止把真实成功丢掉。

退款键与订单号分离。 同一笔退款重放使用同一个退款键,第二次直接返回原记录。只靠订单号去重,会把「部分退款后又申请一笔」误判成重复。

测试与真实输出

六个场景加十条边界用例,全部在进程内运行,真实结果如下:

$ node --test --test-concurrency=1 test/summary/fault-injection.test.ts
✔ 同一个幂等键提交两次只保留一张订单 (1.062084ms)
✔ 重复提交复用同一个订单号 (0.080334ms)
✔ 空幂等键或非法金额被拒绝 (0.23925ms)
✔ 不同幂等键会创建不同订单 (0.086916ms)
✔ 支付超时后查询成功,只支付一次并正常退款 (0.169375ms)
✔ 支付超时后查询失败,不产生退款 (0.08025ms)
✔ 先成功后失败的回调不会把已支付改回失败 (0.127791ms)
✔ 重复的成功通知只确认一次支付 (0.070833ms)
✔ 同一个退款键重放不会退第二次 (0.083417ms)
✔ 先到的失败回调使较早的成功回调成为过期事件 (0.117208ms)
✔ 乱序后到达的成功回调触发查询而不是直接确认 (0.078459ms)
✔ 过期序号的回调被忽略 (0.070125ms)
✔ 未支付订单不能退款 (0.065917ms)
✔ 退款金额不能超过可退额度 (0.061875ms)
✔ 部分退款后订单停留在退款中 (0.050917ms)
✔ 未知故障类型会报错 (0.062ms)
ℹ tests 16
ℹ suites 0
ℹ pass 16
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 110.454625

十六条用例覆盖:幂等键复用、非法输入、不同键各自成单、超时查询成功与失败、成功后失败、重复成功通知、退款键重放、乱序序号、未支付退款拒绝、部分退款状态。

一次三类故障叠加的复盘

某交易客户端在弱网地区收到一批「重复扣款」投诉。单看每一类故障,团队都认为自己处理了:提交有幂等键,支付有超时提示,回调有去重。

复盘把时间线拼起来,问题出在叠加处:

  1. 用户点击提交,请求超时。端上把超时当成失败,引导用户再点一次。
  2. 第二次点击生成了新的幂等键,服务端创建了第二张订单。
  3. 用户为第二张订单付款,渠道扣款成功,但回调延迟。
  4. 端上轮询第一张订单,一直是待支付;用户以为没付成功,又付了一次。

四步里没有任何一步是「模块内部错误」:幂等键生成正确,超时提示正确,回调去重也正确。错在超时后没有复用原键,以及支付结果未知时没有先查询订单。

修复对应两条:提交会话在超时后保留原幂等键,重试必须复用;支付超时后只允许查询,查询确认未支付才允许重新发起。回归时补了「重复提交 + 支付超时 + 回调延迟」的组合用例,而不是三个独立用例。

这个复盘留下一条规则:故障注入要测组合,不测清单。单独注入三类故障都能通过,不代表三类同时发生还能通过。

小结

重复提交、支付超时、回调乱序是同一类问题的三种表现:同一个事实被多个来源描述。系统的职责不是猜哪条最新,而是用幂等键、权威查询和序号去重把事实收敛成一个。

本节的世界里,成功是吸收态,失败不能覆盖已支付,乱序成功必须查询,退款用独立幂等键。四条规则共同保证:不产生第二笔订单、不丢状态、不误退款。

下一节把前十二章的约束整理成可执行的质量场景表与风险台账,并说明哪些问题留给卷四。

阅读导航:上一节:13.1 端到端:选品、下单、支付、退款 · 下一节:13.3 质量场景与风险清单:进入卷四 。

常见错误与边界

  • 超时后生成新的幂等键,用户重试一次就多一张订单。
  • 把支付 unknown 当成 failed,直接进入退款或重新支付。
  • 回调去重只看「是否处理过」,不看序号,过期通知覆盖新状态。
  • 已支付后收到失败通知就把状态改回去,导致已收款订单显示失败。
  • 已失败后收到成功通知直接确认,跳过权威查询。
  • 退款只用订单号去重,部分退款与二次退款被误判为重复。
  • 假时钟只推进支付,不推进查询间隔,超时分支永远测不到。
  • 只测单类故障,不测「重复提交 + 回调延迟」这类组合。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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