数据改造四步:哪一步之后就退不回去了
改造方案里,数据这一节应该排在最难的位置,而不是最后。原因很简单:代码改坏了能退回去,写歪的数据退不回去。一个项目的回滚设计能不能成立,几乎全部取决于数据这一段怎么设计。
下面按四步走,每步只讲它会失败在哪里。
先扩后收:收缩要推迟到退役
库结构改造的标准三段是扩展 → 迁移 → 收缩:先加上新列新表(不动旧的),再把读写逐步挪过去,最后删掉不再使用的部分。这套三段式(Parallel Change)最早由 Joshua Kerievsky 在 2006 年提出,2014 年由 Danilo Sato 写成条目收进 Martin Fowler 的术语页——不是我原先以为的某个云厂商内部实践,它是十几年前的公开方法论。原文也老实列了代价:迁移期新旧两个版本并存,会让外部调用方困惑。
真正容易被忽略的是收缩的时机:
- 收缩必须在所有实例都跑在新代码之后。滚动发布、任务实例、回滚用的旧版本包,任何一处还在跑旧代码,收缩就会把它打挂。
- 所以收缩的正确归属是退役批次,不是改造批次。而这个模式的一手表述比我这里严格:不执行收缩阶段,你会停在一个比动手前更糟的状态(原文用的是 "a worse state than you started"),并明确要求"完成转换的纪律"。我原来写成"僵尸列"的风险,实际是这条警告的具体形态:新旧两套字段同时存活很多年,没人知道哪个是真的,也没人敢删。所以收缩要写进改造批次,带自己的验收条件(见 上线那天,「完成」这个词最容易被用坏 里的退役清单)。
- 收缩能否安全执行,还取决于你所用数据库对该 DDL 类型的在线执行支持。这一条我核实过 MySQL 现在的实际情况:8.4 起「加列 / 删列」的默认算法就是 INSTANT;行版本数上限为 64,9.0 起提到 255;达到上限时 INSTANT 是直接报错并要求改用 INPLACE/COPY,不会静默降级(这点我此前记错过一次,误以为是"自动回退成在线执行"——不是)。所以"老库要不要上第三方在线改表工具"这个问题仍然成立,但要看的是三条具体的:版本够不够新、这条 DDL 属于哪一类操作、目标表是不是已经快到行版本上限。纪律不变:按"当前版本 + 当前这一条 DDL"在生产同构库上实测一次再决定。 凭印象决定,最坏结果是在业务高峰把主库锁住。
双写:挂在哪一层,行为差很多
| 实现位置 | 一致性 | 典型失败 | 什么时候用 |
|---|---|---|---|
| 应用层内联双写 | 最弱,两步之间可失败 | 第二写失败被 catch 掉、只写成功路径 | 过渡初期,且必须配补偿 |
| 事务 outbox(本地消息表) | 写与消息同事务,投递最终一致 | 消费者不幂等,重投即重复 | 默认选它 |
| CDC 订阅变更日志 | 不侵入业务代码 | 拿不到业务语义,DDL 变更即断,顺序依赖单流 | 目标是同步/迁移而非改造逻辑 |
| 库端触发器 | 覆盖所有写入来源(含手工 SQL) | 逻辑藏在库里、难以版本化、性能不可控 | 只作为兜底,且限期拆除 |
"应用层内联"那一行值得展开,因为它的四种失败模式几乎每个项目都会踩到其中一种:
一、只在成功路径上写了第二份。 异常分支、参数校验失败、幂等命中提前返回,这些路径都漏写。 二、事务回滚只回滚了一侧。 新库在同一事务里、旧库在事务外(或跨库),于是旧侧回滚、新侧留下脏数据。 三、外部调用被重复执行。 双写的重试把"发一次短信/扣一次款"变成两次。这是唯一一类回滚也补不回来的失败。 四、顺序错乱。 更新比插入先到达(两条链路、两个分区键、并发重试),结果新侧有一条陈旧数据或一条孤儿记录。
因此双写的正确设计不是"两边都写",而是:每条写入有幂等唯一键,冲突时用版本仲裁而不是最后写入者胜。 用最后写入者胜的前提是你确认可以丢更新——绝大多数业务不允许,但代码看起来很像允许,因为本地测试永远是顺序的。
顺序上还有一个前置:先回填历史,再开双写,还是反过来?答案是先开双写、再回填历史,回填按水位跳过双写窗口内的数据。反过来会丢:双写开启前那一刻的写入,在回填结束时已被旧快照覆盖。
对账:线上还在写的时候,怎么算对上了
三种做法,成本和能看出多少问题差一个量级:
- 计数比对(行数、分组计数):便宜,但只能发现量级错误。同一条记录两侧都在、内容不同,它看不见。
- 分片校验和(按主键区间或哈希桶聚合):能发现内容差异,能定位到区间。注意校验和的输入字段必须先做归一化,否则时间戳、集合顺序会把差异全部淹没。
- 逐字段 diff:唯一能给出可执行结论的一层,也是唯一应该作为最终验收依据的一层。
难点不在手段,在它永远对不齐:两侧都在被写,比对时新侧写完、旧侧还没写,就产生一个假差异。
我用两句话来定什么叫"对上":
在一个冻结写入的时间窗内,逐字段差异为零;窗口外的每一条差异,都能被写入时序解释。
前半句证明映射与转换是对的,后半句证明系统在跑,两半缺一不可。只看"差异条数在往下走"不算对上——变少可能只是因为分母在涨。
还有一件必须事先定的事:差异以哪一侧为准。 这个决定在对账跑起来之前不做,等到真有 3000 条差异时,开会两小时没人签字。
工具现状有两条核实过的结论,写在这里是为了省你一次搜索:逐字段对账这件事基本得自己写。 早年文章里常被点名的 sql-schema-diff 仓库已经从 GitHub 消失;sync-diff-inspector 的独立仓库同样 404,但它作为 TiDB 生态的组件仍在官方文档里维护、经 TiUP 安装——也就是说它服务的是那个生态,不是你的 MySQL 异构改造。所以能借的只有"分片取数与限速"那一层,比对与差异落库这一层是你的代码。
另一条关于 CDC:如果打算用变更日志做双写,先确认它跑起来的门槛。这条路线上最常被选的那套(Debezium)当前是 3.x 线,运行环境要求 Java 17/21,且 3.0 是一次跨大版本的破坏性变更。老平台上"挂个 CDC 就行"这个预设,很可能先要过一次 JDK 版本的关。
对账脚本本身的要求我复述一遍:分片执行、可中断续跑、按主键区间取数、绝不全表扫、限速依据是主库实时负载而不是配置里写死的 TPS。它通常要在灰度期每天跑一次、连跑几个星期——跑不稳就等于没有结论。
回填:把它当在线服务对待
回填不是"跑个脚本",它会持续几十小时地压在生产库上。四件事:
- 水位表 + 可重入。 断点必须持久化,重跑不产生重复效果。
- 限流依据是主库实时压力,不是配置里写死一个数。同一批数据,白天和凌晨的余量差好几倍。
- 与在线写竞争有明确策略:要么按时间戳跳过已被双写覆盖的行,要么允许被覆盖但保证最终一致。两边都不选,就会出现"回填把新数据写回旧值"。
- 抽样先于全量。 先用小样本把映射与归一化跑对,再开全量。全量跑完再发现字段映射错,你要重跑两次。
切读:回退点从哪一步开始消失
四个阶段,顺序不能跳:
- 单读旧(新侧只写不读)
- 影子读:读新侧,结果丢弃,只记差异
- 双读比对:仍以旧侧返回客户端,逐条比对
- 主读新,旧侧只保留写与兜底
前三步都是客户端无感的,风险全在日志里。第四步才是真切换。
回退点的消失位置要写清楚:一旦新侧成为唯一写入源,前三步就不复存在了。 这时再回退读,旧侧数据已经不再更新,回退等于读到陈旧数据。所以"主读新"这一步的实际风险,比"只是改个读开关"看起来高得多——它要求写路径那一边也稳定下来了,而不是两条路一起往前推。
一个常见的错误顺序是:双写刚开、对账还没跑完,就先把读切过去"看看有没有问题"。这在本质上就是拿线上用户当测试。
语义漂移清单:数据搬对了,含义变了
这一节的每一条都能安静存活半年才被引爆:
| 项 | 漂移方式 |
|---|---|
| 唯一键来源 | 一边是数据库自增,一边是应用生成;合并导入即冲突 |
| 字符集与排序规则 | 大小写与重音敏感性不同,等值查询结果集变化 |
| 时间精度与时区 | 毫秒被截断、带时区与不带时区混用、夏令时窗口 |
| NULL 与零值 | 一边把空串存成 NULL,聚合与索引行为随之不同 |
| 枚举与顺序值 | 按序号持久化的枚举,换语言或换顺序即整体错位 |
| 尾部空格与定长列 | 比较时忽略、存储时保留,两边行为不一致 |
| JSON 与二进制列 | 键序、精度、空对象与 null 的区分 |
| 自增步长与多主 | 双写期两侧各自增长造成重复或空洞 |
核对方法不是"看 DDL",是同一批真实数据在新旧两侧做字节级比对,并同时比对查询结果集。逻辑相等但字节不同,就是漂移信号。
这一段怎么算做完了
- 收缩动作已从改造批次挪到退役批次,且有明确的执行前置条件。
- 双写具备幂等唯一键与版本仲裁,异常路径与提前返回路径都覆盖到。
- 对账在一个真实冻结窗口内差异为零,且窗口外差异逐条可解释。
- 回退点消失的位置被写进方案,并被评审人读到过。
第 3 条最耗时,也最不该压缩。双写完三天就切主读的方案,在评审上应该被要求拿出冻结窗口的差异报表——通常拿不出来。
一点收尾
数据改造和代码改造的区别不是难度,是性质:代码改造的失败是"退回重来",数据改造的失败是"得先把世界修回去"。
所以顺序上它应当最先设计、最后收尾——最先,因为前面所有批次的回滚方案都依赖它;最后,因为收缩与删除要等到退役才做。而"最后一步没人做"这件事,正是下一个遗留系统的出生方式。
本文作者:Aryee 发布时间:2026-09-24 20:57
本文出处:ARYEE.cn 固定链接:https://www.aryee.cn/archives/legacy-modernization-data-migration.html
转载或引用请连这一行一起带走;文中的代码与结论按当时环境成立,组件升级后请以官方文档为准。