☰
大表不停服迁移的五阶段灰度方案:双写、增量追平与切读回滚
2026/9/30 8:13:24 网站建设 项目流程

做后端的人,迟早会碰上这么一档子事:一张几千万行甚至上亿行的表,因为业务拆分、分库分表或者换存储引擎,得从旧的库表迁到新的库表。业务方提需求的方式通常很直接——“不能停服”。会议室里安静几秒之后,所有人脑子里同时飘过了一系列问题:不能锁表、不能阻塞读写、不能丢数据、不能起长事务,出问题还得能快速回滚。这篇就把我当时落地过的一套表数据灰度迁移方案完整拆开,讲清楚整体框架、每个阶段的设计意图、实际执行的参数,以及最容易踩的坑。如果你正准备做库表搬迁、分库分表,或者只是想把一张大表从一个库搬到另一个库,这篇文章可以直接当参考手册用。

如果你以为表迁移就是“导出来再导进去”,那大概率会在第一批真实流量打到新表时发现问题。这里的难点从来不是数据搬得动搬不动,而是搬的过程中业务照常跑,搬完后新旧两套数据还能保持一致。下面这套方案,我按五阶段灰度来拆,每一步都保留回滚的余地,希望能帮你少走点弯路。

1. 迁移前,先把这些底数摸清楚

1.1 哪些场景会用到不停服表迁移

我先说几个真实触发场景,你在评估方案的时候可以对号入座。

第一类是单表数据量太大,想拆成按月分表或者按用户分库。比如订单表从一张几亿行的单表拆成 12 张月表,拆分过程中线上交易不能停。第二类是业务库整体搬迁,比如自建机房的数据库要迁移到云数据库,或者从一套 MySQL 环境迁到另一套 MySQL 环境。第三类是表结构重构,一张宽表拆成多张窄表,或者把原来的 int 主键改成 bigint,这类操作在 MySQL 8.0 之前的版本里根本不是 ALTER TABLE 能优雅解决的。第四类是换存储引擎,比如从 MySQL 迁到 TiDB、OceanBase 这类分布式数据库。

这些场景的表都已经在线运行,用户正在读写,数据量又大,停服迁移的业务损失往往难以接受。迁移一张表,影响的绝不只是这一张表。下游报表、定时任务、消息队列、开放接口,凡是消费这张表数据的地方都要列出来。我一般会去代码仓库搜表名,把引用到的服务和 DAO 全部列出来,再看一下 binlog 里最近一段时间访问这张表的来源 IP,确保没有漏网之鱼。影响范围分析做得越细,后面开双写的时候越不容易翻车。

1.2 迁移前必须摸清的五个关键数据

在动手设计迁移方案之前,我建议先把下面这些底数查清楚,每一项都直接决定方案的选型。

要摸清的项怎么查为什么关键
总行数和数据量information_schema 或 count(*)决定全量搬迁的耗时和批量大小
写入峰值 TPS监控系统,或 binlog 统计决定双写是同步还是异步,决定追平能力
主键类型show create table自增 ID 最好办,业务主键要重新算分片
binlog 保留时长show variables like 'binlog_expire_logs_seconds'全量搬迁超过保留期,增量链路就断了
表字段和索引依赖代码仓库 + information_schema决定目标表 DDL 和校验字段

查询表基本信息,我常用下面这段 SQL:

SELECT table_name, table_rows, data_length / 1024 / 1024 AS data_mb, index_length / 1024 / 1024 AS index_mb, create_time FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'orders';

table_rows是估算值,InnoDB 给的是抽样估值,但有量级参考价值。写入峰值这个数据最容易被忽略,如果峰值很高,双写就不能做成同步强一致,否则一次双写慢查询会拖垮整个业务事务;如果 binlog 只保留 24 小时,而全量搬迁预计要跑 20 小时以上,那增量链路很可能还没追完,老的 binlog 就被清理了,这是非常危险的。

2. 五阶段灰度迁移框架,每个阶段都留好后路

2.1 阶段一:双写链路的设计与准备

我习惯把整个过程拆成五个阶段,而不是两步式的“迁移+切换”。拆细的好处非常明确:每一步都是可验证、可回滚的。第一阶段是双写链路的建设,这是整套方案的心脏。

所谓双写,就是业务产生的写入,既进旧表也进新表。双写有三种常见实现,我在选型时会对比如下:

  • 应用层双写:在业务代码里增加写目标表的逻辑,把开关放到配置中心,灰度切换时自由控制。
  • binlog 回放:使用 Canal 或 Debezium 监听源库 binlog,把增量变更同步到目标表,不需要改业务代码。
  • 触发器双写:数据库触发器同步,侵入性强,对主库性能影响大,我基本不推荐。

应用层双写适合团队能掌控代码、希望切换粒度细的场景。binlog 回放适合不想动业务代码的场景,但多了一条链路,位点管理要格外小心。触发器方案虽然看起来省事,但触发器里的逻辑一旦出错,会直接影响主表写入,而且排查困难。

这里要强调一个操作顺序:不要一上来就先搬历史数据。正确顺序是先建好新表、搭好同步链路、准备好配置中心开关,再把双写打开。双写打开后,立刻记录当时的 binlog 位点和时间,这个位点是后面全量搬迁结束之后增量追平的起点。我当时是用一条SHOW MASTER STATUS;记录 file 和 position,如果是 GTID 模式就记录 GTID 集合。这个起点记录绝对不能省,否则增量追平从哪儿开始都对不上。

2.2 阶段二:历史数据全量搬迁

双写链路稳定之后,才开始历史数据全量搬迁。全量搬迁的本质,是把源表在双写开启之前的老数据搬过去,双写开启之后的新数据由双写链路负责。

批量方式建议按主键或者唯一键的范围分片,不要用普通的 SELECT 分页。原因很简单:深度分页的 offset 越来越大,越到后面越慢,还会对源库产生大量随机 IO。我一般把表按主键均分成 N 个区间,每个区间一个迁移任务,N 取 4 到 8。每个任务内部按主键范围循环,每批取 5000 到 20000 条,用 multi-values 的 INSERT 语句批量写入目标表。

伪代码大概是这样的逻辑:

batch_size = 10000 tasks = 8 for shard in split_by_primary_key(source_table, tasks): for batch in shard.iter_batches(batch_size): rows = source.select(batch.range) target.bulk_insert(rows) time.sleep(rate_limit_seconds)

全量搬迁期间,千万不要停掉双写,这两个动作要并行。搬完旧数据后,目标表里已经有了双写写入的新数据,全量数据和增量数据在目标表里自然合流。很多第一次做迁移的人在这里会犯一个错:先停业务,搬完数据再恢复业务。那就不是不停服迁移了。我们的目标恰恰是让搬迁动作对线上完全透明,数据边搬边进,最终合流。

2.3 阶段三:增量追平

全量搬完只是数据“大体对上了”,窗口期内可能还有数据没完全同步。所以要做增量追平:从第一阶段记录的双写开启位点开始,把 binlog 里的增量变更回放到目标表。

如果你用的是 binlog 同步组件,追平就是组件消费位点的过程。如果你用的是应用层双写,增量追平更像是“校验补偿”,主要靠定时任务捞取差异。判断追平是否完成的指标不是“位点停了”,而是同步延迟持续低于阈值。我当时的判断标准是:连续 30 分钟内,同步组件位点和源库最新位点的差距稳定在 1 秒以内。这还不够,追平只能保证数据“基本一致”,距离“可以切读”还差一个严格的校验步骤。

这个阶段要特别注意大事务。源库一个大事务更新了 50 万行,binlog 里会连续产生大量事件,回放端如果串行处理,延迟会瞬间飙升。可以适当增加回放并发,但要注意并发回放带来乱序风险,尤其是存在主外键关联或者唯一键约束的时候,乱序可能导致重复键或外键异常。宁可通过批量回放提升性能,也不要直接把回放线程调到很高。

2.4 阶段四:灰度切读

增量追平完成之后,读请求可以一点点切到新表,而不是一把梭。我常用的灰度维度有三种:

  • 按用户 ID 尾号:先切 1% 的尾号,也就是user_id % 100 < 1,再逐步放大到 5%、10%、50%。
  • 按白名单:内部测试账号、VIP 账号先走新表,这类用户量少,影响范围可控。
  • 按接口维度:流量少的读接口先切,核心订单查询后切。

灰度路由的伪代码大概是这样的:

// 配置中心实时控制灰度比例 if (config.isNewTableReadSwitchOn()) { int grayPercent = config.getNewTableReadGrayPercent(); if (userId % 100 < grayPercent) { return readFromNewTable(); } } return readFromOldTable();

这里的核心设计是路由必须同时具备自动回退和手动回退能力。自动回退指新表查询报错或者超时率达到阈值后,路由自动降级回旧表。手动回退指通过配置中心一键切回。因为数据在这个阶段仍然是双写的,回退后旧表数据也是完整的,业务可以做到无感。我经历过一次新表慢查询把连接池打满的故障,当时预案里写了“错误率超过 1% 立即全量切回旧表”,整个回退动作三分钟完成,业务几乎无感知。如果没有这个回退预案,后果很可能是业务大面积报错。

2.5 阶段五:全量切换与回收

切读灰度到 100%,并且稳定运行一段时间之后,再把写流量从“双写”改为“单写新表”。这一步同样要谨慎。切换之前确认没有其他任务还在写旧表,切换之后旧表可以保留只读,观察几天。这个观察窗口我一般留 7 天,具体看业务和合规要求;窗口结束后,先对旧表做一次全量备份,再清理旧表和同步任务。

清理这一步最容易出问题。很多人切完写流量就把同步组件停掉了,但有些定时任务还在写旧表,导致旧表数据又涨起来。等你发现的时候,旧表已经积累了不少“新数据”,反查起来非常费劲。所以我的习惯是:写切换之后,把旧表的写入账号权限先收回,确认所有写入链路都断了,再停同步任务,最后才是备份和清理。

还有个点要提醒:切换完之后,同步链路不要立刻停。保留一段时间的“只读”同步,让目标表继续接收旧表方向的增量,一旦新表有问题,还可以反向回切到旧表。真正干过迁移的人都知道,回滚能力不是上线那一刻才需要的,而是上线之后几天内都可能用到。

3. 实操细节:双写幂等、搬迁参数与数据校验

3.1 双写的三个关键设计点

双写看起来简单,就是多写一张表,但实际落地有三个关键设计点,踩过的坑都在这里。

第一个是写顺序。我强烈建议旧表优先,新表尽力而为。核心逻辑是主业务继续依赖旧表,新表写入失败不能阻断主流程。如果先写新表,新表一旦失败,你还得决定业务事务要不要回滚,那就会把新表的问题放大成主链路的问题。旧表已经写成功,业务是可用状态,新表数据由补偿任务异步重放,最终可以一致。

已知问题

第二是幂等。新表、旧表都必须保留业务唯一键,补偿或者重试的时候用唯一键防止重复。我一般在目标表使用原业务主键作为主键或唯一键,而不是自增主键。如果目标表也建一套自增主键,两套 ID 对不上,后面的校验和补偿会非常痛苦。

伪代码示意如下:

@Transactional public void createOrder(OrderDO order) { // 先写旧表,核心链路优先 oldOrderMapper.insert(order); try { newOrderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明补偿或并发已写入,忽略即可 } catch (Exception e) { compensationQueue.push(order); log.error("new table write failed", e); } }

第三是开关分离。双写开关、灰度路由开关、补偿任务开关,这三个开关要分开配置,不要绑在一起。我遇到过团队把双写和灰度开关绑在同一个配置项里,结果想回退读流量的时候,双写也被关掉了,新旧两表立刻出现数据断层。当年这个问题排查了很久,最后发现是开关粒度太粗导致的。从此之后,我所有的开关都是独立配置项,并且回退操作只动必要的开关。

3.2 全量搬迁的批次、并发与限流参数

全量搬迁一旦参数设置不对,很容易把源库打满。我给出一个经过实测的参数组合,你可以在此基础上根据自己库的情况调整。

每批行数建议 5000 到 20000 条,太小了传输效率低,太大了单条 SQL 可能超过 max_allowed_packet,还容易把目标库的 redo log 撑满。并发任务数建议 4 到 8 个,我一般先从 4 个开始,观察源库 CPU 和磁盘 IO,如果源库负载很低,再逐步往上加,最高不超过 8 个。批量插入使用 multi-values 语法,每 2000 行一组,一次 INSERT 语句里带多组 values。

源库侧必须限流。迁移任务每秒处理的批次数,要根据主库的写入峰值来算。比如主库峰值 10000 TPS,迁移任务最多占用五分之一的写入能力,也就是每秒处理 2000 行,超过这个阈值就会对线上造成明显影响。我实际跑迁移的时候,会在每个批次之间加一个小 sleep,把整体速度控制在一个相对安全的水平。

还有一个很容易被忽略的点:不要用 REPLACE INTO。REPLACE INTO 本质是 DELETE + INSERT,会在目标表产生大量随机 IO 和间隙锁,影响目标表上正在进行的其他查询。用 INSERT IGNORE 或者 ON DUPLICATE KEY UPDATE 都比 REPLACE INTO 安全得多。不过 ON DUPLICATE KEY UPDATE 也要小心,如果业务字段被覆盖成旧值,反而会造成数据不一致。我偏向使用 INSERT IGNORE,再做独立的差异校验任务去补齐缺失数据。

3.3 数据校验到底校验什么

数据校验是整套方案里最花时间的一步。很多人以为校验就是两边 count 一下,数字对上了就万事大吉。真实情况是,count 对上只说明行数一样,字段值可能差十万八千里。我分三层来做校验。

第一层是总量校验。COUNT(*) 在大表上消耗资源,可以用 information_schema.tables 的行数做参考,最终以低峰期的精确 count 为准。第二层是抽样比对,抽样率可以取 1% 到 5%,对关键字段做指纹比对。第三层是方向校验,既查目标表缺了源表的数据,也查目标表多了源表没有的垃圾行。

抽样比对的思路大概是这样的:

SELECT order_id, MD5(CONCAT_WS('|', user_id, status, amount, create_time)) AS fingerprint FROM source_orders WHERE order_id % 100 = 0 ORDER BY order_id;

同样的 SQL 在目标表跑一遍,然后把两份结果在应用层做 diff,找出 order_id 相同但 fingerprint 不同的记录。这里有个常见坑:NULL 字段和字段类型隐式转换容易造成指纹不一致。比如 DECIMAL 的精度、datetime 默认值,最好先转成字符串,再用分隔符拼接,对 NULL 使用 IFNULL 做统一处理。我在真实迁移中遇到过某张表的 create_time 在旧库是 timestamp 且允许 NULL,新库设成了 NOT NULL,导致两千多行数据校验不通过。这种结构差异,在高亮核查之前就要在 DDL 设计阶段避免。

4. 常见问题与排查思路

4.1 切换后性能反而变差

这几乎是每次切换都会遇到的问题。新表数据量一样、SQL 一样,为什么切过去之后就变慢?大概率是索引或统计信息的问题。

建表的时候要把源表的索引原样复制,但实际执行计划可能还是会走偏。我先对目标表执行一次ANALYZE TABLE,让优化器重新收集统计信息,然后再看慢查询日志。如果某条 SQL 还是全表扫描,多半是目标表的索引缺失或者在 DDL 阶段被漏掉了。还有一种情况是目标表的自增主键和源表主键不一致,导致连接查询时索引失效。所以我在建目标表时,索引和主键都是逐字段和源表对齐的。

4.2 主键冲突与重复数据

双写和全量搬迁同时处理同一行数据时,最常见的报错就是主键冲突或唯一键冲突。出现这个问题的根源,往往是目标表用了自增主键,而搬迁数据又带着源表主键,两边对不上。

解决办法是目标表不使用自增主键,直接用源表主键作为主键,或者用业务唯一键作为唯一键。写入的时候使用 INSERT IGNORE,或者根据业务语义使用 ON DUPLICATE KEY UPDATE。这里要特别提醒,UPDATE 会把目标表里已有的业务字段覆盖成旧值,如果双写已经写入了更新后的值,再用旧值覆盖回去,就会造成数据回退。所以兜底策略要按业务时间排序,让最新的数据覆盖旧数据,而不是简单地用搬迁批次里的值强行覆盖。

4.3 增量延迟一直追不平

增量同步延迟追不平,通常有三个原因:源库大事务、回放端性能不足、目标库写入慢。

先看源库有没有超大事务。一个事务更新几十万行,binlog 里会连续产生大量事件,回放端串行处理需要很长时间。再看同步组件的位点和积压数,如果积压持续增长,说明回放速度跟不上生产速度,需要调整回放并发。最后看目标库的慢查询,如果目标库本身写入慢,比如磁盘 IO 瓶颈、索引太多导致插入慢,也会拖慢回放。

如果延迟始终追不平,最稳妥的做法是先降低灰度比例,确保读流量没有过多集中在新表上。等追平之后再逐步放大灰度。很多人在这个阶段会强行切全量,结果新表数据和旧表差了一大截,最终不得不回滚重来。

4.4 校验失败后的兜底策略

校验失败不等于迁移失败,要先区分是结构差异还是真实数据差异。结构差异包括字段类型不一致、字段默认值不一致、字符集不一致,这类问题通过同步 DDL 就能解决,重跑一遍校验即可。真实数据差异就要根据差异订单号从源表补数据,或者反过来清理目标表的垃圾数据。

我一般把校验差异记录到一张明细表里,后台跑一个补偿任务,从源表读取完整数据,对比业务字段后决定是否覆盖目标表。如果差异数量持续扩大,说明双写链路本身出了问题,要先停灰度,检查业务写入链路和同步组件。差异在一个固定范围内波动,通常是并发时序导致的临时不一致,补偿任务可以追平。

下面是一份简单的排查速查表,可以直接保存:

问题表现优先排查方向解决手段
切读后查询变慢索引、统计信息、执行计划ANALYZE TABLE,补齐索引
duplicate key 报错目标表主键设计、补偿链路INSERT IGNORE,按业务时间覆盖
增量延迟增长源库大事务、回放并发增加回放并发,降低灰度比例
校验差异持续扩大双写链路、开关状态停灰度,检查双写和补偿

5. 实际操作体会

这套流程我完整跑过不止一次,最后分享三个真实体会。第一,不要把灰度迁移当成一次性任务,它的本质是一个产品功能。双写开关、灰度路由、补偿任务都应该做成可运营的组件,平时不需要,出问题的时候它们就是保命符。我见过很多团队临时写一段迁移脚本,切完就删,等下次迁移又重写一遍,每次都在同一个坑里摔一次。

第二,切读阶段要提前演练回退,而不是等出问题再想。我们当时在新表慢查询打爆连接池的时候,因为预案里写了“看到错误率超过 1% 立即全量切回旧表”,整个回退动作三分钟完成,业务几乎无感知。回退剧本要写进值班手册,值班同学要会操作,不能只有资深同事会。

第三,校验比搬迁更重要。很多时候数据搬完了,真正花时间的反而是各种比对、排查差异。与其在后期补校验,不如在方案设计阶段就把校验逻辑设计好。校验字段、抽样比例、差异补偿任务,都要在动工之前想清楚,而不是等数据搬完再临场设计。

表迁移做到不停服,核心不是迁移工具多强大,而是每个阶段都有开关、有校验、有回退路径。把这套思路记在脑子里,下次碰上大表搬迁,你就不会慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询