后端系统工程 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
2
3
4
5
6
7
8
9
10
11
12
13
date_utc=2026-10-20T03:21:01Z
kernel=Linux 5.10.134-013.16.kangaroo.al8.x86_64
cpus=4
mem_total_kb=9244612
java=openjdk version "21.0.12.1" 2026-08-18
python=Python 3.12.3
postgres=postgres (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)
pgjdbc_sha1=67f8093e8d8104c74bbf588392ac3229803f5d17
pg_setting.default_transaction_isolation=read committed
pg_setting.fsync=on
pg_setting.synchronous_commit=on
pg_setting.wal_level=replica
...

几项配置直接关系到结论的适用范围:

  • 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
IM_FAULT=point:action[:skip[:count[:ms]]][,point:action...]
动作 效果 用途
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
2
3
4
5
6
7
图 3-1  发送路径上的三个崩溃点与持久状态
时间 ─────────────────────────────────────────────────────────────────>
BEGIN 锁会话行 查键/墓碑/成员 [before_insert] INSERT notify [before_commit] COMMIT [after_send_commit] 回复
│ │ │
▼ ▼ ▼
崩溃后:无行 崩溃后:回滚,无行 崩溃后:有行,无回复
重发:新行,dup=false 重发:新行,dup=false 重发:原行,dup=true

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 是所有章节实验的运行器。它为每章做四件事:

  1. 重置模式(E1)。
  2. 以子进程方式启动网关 JVM,等待其输出 READY 行,记录进程号和端口。
  3. 在测试进程内创建客户端,执行场景,并用 check(id, kind, ok, expectation, observed) 记录每个断言(E3)。
  4. 结束时强制终止所有网关,写出 result.json、events.jsonl、run.log。任何一项检查失败,或场景抛出异常(记为 scenario-crashed 失败),该章即为 FAIL,进程以非零码退出。

检查的 kind 字段区分 normal、boundary、failure、recovery、invariant、negative-control、observation。observation 只记录观测值,不断言优劣,例如第 08 篇比较推送与拉取的字节数时不宣称哪种更好;negative-control 的 PASS 表示“预期的错误现象出现了”。

运行入口:

1
2
3
4
5
cd batch-1/examples/im
scripts/pg.sh start # 首次启动并初始化
scripts/lab.sh 04 # 单章
scripts/lab.sh all # 04–11 全部
scripts/baseline.sh # 本篇的基线工具:环境、冒烟、故障点清单、模型回放

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
2
3
4
$ python3 replay.py --seed 20261020 --trace replay-a.jsonl
{ "seed": 20261020, "steps": 400, "model": "reference",
"trace_sha256": "8bc358e668f5cc2148ff6326e7960466f41b94494a1c0a9c4d264f8ef367aeb1",
"messages": 177, "violations": [] }

同一种子运行两次,cmp 比较两份轨迹文件,结果一致(replay-compare.txt 中 replay_identical=true)。对同一脚本运行 buggy 模型:

1
2
3
4
5
"violations": [
"c1: seq not gapless [1, 2, 3, 5, 6, 7, 8, 9]...",
"c0: seq not gapless [1, 2, 4, 5, 6, 7, 9, 11]...",
"read moved backwards 17 times" ]
buggy_exit=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);所有耗时只用于说明机制是否在预期量级内生效,不作为性能数据。

下一篇开始构建消息服务本身:会话、成员和消息分别存放在哪里,序号在哪个锁下分配,以及请求键怎样与消息行处在同一个事务中。

参考资料