后端系统工程 03:单库基线与故障实验工具
后端系统工程 03:单库基线与故障实验工具
本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收
前三篇给出了承诺(第 00 篇)、契约(第 01 篇)和负载模型(第 02 篇)。它们都需要一个运行环境来检验:承诺需要可执行的检查,契约需要在崩溃和并发下观察回复与存储状态,模型的结构性质需要测试。本篇搭建这个环境,并规定实验的写法。
基线选择单个 PostgreSQL 实例。不是因为单库最终够用,而是因为它让每一个结论都可以追溯:所有持久状态在一个地方,事务语义由数据库文档定义,崩溃恢复由 WAL 保证,故障可以在进程级别精确注入。在单库上无法成立的不变量,换成分布式存储也不会自动成立;单库上成立的不变量,在迁移到更复杂的存储时可以作为回归基准。
1. 需求:一个实验工具要保证什么
| 编号 | 要求 | 原因 |
|---|---|---|
| E1 | 每次实验从已知状态开始 | 不同实验之间不能通过残留数据互相影响 |
| E2 | 故障发生在指定的程序位置,而不是随机时刻 | 结论要指向具体的代码路径,例如“提交之后、回复之前” |
| E3 | 每个 PASS 都对应一个断言和一个观测值 | 防止“运行完没有报错”被当成“验证通过” |
| E4 | 每个机制至少配一个负对照 | 证明检查有能力发现被测的问题 |
| E5 | 证据按验证级别标注 | 模型、真实组件、本地多进程、生产不能混用 |
| E6 | 模型回放可以逐字节重现 | 能够比较两次运行,定位非确定性 |
| E7 | 环境信息随证据保存 | 版本或配置变化时,旧证据的适用范围可判断 |
E4 需要解释。一个检查“32 个并发重放只写入 1 行”通过了,可能是因为唯一约束起了作用,也可能是因为并发根本没有发生——例如测试代码不小心串行发出了请求。负对照把保护机制去掉,在同样的并发条件下运行,如果仍然只写入 1 行,说明测试没有制造出需要的竞争,原检查的 PASS 没有意义。第 04 篇的“先查再插”对照写入了 27 行,才使得主检查的结果可信。
2. 环境基线
环境由脚本采集,保存在 evidence/design-03/environment.txt:
1 | |
几项配置直接关系到结论的适用范围:
fsync=on、synchronous_commit=on:提交返回时 WAL 已刷到磁盘。第 06 篇的“数据库立即停止后已确认行仍在”依赖这两项。PostgreSQL 文档说明,synchronous_commit=off时提交可能在 WAL 落盘前返回,崩溃会丢失最近的事务,但不会破坏一致性。在这种配置下,R1 的“回复sent时已持久”不成立,需求卡中的假设 A1 必须相应修改。default_transaction_isolation=read committed:大多数写事务使用默认隔离级别,依赖显式行锁串行化;同步读取显式使用REPEATABLE READ只读事务(第 07 篇)。dynamic_shared_memory_type=mmap:实验容器的/dev/shm空间不足,改用文件映射。这一项只影响并行查询等功能的共享内存分配,与本系列的结论无关,但记录下来可以解释为什么配置与默认值不同。- 单机、4 核、约 9 GB 内存,数据库与网关运行在同一台主机上。所有耗时都包含本机调度噪声,不能当作性能数据。
数据库实例完全隔离:独立的数据目录、端口 56432 和套接字目录,由 examples/im/scripts/pg.sh 管理。
| 命令 | 作用 |
|---|---|
pg.sh start |
首次运行时 initdb 并写入上述配置;启动实例;创建 im 库 |
pg.sh reset |
删除并重建 public 模式,载入 schema.sql 和负对照用的 naive.sql |
pg.sh kill |
pg_ctl -m immediate stop:不做检查点直接终止,下次启动走 WAL 恢复 |
pg.sh stop |
快速关闭 |
pg.sh kill 是数据库级的崩溃注入。immediate 模式让所有服务进程立即退出,不执行关闭检查点,下一次启动时从最后一个检查点开始重放 WAL,这正是一次断电后的恢复路径(除了操作系统缓存,它在进程终止时不会丢失;真正断电时,已执行 fsync 的数据仍然安全,这是 fsync=on 的意义)。
3. 数据模型基线
所有实验共用一个模式文件 examples/im/sql/schema.sql。它在第 04 篇详细讲解,这里只列出与实验工具相关的部分:
| 表 | 作用 | 实验中观察的不变量 |
|---|---|---|
conversations |
会话行,含 next_seq、min_seq、epoch |
序号分配的锁点 |
events |
会话日志,主键 (conv_id, seq) |
连续、不重复、请求键唯一 |
membership_periods |
成员区间 | 读取时的可见性 |
device_cursors / read_positions |
送达游标 / 已读位置 | 单调 |
routes / route_floor / gateways |
连接归属与网关租约 | 唯一当前路由 |
idem_tombstones |
清理后的请求键 | 迟到重发不产生新消息 |
负对照用的表在 examples/im/sql/naive.sql 中,故意去掉了约束:naive_events 没有请求键唯一约束,seq_events 用数据库序列分配序号。每次重置都同时载入两者,保证对照实验与主实验在同一个数据库、同一隔离级别下运行。
4. 故障注入
4.1 设计
故障点是代码中的具名位置,通过环境变量 IM_FAULT 在网关进程启动时激活:
1 | |
| 动作 | 效果 | 用途 |
|---|---|---|
halt |
Runtime.getRuntime().halt(137),不执行关闭钩子 |
在指定位置模拟进程被杀 |
throw |
抛出 UNAVAILABLE |
模拟依赖失败,事务回滚 |
drop |
调用方跳过一次副作用 | 丢弃推送帧或回复帧 |
sleep |
暂停指定毫秒数 | 制造迟到的消息或注册 |
skip 让前若干次命中正常通过,count 限制生效次数。例如 send.before_commit:halt:0:1 表示第一次发送在提交前终止进程;gw.push:drop 表示丢弃所有推送。
halt 选择 Runtime.halt 而不是 System.exit,因为后者会执行关闭钩子,而网关的关闭钩子会做优雅排空(第 10 篇),这就不再是“崩溃”了。halt 的效果接近外部发送 SIGKILL:JVM 立即终止,内核关闭它持有的套接字,PostgreSQL 服务端随后发现连接断开,回滚该连接上未提交的事务。二者的区别是 halt 发生在确定的程序位置,SIGKILL 发生在任意时刻;第 10 篇另外使用真正的 SIGKILL 杀死整个网关进程。
4.2 故障点清单
清单由脚本从源码中提取(evidence/design-03/fault-points.txt),保证文档与代码一致:
| 故障点 | 位置 | 使用的篇章 |
|---|---|---|
send.before_insert |
会话行已锁、检查已通过,INSERT 之前 |
06 |
send.before_commit |
INSERT 与 pg_notify 已执行,COMMIT 之前 |
06 |
api.after_send_commit |
事务已提交,回复写出之前 | 06 |
gw.before_claim |
握手已鉴权,写路由之前 | 预留;第 10 篇最终改为直接调用存储层声明接口,未使用此点 |
gw.push |
推送帧入队之前(drop) | 06 |
gw.reply.<t> |
对类型为 t 的请求,回复入队之前(drop) |
06 |
三个发送路径上的故障点覆盖了第 01 篇契约中“没有收到回复”的全部来源,它们的位置可以画在一张时间线上:
1 | |
4.3 方案比较
| 方案 | 能力 | 局限 | 本批使用 |
|---|---|---|---|
| 进程内具名故障点 | 精确到代码位置,可重复 | 需要修改被测代码;只能模拟进程级故障 | 是 |
| 外部信号(SIGKILL/SIGTERM) | 不改代码,真实的进程终止 | 时刻不确定 | 是(第 10 篇) |
| 数据库立即停止 | 真实的 WAL 恢复 | 只覆盖数据库进程 | 是(第 06 篇) |
| 网络层注入(iptables、tc、代理) | 丢包、延迟、分区 | 需要特权或额外组件;单机回环上的效果与真实网络不同 | 否,NOT_RUN |
| 文件系统或磁盘故障注入 | 存储层错误 | 需要特殊驱动 | 否,NOT_RUN |
网络分区是本批最主要的缺口。第 05 篇的“半开连接”实验用一个停止响应的客户端来近似,第 10 篇的“迟到的旧注册”用直接调用存储层声明接口(携带较小的 attempt)来近似,但两者都不是真实的分区。结论中凡是依赖“网络分区下的行为”的部分,都标为 NOT_RUN。
5. 实验框架
examples/im/src/im/lab/Lab.java 是所有章节实验的运行器。它为每章做四件事:
- 重置模式(E1)。
- 以子进程方式启动网关 JVM,等待其输出
READY行,记录进程号和端口。 - 在测试进程内创建客户端,执行场景,并用
check(id, kind, ok, expectation, observed)记录每个断言(E3)。 - 结束时强制终止所有网关,写出
result.json、events.jsonl、run.log。任何一项检查失败,或场景抛出异常(记为scenario-crashed失败),该章即为 FAIL,进程以非零码退出。
检查的 kind 字段区分 normal、boundary、failure、recovery、invariant、negative-control、observation。observation 只记录观测值,不断言优劣,例如第 08 篇比较推送与拉取的字节数时不宣称哪种更好;negative-control 的 PASS 表示“预期的错误现象出现了”。
运行入口:
1 | |
lab.sh 先用 javac 编译(不依赖构建工具,只需要 JDK 21 和 lib/ 下的 pgjdbc 驱动),再运行 im.lab.Lab。
6. 单库冒烟
在启动网关之前,先在单个进程内直接调用存储层,确认数据库和模式可用,并覆盖契约的三类用例(examples/im/src/im/lab/Smoke.java):
| 类型 | 用例 | 预期 | 观测(evidence/design-03/smoke.txt) |
|---|---|---|---|
| 正常 | 发送后用同一 cmid 再发一次 |
两次 seq=3,第二次 dup=true |
PASS |
| 边界 | 非成员发送 | NOT_MEMBER |
PASS |
| 失败 | 事务内更新 next_seq 后抛出异常 |
UNAVAILABLE,next_seq 仍为 4 |
PASS |
seq=3 而不是 1,是因为两名成员入群时各自产生了一个 join 事件,占用了序号 1 和 2(第 04 篇)。失败用例验证的是连接池的事务包装:异常时回滚,连接归还前状态干净,这是后面所有故障实验的前提。
7. 确定性模型回放
7.1 用途
真实组件实验有不可避免的非确定性:线程调度、网络时序、垃圾回收都会改变事件交错。当一次运行出现异常时,很难判断是被测系统的问题还是时序偶然。模型回放用来补充另一面:在一个纯内存的参考模型上,用固定种子生成操作脚本,验证状态机层面的不变量,并且保证两次运行逐字节一致(E6)。
examples/design/replay.py 生成包含发送、重发、确认、已读和崩溃的操作脚本,用两个解释器执行:
reference:按第 01 篇契约实现。重发返回原序号;提交前崩溃丢弃本次发送,提交后崩溃保留;确认和已读取最大值。buggy:在幂等检查之前分配序号(重发会消耗序号),已读位置直接覆盖。
轨迹以 JSON 行写出,时间只用步骤编号,不使用墙钟;每一行按排序后的键序列化,再累积 SHA-256。
7.2 结果
1 | |
同一种子运行两次,cmp 比较两份轨迹文件,结果一致(replay-compare.txt 中 replay_identical=true)。对同一脚本运行 buggy 模型:
1 | |
两个模型写入的消息数相同(177),说明错误不在“消息是否写入”,而在序号分配和已读合并方式上。这正是第 04 篇“序列对照”和第 09 篇“后写者胜出对照”在真实数据库上要复现的两个缺陷。
7.3 回放能证明什么
| 能证明 | 不能证明 |
|---|---|
| 参考模型在该操作分布下满足不变量 | PostgreSQL 的隔离级别和锁行为符合模型假设 |
| 不变量检查器能发现序号空洞与已读回退 | 并发交错(模型是串行的) |
| 轨迹可重现,便于比较代码修改前后的行为 | 网络、崩溃恢复、多进程 |
模型回放属于第 00 篇验证分级中的“模型回放”级。它的价值在于给检查器本身做测试:如果检查器对 buggy 模型也报告零违规,后面所有基于它的实验都不可信。
8. 三类用例的写法规范
每个实验场景至少包含三类用例,按以下规范书写:
- 正常用例:断言具体的值,而不只是“没有异常”。例如断言
seq=3,而不是断言seq>0。 - 边界用例:选择契约中明确规定的边界——非成员、游标超过最大序号、请求键内容不一致、成员区间的端点。
- 失败用例:注入故障,断言恢复后的状态;同时说明故障前后各自可观察到什么。
每个检查同时记录预期描述和实际观测值。观测值是原始数据(行数、序号、关闭码、毫秒数),而不是布尔结论,这样读者可以自行判断断言是否过宽。
涉及时间的断言要给出构成。例如第 05 篇的半开连接检测断言为“在 idle(1200) + ping(300) + 余量(1500) 毫秒内”,余量的存在是因为本机调度和数据库清理路由的耗时不可控;只断言“小于某个值”而不说明组成,读者无法判断这个阈值是否合理。
8.1 一次断言竞态的记录
第 06 篇的 06-lost-push-swept 在第一次全量运行时失败过一次。检查要求“推送全部丢弃后,重投扫描触发提示,设备同步并确认,送达游标到达新消息的序号”。失败原因不在被测系统,而在检查本身:客户端先把事件写入本地存储,再异步发送确认;检查在观察到本地存储后立即读取服务端游标,此时确认尚未到达。修正方式是等待服务端游标而不是本地状态。完整记录见 evidence/im-06-flaky/README.md。
这次失败说明,故障实验的检查和被测系统一样需要考虑并发与顺序。第 00 篇要求区分“检查通过”和“承诺成立”,原因之一就在这里:一个写错的检查既可能误报失败,也可能误报通过。
9. 故障与恢复:基线工具自身
| 故障 | 表现 | 处理 |
|---|---|---|
| 端口被其他进程占用 | 数据库启动失败 | 端口与数据目录可由环境变量 IM_PGPORT、IM_PGDATA 覆盖 |
网关启动超时(15 s 内无 READY) |
抛出异常,记为 scenario-crashed |
输出保存在 run.log |
| 场景中途异常 | 剩余检查不执行 | 该章 FAIL;网关在 finish 中被强制终止,不残留进程 |
| 上一章遗留网关进程 | 端口冲突或路由残留 | 每个网关使用随机空闲端口;每章重置模式 |
| 检查本身的竞态 | 偶发失败 | 按 8.1 记录,修正检查后重新全量运行,保留失败记录 |
10. 本篇交付与限制
- 交付:
scripts/pg.sh、scripts/baseline.sh、scripts/lab.sh、src/im/Faults.java、src/im/lab/Lab.java、src/im/lab/Smoke.java、examples/design/replay.py。 - 证据:
evidence/design-03/下的环境、故障点清单、冒烟、两次回放与对照;evidence/lab-all.stdout.txt为 04–11 的全量运行(8 章 71 项检查,全部 PASS;网关写线程改用 ReentrantLock/Condition 后重新运行)。 - 限制:单机运行,数据库与网关共享 CPU;没有网络层故障注入;没有副本与故障切换(U2,NOT_RUN);所有耗时只用于说明机制是否在预期量级内生效,不作为性能数据。
下一篇开始构建消息服务本身:会话、成员和消息分别存放在哪里,序号在哪个锁下分配,以及请求键怎样与消息行处在同一个事务中。
参考资料
- PostgreSQL 16 文档,20.5 Write Ahead Log(
fsync、synchronous_commit的语义与风险)。https://www.postgresql.org/docs/16/runtime-config-wal.html - PostgreSQL 16 文档,pg_ctl(
-m immediate关闭模式,下次启动执行恢复)。https://www.postgresql.org/docs/16/app-pg-ctl.html - PostgreSQL 16 文档,13.2 Transaction Isolation。https://www.postgresql.org/docs/16/transaction-iso.html
- Java SE 21 API,
Runtime.halt(int)(强制终止,不运行关闭钩子)。https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Runtime.html#halt(int)






