本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 batch-4/ 目录(examples/、evidence/ 等子目录);业务数据均为合成数据。

单个模块的测试证明的是模块自己的承诺:消息服务保证同一个客户端请求只产生一条消息,队列保证至少投递一次,通知服务保证按幂等键推送。用户看到的结果却取决于这些承诺怎样接在一起:消息已经撤回,通知还会不会把正文推出去;队列重投一条事件,用户会不会收到两次推送;配置回退之后,正在进行的撤回按哪个窗口判断;通知服务停了十分钟,恢复后积压能否排空、告警是否如实触发和恢复。

本篇的评审对象是一个专门构造、范围受控的合成系统 Relay,有明确的接口契约和不变量,并沿发送、撤回、配置回退、积压恢复四条路径写成可执行的验收用例。Relay 不是系列 04–35 章实现的汇总。本批次没有取得那些章节的实现,本文不声称审阅或验证了它们;它们与 Relay 的对接点在第 9 节逐项列为待集成项。

1. 评审对象

模块 职责 持久状态
消息 发送、撤回、历史查询 message、conv_seq、member、device
出站表中继 把业务事务中写入的事件复制到队列 outbox
队列 追加日志、消费组提交位置、失败计数、死信 qlog、qgroup、qattempt、dlq
通知 消费队列,向本地假推送渠道发送新消息与撤回通知 notif
配置 不可变版本、校验、乐观并发、回退即发布新版本 config
观测 采样队列最老未确认年龄;用第 36 篇的评估器产生 QueueBacklog 告警 内存

五个模块共用一个 SQLite 文件,运行在一个进程里。这样做的好处是每条跨模块路径都可以在测试中精确地注入故障,坏处是数据库层面的跨模块事务在这里是免费的,而在分布式部署中不是。第 10 节把这一点列为主要限制。

排除项:网络协议与鉴权令牌、多进程部署、真实推送服务、跨数据库事务、数据保留与清理。

2. 接口契约与不变量

完整契约见 examples/ch39_acceptance/CONTRACT.md,这里列出评审要用到的部分。

操作 成功 错误(RFC 9457 风格的问题详情)
send(conv, sender, client_msg_id, body) {msg_id, seq, replayed} 400 键非法;413 正文超过 4096 字节;403 非成员;409 同键不同内容
recall(msg_id, by_user) {msg_id, recalled_at, config_version, replayed} 404;403 非发送者;409 超出撤回窗口(详情中给出配置版本)
history(conv, viewer, after_seq, limit) 按 seq 升序,撤回的消息 body=null 403 非成员
publish_config(cfg, note, expect_version) 新版本号 422 校验失败;409 版本冲突
rollback_config(to_version) 以旧内容发布的新版本号 404 版本不存在

错误体采用 RFC 9457 定义的问题详情结构,包含 type、title、status、detail 四个成员,type 使用 example.test 下的合成 URI。send 的 client_msg_id 承担幂等键的角色,语义与 IETF 幂等键草案(draft-ietf-httpapi-idempotency-key-header-07,已过期,非 RFC)的约定一致:同键同内容返回原结果,同键不同内容拒绝。

确认位置:send 与 recall 在 SQLite 事务提交后返回;队列消费是至少一次;通知以 (msg_id, user, device, kind) 为幂等键。

不变量(括号内为验收用例编号):

  • INV-1 已确认的消息对会话全部成员可见,seq 在会话内唯一(A1)。
  • INV-2 同一 (sender, client_msg_id) 最多一条消息、一条出站事件,与并发重试次数无关(A2)。
  • INV-3 非成员不能发送或读取(A2)。
  • INV-4 每条消息对每个非发送者成员的每台设备至多推送一次新消息通知;无毒消息、积压最终排空时恰好一次(A1、A3、D1)。
  • INV-5 撤回后,任何历史查询都不返回正文(B1、B3)。
  • INV-6 撤回前已推送过新消息通知的设备,最终收到撤回通知(B1、B3)。
  • INV-7 撤回时尚未推送的新消息通知被取消,不推送正文预览(B3)。
  • INV-8 校验失败的配置不产生新版本(C2)。
  • INV-9 依赖配置的拒绝在错误详情中给出配置版本;回退产生更大的版本号(C1、C3)。
  • INV-10 毒消息在 3 次失败后进入死信,不阻塞其后的记录(D2)。
  • INV-11 观测数据中断不被当作积压恢复(D3)。

3. 评审方法

每条路径按同一个顺序检查:列出它经过的模块和每一步的确认位置;找出跨模块保证依赖的那一步;在那一步前后注入故障;用验收用例断言不变量。验收用例共 20 个,每条路径都覆盖正常、边界和失败三类。

运行入口:

1
2
3
cd examples/ch39_acceptance
python3 -m unittest test_acceptance -v # 20 个用例
python3 mutate.py # 6 个变异,期望全部被检出

两条命令的完整输出分别在 evidence/39/test-output.txt 与 evidence/39/mutation-output.txt;积压用例的告警时刻与积压轨迹在 evidence/39/backlog-trace.txt。

4. 路径 A:发送

---
title: 图 39-1 发送路径的确认位置与三个注入点
---
sequenceDiagram
    participant C as 客户端
    participant M as 消息模块
    participant O as 出站表中继
    participant Q as 队列
    participant N as 通知
    participant P as 假推送渠道
    C->>M: send(client_msg_id)
    M->>M: 事务:写 message + outbox
    Note over M: 注入点 1:提交后、响应前崩溃
    M-->>C: {msg_id, seq}
    O->>Q: 追加 qlog(event_id = ob-出站表编号)
    Note over O: 注入点 2:追加后、标记 relayed 前崩溃
    O->>O: 标记 relayed
    Q->>N: 按偏移投递
    N->>P: push(key=msg|user|device|new)
    Note over N: 注入点 3:处理后、提交偏移前崩溃
    N->>Q: 提交偏移

消息和出站事件在同一个事务中写入,所以“消息存在而事件丢失”的状态不会出现。发送路径上真正需要验证的是三个注入点各自产生的重复能否被下游吸收。

用例 注入 观察
A1 正常 — 历史可见;3 台接收设备各收到 1 条通知;队列滞后为 0
A2 并发重试 8 个线程同时用同一个 client_msg_id 发送 只有 1 个 msg_id,只有 1 次 replayed=false,出站表只有 1 行
A2 边界 同键不同内容;非成员发送 409;403
A3 响应丢失 注入点 1 客户端重试得到 replayed=true;通知仍为 3 条
A3 中继崩溃 注入点 2 队列中有 2 份同一 event_id;通知仍为 3 条
A3 消费崩溃 注入点 3 提交位置仍为 0;重新消费后通知仍为 3 条
A3 渠道响应丢失 假渠道送达后返回超时 渠道被调用 2 次;接收记录 1 条

并发重试用例依赖两层检查:事务外先查一次,进入写锁后再查一次。去掉这两次查询(变异 2),并发请求会撞上唯一约束,用例因出现异常而失败。只靠唯一约束并非不可以,但调用方会收到一个内部错误,而不是契约规定的 replayed=true。

最后一行需要特别说明。通知模块给渠道调用了两次,接收端只记录一次,是因为假渠道按幂等键去重。这只能证明 Relay 对渠道的调用次数与幂等键是正确的,不能证明任何真实推送服务会按键去重,也不能证明真实送达。第 33 篇讨论的正是这一类结果未知,该篇不在本批交付。

5. 路径 B:撤回

撤回跨越三个模块:消息模块写撤回标记并产生出站事件;通知模块要处理“撤回事件”与“尚未处理完的新消息事件”的先后关系;历史查询要立即隐藏正文。

关键的一步在通知模块内部:每推送一台设备之前,重新读取消息的撤回状态。

1
2
3
4
# 发送前读取最新撤回状态:已撤回的消息不再推送正文
if db.execute("SELECT recalled_at FROM message WHERE msg_id=?", (p["msg_id"],)).fetchone()[0] is not None:
db.execute("INSERT OR REPLACE INTO notif VALUES (?,?,?,?, 'new', 'cancelled', 0, ?)", ...)
continue
用例 情形 观察
B1 正常 通知已全部推送后撤回 历史查询返回 body=null;收到撤回通知的设备集合与收到新消息通知的设备集合相同
B2 边界 消息年龄恰好等于窗口(120 秒);再多 1 毫秒;非发送者撤回 允许;409 recall-window-expired;403
B3 撤回早于通知 新消息事件已入队但未处理时撤回 没有任何新消息通知被推送,notif 全部为 cancelled
B3 部分送达 第 1 台设备推送成功、第 2 台超时,此时撤回 只有第 1 台收到新消息通知,并且它收到了撤回通知
B3 离线设备 第一次拉取后撤回,重新拉取 第二次拉取不再有正文

窗口端点是契约的一部分:age > window 才拒绝,等于窗口时允许。这类“含不含端点”的问题如果不写进契约并用用例固定,前端倒计时与后端判断就会在最后一秒不一致。

去掉“推送前重读撤回状态”(变异 1),B3 的第一个用例立即失败:撤回之后,通知仍然把正文作为预览推了出去。这条保证无法由任何单个模块独立承担,消息模块不知道通知何时推送,通知模块不知道撤回何时发生。它只能在两者衔接的那一行代码上成立,也只能由跨模块用例来检验。

离线设备用例的结论有边界:重新同步能让设备拿到撤回状态,但已经下载到设备上的正文无法由服务端强制删除。INV-5 约束的是服务端读接口,不是用户设备。

6. 路径 C:配置回退

用例 情形 观察
C1 正常 发布 v2,撤回窗口改为 30 秒 31 秒后撤回被拒绝,错误详情包含 config v2
C2 校验 窗口为 -1、预览开关为字符串、批量为 0 均为 422,当前版本号不变
C2 并发发布 两次发布都基于同一个版本 第二次 409
C3 回退 v2 关闭了通知预览;回退到 v1 回退产生 v3,内容与 v1 相同;v2 期间的通知没有预览,v3 之后恢复;最后一条发布说明为 rollback to v1
C3 收敛 客户端停在 v1,期间发布了 3 个版本后又回退 客户端只需比较版本号即可发现变化,最终拿到窗口 120 秒

回退被设计成“用旧内容发布一个新版本”,而不是把当前版本指针移回旧版本。变异 3 把回退改成删除较新版本、让指针回到旧版本,C3 立即失败。这样设计的理由在收敛用例中能看到:客户端用“版本号是否变大”判断要不要更新;指针倒退会让刚拿到 v4 的客户端认为 v1 是旧数据而拒绝接受,审计记录里也会丢掉“曾经发布过 v2 到 v4”这个事实。

C1 的错误详情带配置版本,是为了让每一个依赖配置的拒绝可以追溯到当时生效的规则。撤回窗口从 120 秒改成 30 秒的那一刻,正在倒计时的用户会突然失去撤回能力;如果事后有人追查,错误详情和 message.recall_cfg_version 列能给出确切答案。

7. 路径 D:积压恢复

D1 模拟 40 分钟的运行:每 15 秒写入 4 条消息,通知模块在第 300–900 秒停止消费,恢复后每轮最多处理 40 条事件。观测模块每 15 秒采样一次“最老未确认事件的年龄”,交给第 36 篇的评估器,规则为年龄超过 60 秒、持续 60 秒触发,keep_firing_for 30 秒,缺数据宽限 120 秒。

时刻(秒) 积压事件数 最老未确认年龄(秒)
300 4 0
600 84 300
900 124 450
1200 0 0

QueueBacklog 告警在 465 秒随组首次通知,恢复通知在 1005 秒发出。全程共发送 640 条消息,新消息通知恰好 1920 条(640 × 3 台接收设备),幂等键无重复。告警的触发晚于停机开始 165 秒,其中 60 秒是“年龄超过 60 秒”本身,60 秒是 for,30 秒是 group_wait,其余来自采样步长。这个延迟是规则参数的直接结果,评审时应当拿它与业务能接受的“通知迟到时长”对照,而不是只确认“告警会响”。

用例 情形 观察
D1 停机与排空 如上 告警触发、恢复;积压归零;INV-4 恰好一次
D2 毒消息 3 条消息中第 2 条处理必然失败 5 轮之后死信 1 条,第 1、3 条的通知都已送达,滞后为 0
D3 采样中断 600 秒后通知模块未恢复,指标也不再上报 告警触发后没有发出恢复通知,另外产生 QueueBacklogNoData

D2 的处理顺序值得解释。队列按偏移顺序处理,失败的记录在达到 3 次之前会阻塞其后的记录,这保证了正常情况下通知的顺序;达到 3 次后进入死信并被跳过,保证一条坏记录不会让整个队列永久停住。代价是在前两次失败期间,后面的记录要等待。死信非空本身应当是一条告警,本批的观测模块只采样了积压年龄,死信告警记为未实现。

D3 复用了第 36 篇的结论:通知模块和它的指标同时消失时,告警不能因为“没有数据”而宣布恢复。

8. 负向对照:变异检查

验收用例通过只说明实现满足了这些断言。为了确认断言本身有效,mutate.py 在进程内逐个破坏一项实现保证,再运行对应用例:

变异 破坏的保证 检出用例 结果
1 通知推送前不重读撤回状态 B3 撤回早于通知 检出
2 发送不查 client_msg_id A2 并发重试 检出
3 回退时把版本指针移回旧版本 C3 回退 检出
4 队列先提交偏移再处理(至多一次) A3 消费崩溃 检出
5 关闭配置校验 C2 校验 检出
6 通知幂等键去掉设备维度 A1 正常 检出

6 个变异全部被检出。变异只覆盖了评审时认为最关键的 6 处,不代表测试集对所有可能的错误都敏感。

9. 未验证能力与待集成项

下列连接点在本批次中没有取得对应章节的实现,Relay 用替身代替。每一项都是“集成后才能宣称”的能力,列出了集成后必须重跑的用例:

编号 待集成内容 Relay 中的替身 集成后重跑
I-1 04–07 持久化边界、长连接接入、发送确认、会话排序与断线同步 send / history A1–A3、B3 离线同步
I-2 08–09 群消息扇出、多设备已读 成员 × 设备循环推送 A1、D1
I-3 10 网关故障后的重连与路由收敛 无(单进程) 新增网关故障用例
I-4 11 撤回、保留与消息系统验收 recall B1–B3
I-5 15 配置发布与客户端收敛 config 表 + 轮询 C1–C3
I-6 17–18 持久日志队列与真实消息组件 qlog 表 A3、D1、D2
I-7 33 通知渠道的结果未知 FakePush A3 渠道响应丢失、B3
I-8 37 超时预算与负载保护 无 在 D1 上叠加慢下游
I-9 38 备份恢复 无 在恢复出的实例上重跑全部用例

本批之内已经接上的只有一处:D1、D3 使用了第 36 篇的告警评估器。第 37、38 篇的机制在 Relay 中没有接入,第 37 篇的仿真与第 38 篇的 PostgreSQL 演练各自独立验证。

除待集成项之外,以下能力在本批中完全没有验证:多进程与多副本部署下的并发与故障;真实推送服务的送达与去重;真实消息组件的确认语义;跨地域的复制与切换;任何规模下的性能。

10. 限制

  • 跨模块事务在单个 SQLite 文件中是原子的。分布式部署时,消息模块与出站表仍可同库,但通知状态、队列偏移通常不在同一个库中,A3、B3 的结论需要在那样的拓扑下重新验证。
  • 时钟由测试控制,没有覆盖时钟跳变与多节点时钟偏差。
  • 撤回窗口的判断使用服务端时间,客户端倒计时与服务端判断的不一致没有测试。
  • 积压用例的消费速率、批量和告警参数都是教学值;告警延迟与它们直接相关,不能外推。

11. 来源

核对日期 2026-10-18。

  • RFC 9457 Problem Details for HTTP APIs(2023 年 7 月,取代 RFC 7807):type、title、status、detail、instance 成员。https://www.rfc-editor.org/rfc/rfc9457
  • draft-ietf-httpapi-idempotency-key-header-07(2025-10-15,已于 2026-04-18 过期):同键不同请求体不得复用。
  • 本批第 36 篇的评估器与通知器实现(examples/ch36_alerting/alerting.py),D1、D3 直接导入使用。

本批导航