云迁移学习路线 · 学习站

机制案例 · 90–120 分钟 · Python 3.10+ 与基础 SQL · 无云账号

行数和总额都一样,为什么不能切?

你把订单从源库复制到目标库。两边都是四行,总额都是 800 元,检查脚本全绿。客户却指出 O1 的钱记到了 O2。稍后目标又接收三次新写入;服务出故障,改回连接地址会让这些新事实消失。这个案例把两件容易混淆的事拆开:证明两端在同一位置一致,以及把新的事实完整带回源端。

你会实际创建两个 SQLite 文件、更新行、按业务键对账并执行反向回放。变化日志、水位和写入开关由教学程序模拟;没有真实 CDC、网络并发、跨库事务或支付接口。成功只能证明这份有限数据与模型中的机制,不能证明任何生产数据库迁移能力。

先固定什么叫“同一份数据”

业务键是订单 ID。我们用整数分保存金额,避免把二进制小数误差混入本题;状态也参与比较。源端快照水位为 0,表示下面四单已经包含在快照中,后续变化从事件 1 开始。水位是连续应用到的事件序号,不是时钟,也不能通过看到事件 6 就猜前面五条都成功。

完整初始输入(与 input.json 一致)
ID金额/分状态
O110000paid
O220000paid
O330000paid
O440000paid

把这份稳定快照装入两端后,源端依次发生:事件 1 将 O2 从 20000 改为 22500;事件 2 删除 O3;事件 3 插入 O5=7500。目标按同序回放后,两端应为 O1=10000、O2=22500、O4=40000、O5=7500,总计 80000 分、四行、水位 3。这里一删一增使行数未变,正好说明行数看不见全部变化。

相同行数与总额不能确定订单金额相同源端O1为100元O2为225元;错误目标交换了两单金额,仍有相同行数和总额。只有按订单ID对账才能找到差异。源端 · 水位 3O1 = 100 元O2 = 225 元另两单 475 元;合计 800错误目标 · 水位 3O1 = 225 元 ← 错配O2 = 100 元 ← 错配另两单 475 元;合计 800四行相同、总额相同、水位相同;业务键对应的内容仍不同。
图 1 · 汇总可以筛查异常,不能替代业务键与字段比较。交换的两笔恰好互相抵消。

本题的通过条件是:两端业务键集合相同、每个键的金额与状态相同、连续水位相同。实际系统还会有多表关系、字符集、权限和外部副作用;不能把这个三条件直接当成普适验收合同。

为什么目标开放写入,会改变回退方法?

在水位 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 退回旧金额。

切换和回退要转移写入权并等待完整对账源端可写时正向应用事件1到3。对账后关闭源写开启目标,目标产生4到6。回退时先关闭目标写,两端都不可写,把4到6回放到源,核对成功后仅开放源写。客户可写端数据变化与条件仅源端事件 1–3 → 目标;同水位逐键核对仅目标事件 4–6 在目标产生;源端仍停在 3两端都关闭排空 → 反放 4–6 → 对账;失败保持关闭仅源端确认水位 6 和每个业务键相同后恢复
图 2 · 回退是一条新的数据路径。箭头倒过来之前,先确定哪些写入已经成为事实。

每条事件保存改变前与改变后的值。回放先核对当前值等于 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 都相等,所以付款已回滚”。

交付一个能被别人复算的差异说明

  1. 保留三份原始 report,列出正常与两种故障各自的首次不一致阶段、键、水位、总额和写入状态。
  2. 把 input.json 另存 unseen.json,只把目标事件 6 的 9000 改为 15000。先手算,再用 python3 lab.py --data unseen.json --out runs/unseen验证;解释哪些步骤无需改变。
  3. 把事件 4 的 before 从 10000 改为 9999,在新目录运行,说明为何不能“修程序让它覆盖”。若目标还有真实付款,请写出本实验没有验证的两项条件。
陌生变式参考与完成标准

金额变式 final 应为 57000 分、4 行、水位 6;比较的是输入推导出的状态,不是硬编码 51000。before=9999 与目标实际 O1=10000 冲突,目标事件 4 不能提交,两端写入均关闭;这暴露输入与实际旧状态不一致。完成需能解释 insert/update/delete、重复回放、缺序与冲突各为何不同,并留下可重跑输入。只抄 RESTORED 文本不算完成。

把这个机制放回完整迁移

回到数据库迁移检查快照、DDL、写入者与水位,再到切换与回退加入真实执行时间和可逆点。阅读 Python sqlite3 文档的参数绑定与事务章节,核对本例为何一起提交业务行和回执;阅读 SQLite 事务说明后写出一条本例不能证明的并发保证。课堂式演算已经完成,真实平台仍需另外测量和演练。