机制案例 · 90–120 分钟 · Python 3.10+ 与基础 SQL · 无云账号
行数和总额都一样,为什么不能切?
你把订单从源库复制到目标库。两边都是四行,总额都是 800 元,检查脚本全绿。客户却指出 O1 的钱记到了 O2。稍后目标又接收三次新写入;服务出故障,改回连接地址会让这些新事实消失。这个案例把两件容易混淆的事拆开:证明两端在同一位置一致,以及把新的事实完整带回源端。
你会实际创建两个 SQLite 文件、更新行、按业务键对账并执行反向回放。变化日志、水位和写入开关由教学程序模拟;没有真实 CDC、网络并发、跨库事务或支付接口。成功只能证明这份有限数据与模型中的机制,不能证明任何生产数据库迁移能力。
先固定什么叫“同一份数据”
业务键是订单 ID。我们用整数分保存金额,避免把二进制小数误差混入本题;状态也参与比较。源端快照水位为 0,表示下面四单已经包含在快照中,后续变化从事件 1 开始。水位是连续应用到的事件序号,不是时钟,也不能通过看到事件 6 就猜前面五条都成功。
| ID | 金额/分 | 状态 |
|---|---|---|
| O1 | 10000 | paid |
| O2 | 20000 | paid |
| O3 | 30000 | paid |
| O4 | 40000 | paid |
把这份稳定快照装入两端后,源端依次发生:事件 1 将 O2 从 20000 改为 22500;事件 2 删除 O3;事件 3 插入 O5=7500。目标按同序回放后,两端应为 O1=10000、O2=22500、O4=40000、O5=7500,总计 80000 分、四行、水位 3。这里一删一增使行数未变,正好说明行数看不见全部变化。
本题的通过条件是:两端业务键集合相同、每个键的金额与状态相同、连续水位相同。实际系统还会有多表关系、字符集、权限和外部副作用;不能把这个三条件直接当成普适验收合同。
为什么目标开放写入,会改变回退方法?
在水位 3 对齐后,先关闭源端的客户写入,再开放目标。称它为写入隔离:让业务变更只从一个入口进入。这里程序检查 control.writable;它不是数据库级权限隔离,直接用 SQL 修改文件可以绕过它。真实系统要枚举 Web、定时任务、队列消费者、后台和外部回调,不能只把网页设维护页。
目标现在接收事件 4:O1 改为 12000;事件 5:删除 O4;事件 6:新增 O6=9000。目标变成 O1、O2、O5、O6 共四单,总额为 12000+22500+7500+9000=51000 分。此时源端仍是 80000 分。两边都是四行,改回源库地址却会丢掉 O6、恢复已删除的 O4、把 O1 退回旧金额。
每条事件保存改变前与改变后的值。回放先核对当前值等于 before,再更新为 after;不相等就停,而不是用旧日志强行覆盖新事实。事件序号和内容作为回执,与订单变更在同一个 SQLite 事务中提交。相同序号与相同内容重来一次是无操作;同序号换了内容必须报错。删除的 after 为 null,也必须记录回执,否则“已经删过”和“从来没收到”难以区分。
先预测,再运行三条路径
下载完整实验包,解压进入 sqlite-recovery。输入、代码、测试和参考都在包内;README也可在线读。先写下每条路径的最终总额、差异键和是否开放写入,再执行:
python3 lab.py --out runs/normal
python3 lab.py --out runs/swapped --fault swap-amounts
python3 lab.py --out runs/missing-delete --fault skip-delete
python3 -m unittest -v test_lab.py
每次使用一个不存在的结果目录,程序会拒绝覆盖旧实验。正常路径退出码为 0,两种故障为 2,故障不是需要改成绿色的安装问题。结果目录含两个可检查的数据库及 report.json。打开 phases:forward 是切换前,newWrites 是目标产生新事实后,reverse 是反向回放后;被提前阻断的路径没有后续阶段。
参考结果:在哪一行证据上做决定?
normal:forward 两端 4 行、80000 分、水位 3;newWrites 源 80000、目标 51000;final 两端 4 行、51000 分、水位 6,仅源可写,duplicateApplied=0。swapped:总数仍相同,差异键为 O1/O2,切换被阻断,两端均关闭客户写入。missing-delete:事件 4 已反放,跳过 5 后,6 因“expected 5”被拒;源仍留 O4,缺 O6,源水位 4、目标 6,不能恢复源写。
卡住时,追第一处不一致
| 观察 | 含义与下一步 |
|---|---|
| 同额、同行,equal=false | 先读 differences 的业务键,不要再取一个更大的随机样本。 |
| sequence gap | 缺了前序事件;查水位和日志。不能把 MAX(seq) 人工调大。 |
| before-image conflict | 当前行不是事件预期旧值。查是否双写、错快照、乱序或改错输入;保留现场。 |
| client write fenced | 入口处于关闭状态;先完成回放与对账。不能为了继续演示解除保护。 |
| 输出目录已存在 | 另选新目录,旧结果保留作对照。不要改 reference 伪造通过。 |
注意:程序自动串行跑完整流程,没有测停机时长。它也不自动撤销已发出的付款、邮件等外部效果。若业务已经扣款,即使数据库回到一致状态,还需按业务回执与幂等规则处理;不能声称“SQLite 都相等,所以付款已回滚”。
交付一个能被别人复算的差异说明
- 保留三份原始 report,列出正常与两种故障各自的首次不一致阶段、键、水位、总额和写入状态。
- 把 input.json 另存 unseen.json,只把目标事件 6 的 9000 改为 15000。先手算,再用
python3 lab.py --data unseen.json --out runs/unseen验证;解释哪些步骤无需改变。 - 把事件 4 的 before 从 10000 改为 9999,在新目录运行,说明为何不能“修程序让它覆盖”。若目标还有真实付款,请写出本实验没有验证的两项条件。
陌生变式参考与完成标准
金额变式 final 应为 57000 分、4 行、水位 6;比较的是输入推导出的状态,不是硬编码 51000。before=9999 与目标实际 O1=10000 冲突,目标事件 4 不能提交,两端写入均关闭;这暴露输入与实际旧状态不一致。完成需能解释 insert/update/delete、重复回放、缺序与冲突各为何不同,并留下可重跑输入。只抄 RESTORED 文本不算完成。
把这个机制放回完整迁移
回到数据库迁移检查快照、DDL、写入者与水位,再到切换与回退加入真实执行时间和可逆点。阅读 Python sqlite3 文档的参数绑定与事务章节,核对本例为何一起提交业务行和回执;阅读 SQLite 事务说明后写出一条本例不能证明的并发保证。课堂式演算已经完成,真实平台仍需另外测量和演练。