后端系统工程 11:撤回、历史保留与消息系统集成验收

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收|下一篇 12 有界连接池与等待取消

前面七篇都在处理一条消息“怎样写进去、怎样送出去”。本篇处理消息写入之后的变化:发送者撤回、保留期满后被删除、整个数据库从备份恢复。三者有一个共同点,都会和前面建立的不变量发生冲突。

  • 第 07 篇假定已提交事件不可变,客户端可以只按序号增量同步。撤回却要求修改一条已经被别人收到的消息。
  • 第 04 篇保证会话内序号从 min_seq 起连续,第 01 篇保证同一请求键只产生一条消息。保留清理要删除的,正好是序号和请求键的载体。
  • 备份恢复会把数据库带回过去某个时刻,所有不变量都需要在副本上重新检查,而不能默认成立。

本篇的另一项任务是集成验收:把第 04–11 篇的全部 71 项检查与第 00 篇的需求卡 R1–R7 对照,说明哪些承诺有证据、证据属于哪一级,哪些仍然是 NOT_RUN。

1. 需求与不变量

编号 不变量 观察位置
H1 撤回不修改已有事件的序号,也不删除事件行;它追加一个 kind=recall 的新事件,并清空原消息的正文 events
H2 撤回生效后,服务端的任何读取路径(sync、fetch、推送)都不返回原文 eventJson
H3 只有发送者能撤回,且撤回必须在时限内;时限以数据库时钟判断 Store.recall
H4 撤回请求本身幂等:同一 cmid 重发返回原撤回事件的序号 部分唯一索引
H5 保留清理只删除日志前缀,清理后 [min_seq, head] 内的序号仍然连续 Store.purge
H6 被清理消息的请求键以墓碑形式保留;迟到的重发得到 ORIGINAL_EXPIRED,不产生新消息 idem_tombstones
H7 备份恢复得到的数据库满足 H1–H6 和第 04–09 篇的结构性不变量 backup_restore.sh

H1 与 H2 合起来,就是第 00 篇需求卡 R6 的实现方式。H5 和 H6 是 R2 与第 01 篇契约 C5 的延续。H7 只承诺“副本满足不变量”,不承诺“副本包含最新数据”:逻辑备份反映的是开始备份那一刻的快照,之后提交的数据不在其中。

撤回还有一个必须写进契约的限制:撤回是请求,不是擦除。服务端能做到的是不再提供原文,并通知所有在线设备删除本地副本;离线设备在同步之前仍然持有原文,已经被截屏、转发或导出的内容更无从收回。产品文案如果写成“消息已删除”,就超出了系统能兑现的范围。

2. 规模假设

撤回频率远低于发送频率,第 02 篇的模型没有单独计入。每次撤回是一个事务:锁会话行,锁目标事件行,插入一行,更新两行,与一次发送的代价相当。

保留期按第 02 篇假设为 30 天,存储预算利用率 0.972,几乎贴着上限。清理是让存储不再增长的唯一手段。清理本身的代价是删除行加上写墓碑,墓碑每行只有几十字节,但数量与被清理的消息数相同,所以墓碑也需要自己的保留期(第 6.3 节)。

实验规模很小:一个三人群和一个单聊,共 14 个事件。清理和备份的耗时没有测量意义,本篇不报告任何耗时。

3. 接口

撤回请求沿用发送请求的幂等结构:

1
2
{"t":"recall","rid":"r9","conv":"c-g","target":4,"cmid":"rc-1-recall"}
{"t":"recalled","rid":"r9","conv":"c-g","seq":5,"dup":false}

target 是被撤回消息的序号,cmid 是撤回请求自己的请求键,与原消息的 cmid 无关。返回的 seq 是撤回事件的序号。

撤回事件在同步与推送中的形式:

1
{"conv":"c-g","seq":5,"kind":"recall","sender":"u-ana","target":4,"at":1792466430861}

被撤回的原消息再被读取时:

1
2
{"conv":"c-g","seq":4,"kind":"msg","mid":"…","sender":"u-ana","sdev":"u-ana-d1",
"cmid":"rc-1","recalled":true,"at":1792466430840}

注意,这里没有 body 字段,而不是 "body": null。客户端只需要判断字段是否存在,不需要区分空字符串、null 和缺失三种情况。

错误码(第 01 篇已列出):

码 条件
NOT_SENDER 调用者不是原消息的发送者
ALREADY_RECALLED 目标已被另一个撤回请求(不同 cmid)撤回
RECALL_WINDOW_EXPIRED clock_timestamp() > created_at + recall_window_ms
NOT_FOUND 目标不存在或已被清理
INVALID_ARGUMENT 目标不是 msg 事件
ORIGINAL_EXPIRED 发送请求的原消息已被清理(来自 send,不是 recall)

同步回复中与保留相关的字段(第 07 篇):min_seq 是仍然存在的最小序号;当请求的 after + 1 < min_seq 时,trimmed=true,表示客户端请求的起点之前有一段历史已经被清理。

4. 数据模型

与本篇相关的列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
CREATE TABLE conversations (
...
min_seq bigint NOT NULL DEFAULT 1 CHECK (min_seq >= 1),
recall_window_ms bigint NOT NULL DEFAULT 120000,
retention_ms bigint, -- NULL 表示不清理
CHECK (min_seq <= next_seq)
);

CREATE TABLE events (
...
kind text NOT NULL CHECK (kind IN ('msg', 'recall', 'join', 'leave')),
body text, -- 撤回后为 NULL
target_seq bigint, -- recall 事件指向的消息
recalled_by bigint, -- 原消息上记录撤回事件的 seq
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CHECK (kind <> 'recall' OR target_seq IS NOT NULL)
);

CREATE TABLE idem_tombstones (
conv_id text NOT NULL,
sender_user text NOT NULL,
client_msg_id text NOT NULL,
seq bigint NOT NULL,
purged_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (conv_id, sender_user, client_msg_id)
);

撤回在数据上留下两处记录:新的 recall 事件,以及原消息行上的 recalled_by。recalled_by 看起来是冗余的,通过 target_seq 反查也能得到。但读取原消息时,它让“是否已撤回”成为一次主键查找就能得到的列值,未读计数(第 09 篇)也可以直接用 recalled_by IS NULL 过滤,不必与撤回事件做连接。

5. 正常路径

5.1 撤回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
u-ana(发送者)          网关              PostgreSQL                         u-ben(在线)   u-cai(离线)
| recall(target=4) ->| | | |
| | BEGIN | | |
| | 锁 conversations 行,读 next_seq=5、recall_window_ms | |
| | 查 cmid 是否已用过 → 否 | |
| | 锁 events(seq=4):sender、recalled_by、是否超时(数据库时钟)| |
| | INSERT events(seq=5, kind=recall, target=4) | |
| | UPDATE events SET recalled_by=5, body=NULL WHERE seq=4 | |
| | next_seq=6; pg_notify(im_events, c-g:5); COMMIT | |
|<- recalled(seq=5) -| | | |
| |<-- NOTIFY ------| | |
| |-- event(recall, target=4) ----------------------------->| 本地删 body |
| | | | |
| | | (重连后) sync(after=3) ------------------->|
| | | 返回 seq4{recalled:true, 无 body}, seq5 |

图 11-1 撤回的写入与传播路径

在线设备通过推送收到 recall 事件,在本地找到 target 对应的消息,删去正文并标记为已撤回。离线设备之后同步时,同时收到原消息和撤回事件。原消息的返回中已经没有正文,所以离线设备从头到尾都拿不到原文。

客户端合并逻辑有一个顺序问题:如果一台设备先收到 recall 事件,后收到原消息(例如推送与同步交错,第 07 篇允许乱序到达),原消息会覆盖本地的撤回标记。Client.applyEvent 的处理是:已经标记为撤回的本地副本,不会被一个未标记撤回的版本覆盖。由于服务端在撤回后不再返回原文,这种覆盖只可能来自撤回之前发出、在途中延迟的推送。

5.2 保留清理

1
2
3
4
5
6
7
8
9
10
11
12
c-ret 日志(清理前):
seq 1 2 3 4 5 6 7 | 8 9 10
kind join join msg msg msg msg msg| msg msg msg
←──── created_at < now − retention ───→|←── 未到期 ──→
cutoff=7

清理事务:
INSERT idem_tombstones ← seq 3..7 中带 cmid 的请求键
DELETE events WHERE seq <= 7 (7 行)
UPDATE conversations SET min_seq = 8

清理后:min_seq=8, head=10, 剩余 seq 8,9,10

图 11-2 前缀清理与墓碑写入(与实验数据一致)

清理按“前缀”进行:找出已过期事件中最大的序号作为 cutoff,删除所有 seq <= cutoff 的事件。因为序号在会话行锁下按提交顺序分配(第 04 篇),created_at 与序号单调一致,过期事件天然构成前缀,不会出现“中间某条没过期、两边都过期”的情况。即使出现时钟回拨,按 cutoff 删除也只会多删几条比 cutoff 小的事件,日志仍然是连续的。

同步请求从 0 开始时,返回 trimmed=true,事件从 8 开始。客户端据此提示“更早的历史已过期”,并从 min_seq 开始保存,而不是认为自己缺失了 1–7 而反复请求。

5.3 备份与恢复检查

scripts/backup_restore.sh 依次执行:

  1. pg_dump -Fc 导出 im 数据库;
  2. 删除并重建 im_restore,用 pg_restore --exit-on-error 恢复;
  3. 在两个库上分别运行三条不变量查询:序号连续性、已撤回消息不带正文、设备游标不超过 head。

PostgreSQL 文档对 pg_dump 的说明是:即使数据库正在被并发使用,它也能产生一致的备份;导出期间不阻塞其他用户的读写。这里的“一致”指导出内容对应一个事务快照,不是“包含最新提交”。

6. 方案比较

6.1 撤回的数据表示

方案 序号与同步 原文去向 问题
物理删除原消息行 产生序号空洞,第 07 篇的连续游标会卡住或误判丢失 删除 破坏 R2
原地改写正文为“已撤回”,不追加事件 序号不变,但已同步的设备不知道发生了变化 删除 在线设备收不到通知,离线设备的增量同步跳过该行
追加撤回事件,原行保留但清空正文(本实现) 序号连续,撤回本身是一个可同步的事件 从在线行中删除 原文仍可能存在于备份、WAL、客户端本地
追加撤回事件,原行原样保留,读取时过滤 同上 仍在数据库中 任何绕过过滤的读取路径都会泄露原文

本实现选择第三种。第二种的问题很隐蔽:增量同步只拉 seq > cursor 的事件,一条已经同步过的消息被原地修改,同步协议看不到。变更必须以新事件的形式出现在日志尾部,这是“日志不可变”约定的直接推论。第四种在数据库中保留原文,再依赖每条读取路径都做过滤。这把一个数据问题变成了代码审查问题。清空正文之后,即使某条读取路径忘了检查 recalled_by,也读不到原文。

“从在线行中删除”这几个字有明确的边界。PostgreSQL 的 UPDATE 采用多版本并发控制:更新会写入新版本的行,旧版本在 VACUUM 回收之前仍然留在数据页中;更新本身也会写入 WAL。所以撤回之后的一段时间里,原文仍然存在于数据文件和 WAL 归档中。逻辑备份 pg_dump 只导出可见版本,撤回之后做的导出不包含原文;撤回之前做的导出则包含。物理备份和 WAL 归档的保留期,必须作为单独的策略与撤回承诺一起制定。本批只验证了撤回之后的逻辑导出(第 8 节)。

6.2 撤回时限用哪个时钟

方案 问题
客户端时钟 客户端可以任意设置
网关本地时钟 多网关之间存在偏差,同一请求在不同网关上结果不同
数据库时钟,事务内判断(本实现) 单主库下只有一个时钟;判断与写入在同一事务中

用 clock_timestamp() 而不是 now():PostgreSQL 的 now() 返回事务开始时刻,在同一事务内保持不变;clock_timestamp() 返回语句执行时的实际时间。事务如果因为等待会话行锁而排队较久,now() 会让一个实际已经超时的撤回通过判断。本实现在持有锁之后再读取 clock_timestamp(),判断的是拿到锁那一刻的时间。

6.3 清理与幂等

方案 迟到的重发 存储
只删除事件 查不到原记录,被当作新消息,序号更大,内容重复 最少
删除事件,保留墓碑(本实现) ORIGINAL_EXPIRED 每条被清理的消息一行墓碑
不删除请求键列,只清空正文 原记录仍在,返回原序号 行不删除,序号空间不回收

墓碑本身也需要清理,否则它就成了另一张只增不减的表。墓碑只需要比“客户端可能重发的最长时间”保留得更久。第 01 篇的客户端在本地持久保存待发消息,直到收到终态回复才删除,所以理论上一台离线很久的设备可以在很久以后重发。这个上限需要由客户端策略约束,例如“待发消息超过 N 天未确认则提示用户,不再自动重发”。Store.purgeTombstones(olderThanMs) 提供按时长清理墓碑的能力,本批没有为它编写检查(NOT_RUN)。墓碑被清理之后,H6 不再成立,这必须作为契约的明确限制写出来。

6.4 读取时还是写入时判定权限

撤回依赖的“成员区间”判定沿用第 08 篇:权限在读取时判定。一个成员在区间内收到的消息,退群后再次 fetch 仍然可以取到(11-access-time-check 中 seq 6 在区间 [3, 9] 内);区间之外的消息则返回 NOT_MEMBER。这意味着退群并不会撤销对历史的访问权,只是不再获得新消息。如果产品要求“退群后看不到任何历史”,就需要改变区间语义,例如退群时把区间改为空,而不是改动撤回逻辑。

7. 关键实现

撤回(Store.recall,节选,异常分支按检查顺序排列):

1
2
3
4
5
6
7
8
9
10
11
12
13
// SELECT next_seq, epoch, recall_window_ms FROM conversations WHERE conv_id=? FOR UPDATE
// 同一撤回 cmid 已存在 → 指纹一致则返回原 seq(dup=true),否则 IDEMPOTENCY_MISMATCH
// 调用者当前不是成员 → NOT_MEMBER
SELECT kind, sender_user, recalled_by IS NOT NULL,
clock_timestamp() > created_at + (? * interval '1 millisecond')
FROM events WHERE conv_id=? AND seq=? FOR UPDATE;
// 无行 → NOT_FOUND;kind≠msg → INVALID_ARGUMENT;
// sender≠调用者 → NOT_SENDER;已撤回 → ALREADY_RECALLED;超时 → RECALL_WINDOW_EXPIRED
INSERT INTO events(conv_id,seq,kind,sender_user,sender_device,client_msg_id,request_hash,target_seq)
VALUES (?, :next_seq, 'recall', ?, ?, ?, ?, ?);
UPDATE events SET recalled_by = :next_seq, body = NULL WHERE conv_id=? AND seq=?;
UPDATE conversations SET next_seq = :next_seq + 1 WHERE conv_id=?;
SELECT pg_notify('im_events', conv || ':' || :next_seq);

检查顺序有讲究。幂等检查放在权限与时限检查之前:撤回成功、但回复丢失的请求,在撤回窗口过期之后重发,仍然应该得到原来的成功结果,而不是 RECALL_WINDOW_EXPIRED,否则客户端会把一个已经生效的撤回显示为失败。“已撤回”与“新 cmid”的组合返回 ALREADY_RECALLED 而不是成功,因为这是一次不同的请求,告诉调用者真实状态更有用。

撤回请求的指纹是 sha256(conv || '\0recall:' || target)。同一个 cmid 被用来撤回另一个目标,会得到 IDEMPOTENCY_MISMATCH。

清理(Store.purge,节选):

1
2
3
4
5
6
7
8
9
// SELECT retention_ms, next_seq-1 FROM conversations WHERE conv_id=? FOR UPDATE
SELECT coalesce(max(seq), 0) FROM events
WHERE conv_id=? AND seq<=:head AND created_at < clock_timestamp() - retention; -- cutoff
INSERT INTO idem_tombstones(conv_id,sender_user,client_msg_id,seq)
SELECT conv_id,sender_user,client_msg_id,seq FROM events
WHERE conv_id=? AND seq<=:cutoff AND client_msg_id IS NOT NULL
ON CONFLICT DO NOTHING;
DELETE FROM events WHERE conv_id=? AND seq<=:cutoff;
UPDATE conversations SET min_seq = GREATEST(min_seq, :cutoff + 1) WHERE conv_id=?;

清理事务锁住会话行,与发送、撤回串行。这保证了三件事在同一个快照中完成:墓碑写入、事件删除、min_seq 推进。如果墓碑写在单独的事务中,两次提交之间到达的重发会在“事件已删、墓碑未写”的窗口里被当作新消息。

发送路径的检查顺序(第 01 篇):先查原记录,再查墓碑,最后检查成员资格并分配序号。墓碑检查放在成员检查之前,因此即使发送者已经退群,迟到的重发得到的仍然是 ORIGINAL_EXPIRED,而不是 NOT_MEMBER,客户端可以准确地把草稿标记为“已发送、已过期”。

撤回的事件可能比原消息存活得更久:原消息在 cutoff 之前被清理,撤回事件在 cutoff 之后保留,它的 target_seq 指向一个小于 min_seq 的序号。客户端处理撤回事件时,本地找不到目标就忽略,不需要报错。

min_seq 使用 GREATEST,保证并发或重复执行的清理不会让它回退。会话行锁本来就让清理串行,GREATEST 是对“重复执行同一次清理”的额外防护。

8. 验证

运行方式:真实 PostgreSQL 16.15,网关为独立 JVM 进程,测试客户端运行在测试进程内;pg_dump/pg_restore 使用同一实例上的另一个数据库 im_restore。命令:scripts/lab.sh 11(或 all)。结果见 evidence/im-11/result.json。验证级别为本地多进程;备份恢复是同实例、同版本的逻辑备份恢复,不是跨主机灾难恢复,也不是时间点恢复。

三人群 c-g 的事件序列:1–3 为 join,4 为 rc-1,5 为撤回 rc-1,6 为 rc-2,7 为 rc-3,8 为撤回 rc-3,9 为 u-cai 退群,10 为 rc-4。

类型 检查 预期 观测
正常 11-recall-online 撤回追加事件;在线成员 u-ben 的本地副本失去正文;数据库原行 body 为 NULL 撤回事件 seq 5;u-ben 已更新;db_body=null
边界 11-recall-idempotent 同一撤回请求重发 seq=5, dup=true
边界 11-recall-rejections 非发送者 / 新 cmid 再撤回 / 超时(窗口临时设为 300ms,等待 600ms) NOT_SENDER / ALREADY_RECALLED / RECALL_WINDOW_EXPIRED
恢复 11-recall-offline-device 撤回时离线的 u-cai 连接并同步 seq 4 recalled=true,无 body
边界 11-downloaded-copy-limit 一台持有 rc-2 原文、保持离线的设备,在此期间发生了另一次撤回 本地 rc-2 正文仍为 second:离线副本不受服务端控制
边界 11-access-time-check u-cai 在 seq 9 退群;fetch seq 6(区间内)与 seq 10(区间外) fetched / NOT_MEMBER
边界 11-retention-purge 单聊 c-ret:5 条旧消息等待 800ms 后再写 3 条新消息,保留期设为 500ms 后清理 删除 7 行(2 个 join + 5 条消息);min_seq=8;从 0 同步 trimmed=true、返回 3 条;重发 old-1 得到 ORIGINAL_EXPIRED
不变量 11-purge-keeps-contiguity 清理后 (max−min+1)−count 0
负对照 11-control-no-tombstone 删除 c-ret 的墓碑后重发 old-2 被当作新消息:seq 11,dup=false
恢复 11-backup-restore 导出、恢复,两库行数一致,副本上三项不变量违规数为 0 dump_bytes=23214;两库 events 均为 14;三项违规均为 0
边界 11-backup-keeps-recalled-bodies-absent 撤回之后的导出中,已撤回消息不带正文 0

对其中几项需要补充说明。

11-downloaded-copy-limit 是一个“证明系统做不到什么”的检查。测试构造了一台本地持有 rc-2 原文的设备,它不连接网关。随后 u-ana 撤回了 rc-3,这台设备上的 rc-2 原文仍然存在。这个检查断言的是限制本身:在设备下次同步之前,服务端无法影响它的本地数据。即使 rc-2 本身被撤回,结论也是一样。

11-backup-restore 中两库的墓碑数都是 0。这是因为在它之前运行的负对照删除了 c-ret 的墓碑,所以本次备份没有覆盖“墓碑在备份恢复后仍然有效”这一点。脚本对墓碑行数做了两库一致性比较,但比较的是 0 与 0。这一点记为证据缺口:H7 对墓碑的部分只有结构比较,没有非空数据。

11-backup-keeps-recalled-bodies-absent 与 11-backup-restore 读取的是同一次导出的结果,它单独列出,是为了把“撤回之后的导出不含原文”与“备份恢复成功”两个结论分开记录。它没有验证“撤回之前的导出包含原文”,那一半只是根据 pg_dump 导出快照的语义推出的结论,没有实验支撑。

撤回窗口用 300ms 加 600ms 等待来触发超时,依赖的是数据库时钟实际前进。这里没有模拟时钟,结果取决于数据库主机的真实时间。

9. 集成验收

9.1 需求卡对照

第 00 篇的 reqcheck.py 读取需求卡,按每个承诺项登记的检查编号(支持通配)在 evidence/im-*/result.json 中查找结果。对最终代码的运行输出见 evidence/design-00/reqcheck-real.txt,退出码 0:

需求 承诺 支撑检查 结果 级别
R1 sent 回复时消息已提交,同一请求键只对应一条消息 04-concurrent-replay-one-row,06-crash-*(3 项) PASS 真实组件 / 本地多进程
R2 会话内序号从 min_seq 起连续 04-seq-gapless,11-purge-keeps-contiguity PASS 真实组件
R3 成员只能读取自己成员区间内的事件,读取时判定 08-join-no-prior-history、08-leave-closes-interval、08-rejoin-gap-stays-hidden、08-no-push-after-leave、11-access-time-check PASS 本地多进程
R4 送达游标单调、有界;已读单调且不受送达影响 06-ack-monotonic-and-bounded、09-read-max-wins、09-read-does-not-fake-delivery、09-cursor-rollback、09-reinstall PASS 本地多进程
R5 重连后保留期内的消息可全部补齐 05-slow-receiver-recovers-by-sync、10-sigkill-recovery PASS 本地多进程
R6 撤回后服务端不再返回原文;离线设备同步后看到撤回状态 11-recall-*(4 项) PASS 本地多进程
R7 每台设备只有一个当前路由;迟到的旧注册不能覆盖新归属 05-old-close-keeps-new-route、10-late-old-route-rejected PASS 本地多进程

这张表中的 PASS 只表示“登记的检查在本地多进程环境下全部通过”,不表示需求在任何环境下都成立。每一项的适用条件见对应章节的限制部分。

reqcheck.py 对一张故意写坏的需求卡(examples/design/bad-card.json)报告格式错误并以非零码退出,见 evidence/design-00/reqcheck-bad-card.txt。检查器本身因此也有一个正例和一个反例。

9.2 检查总表

scripts/lab.sh all 在最终代码上的一次完整运行(evidence/lab-all.stdout.txt):71 项检查,71 项 PASS,0 项 FAIL。

章节 检查数 运行环境
04 持久化边界 9 真实 PostgreSQL,测试进程直连
05 长连接接入 13 PostgreSQL + 网关 JVM + 进程内客户端
06 发送确认 8 同上,含网关进程崩溃注入与数据库 immediate 停止
07 排序与同步 7 同上
08 群消息扇出 7 同上
09 多设备已读 10 同上
10 网关故障 6 PostgreSQL + 两至三个网关 JVM(SIGKILL / SIGTERM)
11 撤回与保留 11 PostgreSQL + 网关 JVM + pg_dump/pg_restore
合计 71

按检查类型统计:正常 12、边界 25、失败 12、恢复 7、不变量 2、负对照 10、观察 3。第 04–11 篇每篇都至少包含一项正常类和一项边界类检查,并至少包含一项失败类或恢复类检查。负对照共 10 项,每一项都是把防护机制关掉或换成朴素实现,确认同一场景下会出现被防护的错误。负对照通过,说明对应的正向检查确实覆盖到了它要防护的那条路径,而不是因为场景根本没有触发问题而“自然通过”。

第 06 篇的检查曾出现过一次断言竞态(测试代码在推送到达之前就读取了本地状态),记录在 evidence/im-06-flaky/README.md。修正测试代码后在最终运行中通过。这说明本地多进程测试本身也有时序问题,71/71 是一次运行的结果,不是多次重复的统计。

第 00–03 篇的设计侧证据另行记录:容量模型单元测试与参数扫描(evidence/design-02/),环境、冒烟测试、故障点清单与回放器的确定性对比(evidence/design-03/)。回放器属于模型级验证。

9.3 未验证项(NOT_RUN)

项 原因 需要的条件
U1 每连接内存 需要在目标环境建立大量空闲连接 目标 JVM 与内核参数、压测客户端
U2 主库故障切换期间已确认消息是否丢失 本批只有单个主库,无副本 流复制、同步复制配置、切换演练
网络分区(网关与数据库之间、客户端与网关之间) 无网络故障注入工具 多主机或网络命名空间 + 流量控制
TLS 网关只监听回环明文 TLS 终止层
真实延迟与吞吐 单机回环,数值不具代表性 目标环境压测
断电 / 内核崩溃 进程级注入不等价于断电 虚拟机级故障注入
监听连接断开注入(LISTEN 重建后的 resync) 有代码路径,无测试 终止网关的监听后端
HTTP 接口映射 本批只实现 WebSocket 上的 JSON 协议 第 01 篇只讨论了错误码映射
客户端遵守 Retry-After 数值、排空期间的 503 分支 测试客户端未解析该头;排空时客户端直接连接新网关 见第 10 篇
网关长停顿后路由重新登记 未实现 见第 10 篇
墓碑清理(purgeTombstones)与墓碑跨备份恢复 无检查;备份时墓碑表为空 增加检查
大数据量下的执行计划与未读计数代价 只有小数据量 填充保留期规模的数据
物理备份、WAL 归档、时间点恢复 只做了逻辑备份 归档配置与恢复演练

10. 故障与恢复

故障 处理 结果
撤回成功但回复丢失 原 cmid 重发;幂等检查在时限检查之前 返回原撤回事件,dup=true
撤回事件推送丢失 第 06 篇的重投扫描提示设备落后,设备同步 撤回最终到达
撤回之前发出的推送延迟到达 客户端不让未撤回版本覆盖已撤回副本 本地保持撤回状态
撤回时目标设备离线 同步时原消息已无正文 离线设备从未见过原文的,也不会再见到
已持有原文的设备长期离线 无法处理 原文保留在设备上,直到它同步
清理与发送并发 会话行锁串行 不会出现“事件已删、墓碑未写”的窗口
清理后迟到的重发 墓碑 ORIGINAL_EXPIRED
清理后墓碑也被清理,又一次迟到的重发 无防护 产生重复消息;由客户端的重发时限约束
设备游标落在已清理区间 trimmed=true 客户端从 min_seq 开始保存
数据库损坏,从逻辑备份恢复 pg_restore + 不变量检查 回到备份时刻;之后提交的消息丢失
恢复后客户端游标超过新的 head 第 07 篇的 CURSOR_AHEAD + 纪元 客户端按纪元变化重新同步;本批未把 epoch 递增与恢复流程串起来

最后一行涉及一个跨章问题。从备份恢复后,数据库回到过去的某个时刻,但客户端的游标没有跟着回去。客户端请求 after=50,而恢复后的 head 只有 40。第 07 篇为此设计了 CURSOR_AHEAD 错误与会话纪元 epoch:恢复流程应当递增所有会话的 epoch,客户端看到纪元变化后丢弃本地游标,从头重新同步。第 07 篇验证了 CURSOR_AHEAD 与纪元的判断(07-cursor-ahead-and-epoch),但本批的恢复脚本并没有执行纪元递增,因此“恢复后客户端自动收敛”这条链路没有被端到端验证。同时,恢复会让序号 41–50 被重新分配给新消息。持有旧 41–50 的客户端如果不按纪元丢弃本地数据,就会把两条不同的消息当成同一序号。这是恢复流程必须包含纪元递增的原因。

11. 限制

  • 撤回不能清除离线设备上的副本、备份、WAL 和数据文件中的旧版本。逻辑导出只验证了撤回之后的情况。
  • 撤回时限依赖数据库主机的真实时钟,没有在时钟跳变下测试。
  • 墓碑清理与墓碑在备份恢复后的有效性没有检查。
  • 备份恢复只在同一实例、同一版本上做了逻辑备份;没有跨版本、跨主机或时间点恢复,恢复流程也没有执行纪元递增。
  • 集成验收的全部结论属于本地多进程级。第 00 篇需求卡中的 U1、U2 仍然是未知项,需求卡的假设 A1–A3 没有在生产负载下检验。
  • 71/71 来自一次完整运行,没有重复运行统计稳定性。

本篇是本批的最后一篇。从第 00 篇的需求卡到这里的验收总表,一个约束贯穿始终:每一条承诺都要指向一个可以重复执行的检查,每一个检查都要说明它在什么环境下运行。能写进总表的结论,都可以用 scripts/lab.sh all 重新得到;写不进总表的,都列在 NOT_RUN 中。

参考资料