系统设计 33:回填期间更新怎样安全迁移
迁移失败不一定发生在切流那一秒。旧库的一条 v1 记录被回填器读走,业务紧接着写入 v2,新库也已接收 v2;几秒后回填器把 v1 覆盖回去。两边都有记录、总行数相同、服务没有报错,用户却看到了旧值。
本篇把分享元数据从旧表迁到增加访问权限字段的新表。讨论在线回填、兼容读取与可回退范围,排除真实跨数据库复制的实现。核心实验使用实际 SQLite 3.51.3 执行版本条件 UPSERT;跨库原子性、CDC 完整性和线上性能不在实验结论内。
先确定迁移后必须相同的东西
业务不变量是同一分享的较旧版本不能覆盖较新版本,撤销墓碑不能被回填复活;同一个创建幂等键仍指向同一分享。迁移成功不是两个库行数接近,而是指定切换位点之前的业务状态可解释一致。读取目标拟定为 p99 200 ms、写入 p99 300 ms、回填每天不超过原存储可用 IO 的二成,这些是计划预算,不是本实验测出的能力。
旧模型为 shares(id, target, version, deleted),新模型增加 owner_id, visibility, schema_version。更新接口 PATCH /shares/{id} 携带 expected_version;服务只在当前版本吻合时产生更大的版本。删除也产生一条更高版本墓碑。不能把系统墙钟毫秒当成天然无冲突版本:并发写入、时钟回拨和批量导入都可能让排序失去意义。
读取契约包含三种状态:记录存在、明确已删除、记录尚未迁入。若新库返回“已删除”,不能回退旧库取正文;只有确认新库尚未承载该对象时,才允许按迁移状态读取旧库。把所有空结果都当成 cache miss,会绕过墓碑。接口层保留明确的内部状态,外部仍可按权限契约统一返回 404。
从兼容扩展开始,而不是先删除旧字段
最小方案仍由旧库承担唯一写入权威。先部署能识别新字段但兼容旧记录的程序,再开始回填;业务更新通过可靠变更日志传播到新库;新库只做影子读取和比对。只有当回填扫描结束、增量日志追上切换位点、关键不变量核对通过,才对少量账户打开新读。
可靠日志可以来自旧库同事务 Outbox 或已有 CDC,但不能省略“事务提交成功、通知还没发出就崩溃”的窗口。应用直接调用旧库和新库两次并不等于原子双写。若短期只能应用双写,就要存持久补偿任务、记录每侧成功状态,并在切换前证明未完成项为零;若做不到,保持旧库权威。
flowchart LR
C[写请求] -->|expected_version| W[兼容写服务]
W -->|原子更新与变更记录| O[(旧库及 Outbox)]
O -.->|增量版本事件| N[(新库)]
O -->|有位点的历史快照| F[限速回填器]
F -->|仅较新版本覆盖| N
R[影子比对器] -->|按相同逻辑投影读取| O
R -->|读取并比版本| N
R -->|差异清单及切换门槛| S[迁移控制表]
回填的数据投影也必须版本化。假如旧字段 public=true 曾表示“所有知道链接的人可读”,新字段 visibility=public 却表示“允许被公开搜索”,直接同名映射会扩大权限。迁移规则需要业务语义审查,不能仅通过 SQL 类型检查。对于未知值宁可拒绝迁移并记录异常对象,也不要默默填一个权限更宽的默认值。
容量预算包含暂时双份与追赶能力
教学数据量为五千万行,每行旧格式 600 B、新格式 800 B;迁移期间仅两份逻辑正文占 50000000×(600+800)=70 GB,索引和副本另计。原本只为旧格式留 30 GB 空间,无法容纳新格式全量回填。每秒回填五千行,理想扫描时间 50000000/5000=10000 s,约 2.78 小时,实际受 IO、限流、暂停和重试影响更长。
若平均业务更新 200 row/s、峰值 1000 row/s,每个变更事件 1 KiB,峰值增量约 1.024 MB/s。回填若占尽新库写能力,增量追赶就会被饿死;回填应让位于实时变更,并观察最老未应用事件年龄。旧库峰值翻倍时,固定五千行回填速率可能突破 IO 预算,应自动降低回填速度,而不是让用户写请求一起超时。
切换前可设置“扫描全部完成、增量落后为零至已封存位点、抽样差异为零、全量计数与墓碑核对通过”。抽样零差异只说明样本,没有证明全量相同。权限字段及删除记录应做更强核对,必要时逐键检查;连续大表全量扫描成本太高时,可先按稳定分区摘要定位差异,再对差异分区逐键比对。
sequenceDiagram
participant F as 回填器
participant O as 旧库
participant W as 正常写服务
participant N as 新库
F->>O: 读取 s1 的 v1 快照
W->>O: 提交 s1=v2,version=2
O->>N: 增量应用 v2
F->>N: 迟到的 v1
N-->>F: version 1 小于 2,跳过
W->>O: 删除 s1,version=3
O->>N: 应用墓碑 v3
F->>N: 再次重试 v2
N-->>F: version 2 小于 3,仍删除
SQLite UPSERT 文档允许在冲突更新后增加 WHERE 条件。本实验用 WHERE excluded.version > new.version,使较旧回填成为无操作。唯一键是 id,版本条件保护的是内容演进;两者缺一不可。相同版本却不同内容属于数据损坏或上游版本生成错误,不应选择随机覆盖,工程实现应额外记录校验冲突。
回退是数据兼容问题
读流量切回旧库只有在旧库仍保存全部新写入时才安全。若新版本开始接受旧模型无法表达的 acl,简单把读开关拨回旧库会丢失访问规则。此时要么仍禁止新功能、维持双向可表示的数据子集,要么给旧服务增加兼容读取,再谈回退。所谓“随时回滚”需要有结束日期和明确限制。
可以按 expand、migrate、contract 分阶段:先扩展兼容能力,迁移与观察,最后收缩旧字段和旧写路径。删除旧表是不可逆边界,应晚于业务回退窗口、备份恢复验证和未迁移对象清零。存储费用会多花一段时间,但比在故障时恢复一个旧程序不能理解的新模式更容易控制。
故障时先暂停回填与切流,保留唯一权威写入;不要同时回退程序、逆向复制和清理新表。回填任务用分区边界及游标恢复,重复执行靠版本合并保证安全。若变更日志保留期短于回填耗时,丢失的增量不能靠“游标继续跑”补上,必须重建快照并重新建立连续位点。
运行 python3 examples/system-design/labs/33/run.py:实际数据库先插入 v2,再应用旧快照 v1,保留 v2;删除 v3 后重复 v2,保留墓碑;负例使用无条件替换,结果退回 v1。兼容性反例还确认旧字段集合不能表达新增 acl,因此不允许宣称无损回退。原始 JSON、版本和命令见 examples/system-design/evidence/33/。这证明固定交错下 SQL 条件有效,没有证明网络双写、并发压力下的延迟或 CDC 绝不丢事件。
面试追问包括:扫描完成但增量尚有积压能否切换;墓碑何时可删除;同版本不同值如何发现;新字段上线后还能否回退。可迁移规则不是“迁移一律双写”,而是先选权威源,再证明从快照到增量之间没有缺口。
[PATTERN] 在线迁移需要兼容表示、单调版本与连续变更链。新增存储只解决目的地,切换证明决定能否交付。
实验附件与导航
可运行实验源码 · 本次原始结果系列导读;容量和数据承诺分别沿用系列的方法,本文数字为独立教学假设。






