ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大表不锁表迁移:双写、回填、校验与切换的灰度落地策略

大表不锁表迁移:双写、回填、校验与切换的灰度落地策略 如果你经历过凌晨两点的表迁移大概率会理解我接下来说的这种焦虑业务方在群里催进度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) { ListOrder oldList oldOrderRepo.findByRange(range.start, range.end); ListOrder newList newOrderRepo.findByRange(range.start, range.end); MapLong, 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)会忽略 NULLCOUNT(*)不会对账时候没注意可能漏数一行。浮点数精度问题线上金额字段如果用浮点两边读出来可能差 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 到云数据库的异构迁移中都验证过虽然每轮多少都会遇到一些小问题但因为每一步都可回退再也没有出现过“全体等一个表”的场面。如果你正在为一张大表的迁移发愁先别急着选停机窗口画一下双写、回填、校验、切换的流程图把这篇文章里的几个原则套进去大概率能省下一整夜的通宵。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进