故障注入不是造假
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 故障注入的结果宣称真实弱网或真机后台行为。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。