大概半年前我接手了一个有点棘手的活儿:把一个核心业务系统的数据库,从自建的 MySQL 集群迁移到云上的新架构,而且要求全过程不停机、不中断业务。说实话,数据迁移这事儿本身不难,难就难在“不停机”这三个字上——这意味着你不能简简单单地把服务停掉、导数据、再启动,而是必须在业务持续写入的情况下,把数据从一个库搬到另一个库,还要保证两边最终一致,最后切换的时候用户几乎无感知。
这篇文章我就把这次迁移的完整思路、踩坑过程、核心细节和排查方法分享出来。不管你是刚接触数据库运维的开发,还是已经在做 DBA 的老手,只要碰到“不停机数据迁移”这个场景,这篇应该都能给你一些可以直接上手的参考。
1. 迁移方案的整体设计思路:核心是“写双份、读单份”
先说结论:不停机数据迁移,本质上不是“搬数据”,而是“搭两条持续同步的链路,然后找一个瞬间把读流量切过去”。理解了这个底层逻辑,后面所有的方案设计都不会跑偏。
1.1 业务需求画像与迁移目标的确定
动手之前,先把需求和约束盘点清楚。我当时梳理下来,核心约束是这样几条:
- 业务 7×24 小时在线,不能有超过 30 秒的写不可用窗口
- 老库是 MySQL 5.7,大概 800GB 数据,日均新增约 3GB,写入峰值在晚上 8 点到 10 点
- 新库是云上的 MySQL 8.0,架构做了读写分离,而且未来要支持分库分表
- 数据一致性要求很高,涉及到订单和账户流水,一分钱都不能错
- 需要一个可回滚的方案,一旦切换后出问题,能在短时间内切回老库
把这些约束列出来之后,方案的大方向就明确了:必须采用“双写 + 数据同步 + 一致性校验 + 最终切换”的策略。这跟传统停机迁移最大的区别在于,停机迁移是“一次搬完”,不停机迁移是“边跑边搬,最后换轨”。
1.2 备选方案的取舍:为什么没有直接上 DTS 之类的工具
可能有人会说,现在云厂商不是都有数据传输服务(DTS)吗?直接用不就行了?这话对一半。DTS 这类工具确实能解决增量同步的问题,但它在很多场景下有两个绕不开的痛点:
第一,DTS 通常只能同步数据,对于“双写改造”这种需要业务代码配合的迁移模式,它没法帮你做。如果你的新库表结构有变化(比如从单表改成了分表),或者需要做字段级别的转换,DTS 的灵活性就不够了。
第二,如果迁移是一个跨云平台、跨机房甚至从自建到云的场景,网络延迟和带宽会成为瓶颈,DTS 在这种情况下的同步延迟往往不太好控制。
所以我最后选择了“应用层双写 + Canal 监听 binlog 同步 + 自研校验脚本”的组合方案。这个方案的好处是每一层都可以自己控制,出了问题也知道从哪里排查,而不是对着一个黑盒工具干瞪眼。
1.3 不停机迁移的整体架构
整体架构用一句话描述就是:旧库继续承担所有读写,新库通过 binlog 同步追数据,业务层同时把写操作发一份到新库作为双保险,最后通过校验工具确认两边数据一致后,把读流量切到新库。
这个架构里最核心的三个角色是:
- 双写代理:在应用层拦截所有数据库写操作,先写老库,再异步写到新库
- 同步管道:用 Canal 监听老库的 binlog,把增量变更实时投递到新库
- 校验引擎:对比两边的数据,找出不一致的记录并触发补偿
这三个角色的协作关系是:双写代理负责让新库先追上“当前时刻”的数据,同步管道负责后续持续的增量同步,校验引擎负责兜底——把所有可能出现不一致的地方找出来并修掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:每个环节的技术选型和理由
方案定了之后,真正难的才刚刚开始。不停机迁移最怕的不是方案不够先进,而是细节处理不到位,导致迁移完了数据对不上,又找不到是哪个环节出的问题。这一部分我把每个核心环节的关键操作和背后的设计理由说清楚。
2.1 第一道工序:用 Canal 抓 binlog,注意 Row 格式和其他坑
Canal 是阿里巴巴开源的一个 MySQL binlog 解析组件,工作原理是伪装成 MySQL 的从库,向主库请求 binlog,然后把解析后的变更事件推送给下游。我选择 Canal 而不是自己解析 binlog,主要是因为它已经处理好了 binlog 的协议解析、断点续传、GTID 位置记录这些麻烦事,稳定性和活跃度都有保证。
启动 Canal 之前,有几个硬性前提必须先确认:
- 老库必须开启 binlog 日志,并且格式为 ROW 模式。如果是 STATEMENT 模式,拿到的是 SQL 语句而不是变更前后的数据,无法用于增量同步。可通过命令确认:
SHOW VARIABLES LIKE 'binlog_format'; - binlog 的保留时间要足够长,建议至少 7 天。否则如果新库追数据追得慢,或者中间断过,binlog 已经被清理掉了,就要重新全量导一次,那个成本是毁灭性的。
- Canal 所在的主机需要能访问老库的 3306 端口,并且需要一个有复制权限的账号:
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
我把 Canal 部署在迁移专用的一台 ECS 上,配置了 MQ 作为投递通道,这样 Canal 只管解析 binlog、把消息投递到 MQ,由下游消费者负责写入新库。之所以中间加了一层 MQ,而不是让 Canal 直接写入新库,是为了削峰填谷——万一新库写入出现抖动,消息还能在 MQ 里缓冲一段时间,不会直接丢数据。
Canal 的配置里有一个非常关键的参数叫 canal.instance.filter.black.regex,用来过滤不需要同步的表。我当时把日志表、临时表都过滤掉了,一方面是为了降低同步压力,另一方面也避免这些无意义的表产生无效变更记录。但这里要特别提醒:过滤规则一定要在做全量校验之前确认好,否则可能把不该过滤的表给过滤了,最后对账的时候才发现新库少了一堆数据。
2.2 第二道工序:应用层双写的幂等控制
加了 Canal 同步之后为什么还要做应用层双写?可能有经验的兄弟已经发现问题了:如果只靠 binlog 同步,新库的数据会一直追老库,但追到“当前时刻”之后,仍然存在一个短暂的同步延迟窗口。在这个窗口内,如果直接把读流量切到新库,用户可能会读到旧数据。双写的作用就是把这个延迟窗口尽量压缩到接近零。
但双写会引入一个经典的副作用:同一个写操作既通过应用层直接写入了新库,又通过 binlog 同步到了新库,那新库的这条数据会被写两遍。所以双写必须有幂等控制。我当时的做法是,在新库的业务表里增加一个 sync_source 字段,应用层双写时标记为 app,Canal 同步时标记为 binlog,写入的时候判断如果已经存在 app 标记的记录,就跳过。
这个方案对小表有效,但对于大表来说每次写入都去查一下 sync_source 会带来额外的性能开销。所以我后来换了一种更高效的方式:在 Canal 的消费端做去重,消费 MQ 消息时用 unique_key + binlog_position 做成 Redis 的幂等键,重复消息直接丢弃。Redis 键设置 5 分钟过期,足够覆盖 binlog 同步和应用层双写的时间差。
另外,双写还有一个容易出现坑的地方:老库写入成功,但新库写入失败,怎么办?这种情况一定不能因为一棵树放弃整个森林。我当时在双写代理里设置了降级策略——如果新库写入连续失败超过 3 次,就自动熔断双写,只写老库,等新库恢复后再通过 binlog 补偿。因为新库最终会通过 binlog 追平数据,所以双写失败并不会导致最终不一致,只是同步延迟会变大。
2.3 初始数据全量导出导入,以及增量回放的无缝衔接
Canal 解决的是增量同步的问题,但迁移之前新库还是空的,所以第一步必须先把老库已有的数据全量导到新库。这里有个技术要点:全量导出必须在某个 binlog 位点开始之前完成,这样后续的增量同步才能从这个位点接上。
我先记录老库当前 binlog 的位点(SHOW MASTER STATUS;),然后把这个位点之前的全量数据导出。为了保证导出期间的数据一致性,我用 mysqldump --single-transaction 开启一个可重复读事务,这样导出过程中其他事务的提交不会影响导出结果。这一步很关键,因为如果你不开启事务或者隔离级别不对,导出的数据可能是不一致的——比如同一笔订单的订单表导出来了,但它的明细表还没导,两边对不上。
全量导出的命令大概是这样:
bash复制mysqldump -h 老库地址 -u迁移账号 -p --single-transaction --set-gtid-purged=OFF \
--databases 业务库名 --routines --triggers --events > /data/dump/full_dump.sql
然后在新库上执行导入:
bash复制mysql -h 新库地址 -u迁移账号 -p < /data/dump/full_dump.sql
这里有个比较隐蔽的坑:mysqldump 默认会导出触发器和存储过程,但如果你用 Canal 做增量同步,新库上再保留触发器会导致重复执行,甚至在同步时触发一些意想不到的副作用。所以我建议导入到新库之后,手动把触发器删掉。触发器的逻辑如果需要保留,应该在下游的同步消费端用代码实现,而不是放在数据库里。
全量导入完成后,启动 Canal 同步,从刚才记录的那个 binlog 位点开始追赶。因为全量导出期间业务还在写入,所以这段时间产生的 binlog 变更会全部积压在同步管道里,启动后 Canal 会自动从位点开始回放。追赶的速度取决于新库的写入能力和 MQ 的消费速度,正常情况下几分钟到几十分钟就能追平。
3. 实操过程与核心环节实现:从校验到切换的完整步骤
方案设计和细节确认之后,实际操作阶段才是真正考验耐心的地方。这一章我把校验、预切换、正式切换三个阶段的完整操作写出来,每个阶段都有可以直接照做的步骤和参数计算过程。
3.1 一致性校验:不能只 count 行数,要校验业务语义
很多人做数据校验的时候,习惯先 SELECT COUNT(*) FROM 老表 和 SELECT COUNT(*) FROM 新表,数量对上了就觉得数据一致了。这是最大的误区。行数一致不代表数据一致,完全可能出现两边的数据条数相同,但有部分行的某个字段值不同。
我当时做的校验分三层:
第一层是全表行数校验,只做粗筛,用并行的方式跑,快速发现巨大的差异。
第二层是校验和对比,对每张表按照主键排序,计算每一行的 CRC32 校验和,然后对比两边的校验和结果。计算校验和可以用如下 SQL 在两面分别执行:
sql复制SELECT CONCAT(TABLE_NAME, '-', PK_ID, '-', CRC32(CONCAT_WS('|', field1, field2, field3, ...)))
FROM 业务表;
然后导出结果做 diff。对于 800GB 的数据,这个操作会比较耗时,所以我没有对全部数据做,而是优先处理订单、账户流水、用户等核心表,非核心表只做抽样校验。
第三层是业务语义校验,这一层最能发现“技术上看不出来但业务上是错的”的问题。我举一个例子:订单表和订单明细表都从老库同步到了新库,两边行数和 CRC 都一致,但订单表里有几个订单被打上了“已取消”标记,而对应的明细表里还有这些订单的明细记录,这在业务上就是脏数据。这种问题纯靠校验工具是发现不了的,必须写出业务规则脚本去检查。
校验的整体耗时也要有个预算。我当时核心表大概 200GB,单表校验加 diff 大约花了 3 个小时。因为校验是并行跑的,所以我建议在业务低峰期做,同时控制并发度,不要让校验任务把新库的 IO 打满了,影响正常业务。
3.2 预切换演练与流量灰度:先验证再动真格
正式切换之前,我做了一次完整的预切换演练,把所有步骤都走了一遍,包括:停止双写、确认同步延迟归零、切换读流量到新库、持续观察 24 小时、验证正常后切换写流量。
预切换演练的核心目标是验证两个问题:一是新库能不能扛住真实流量的压力,二是切换过程中有没有遗漏的读写逻辑依赖老库。
流量灰度我采用的是“按用户 bucket 灰度”的方式。具体来说,在网关层根据用户 ID 做哈希取模,把 5% 的用户流量切到新库读。灰度期间,我重点观察新库的查询延迟、CPU 和连接数,同时对比灰度用户和未灰度用户的接口响应时间。
这里有一个很容易被忽略的点:如果老库和新库的数据库账号权限不一致,灰度流量切换到新库后,可能会有一堆 SQL 因为权限不足而报错。所以我在灰度之前,专门写了一组账号权限对比脚本,把老库每个账号的库表权限、明细权限全都拉出来,同步生成到新库。看起来是个小事,但真的能避免切换当天的意外事故。
3.3 正式切换的倒计时操作与参数计算
正式切换我选在了凌晨两点到四点之间,这是业务的绝对低谷。整个切换过程严格按分钟执行:
- 02:00 通知所有相关方,进入切换窗口
- 02:05 应用层双写熔断开关打开,停止向新库发送双写请求
- 02:10 查看 Canal 消费位点,确认同步延迟为 0(通过 Canal 的监控指标
delayedLogSize判断) - 02:15 再次运行核心表校验,确保老库和新库数据完全一致
- 02:20 网关层把 100% 读流量切到新库
- 02:30 观察新库的读写延迟、错误率、慢查询数量
- 02:45 确认新库稳定后,把写流量也切到新库,老库进入只读状态
- 02:50 关闭老库的只读开关前,再最后做一次数据比对
这里有一个参数计算可以分享出来。切换前我评估了“如果新库有问题,最多可以承受多长的观察时间再回滚”。切换窗口剩余时间 = 低谷时长 2 小时 - 切换操作耗时约 30 分钟 = 90 分钟。考虑到如果新库出了比较严重的问题,DBA 响应并确认回滚决策大概需要 10 分钟,所以真正留给“观察新库是否稳定”的时间是 80 分钟。基于这个预算,我把灰度观察和正式切换的时间节点都做了严格的规定,超过时间窗口不出决策,就强制回滚。
4. 常见问题与排查技巧实录:迁移过程中踩过的真实坑
不停机迁移的每个环节都可能出问题,有些问题是迁移手法不对导致的,有些则是业务本身数据的问题。我把这次迁移中实际遇到的几个值得记录的问题和排查思路整理一下,希望能帮大家少走一些弯路。
4.1 主键冲突:唯一键和业务主键在双写下的冲突
双写开启后,我遇到的第一个问题就是主键冲突。原因是老库的自增主键用的是 AUTO_INCREMENT,但新库我为了兼容未来的分库分表,把主键改成了雪花 ID。结果应用层双写的时候,代码里没有对主键生成逻辑做切换,导致新库写入的时候还在用老库的主键值,而老库的主键值到了新库已经存在了一部分(通过 binlog 同步过来的),于是大量写入报主键重复。
这个问题的排查过程比较典型:先是新库出现大量 Duplicate entry 'xxx' for key 'PRIMARY' 错误,我第一反应是 Canal 消息重复消费了,检查了幂等键发现没问题;然后查应用日志,发现双写的时候传入的主键值是老库的自增 ID,这才定位到问题。
解决办法是双写代码里增加主键生成策略的开关,在预写阶段判断当前是“老库主键模式”还是“新库主键模式”,如果是新库主键模式就生成雪花 ID,并在双写请求里带上这个新主键,同时写入老库时也使用新主键。这样老库的表结构不变,但新库就有了自己的主键分配逻辑,两边通过一个映射表关联新旧主键。
4.2 binlog 延迟为什么会越积越多:大事务是罪魁祸首
迁移过程中有一段时间我发现 Canal 的延迟从几秒涨到了十几分钟,而且还在往上走。排查之后定位到一个 SQL:有个定时任务每天晚上会对一张大表做批量 UPDATE,一次性更新几十万行。这个操作在 MySQL 里是一个大事务,binlog 会生成一个非常大的事务记录,Canal 要完整解析完这个事务才能继续投递后面的消息,所以延迟一下子就上来了。
针对这个问题的处理方案有两个方向,一个是在业务层面把大事务拆分成小批次提交,比如每次更新 1000 行就 COMMIT 一次;另一个是在 Canal 消费端做针对大事务的单独处理,比如调大 MQ 消息体的大小限制,或者把大事务按行拆分为多条消息。
我当时两个方向都做了:业务定时任务改成循环分批更新,同时 Canal 消费端把超过一定大小的消息单独走一个专用的消费者队列,避免阻塞后续消息。改动之后延迟稳定在 3 秒以内,整个迁移窗口期都没有再出现大延迟的问题。
4.3 数据对账时“日期错乱”的排查:时区和类型是两大主要因素
迁移完成后,有一个业务反馈说,在新库查出来的记录创建时间和老库不一致,有的差了 8 小时,有的差了 1 天。这不光影响展示,如果业务逻辑里有基于时间的判断,后果会很严重。
排查过程先看连接层的时区设置。MySQL 的 time_zone 参数老库是 SYSTEM,新库是 +08:00,而 Java 应用连接数据库时 JDBC URL 里的 serverTimezone 配置没有统一,导致部分连接解析时间时用了不同时区。把新库的 time_zone 统一为 +08:00,并让应用连接的 serverTimezone 也改成一致后,8 小时的偏差解决。
剩下 1 天的偏差,检查发现是数据格式问题:老库的日期字段有的是 DATETIME,有的是 TIMESTAMP,导入新库时建表语句把某些 DATETIME 字段建成了 DATE。这样原本带时分秒的时间被丢掉了时分秒,看起来就差了 1 天。这个问题的根因在最初的 DDL 设计阶段,没有用工具做字段类型的自动映射比对。我后来写了一个脚本,用 information_schema.COLUMNS 自动对比两边表的字段类型,把不一致的字段都列出来逐一修正。
4.4 手机相册迁移的教训:文件元数据保留比文件本身更重要
上面聊的都是数据库场景,但“不停机迁移”这个概念不只适用于数据库,也适用于各种文件的迁移。比如网上很多人反馈手机换新、数据迁移之后,图库里的照片拍摄日期不对了,显示的全是迁移当天的时间。这个问题的本质,就是迁移工具在拷贝照片文件时,没有保留文件的 EXIF 元数据,或者读取拍摄日期的方式有问题,没有读取 EXIF 里的拍摄时间,而是用了文件系统的创建时间。
从迁移的角度看,这个问题的原理和数据库迁移是一样的:文件数据本身(照片的二进制内容)很容易搬,但围绕数据的元数据(拍摄时间、GPS 位置、相册归属、缩略图关系)才是迁移的核心价值,一旦丢失,恢复成本极高。
所以我在做数据迁移时,给团队定了一条规矩:任何迁移都必须把“元数据完整性”作为验收标准之一。数据库迁移要校验字段值,文件迁移要校验 EXIF 和文件属性,不能只是看到文件个数对上了就觉得成功。
4.5 Oracle 迁移的差异:不像 MySQL 有那么好用的 binlog 生态
如果迁移的源库是 Oracle,情况会更复杂一点。Oracle 的日志机制是 redo log + archive log,不像 MySQL binlog 那么直观,开源生态也不像 Canal 那么成熟。Oracle 到 MySQL 的迁移,通常只能用 Oracle GoldenGate(OGG)或者云厂商的商业迁移工具来捕获增量变更。
另外 Oracle 的数据类型和 MySQL 差异很大,比如 NUMBER 对应到 MySQL 可能是 DECIMAL 或 BIGINT,DATE 对应到 MySQL 可能是 DATETIME 或 TIMESTAMP。这些映射关系如果建表时没有仔细设计,迁移后会有一堆隐性类型转换导致的问题,比如索引失效、比较出错、精度丢失。
我给一个建议:Oracle 迁移 MySQL 时,最先要做的不是搭同步链路,而是把两边的字段类型映射表列出来,让业务方确认每个字段的含义,再决定对应的 MySQL 类型。这一步虽然耗时,但能避免后面 90% 的坑。
5. 迁移过程中积累的几条通用经验
最后分享几条我在整个迁移过程中沉淀下来的经验。这些经验不限于某一个具体工具,而是适用于所有“不停机数据迁移”类型的工作。
第一,双写和同步不是非此即彼的关系。双写是为了缩短延迟窗口,同步是为了备份兜底,两者同时开启,即使某一个环节出了问题,另一个还能兜住。但两个同时在跑,就必须有幂等机制,这个优先级最高。
第二,迁移的验收必须包含业务层校验,技术层校验只是底线。行数一致、CRC 一致都不代表业务是正确的,一定要让业务方参与写校验规则,把关键业务场景当成验收用例跑一遍。
第三,回滚方案要在切换之前反复演练,而不是等出了问题再想。切换时最怕的就是犹豫不决,所以我把回滚触发条件写成了明确的“如果 A 指标超过阈值 X,则执行回滚”,这样执行的人不需要临场做决策,只用按预案走。
第四,所有迁移工具的选择要结合自己的维护能力。开源工具虽然灵活,但出了问题要靠自己排查;云厂商工具虽然省心,但可能不够透明。没有绝对的好坏,只有适不适合当前的场景。
数据迁移这个方向说大不大,说小不小。它不像架构设计那么光鲜,也不像性能优化那么刺激,但它是一个系统和另一个系统之间平稳交替的必经之路。我希望这篇分享能让你在面对“不停机数据迁移”不再心里打鼓——把方案拆清楚,把细节想明白,把预案做到位,哪怕数据量再大、业务再敏感,也完全可以稳稳地迁过去。
