如果你经历过凌晨两点的表迁移,大概率会理解我接下来说的这种焦虑:业务方在群里催进度,DBA在等锁表窗口,而你面前那张几个亿行的大表,光物理拷贝就要跑四五个小时,停机窗口却只有两三个小时。表迁移按理说是后端和数据团队的家常便饭,但真正负责过几次之后你会明白,最难的从来不是SQL怎么写,而是如何在不停服的前提下,把一个和线上并发流量并存的表,无缝换成另一个结构完全不同的表。我们后来在生产环境沉淀下来一套表数据灰度迁移策略,不锁表、不停服,把一次大迁移拆成双写、回填、校验、切换四个阶段,每个阶段都留好回滚余地。这篇文章就聊聊这套策略的完整设计、落地代码和踩过的坑,适合即将要动大表结构、换存储引擎、做分库分表或者做冷热分离的同学参考。
1. 停机迁移的代价:为什么我们不得不换一条路
1.1 一次“常规”停机迁移的翻车现场
先说一段真实经历。两年前我接手一个电商订单库,订单表已经到了三亿行,当时要做两件事:一是把订单ID从 int 升成 bigint,二是给买家ID和下单时间加一个联合索引,以支撑运营后台的查询。我当时的处理方式非常传统:申请一个维护窗口,半夜两点开始,先把应用停掉,再把表改成新结构,然后全量拷贝数据,最后验证、切流、恢复。听起来没什么问题,对吧?
但实际执行时,光 ALTER TABLE 重建表就花了快四个小时,远超过申请的两个半小时窗口。后面所有人都在等我,业务方在群里问“还要多久”,老板打电话问“能不能先恢复一部分”,而数据还没拷贝完,根本没得选。最后只能硬着头皮接着跑,整个迁移花了接近十个小时,早上七点多才勉强恢复。那之后我被拉着复盘了两个小时,结论只有一句话:对大表来说,“先把服务停了再迁移”这条路,窗口根本不够用,而且每多等一分钟,损失都在翻倍。
也是那次之后我才认真研究不停服的灰度迁移。必须说明,不是所有表都一定要灰度迁移,几万行的小表,直接锁表重建也就几秒钟,完全没必要搞这么复杂。灰度迁移的适用对象有明显特征:数据量大、和线上流量强相关、业务不允许长时间中断。判断标准就一条——如果把表锁住或把服务停下来,单位成本有多高,你愿不愿意承受。
1.2 哪些场景绕不开灰度迁移
结合我实际遇到的情况,下面这几类场景基本绕不开灰度迁移:
- 超大表结构变更:几千亿行级别的大表,加字段、改类型、重建索引,原生 ALTER TABLE 会在执行期间锁表,时间还长得离谱。尤其像 MySQL 8.0 之前的版本,很多 DDL 是 copy 算法,等于把整张表重建一遍。
- 存储引擎升级:常见的 MyISAM 迁 InnoDB,或者调整分区规则。这类操作对线上读写影响极大,不做灰度基本就是全站故障。
- 分库分表改造:单表单库拆成多库多表,数据路由规则也要跟着变。这种迁移不只是表结构变化,连访问路径都变了,代码和流量都得灰度。
- 异构数据库迁移:MySQL 迁到 PostgreSQL、自建库迁到云数据库,结构、方言、事务行为全都不一样。这种场景下停机的时间成本是最不可控的。
- 冷热数据分离:把三年以上的历史订单搬到归档表或数仓,在线表只保留近期数据。表面上看起来只是搬数据,但如果白天直接大批量 DELETE,线上的锁等待和主从延迟会把业务拖垮。
这些场景的共同点很清楚:数据量大、和在线流量强相关、迁移耗时不可预估。停服窗口只是一个“看起来可行”的选项,真到了执行环节,你会发现时间根本不够,于是只能一路延时,把所有人都耗在工位上。灰度迁移的思路就是从这里长出来的:既然一次干不完、也不能停,那就分阶段地干,把影响摊到平常的业务节奏里去。
2. 灰度迁移的底层逻辑:双写、回填、校验三件事的配合
2.1 整体节奏:老房翻新式的渐进搬迁
理解灰度迁移,最贴切的一个比喻是老房翻新。你不会先把全家赶出房子再动工,而是一边住着,一边在旁边盖一个新房间,然后把家当分批次搬过去,搬一件核对一件,确认旧房间可以拆了,才把它封起来。换到表迁移上:新表就是新房间,旧表就是老房间。业务写入的数据,在迁移期间同时落到两张表——这是双写。旧表里已经存在的几亿行历史数据,按批搬到新表——这是回填。搬完一批就比一比两边数据是否一致——这是校验。等到两边的数据完全对齐,再把读流量、写流量逐步切到新表,最后把旧表下线。
整套流程的步骤可以梳理成下面这条线:
- 建新表,结构、索引、约束按目标设计。
- 开启双写,从这一刻起,新写入的数据在新旧两张表都有。
- 存量回填,历史数据分批搬入新表。
- 全量校验,确认新旧表数据一致。
- 切读流量,逐步把查询请求切到新表。
- 切写流量,写入只走新表,旧表停止双写。
- 观察期结束,旧表下线或保留为备份。
这套流程最关键的地方在于,每一步都是可逆的。双写开错了可以关,回填回错了可以重跑,校验不过可以继续修,流量切了一半发现不对可以退回去。正因为所有操作都被切成了足够小的步骤,才真正做到了不停服。
2.2 三条必须坚持的设计原则
灰度迁移看着步骤不多,但真落地的过程中,我逼着自己定下了三条铁律,每次迁移前都要逐条过一遍。
第一,任何一步都不能让旧逻辑的可用性变差。双写失败、回填脚本报错、校验脚本超时,都只能告警,绝不能让主流程 hang 住或者触发事务回滚。很多人做双写时喜欢把新表写入放在业务主事务里,新表一抖动,整个下单都失败,这就是典型的灰度迁移事故。
第二,任何时刻都要保留回滚能力。也就是说,旧表在迁移完成之前,必须一直保持可用、一直在接收数据。只有在旧表始终是最新、最全的状态下,你才敢把读流量切过去又切回来。这也是为什么我后面会反复强调,旧表千万别急着删。
第三,整个迁移过程必须可观测。进度、失败率、校验差异数、切换后的错误率和耗时,全部要有指标和日志。否则出问题时,你连从哪一步开始排查都不知道,只能对着生产库干瞪眼。
这三条原则贯穿了后面所有环节。你会发现,每个阶段的具体设计,本质上都是在为“可回滚”和“可观测”服务的。
3. 双写环节的实现:应用层改造是最稳妥的起点
3.1 三种双写方案的选型对比
双写是灰度迁移的第一个真正有技术含量的环节。实现方式无非三种:应用层双写、数据库触发器双写、CDC 中间件同步。先把各自的差异放在一张表里,方便对比。
| 双写方案 | 实现思路 | 侵入性 | 一致性保障 | 适合场景 |
|---|---|---|---|---|
| 应用层双写 | 业务代码里同时写新旧两张表 | 需要改业务代码 | 需要自己处理失败重试和补偿 | 业务代码可控,中小团队,快速落地 |
| 触发器双写 | 在数据库里建触发器,自动把写入复制到新表 | 对应用代码无侵入,但对数据库有侵入 | 触发器失败容易被忽略,难发现 | 老系统改不动代码、短期过渡 |
| CDC + 中间件 | 用 Canal、Debezium 订阅 Binlog,增量事件转发到新表 | 代码零侵入,但架构复杂度高 | 较好,但需要处理延迟、重复、位点管理 | 大规模、异构迁移、长期演进 |
如果业务代码在自己手里,我的建议是一上来不要搞 CDC,先把应用层双写做出来。原因很简单:应用层双写逻辑直白、可以加开关、可以埋指标、出问题时好定位。CDC 虽然听起来高大上,但引入消息队列、消费位点、幂等去重、延迟监控这一堆事,复杂度一下就上去了。等应用层双写跑通、迁移稳定之后,再考虑要不要上中间件也不迟。
触发器方案我个人比较谨慎。触发器是隐式的,出了问题 DBA 和应用团队都很难第一时间感知,经常是数据已经坏了才发现。而且触发器在高并发写入下会成为数据库性能的隐形瓶颈,排查起来非常痛苦。
3.2 双写代码的落地细节
假设我们有一个订单表要做迁移,应用层双写最简单的写法是这样的:
public class OrderService { private final OrderRepository oldOrderRepo; private final OrderRepository newOrderRepo; private final MigrationSwitch migrationSwitch; public void createOrder(OrderDTO order) { // 主链路:还是写旧表 oldOrderRepo.insert(order); // 灰度开关关闭时,双写逻辑一个字节都不执行 if (!migrationSwitch.isOn("order_migration_double_write")) { return; } try { newOrderRepo.insert(order); } catch (Exception e) { // 双写失败不能影响主流程 log.error("double write new order failed, orderId={}", order.getId(), e); Metrics.counter("order.migration.double_write_failure").inc(); } } }几个细节值得强调。
第一,双写一定要放在主链路之后,并且用 try-catch 包住,绝对不能因为新表写入失败导致业务报错。我当时给新表 DB 故意做了一次演练——直接把新表连接池停掉,验证了业务下单完全不受影响,只会在监控面板上看到双写失败指标上升。
第二,开关必须在配置中心里,而不是靠改代码发版来控制。不然你想关的时候还要重新发布一次,这就不是灰度了。用配置中心下发,整个开关动作秒级生效,并且有审计日志,谁在什么时间改了什么配置,一清二楚。
第三,写入新表时如果新表结构更复杂,比如有旧表没有的字段,你要在转换层把默认值、枚举映射都处理好,否则每天产生大量写失败日志,噪音会把真正的异常淹没掉。我见过一个团队做双写,新表多了一个渠道来源字段,他们忘了给老接口传默认值,结果几千条数据全部写成了“unknown”,后面校验时才追悔莫及。
3.3 双写阶段的高频故障与缓解
双写一旦跑起来,下面几个问题很容易冒出来,提前做好预案能少熬好几个夜。
- 唯一键冲突:旧表和新表唯一约束不一致,比如旧表允许相同手机号注册多个账号,新表加了唯一索引,双写直接炸。处理方式:先清理脏数据,或者分阶段收紧约束,不要指望一次到位。
- 自增主键漂移:新表单独自增,两边 ID 对不上,后面校验和切换都很麻烦。处理方式:新表采用与旧表完全相同的 ID 生成策略,或者直接用业务主键,避免依赖自增。
- 性能损耗:双写意味着写 IO 翻倍,高峰期可能把磁盘打满。处理方式:评估写入性能余量,必要时改成异步双写,但异步会引入一致性问题,要权衡。我一般建议先同步双写,确认扛不住了再降级。
- 约束和触发器副作用:新表如果有额外的外键或触发器,双写时可能触发本不该触发的逻辑。一个典型的例子:新表加了更新时间的触发器,双写插入时自动把更新时间改了,导致两边行的 updated_at 对不上。
4. 存量数据回填:分批、限速、断点续传
4.1 分片策略怎么定
存量数据回填的第一件事,是怎么把一个几亿行的表切成很多个小块,让每个任务都能独立执行、独立失败、独立重跑。我们最常用的分片方式有两种:
- 主键范围分片:
SELECT * FROM old_order WHERE id BETWEEN 1 AND 100000,适合自增主键且数据连续的表,逻辑最简单。 - 时间分片:按
created_at划分,比如一个月一个任务,适合主键不连续、有明显时间分布的表,比如订单表、流水表。
分片大小不是越大越好,也不是越小越好。我一般会先看单行平均大小和可用 IO。比如订单表单行 1KB 左右,单批 5 万行就是 50MB,如果每行一条 INSERT 的话,很容易把数据库连接池打爆。我们实际操作时会把批次大小调到 1 万到 5 万行,并同步观察主从延迟,主从延迟超过阈值就自动降低并发。
4.2 回填任务的状态管理与进度可视化
回填一定要有状态记录,不能脚本一跑就完事。我们会建一张迁移进度表,每个批次一行,状态从待执行、执行中、完成、失败流转。脚本重启后根据状态字段决定从哪个批次继续跑,而不是从头再来。表结构大致长这样:
CREATE TABLE migration_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_no INT NOT NULL, id_start BIGINT NOT NULL, id_end BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待执行 1执行中 2完成 3失败 retry_count INT NOT NULL DEFAULT 0, started_at DATETIME, finished_at DATETIME, UNIQUE KEY uk_batch (batch_no) );调度上用定时任务把“待执行”的批次捞出来,控制并发数,比如同时只能跑 4 个批次。每个批次跑完更新状态和完成时间。这样整个迁移进度就是一个可以随时查看的列表,前端甚至能直接画个进度条。
这里有个小技巧:批量插入新表时尽量用INSERT INTO new_order (...) SELECT ... FROM old_order WHERE ...这种 SQL,在数据库内部完成,减少网络往返。如果两张表在同一个实例上,这种方式的效率远高于把数据拉到应用层再插入。跨实例迁移的话,才需要走应用层中转。
4.3 回填与在线写入的并发冲突处理
回填最大的隐患不是搬得慢,而是边搬边有新的写入,如果不处理,搬过去的行可能立刻就是脏的。这里有两种方案。
方案 A:只回填截止某个时间点之前的数据。这个时间点之后由双写负责。做法:回填开始前记录一个全局水位线,比如max(id)或max(updated_at),所有小于这个水位线的行由回填任务负责,大于的由双写负责。这是最常用也最不容易出错的方案。注意水位线要在双写开启之后、回填正式开始之前记录,避免出现“这一行既没被双写覆盖、又不满足水位线条件”的空洞。
方案 B:回填时记录每一行的版本号或更新时间。如果发现行在回填期间被修改,就把该行标记为“需重跑”,最后统一补刷一轮。这个方案能处理更复杂的并发场景,但实现成本高,要额外记录版本字段。
我强烈建议优先用方案 A,逻辑最简单,出问题也好解释。我们迁移订单表时就是先取了一个max(id)作为水位线,然后所有小于该 ID 的数据都交给回填任务,大于该 ID 的数据只依赖双写,最后两边数据完美对齐。
5. 数据校验:灰度迁移的质检关
5.1 校验的四个层次:行数、聚合、分片、全量
数据校验是灰度迁移的质检关,也是很多团队最容易糊弄过去的一步。我见过有人在回填完成后数了一下行数,发觉差不多就直接切流了,结果业务上线后各种数据对不上。校验至少分四个层次:
- 行数级:
COUNT(*)对比,只能发现明显的丢失或重复,基本是最低要求。 - 聚合级:
SUM(金额)、MAX(ID)、MIN(ID)对比,可以发现部分数据异常,但对单行内容差异不敏感。 - 分片级:把两张表都按同样条件分片,对每个分片做 COUNT、SUM、哈希,能快速定位差异集中在哪些区间。
- 行级全量:逐行或逐批对比两张表的字段值,这是最严格的方式,成本也最高。
我自己的习惯是:先跑聚合和分片,快速排除大范围问题;发现差异后,再对差异区间做行级全量比对。这样既能保证覆盖面,又能控制成本。
5.2 校验脚本怎么写才不挖坑
校验脚本最容易犯的错误,是一次性把整个表加载到内存里比较,几亿行直接 OOM。正确做法是按主键范围分批拉取,每批 1 万行,拉回来在应用内存里拼成 Map,再逐条比对字段的哈希值。核心伪代码大概是这个样子:
for (BatchRange range : batches) { List<Order> oldList = oldOrderRepo.findByRange(range.start, range.end); List<Order> newList = newOrderRepo.findByRange(range.start, range.end); Map<Long, Order> newMap = newList.stream() .collect(Collectors.toMap(Order::getId, Function.identity())); for (Order old : oldList) { Order newOne = newMap.get(old.getId()); if (newOne == null) { diffCollector.collect("missing", old.getId()); } else if (!hashOrder(old).equals(hashOrder(newOne))) { diffCollector.collect("mismatch", old.getId()); } } }三个要点需要特别注意。第一,分批拉取时要按主键范围顺序读取,并记录已对比到的最大主键,方便断点续跑。第二,比较时把整行序列化成规范化字符串再做 hash,避免字段顺序或空格差异导致误报。第三,既然我们迁移后字段结构变了,校验逻辑要按“业务映射后的结果”来比,而不是直接比两张表的原始行。比如旧表的status是数字 0 和 1,新表改成了枚举字符串pending和finished,你要先做映射再比较。
5.3 我在校验环节踩过的真实坑
校验环节看起来简单,实际上坑特别多,我踩过的就有好几次。
- NULL 值不参与聚合:
COUNT(column)会忽略 NULL,COUNT(*)不会,对账时候没注意可能漏数一行。 - 浮点数精度问题:线上金额字段如果用浮点,两边读出来可能差 0.0000001,直接做哈希比对必挂。处理方式是统一用 DECIMAL,或者在比对时先做 round。
- 时间精度和时区:
datetime和timestamp精度不一致,时区转换后看起来差 8 个小时。尤其是跨机房迁移,这个坑特别容易踩。 - 大字段处理:
TEXT/BLOB做哈希很耗资源,长文本容易在输出、网络传输时被截断。处理方式是对超大字段单独抽样对比,或只比对字段长度和前 N 个字符的哈希,线下再补一轮全量抽样。 - 默认值差异:旧表某个字段默认值是 0,新表默认值是 “unknown”,如果迁移时没有显式回填,NULL 值到新表会被默认值填掉,导致两边数据看起来一致,实际语义不一致。这类问题往往要等业务方报障时才会发现。
6. 切换与回滚:让最后一跳不那么惊心动魄
6.1 开关体系设计:读开关、写开关、双写开关
切换前,先把开关设计好。我们的开关分三层:双写开关、读开关、写开关。双写开关控制整个灰度迁移是否进入双写状态;读开关控制读流量按多少比例走新表,通常做成 0%、10%、50%、100% 这样的灰度值;写开关控制写入是否只走新表,一旦打开,旧表不再接收写入,这是最后一步。
开关放在配置中心里,改的时候有审计日志,操作人可以追溯。我见过有人在代码里写死常量来切流量,改一次发一次版,非常痛苦,这根本不是灰度。配置中心的优势是秒级生效,你可以在几分钟内完成流量切换,也能在发现异常时瞬间切回。
设计开关时还要注意,开关本身要有上下线流程。迁移完成后,这些开关要从配置中心移除,不然它会一直留在代码里变成技术债。我们就有过一次教训:订单表迁移完成后忘了清理双写开关,后来订单服务重构时把旧表逻辑删掉了,但双写代码还在,白白写了一张已经废弃的表好几个月。
6.2 切换的标准动作与灰度节奏
切换顺序是整个迁移的最后一跳,我建议严格按下面这个节奏走:
- 读流量灰度:10% -> 30% -> 50% -> 100%。每个比例至少观察 10 到 30 分钟,看错误率、P99 耗时、业务方反馈。切到 50% 以上时,最好选择一个业务低峰期进行。
- 读流量全量后,观察一个完整的业务周期,比如一个白天,确认读没问题再动写。
- 打开写开关,写入只走新表,旧表停写但保留数据,双写同时关闭。这个时候新表开始接收全部线上写流量,要重点盯写入延迟和错误率。
- 再观察一段时间,等旧表上的依赖全部梳理干净,才允许下线旧表或转入归档。
特别提醒,写开关打开之前,最好确保还能用旧表把新表的数据反向补齐一次。也就是说,切写之前我们要做一次最终一致性校验,确认这个时间点新表完全追上了旧表。否则切写后旧表不再更新,一旦新表有缺失,你补数据会很痛苦。
6.3 回滚预案:旧表留多久、数据怎么补
回滚这件事一定要提前写,不要等出了问题再想。切读阶段如果发现读新表有问题,处理方法很简单:把读开关直接切回旧表。因为旧表一直在接收双写,数据是最新最全的。这个问题不大。
切写阶段发现问题就复杂一些。如果新表数据已经落后,需要从旧表把缺失部分重新同步过去;如果新表数据本身错了,可能要从备份恢复。所以在切写之前,我强烈建议先做一次备份,并把旧表的 binlog 或增量日志保留一段时间,方便做数据回放。
旧表到底留多久?我的建议是至少保留一个完整业务周期,甚至一个月。很多团队迁移完一周就删旧表,后来要查历史数据或者发现新表有漏数据时才后悔。旧表改成只读归档表,放在分析库或者冷存储上,也不会占用太多在线资源。我们订单表迁移完成后,旧表在归档库里额外留了三个月,期间确实帮业务方捞回来好几批因新表早期缺字段而丢失的补充数据。
从第一次停机迁移翻车,到现在把灰度迁移做成体系化的操作流程,我最大的一个体会是:迁移本身不是一个技术问题,而是一个风险管理问题。你不可能保证迁移过程零故障,但可以通过拆小步骤、留回滚空间、加可观测性,把故障的影响面控制在最小。这套策略在订单表、用户表、以及一次 MySQL 到云数据库的异构迁移中都验证过,虽然每轮多少都会遇到一些小问题,但因为每一步都可回退,再也没有出现过“全体等一个表”的场面。如果你正在为一张大表的迁移发愁,先别急着选停机窗口,画一下双写、回填、校验、切换的流程图,把这篇文章里的几个原则套进去,大概率能省下一整夜的通宵。