正常主线证明不了的事
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
十六条用例覆盖:幂等键复用、非法输入、不同键各自成单、超时查询成功与失败、成功后失败、重复成功通知、退款键重放、乱序序号、未支付退款拒绝、部分退款状态。
一次三类故障叠加的复盘
某交易客户端在弱网地区收到一批「重复扣款」投诉。单看每一类故障,团队都认为自己处理了:提交有幂等键,支付有超时提示,回调有去重。
复盘把时间线拼起来,问题出在叠加处:
- 用户点击提交,请求超时。端上把超时当成失败,引导用户再点一次。
- 第二次点击生成了新的幂等键,服务端创建了第二张订单。
- 用户为第二张订单付款,渠道扣款成功,但回调延迟。
- 端上轮询第一张订单,一直是待支付;用户以为没付成功,又付了一次。
四步里没有任何一步是「模块内部错误」:幂等键生成正确,超时提示正确,回调去重也正确。错在超时后没有复用原键,以及支付结果未知时没有先查询订单。
修复对应两条:提交会话在超时后保留原幂等键,重试必须复用;支付超时后只允许查询,查询确认未支付才允许重新发起。回归时补了「重复提交 + 支付超时 + 回调延迟」的组合用例,而不是三个独立用例。
这个复盘留下一条规则:故障注入要测组合,不测清单。单独注入三类故障都能通过,不代表三类同时发生还能通过。
小结
重复提交、支付超时、回调乱序是同一类问题的三种表现:同一个事实被多个来源描述。系统的职责不是猜哪条最新,而是用幂等键、权威查询和序号去重把事实收敛成一个。
本节的世界里,成功是吸收态,失败不能覆盖已支付,乱序成功必须查询,退款用独立幂等键。四条规则共同保证:不产生第二笔订单、不丢状态、不误退款。
下一节把前十二章的约束整理成可执行的质量场景表与风险台账,并说明哪些问题留给卷四。
阅读导航:上一节:13.1 端到端:选品、下单、支付、退款 · 下一节:13.3 质量场景与风险清单:进入卷四 。
常见错误与边界
- 超时后生成新的幂等键,用户重试一次就多一张订单。
- 把支付
unknown当成failed,直接进入退款或重新支付。 - 回调去重只看「是否处理过」,不看序号,过期通知覆盖新状态。
- 已支付后收到失败通知就把状态改回去,导致已收款订单显示失败。
- 已失败后收到成功通知直接确认,跳过权威查询。
- 退款只用订单号去重,部分退款与二次退款被误判为重复。
- 假时钟只推进支付,不推进查询间隔,超时分支永远测不到。
- 只测单类故障,不测「重复提交 + 回调延迟」这类组合。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。