空日志不代表已经同步到最新
一句话总结
游标是重新接收的位置,保留边界则是判断该位置是否仍然存在的证据。收到空响应并不意味着已是最新状态。
为什么需要它
假设要把社团零食仓库的库存显示在电子屏上。入库用正数、发放用负数的变化量来记录。仓库服务器一直在运行,但电子屏在周末期间关闭了。周一发送最后处理的编号来重新连接时,期望像往常一样收到遗漏的事件。然而运维人员为了节省磁盘,删掉了周末日志的前半部分。如果服务器只发送剩下的事件,电子屏会算出一个看似合理的数字,但与真实库存永远对不上。
这不是连接失败。即使 TCP 连接已建立、HTTP 响应也成功,过去已经不存在这一事实也不会改变。前面模块中的 outbox 保存的是尚待投递的意图。这个模块要解决的是:在有意丢弃旧的投递明细之后,仍然恢复当前状态。无条件永久保留日志,避免不了保留成本;把任意状态都改回初始值,则会丢失真实业务。因此要把快速重放与完整状态恢复设计成两条独立的路径。
工作原理
实验中的流编号从 0 开始递增,把尚无事件时的最后编号表示为 -1。last 是最后应用的编号,floor 是服务器仍保留的最早编号。正常保留下来的事件从 floor 到 last 连续,全部被截断时 floor 为 last+1。这是本实验的整数契约。不要原样套用到其他产品的基于时间的 ID 或分区偏移量上。
| 服务器状态 | 客户端 last | 判断 |
|---|---|---|
| floor=4, last=8 | 3 | 可以从 4 开始重放 |
| floor=4, last=8 | 2 | 缺少 3,需要快照 |
| floor=9, last=8 | 8 | 已是最新,空批次属正常 |
| floor=9, last=8 | -1 | 整个日志已被截断,需要恢复 |
关键的不等式是:客户端的 last 是否小于 floor-1。如果相等,下一个编号就是 floor,所以是安全的。如果更小,至少会漏掉一个事件。比服务器更靠前的游标也不会被当作正常情况接受。可能是连到了错误的存储,或者本地状态属于另一个代次,需要作为单独的错误来调查。一个不等号就可能造成数据重复或遗漏,所以要分别测试各个边界值。
源存储使用 checkpoint、stock、events 三张表。events 保存要重放的单个变化,stock 保存累计库存,checkpoint 保存代次、最后编号和保留边界。trim(through) 只删除编号不大于 through 的事件,并把 floor 改为 through+1。库存和 last 不会改变。减少重放用的明细,与取消已经发生的业务是两回事。即使事件变成 0 个,也会保留 checkpoint 的一行,因此可以区分一开始就是空的仓库和过去被截断的仓库。
查询时,同样要在一个读事务内查看元数据和事件。先判断保留范围足够,随后其他写操作截断了日志,然后才读取事件——这种结构中检查时点和使用时点是不同的。依据 SQLite 关于不同连接之间隔离的说明,保持同一个读取时点。如果不真正使用库所提供的隔离,把数据拆到多张表中保存的理由就不存在了。
在现场相遇的样子
如果电子屏每天关闭的时间是四个小时,而重放日志只保留两个小时,那么无论把重连实现打磨得多好,都需要频繁地做完整恢复。这里必须一并考虑产品允许的最长离线时间、事件产生量,以及快照的生成与传输时间。增加保留量可以降低恢复频率,但存储成本和个人信息的保留范围可能随之变大。反过来,对于快照很小的服务,较短的日志加上快速的完整恢复可能更简单。
诊断日志中不要只写“连接成功”,而要记录请求代次、客户端 last、服务器的 floor 和 last,以及所选择的恢复路径。本实验不涉及客户个人信息,只使用整数库存。在真实服务中,不应把整个状态输出到日志里,而应当把标识范围和诊断用的元数据降到最少。包含业务数据的快照,不是可以随便放在任何公开链接上的文件。
一次 replay 最多只返回 16 个。这个数字并不保证整个恢复完成,而只是一次调用的资源预算。如果剩下 20 个,那么第一次调用之后还剩 4 个是正常的。必须区分空批次与遗漏错误,调用方才能选择下一步行动。如果明明还剩很多,却把一次成功标记为全部完成,进度就会欺骗用户。
下一项检查要做什么
这个模块先通过测验来判断边界,然后在之后的综合实验中实现 append、trim 和 replay。会比较:在整个被截断的日志上发送旧游标时是否会报错,发送最新游标时是否会返回空批次。还要检查截断之后新编号是否不会回到 0。在下一篇理论中,会把用于恢复的库存与游标绑定到同一个时点。
参考:SQLite 连接之间的隔离。floor 和 last 的含义、批次大小以及错误分类,是上面的仓库实验所定义的应用协议。