数据改造四步:哪一步之后就退不回去了

2026-09-24 Aryee 11

改造方案里,数据这一节应该排在最难的位置,而不是最后。原因很简单:代码改坏了能退回去,写歪的数据退不回去。一个项目的回滚设计能不能成立,几乎全部取决于数据这一段怎么设计。

下面按四步走,每步只讲它会失败在哪里。

先扩后收:收缩要推迟到退役

库结构改造的标准三段是扩展 → 迁移 → 收缩:先加上新列新表(不动旧的),再把读写逐步挪过去,最后删掉不再使用的部分。这套三段式(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) 逻辑藏在库里、难以版本化、性能不可控 只作为兜底,且限期拆除

"应用层内联"那一行值得展开,因为它的四种失败模式几乎每个项目都会踩到其中一种:

一、只在成功路径上写了第二份。 异常分支、参数校验失败、幂等命中提前返回,这些路径都漏写。 二、事务回滚只回滚了一侧。 新库在同一事务里、旧库在事务外(或跨库),于是旧侧回滚、新侧留下脏数据。 三、外部调用被重复执行。 双写的重试把"发一次短信/扣一次款"变成两次。这是唯一一类回滚也补不回来的失败。 四、顺序错乱。 更新比插入先到达(两条链路、两个分区键、并发重试),结果新侧有一条陈旧数据或一条孤儿记录。

因此双写的正确设计不是"两边都写",而是:每条写入有幂等唯一键,冲突时用版本仲裁而不是最后写入者胜。 用最后写入者胜的前提是你确认可以丢更新——绝大多数业务不允许,但代码看起来很像允许,因为本地测试永远是顺序的。

顺序上还有一个前置:先回填历史,再开双写,还是反过来?答案是先开双写、再回填历史,回填按水位跳过双写窗口内的数据。反过来会丢:双写开启前那一刻的写入,在回填结束时已被旧快照覆盖。

对账:线上还在写的时候,怎么算对上了

三种做法,成本和能看出多少问题差一个量级:

  1. 计数比对(行数、分组计数):便宜,但只能发现量级错误。同一条记录两侧都在、内容不同,它看不见。
  2. 分片校验和(按主键区间或哈希桶聚合):能发现内容差异,能定位到区间。注意校验和的输入字段必须先做归一化,否则时间戳、集合顺序会把差异全部淹没。
  3. 逐字段 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。它通常要在灰度期每天跑一次、连跑几个星期——跑不稳就等于没有结论。

回填:把它当在线服务对待

回填不是"跑个脚本",它会持续几十小时地压在生产库上。四件事:

  • 水位表 + 可重入。 断点必须持久化,重跑不产生重复效果。
  • 限流依据是主库实时压力,不是配置里写死一个数。同一批数据,白天和凌晨的余量差好几倍。
  • 与在线写竞争有明确策略:要么按时间戳跳过已被双写覆盖的行,要么允许被覆盖但保证最终一致。两边都不选,就会出现"回填把新数据写回旧值"。
  • 抽样先于全量。 先用小样本把映射与归一化跑对,再开全量。全量跑完再发现字段映射错,你要重跑两次。

切读:回退点从哪一步开始消失

四个阶段,顺序不能跳:

  1. 单读旧(新侧只写不读)
  2. 影子读:读新侧,结果丢弃,只记差异
  3. 双读比对:仍以旧侧返回客户端,逐条比对
  4. 主读新,旧侧只保留写与兜底

前三步都是客户端无感的,风险全在日志里。第四步才是真切换。

回退点的消失位置要写清楚:一旦新侧成为唯一写入源,前三步就不复存在了。 这时再回退读,旧侧数据已经不再更新,回退等于读到陈旧数据。所以"主读新"这一步的实际风险,比"只是改个读开关"看起来高得多——它要求写路径那一边也稳定下来了,而不是两条路一起往前推。

一个常见的错误顺序是:双写刚开、对账还没跑完,就先把读切过去"看看有没有问题"。这在本质上就是拿线上用户当测试。

语义漂移清单:数据搬对了,含义变了

这一节的每一条都能安静存活半年才被引爆:

项 漂移方式
唯一键来源 一边是数据库自增,一边是应用生成;合并导入即冲突
字符集与排序规则 大小写与重音敏感性不同,等值查询结果集变化
时间精度与时区 毫秒被截断、带时区与不带时区混用、夏令时窗口
NULL 与零值 一边把空串存成 NULL,聚合与索引行为随之不同
枚举与顺序值 按序号持久化的枚举,换语言或换顺序即整体错位
尾部空格与定长列 比较时忽略、存储时保留,两边行为不一致
JSON 与二进制列 键序、精度、空对象与 null 的区分
自增步长与多主 双写期两侧各自增长造成重复或空洞

核对方法不是"看 DDL",是同一批真实数据在新旧两侧做字节级比对,并同时比对查询结果集。逻辑相等但字节不同,就是漂移信号。

这一段怎么算做完了

  1. 收缩动作已从改造批次挪到退役批次,且有明确的执行前置条件。
  2. 双写具备幂等唯一键与版本仲裁,异常路径与提前返回路径都覆盖到。
  3. 对账在一个真实冻结窗口内差异为零,且窗口外差异逐条可解释。
  4. 回退点消失的位置被写进方案,并被评审人读到过。

第 3 条最耗时,也最不该压缩。双写完三天就切主读的方案,在评审上应该被要求拿出冻结窗口的差异报表——通常拿不出来。

一点收尾

数据改造和代码改造的区别不是难度,是性质:代码改造的失败是"退回重来",数据改造的失败是"得先把世界修回去"。

所以顺序上它应当最先设计、最后收尾——最先,因为前面所有批次的回滚方案都依赖它;最后,因为收缩与删除要等到退役才做。而"最后一步没人做"这件事,正是下一个遗留系统的出生方式。

你手上那套系统,卡在哪一步?

留个手机号和方便的时间,我打过来先听你说现状与约束,再给可执行的判断:这套系统是该继续修、该动哪里,还是干脆重做一版设计更省。

约一次沟通不接纯 UI 外包,只做系统层面的问题。