29 在现实世界中抛球杂耍
用事件、状态机和明确时序处理现实输入,保证突发、乱序和重复事件都能被解释。
学习目标
- 能解释外部事件为什么必须先保存身份、顺序和拒绝理由,再触发副作用
- 能修改一段事件处理代码,使重复与乱序输入不会重复改变外部状态
- 能回答:当迟到事件已经越过排序节点时,首差在哪里、如何从原始输入恢复?
为什么整齐到达的输入不值得当作前提
想象机场行李被五个传送带依次处理:先收下行李,再判断去向,最后才让它登机。现实里,同一位旅客的箱子可能分开到达、重复扫描,或者在下一班飞机已经起飞后才出现。若工作人员只看最后一件箱子,系统就会把迟到、重复和丢失混成一个“偶发错误”。
这章要解决的是:怎样让每一次外部输入都有身份、有顺序、有停点,并且在出错后能重建现场。没有这条纪律,程序可能重复扣款、倒退订单状态,或为了“继续成功”偷偷丢掉无法解释的输入。
本章的验收合同与证据包
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 29 在现实世界中抛球杂耍。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或练习答案。
验收对象是一批订单事件,固定输入为 e-101(seq=1)、e-103(seq=3)、e-102(seq=2) 和重复的 e-101(seq=1)。每次实验只改变一个条件,并保存:输入身份、当前节点、实际首差、拒绝理由、外部写入次数和恢复动作。读者完成本章后,应能在同一批输入上重放“正常、边界、单故障”三种样本。
先猜一猜:如果 seq=3 比 seq=2 先到,哪一个节点应该先改变?打开实验台,推进到排序节点,再注入乱序故障;不要只看最终的“成功”文本。
第 1 / 5 步:接收 已留下证据。
常见误区与回退
五个节点:让事件经过一条可解释的路径
接收:先保存事件流
<Term def="按到达顺序保存外部事件及其身份、序列号和原始内容的输入队列。">事件流</Term>不是一串可以随手丢弃的回调,而是恢复所需的第一份证据。每个输入至少包含 eventId、业务对象、seq 和发生时间;解析失败也要留下拒绝记录,不能用空值代替。
分类:先决定能否继续
<Term def="用有限的状态和转移规则描述事件处理过程;不允许的转移会在边界被拒绝。">状态机</Term>把“收到什么”与“允许做什么”分开。收到 PAID 时可以从 PENDING 进入 PAID,但迟到的 PENDING 不能把它倒退回去。分类节点要返回接受、延迟或拒绝,并说明依据。
排序:让时间关系显式化
<Term def="事件实际到达顺序与业务序列顺序不一致的情况;处理器必须等待、重排或拒绝,而不能盲目应用。">乱序</Term>不是网络的借口,而是输入合同的一部分。先把 seq=3 放入等待区,直到 seq=2 到达或超时规则明确拒绝;排序的目的不是追求完美时间,而是避免没有依据地执行副作用。
去重:同一输入只改变一次
<Term def="让重复执行同一个输入只产生一次可观察结果的处理方式;重复请求会得到已有结果而不是再次写入。">幂等</Term>需要稳定的 eventId 和持久的已处理记录。内存里的 Set 只能说明进程还没重启;真实边界要把去重标记和副作用结果放在同一事务或等价的可恢复合同里。
恢复:慢下游也不能吞掉输入
<Term def="下游处理速度变慢时,限制继续流入的工作量并保留未处理输入的机制。">背压</Term>把“继续接收”变成可控制的选择:暂停、排队、降级或拒绝。恢复时从原始输入重放,而不是手动修改最后一个状态;这样复核者才能知道修复后的路径是否真的可重复。
读图:五步抛球怎样避免重复副作用
图中的五个节点对应同一批事件,不是五个互相独立的服务。每条边都要回答“传递了什么、谁拥有状态、失败后停在哪里”。当乱序故障打开时,红色首差会停在排序之后;后面的去重和恢复不能假装收到了可信输入。
逐步观察:把一次输入变成可重放的状态变化。
1. 接收并冻结原始输入
接收:先留住原始事件
先把四个事件按收到的顺序写入事件流,保留 eventId、seq、payload 和接收时间。此时只完成“保存”,不调用扣款、发货或通知;验证点是每个输入都能被独立定位。
代码对照:把状态转移与副作用分开
先定义输入和状态转移的边界。处理器返回“接受、等待或拒绝”,这样测试可以在副作用发生前验证时序;代码块控制在一个逻辑段内,完整实现仍应放进项目测试中。
type Event = {
id: string;
seq: number;
kind: "PAID" | "SHIPPED";
};
type OrderState = "PENDING" | "PAID" | "SHIPPED";
type Decision =
| { kind: "apply"; next: OrderState }
| { kind: "wait"; reason: "out-of-order" }
| { kind: "reject"; reason: "invalid-transition" };
function decide(
state: OrderState,
event: Event,
expectedSeq: number,
): Decision {
if (event.seq !== expectedSeq)
return { kind: "wait", reason: "out-of-order" };
if (state === "PENDING" && event.kind === "PAID")
return { kind: "apply", next: "PAID" };
if (state === "PAID" && event.kind === "SHIPPED")
return { kind: "apply", next: "SHIPPED" };
return { kind: "reject", reason: "invalid-transition" };
}这里的 decide 没有发送通知或扣库存;它只回答当前事件能否改变状态。wait 不是成功,也不是丢弃,而是把输入留在等待区并等待更多证据。
再把去重和副作用放到一个能被测试的边界。示例中的 seen 代表持久存储接口,真实实现要让“记录已处理”和“提交副作用”具有同一份一致性合同。
function applyOnce(event: Event, seen: Set<string>, write: () => void) {
if (seen.has(event.id))
return { applied: false, reason: "duplicate" as const };
write();
seen.add(event.id);
return { applied: true, reason: "new" as const };
}若 write() 成功而 seen.add() 失败,内存示例会暴露一个需要事务或幂等存储解决的边界;不要把这个边界藏进“重试就好”的口号里。至少要测试重复输入、排序等待、非法转移和下游变慢四种结果。
正常、边界与单故障矩阵
| 样本 | 只改变的变量 | 预期 | 必存证据 |
|---|---|---|---|
| 正常 | 事件按 seq=1,2,3 到达 | 状态逐步前进,每个事件只产生一次副作用 | 原始输入、状态版本、写入次数 |
| 边界 | seq=3 先到或恰好超时 | 等待或明确拒绝,不倒退状态 | 等待原因、超时规则、队列内容 |
| 单故障 | 只注入重复 eventId | 返回已有结果,不重复通知或扣款 | 首差、去重记录、恢复与重放 |
三类样本共享代码版本、对象身份和起始状态。若同时改排序窗口、重试次数和下游实现,就无法知道首差来自哪里;应恢复基线,再只注入一个条件。
可重放记录
juggling_record:
unit: tpp20-topic-29-juggling-real-world
object: order-101
input: [e-101:1, e-103:3, e-102:2, e-101:1]
expected_path: receive -> classify -> order -> dedupe -> recover
injected_change: out-of-order-seq-3
first_difference: order-boundary
rejected_effect: notification-and-inventory-write
recovery: restore-queue-and-replay-original-events
proof: state-version-and-side-effect-count独立复核者只需要这份记录、同一批输入和验收命题,就能重建首差。若重放结果不同,先比较第一个分叉的输入与状态版本;不要用最终状态覆盖原日志。
跨团队迁移:保留顺序证据,替换实现
迁移到消息队列、移动端同步或 AI 辅助开发时,工具可以替换传输方式、生成测试或画出状态图,但不能替换事件身份、顺序合同和恢复证据。先写清哪些输入可重复、哪些输入可延迟、哪些输入必须拒绝,再选择重试、缓存和并发策略。
在自动化工具生成的补丁上,分别运行正常、乱序、重复和下游变慢四类样本。通过静态检查只说明代码符合某个形状;只有状态版本、外部写入次数和原始输入重放一致,才说明副作用边界真的守住了。
本章回顾
- 事件先保存身份和顺序,再进入状态转移。
- 乱序输入应等待、重排或拒绝,不能直接触发副作用。
- 幂等把重复事件变成已有结果,而不是第二次写入。
- 背压保留慢下游期间的输入;恢复从原始队列重放。
- 首差、拒绝理由和写入次数比最后一行成功文本更可复核。
可验证练习
练习
本组练习围绕 29 在现实世界中抛球杂耍 的事件流、状态机、乱序、去重和恢复证据展开。
问题 1:改 Demo 代码。 修改 applyOnce,让重复 eventId 返回第一次的副作用结果,并写一个断言证明重复事件不会再次调用 write。
问题 2:定位首差。 seq=3 先到、seq=2 后到,订单已经显示“已发货”。你会把首差记在哪个节点?还要保存什么?
问题 3:设计恢复合同。 下游处理速度下降,事件队列持续增长。请写出一个接受、暂停或拒绝的选择,并说明何时可以恢复消费。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 事件流
按顺序保存外部输入的队列;每个输入都带身份和原始内容,方便之后重放。
- 状态机
一张写清“现在是什么状态、收到什么输入后能去哪”的规则表。
- 乱序
业务上应该先发生的输入晚到了;系统要等待、重排或拒绝它。
- 幂等
同一个输入重复执行时,外部世界只被改变一次,后续调用返回已有结果。
- 背压
下游变慢时放慢、暂停或拒绝上游输入,避免队列和未完成工作失去控制。
来源与改写范围
- 作者与英文版页面:核对 20 周年版书名、版本与 Topic 29 目录坐标。
- 中文公开目录:核对“29 在现实世界中抛球杂耍”的中文目录坐标。
- 书目与版本记录:辅助核对译者、出版社、出版时间与 ISBN。
本页是基于公开目录的 independent rewrite;五节点模型、TypeScript 示例、专属图示、故障实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。