后端系统工程 11:撤回、历史保留与消息系统集成验收
后端系统工程 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 | |
target 是被撤回消息的序号,cmid 是撤回请求自己的请求键,与原消息的 cmid 无关。返回的 seq 是撤回事件的序号。
撤回事件在同步与推送中的形式:
1 | |
被撤回的原消息再被读取时:
1 | |
注意,这里没有 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 | |
撤回在数据上留下两处记录:新的 recall 事件,以及原消息行上的 recalled_by。recalled_by 看起来是冗余的,通过 target_seq 反查也能得到。但读取原消息时,它让“是否已撤回”成为一次主键查找就能得到的列值,未读计数(第 09 篇)也可以直接用 recalled_by IS NULL 过滤,不必与撤回事件做连接。
5. 正常路径
5.1 撤回
1 | |
图 11-1 撤回的写入与传播路径
在线设备通过推送收到 recall 事件,在本地找到 target 对应的消息,删去正文并标记为已撤回。离线设备之后同步时,同时收到原消息和撤回事件。原消息的返回中已经没有正文,所以离线设备从头到尾都拿不到原文。
客户端合并逻辑有一个顺序问题:如果一台设备先收到 recall 事件,后收到原消息(例如推送与同步交错,第 07 篇允许乱序到达),原消息会覆盖本地的撤回标记。Client.applyEvent 的处理是:已经标记为撤回的本地副本,不会被一个未标记撤回的版本覆盖。由于服务端在撤回后不再返回原文,这种覆盖只可能来自撤回之前发出、在途中延迟的推送。
5.2 保留清理
1 | |
图 11-2 前缀清理与墓碑写入(与实验数据一致)
清理按“前缀”进行:找出已过期事件中最大的序号作为 cutoff,删除所有 seq <= cutoff 的事件。因为序号在会话行锁下按提交顺序分配(第 04 篇),created_at 与序号单调一致,过期事件天然构成前缀,不会出现“中间某条没过期、两边都过期”的情况。即使出现时钟回拨,按 cutoff 删除也只会多删几条比 cutoff 小的事件,日志仍然是连续的。
同步请求从 0 开始时,返回 trimmed=true,事件从 8 开始。客户端据此提示“更早的历史已过期”,并从 min_seq 开始保存,而不是认为自己缺失了 1–7 而反复请求。
5.3 备份与恢复检查
scripts/backup_restore.sh 依次执行:
pg_dump -Fc导出im数据库;- 删除并重建
im_restore,用pg_restore --exit-on-error恢复; - 在两个库上分别运行三条不变量查询:序号连续性、已撤回消息不带正文、设备游标不超过 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 | |
检查顺序有讲究。幂等检查放在权限与时限检查之前:撤回成功、但回复丢失的请求,在撤回窗口过期之后重发,仍然应该得到原来的成功结果,而不是 RECALL_WINDOW_EXPIRED,否则客户端会把一个已经生效的撤回显示为失败。“已撤回”与“新 cmid”的组合返回 ALREADY_RECALLED 而不是成功,因为这是一次不同的请求,告诉调用者真实状态更有用。
撤回请求的指纹是 sha256(conv || '\0recall:' || target)。同一个 cmid 被用来撤回另一个目标,会得到 IDEMPOTENCY_MISMATCH。
清理(Store.purge,节选):
1 | |
清理事务锁住会话行,与发送、撤回串行。这保证了三件事在同一个快照中完成:墓碑写入、事件删除、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 中。
参考资料
- PostgreSQL 16 文档,pg_dump(并发使用时也能产生一致的备份;不阻塞其他用户;
-Fc自定义格式)。https://www.postgresql.org/docs/16/app-pgdump.html - PostgreSQL 16 文档,pg_restore(
--exit-on-error,从自定义格式恢复)。https://www.postgresql.org/docs/16/app-pgrestore.html - PostgreSQL 16 文档,DELETE。https://www.postgresql.org/docs/16/sql-delete.html
- PostgreSQL 16 文档,13.1 MVCC Introduction 与 25.1 Routine Vacuuming(更新产生新行版本,旧版本由 VACUUM 回收)。https://www.postgresql.org/docs/16/mvcc-intro.html 、https://www.postgresql.org/docs/16/routine-vacuuming.html
- PostgreSQL 16 文档,9.9.5 Current Date/Time(
now()为事务开始时间,clock_timestamp()为实际当前时间)。https://www.postgresql.org/docs/16/functions-datetime.html - PostgreSQL 16 文档,13.3.2 Row-Level Locks。https://www.postgresql.org/docs/16/explicit-locking.html
- PostgreSQL 16 文档,9.18.3 GREATEST and LEAST。https://www.postgresql.org/docs/16/functions-conditional.html
- PostgreSQL 16 文档,26.3 Continuous Archiving and Point-in-Time Recovery(本批未使用,作为物理备份与 WAL 保留的依据)。https://www.postgresql.org/docs/16/continuous-archiving.html





