TT Lab
开始
学习 学习路径 课程

FDE综合实战:仓库收到了三次相同订单

从未执行过的回滚:SQLite WAL 备份与恢复演练

在 TT Lab 中继续学习

一句话总结

回滚计划如果没有实际执行过,就不是计划,而是希望。对于以 WAL 模式使用 SQLite 的服务,备份不是复制文件,而是用 .backup 或 VACUUM INTO 来做;迁移要包在一个事务里;恢复要处理掉残留的 -wal,并用内容哈希证明与备份时刻相同,才算结束。

为什么需要它

客户公司部署前一天的评审会上,回滚计划书传阅了一圈:“出问题就用 cp /backup/orders.db /srv/app/orders.db 恢复。”大家都点了头,第二天迁移在中途失败了。按计划书复制之后服务起来了,一个小时后客户支持团队问:“昨天晚上的订单看不到了。”那份备份是在服务运行中用 cp 做的,而恢复之后,本该撤销的紧急修改依然留在里面。

命令本身没错。但没有人实际执行过,看那条命令在这个服务的文件布局上到底做了什么。FDE 在客户现场要做的不是写计划书,而是做演练并留下证据。备份是否完整,迁移失败后是否留下痕迹,恢复后是否真的回到了那个时刻——每一项都要用数字展示出来。

工作原理

1. WAL 文件是数据库的一部分。根据 WAL 文档,在 WAL 模式下,修改追加到 -wal 文件而不是主文件,提交也记录在那里。检查点把内容移到主文件,默认在 WAL 达到 1000 页时自动触发。WAL 文件在连接打开期间会保留,通常在最后一个连接关闭时被删除。而且文档明确写道,WAL 是数据库的持久状态,所以复制、移动时必须一起带走,一旦分开,可能丢失已提交的事务,或者文件损坏。

实测的结果很吓人。把 500 笔订单放进主文件,在连接保持打开(关闭自动检查点)的情况下再提交 137 笔,然后只 cp 主文件。副本里有 500 笔,PRAGMA integrity_check 的结果是 ok。文件完好,只是提交悄悄丢了。检查通过并不能证明备份是完整的。

2. 在线备份有两种。备份 API 文档说明,备份是一边一点点复制,一边只在读取期间加锁,结果是与开始复制那一刻的原库相同的副本。如果其他连接在中途写入,通常要从头重新复制。CLI 的 .backup 用的就是这个 API。VACUUM 文档中的 VACUUM INTO 是另一种方案,把原库的一致快照写成最小体积的新文件。如果目标文件已存在(且不是空文件)就会失败,中途断电的话结果可能不完整。

两者的结果文件不同(实测:sqlite3 3.45.1)。在有 637 笔记录的 WAL 数据库上,.backup 的副本,头部仍保持 WAL 模式,而 VACUUM INTO 的副本是回滚日志(delete)模式。文件的 sha256 也互不相同。但是 CLI 文档中的 .sha3sum 是内容的哈希,而不是磁盘表示的哈希,所以不会因 VACUUM 之类的转换而改变。加上 --schema 还会把 schema 也算进去。两个副本的 .sha3sum --schema 相同。“是不是同一时刻的同一份数据”要用这个值来判断,而不是文件哈希。

3. 迁移是一个事务,版本用 user_version。根据 PRAGMA 文档,user_version 是文件头第 60 号偏移处的整数,是 SQLite 自己不使用的、留给应用的值。适合用作 schema 版本号。把迁移内容和 user_version 的提升放进同一个事务,两者就要么一起成功,要么一起不成功。

这里有两个陷阱。CLI 文档写道,CLI 默认在出错之后也会继续执行下一条命令,必须给出 -bail 才会停下。ON CONFLICT 文档说明,默认的解决方式 ABORT 只回滚那一条语句,同一事务中前面的语句保留,事务也继续存活。两件事叠加,实测的结果是这样的。

应用方法 第三条语句违反 UNIQUE 时
sqlite3 db < migration.sql 前面的 ALTER、UPDATE 留下来了
不加 -bail 使用 BEGIN; …; PRAGMA user_version=3; COMMIT; 前面的语句留着就 COMMIT 了,user_version 也变成 3
用 sqlite3 -bail 执行同样的内容 退出码 1,列和 user_version 都保持原样

事务文档中的 BEGIN IMMEDIATE 会在开始时就获取写锁,所以如果其他写入正在进行,会在一开始就失败,而不是在中途失败。

4. 恢复要从残留的 -wal 开始。服务被强制终止时,连接没有关闭,所以会留下 -wal。在这种状态下把备份 cp 到主文件之上,下次打开时,残留的 WAL 会被应用到恢复的文件上。实测中 user_version 回到了备份的值,但本该撤销的紧急修改行依然可见。恢复之前要清除 -wal、-shm,或者用 CLI 的 .restore 通过 SQLite 来覆盖,完成后把 .sha3sum --schema 与备份的值比较。

리허설 한 바퀴
  .backup → integrity_check · 행 수 · .sha3sum --schema · user_version 기록
  migrate(N)            → user_version N
  migrate(깨진 N+1)     → 실패해야 하고, 내용 해시가 그대로여야 한다
  restore(백업)         → 내용 해시 = 백업, user_version = 백업

在现场相遇的样子

“备份每天都在跑”这句话,通常意思是 cron 在跑 cp。凌晨服务安静,大多数时候没问题,而有问题的那天没人知道。在演练中,把备份的行数与服务已提交的数量摆出来对比的那一刻,对话就变了。

迁移也类似。在开发数据库里通过的文件,在生产数据上中途失败是常有的事(在每个客户只有一笔订单的开发数据上,UNIQUE 索引是能建出来的)。如果提前展示失败时不会留下痕迹,客户打开部署窗口时相信的,就不再是“失败了回滚就行”,而是“失败了也什么都不会改变”。

实际工作中真正重要的事

下一项实验要做什么

启动一个以 WAL 模式运行的订单服务,测量 cp 备份会丢掉什么,用 .backup 和 VACUUM INTO 做备份后留下基准值。制作遵守版本号的 migrate.sh,以及拒绝损坏备份的 restore.sh,并真正恢复一个在部署中被强制终止的生产数据库。最后制作一个从备份到恢复证明一次性跑完的 rehearse.sh,评分器会用残留了 -wal 的新数据库运行这次演练。