在同一时刻捕获库存与游标
一句话总结
快照不是漂亮的 JSON 文件,而是把状态与该状态所包含的最后一个事件,在同一时点绑定在一起的约定。
为什么需要它
仓库库存为 7、最后编号为 0 时,电子屏请求了全量恢复。假设服务器先读到 last=0,在此期间入库 5 提交了。接着再读取库存,得到的是 total=12。每个值都是曾经真实存在过的值,但 last=0 与 total=12 这种组合是从未存在过的状态。安装了这份快照的电子屏会再次收到 1 号事件的 +5,并显示 17。
这类错误仅靠 JSON 语法检查、类型检查和文件哈希比较是发现不了的。这是服务器自己生成的 JSON,所以来源正确,数字也在范围内,传输过程中也没有被改动的字节。错误在于两次查询读取的是不同的时点。必须分清完整性检查确认了什么、没有确认什么。严格验证格式,和构造含义一致的快照,二者缺一不可。
工作原理
实验中的 export_snapshot 在读事务内,先读取 checkpoint 的 epoch 和 last,再读取 stock.total。中间的 between 钩子是让其他连接提交新事件的测试点。返回值是 version、epoch、last、total 四个键,version 是整数 1。同一个事务中的两次查询构成同一个状态,并且还要另外确认:事务结束后重新查询,能看到新的状态。
这里使用的是 SQLite WAL 模式的读取时点隔离。在读取期间,即使其他连接提交了,该读事务也可以保持原来的时点。反之,如果用 BEGIN IMMEDIATE 启动 export,就会先占住写入位置,从而阻塞测试中的其他连接。对原始数据的写入需要串行化,但只做查询的导出并不应该阻塞所有入库。何时使用读事务、何时使用写事务,意图应当有所区别。
测试中,reader 连接第一次查询之后,writer 连接提交 +5,然后 reader 进行第二次查询。结果必须是之前的游标与之前的库存,而重新查看 writer 时,必须有新的库存。不会靠假的 sleep 去碰运气等待竞争发生。因为通过钩子直接指定了两次查询之间的位置,所以同样的错误每次都会得到同样的失败。这验证的是并发读写在逻辑上的重叠,而不是测量大量线程吞吐量的压力测试。
把导出的快照放进副本的过程同样必须是原子的。删除旧的 events、放入新库存、再更改游标,这一系列操作都在一个事务中。如果中途进程退出,要么完整保留之前的状态,要么完整保留新状态。如果 old total 与 new last 混在一起,下一次重放就会遗漏。提交结束之后响应丢失,并不意味着没有安装过,所以要把同一快照的重新安装设计为不改变状态并返回 False。
在现场相遇的样子
为了生成快照而复制一个 DB 文件的做法,与导出逻辑状态不同。在 WAL 模式下,可能有已经确定、却尚未反映到主文件中的内容,存放在单独的文件里。本实验不复制原始的 .db 文件,而是通过查询只取出所需的业务状态。真正的备份需要用所用 DB 的备份 API 和恢复流程另行验证。不能因为生成了供电子屏使用的 JSON,就说已经具备了用于服务器故障时恢复的备份。
另一个运维问题是无限制地拉长读取时间。如果在通过网络传输快照期间一直持有事务,慢速接收方就可能让 DB 一直停留在旧状态。这里是把很小的固定元数据和整数库存读入内存之后,就关闭事务。真实的大型状态则需要把分片、校验和、完成标记、访问权限乃至传输续传都设计进去。只有一个整数的示例,并不能消除这些成本。
版本同样是需要观察的对象。经确认,Linux 实验镜像所报告的 SQLite 版本是 3.45.1。官方 WAL 文档说明,在 3.51.3 之后以及部分向后移植的版本中修复了 WAL-reset 缺陷。本实验是有限的单写入流程,会关闭每个连接的自动 checkpoint,也不会产生并发的 checkpoint 操作。关闭连接也是在写入结束之后依次进行。这并不是在为多写入生产服务器推荐安全的默认设置。在实际部署中,需要确认发行版是否包含补丁以及修复所在的发布版本,不能只凭一个版本字符串就断定补丁状态。
下一项检查要做什么
在接下来的测验中,要判断错位的快照与写锁之间的区别。在之后的综合实验中,要实现 export_snapshot 和 install_snapshot。如果在两个 SELECT 之间错误地插入了提交,就会同时出现实际值 total=12 与期望值 total=7。在安装过程的 after-clear、after-install 位置,分别插入异常和真实的子进程终止。要区分观察:异常处理中的回滚是否正确,以及在清理代码完全不执行的情况下 DB 能否恢复。
参考:SQLite 读取时点隔离、WAL 的文件、并发与已知缺陷。本实验中的库存 JSON 并不是整个数据库的备份。