1. 为什么“凌晨两点停机迁移”越来越行不通了
先聊个实际场景。早些年做数据库迁移,最稳妥的方案就是停机窗口:凌晨两点,挂公告,停服,导数据,校验,切流量,开盘。一套流程走下来,运气好四五个小时,运气不好直接拖到天亮,运维和研发一起在机房里熬红眼。
这套做法在今天正变得越来越尴尬。业务对可用性的要求已经不是“99.9%”这种口号了,而是实打实的考核指标。你凌晨停服四个小时,SLA 直接触线,老板半夜看到监控告警电话就过来了。更麻烦的是,现在的系统架构普遍是微服务化、多活部署,数据库之间还有实时同步链路,你停掉一个核心库,上下游全部阻塞,恢复起来根本不是“把服务拉起来”那么简单。
所谓“不停机数据迁移”,翻译成人话就是:在业务完全不中断、用户无感知的前提下,把数据从旧存储完整搬迁到新存储,并且保证迁移过程中写入不丢、查询不脏、切换瞬间无缝。这事听起来简单,做起来动辄涉及几十张表、上亿行数据、多个消费者链路,任何一个环节没考虑周全,都可能变成线上事故。
这篇内容我会从方案选型、架构设计、搬迁执行、校验兜底、踩坑复盘这几个维度,把不停机迁移的完整链路拆开讲。适合正在准备做存储迁移的同学参考,也适合已经踩过坑、想回头对照一下自己哪里做漏了的同行。
需要先说明一点:不同业务的数据量级、一致性要求、写入模型差异很大,没有一套配置能通吃所有场景。我这里给出的经验和方案是基于通用实践的合理补充,你在落地时一定要结合自己的业务形态做裁剪。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迁移方案选型:先搞清你面对的是哪种“不停机”
不停机迁移不是一种技术,而是一类技术方案的统称。选哪个方案,取决于一个核心变量:旧库和新库之间是否需要在迁移期间保持双向写入。
2.1 一次性搬迁 vs. 增量同步,两者的适用边界
如果你的场景是数据量不大(比如千万行以内)、业务允许短时间只读(比如内部管理系统),那“先全量导出——导入新库——校验——切读流量——放开写流量”这种简化版流程就够了,其实算不上严格的“不停机”,顶多是“短停写、不停读”。
但真正的线上核心业务(订单、支付、用户、库存)几乎都是持续写入的,停写哪怕一分钟,都会产生数据空洞。这时候就需要引入增量同步机制:全量迁移老数据的同时,把新产生的变更实时或准实时地同步到新库,等两边数据追平之后,再找一个流量低点完成切换。
我见过不少团队在这个环节走了弯路:一上来就想着“直接用同步工具全量导”,结果忘了业务还在写,数据导到一半,老库新增的数据根本没有同步过去,切换一发生,用户已下的订单直接消失。这不是工具问题,是方案设计问题——你必须先想清楚,迁移期间老库继续写入这件事,怎么处理。
2.2 CDC(变更数据捕获)为什么是当前主流选择
不停机迁移的增量同步环节,主流做法是 CDC(Change Data Capture)。它的核心思路是:监听数据库的 binlog(MySQL)或 WAL(PostgreSQL),把每一次 insert、update、delete 操作解析成结构化事件,再投递到新库执行,从而保证新库能“重放”老库发生的所有变更。
相比起业务代码双写、定时任务扫表对比这类方案,CDC 的优势很明显:
- 对业务代码零侵入,不需要修改现有写入逻辑;
- 捕获的是数据库底层的真实变更,不存在漏写、错写的情况;
- 延迟通常在毫秒到秒级,能保证切换窗口内两边数据基本一致。
主流的开源 CDC 工具有 Debezium、Canal、Flink CDC 等。选型上我个人的实践倾向是:MySQL 生态用 Canal 或者 Flink CDC 都可以,PostgreSQL 生态直接用 Debezium 会更顺手。关键不是选哪个,而是你要理解它的同步机制和延迟边界,后面校验和切换才心里有底。
2.3 我为什么最终放弃了“双写”方案
有一种方案是改造业务代码,在迁移期间同时写入老库和新库,等新库稳定后再下线老库。这就是所谓的“双写”。它看起来最直接,但实际落地时坑极多。
双写要求所有写操作都经过一个统一入口,但很多老系统的写入路径是散落的,有定时任务、有消息队列消费、有运营后台直连,你很难保证全部覆盖。就算全覆盖了,双写还会带来一致性问题:老库写成功了新库失败了怎么办?两边都写成功了但后续数据不一致怎么发现?这些问题的排查成本,往往比迁移本身还高。
所以我的建议很明确:除非你本来就有统一的数据访问层,否则别碰双写。CDC 方案能解决 90% 的增量同步诉求,剩下的边界情况用对账任务兜底就够了。
3. 双写在不停机迁移中的正确打开方式
刚才我说不建议用双写做迁移主链路,但这里有个例外:对于读多写少、数据结构简单的配置类数据(比如商品类目、活动配置、渠道白名单),双写反而是最简单可控的方案。
3.1 双写老库和新库的时序问题
一旦走双写,最先遇到的就是时序问题:先写老库,再写新库;还是反过来?如果新库写入失败,要不要回滚老库的写入?
以我的经验,建议采用“先写老库,成功后异步写新库,新库写失败则记录失败日志并重试”的策略。原因是:老库是当前业务实际依赖的存储,任何一笔写操作都必须保证老库成功,否则业务直接出错;新库的写入是“额外动作”,失败不阻塞主流程,靠补偿机制兜底。
时序上要注意的是,双写操作要尽量放在同一个事务边界里,做不到的话至少要在业务日志里埋点记录双写结果,方便事后排查。
3.2 代码层面的双写改造示例
给你一个最小可用的伪代码思路:
java复制// 伪代码:双写迁移示例
public void writeWithDualWrite(Data data) {
// 第一步:写老库,失败直接抛出异常
oldRepository.insert(data);
// 第二步:异步写新库,失败进入重试队列
try {
newRepository.insert(data);
} catch (Exception e) {
dualWriteRetryQueue.add(data);
log.error("dual write to new repo failed", e);
}
}
这里有个细节很多人容易忽略:双写期间,老库的 update 也要同步处理。如果只处理了 insert,更新操作只落在老库,新库的数据就会慢慢“变旧”。所以双写改造要覆盖所有写类型,包括 update 和 delete——但这些都会让代码变得越来越重,这也是我不建议大范围使用双写的原因。
3.3 双写迁移如何做数据校验
双写方案的数据校验,不能只核对行数,要核对字段级别的内容一致性。上面这套方案我在实际项目中用过一次,之后还是回归了 CDC 路线。核心原因就是:双写的补偿逻辑、失败重试耗时太长,还要配合大量的对账作业,负担比预期大得多。
4. 一次完整的不停机迁移:从存量同步到增量追平
接下来是整篇文章最核心的部分,我会以一套典型的“MySQL 老库迁移到 MySQL 新库 / 或 NewSQL”场景为例,拆解完整的执行过程。这套流程适用于绝大多数在线业务,你只需要在细节上做适配。
4.1 第一步:存量数据的全量搬迁
全量搬迁环节的第一个关键操作,不是“导出”,而是“确定迁移范围”。先梳理清楚要迁移哪些库、哪些表,哪些表是归档表不需要迁,哪些表是日志表可以丢弃。范围没定清楚,后面校验和切换都会被拖累。
范围确定后,这时候不能直接对老库执行 dump 命令。因为 dump 期间,老库的写入并没有停止,你 dump 出来的数据本身就是一个“有洞”的快照——它在时间轴上是不一致的。解决思路是:通过 CDC 记录全量导出开始时的 binlog offset(或者用统一的快照时间点),这样后续增量同步可以从这个点开始。
具体的操作可以简化为:
- 开启 CDC 任务,记录初始位点;
- 在老库上执行全量导出(注意用一致性快照,避免单表导出时跨表数据不一致);
- 将导出的数据导入新库,导入期间暂停或放慢 CDC 增量消费,避免新库数据版本冲突;
- 全量导入完成,恢复增量消费。
这个流程里最容易翻车的是“全量导入期间增量数据堆积”。如果你用 Canal 或 Debezium,增量事件是持续写入消息队列的,全量导入耗时越长,堆积越多。建议控制单批导入的数据量,或临时扩容消费端,把堆积消化在可控范围内。
4.2 第二步:增量同步的追平与校验
全量导入完成后,新库里有了一份“过去某个时刻”的完整数据,加上 CDC 持续同步,新库的数据会不断追上老库。这里的关键指标是“延迟时间”——也就是新库和老库的数据差了多少秒。
追平阶段我建议按这个顺序推进:
- 第一步,观察增量延迟从分钟级降到秒级,并稳定一段时间(至少 15 分钟);
- 第二步,做抽样数据校验,重点核对最近变更过的数据,因为这部分最容易出现同步问题;
- 第三步,做总量校验:行数、关键表的唯一键数、几个核心金额字段的 sum 值。
校验工具可以用现成的数据比对平台,也可以自己写脚本。我自己的习惯是先跑一个“表级行数对比”,快速发现丢数据的大头;再抽几张大表做“字段级校验”,慢一点但能发现“数据在但值不对”的问题。
4.3 第三步:切换窗口的操作细节
数据追平之后,就进入切换环节。切换的核心原则是:先切读流量,再切写流量;先灰度放量,再全量放开。
我见过的最常见失误是“一次切换,全部流量秒切”。这不是不停机迁移的推荐做法,更稳妥的切换节奏应该是分层推进的:
- 先把新库设为只读从库,验证查询性能和结果正确性;
- 切 1% 的读流量到新库,观察监控和错误日志;
- 逐步放大读流量到 10%、50%、100%;
- 读流量全部稳定后,找一个流量低点,把写流量切到新库。
这里要强调一个很容易被忽视的细节:切读流量的链路改造可能要动代码(比如数据源切换或读写分离改造),而切写流量通常只需要在配置中心或网关层调整。两部分的改动范围、风险等级、回滚难度都完全不同,务必分开评估、分开操作。
4.4 第四步:留好回滚能力
不停机迁移最大的优势不是“不失败”,而是“失败了能快速回滚”。所以切换完成后,不要马上把老库原地销毁,至少要保留一段时间的双写保留期。
具体建议:切写流量后,老库继续运行并持续接收 CDC 反向同步(从新库同步回老库),保留 3~7 天,确认新库无线上问题后,再逐步下线老库。这样即使新库出现问题,你还能切回老库,业务损失控制在分钟级。
这个“反向同步”步骤经常被省略,但它是回滚能力的核心。没有反向同步,一旦新库有问题,老库数据已经停止更新,你想回滚都回不去。
5. 迁移中的校验问题:字段对得上并不代表真相
如果你做过一次完整的迁移,你会发现:数据校验是全程最耗时、也最容易自欺欺人的环节。
5.1 行数一致、金额一致,不代表没有脏数据
常规校验只对比行数和 sum 值,但这类校验对“内容层面”的问题基本无效。比如:老库的某个时间字段是 datetime 类型,新库迁移后被错误地变成了 timestamp 类型,字段值因为时区转换差了 8 小时,行数没变、sum 没变、业务却悄悄出了问题。
再比如主键冲突:老库的主键是逻辑删除的 id,新库使用了自增主键,导致历史数据迁移后主键重排,所有以主键为关联条件的业务全部错乱。
所以字段级校验一定要做,而且要做“类型、时区、字符集、默认值”全维度校验,不能只看行数和 sum。我自己的经验是:先写一个通用比对脚本,把两张表的每一行做哈希(MD5 或 CRC32),再对比哈希集合差集,这样能快速定位到具体的差异行。
5.2 日期时间字段的坑:时区与格式的隐性错位
这里特别想展开说一下日期时间字段的坑,因为最近“小米迁移数据后图库拍摄日期不对”这类话题引发了挺多讨论。事实上,这在数据库迁移里也极其常见。
一个典型的场景:老库存储的是带时区的 UTC 时间,新库为了业务展示方便改成了本地时间存储,迁移后再读出来,所有记录都偏移了几个小时。还有更隐蔽的:老库的字段是 datetime(0) 没有毫秒,新库变成了 datetime(3),同步工具解析时补了 000,导致某些以时间为条件的查询结果不一致。
这类问题的本质,是迁移过程中“数据的语义”变了,而不仅仅是“数据的值”变了。规避方法只有一个:在迁移设计阶段就明确所有时间字段的存储规范和时区规则,并在测试环境做时间字段的专项校验——专门挑出跨时区、跨零点、跨夏令时的样本数据来测。
6. 那几个我踩过的、文档里不会写的坑
6.1 大事务产生的 binlog 风暴,直接让 CDC 断流
有一次迁移,增量同步跑到一半突然延迟暴涨,消费者积压了几百万条消息。排查下来发现,罪魁祸首是一个批量更新任务,一次性 update 了几百万行数据,在 binlog 里产生了海量事件,消息队列直接被打满。
这个问题在全量导入期间尤其容易触发,因为你导入的数据量大,单条事务动辄几万行。建议做法是:全量导入时按主键分批执行,每批 1000~5000 行提交一次;同时给 CDC 任务设置合理的并发度和背压策略,避免消费端被冲垮。
6.2 索引缺失让追平后的新库查询性能“突然崩掉”
迁移过程中,数据是导进去了,但索引往往没有完整迁移过去——特别是有些索引是老库运行多年后根据业务慢查询逐步补出来的,建表语句里根本没有。结果就是切换读流量后,新库的慢查询数量直接飙升。
解决方法是:在切换前,把老库的所有索引(包括二级索引、联合索引、唯一索引)完整导出,在新库上逐一核对建立。不要只看主键和唯一键,慢查询优化不是迁移的事,但迁移是暴露问题的时机。
6.3 外键和触发器这些“老古董”拖慢了整体切换节奏
现代分布式数据库基本不推荐外键和触发器,但很多老系统里依然存在。迁移时,这些对象如果不处理,会导致两个问题:一是导入时外键校验拖慢导入速度;二是切换后触发器逻辑如果被忽略,会直接影响数据正确性。
我的建议是迁移前就把外键和触发器的逻辑梳理清楚,能改造成应用层逻辑的直接改造,不能改造的就迁移后在目标库重新创建,并且在测试环境验证一遍完整链路。这个环节容易被认为“小事”,实际上它经常是数据不一致的真凶。
6.4 切换瞬间的连接风暴和缓存穿透
切写流量时,所有应用连接会瞬间指向新库,如果新库的连接池参数没有提前调优,瞬间涌入的连接会把数据库打挂。这个问题几乎每次切换都会遇到,但极少有人提前准备。
做法是:切换前一天做连接数压测,确认新库的 max_connections、连接池超时、吞吐上限等参数能支撑全量流量;切换时先调低应用的连接池初始大小,逐步放大,而不是一把梭哈。
另外,如果迁移换了缓存中间件,切流后还要注意缓存穿透问题——老缓存可能还有存量数据,但新缓存是空的,流量进来后会全部打到数据库上。提前做好缓存预热,比事后救火强太多。
7. 迁移完成之后,别急着庆祝
数据切到新库、业务跑起来,很多人会觉得“大功告成”,但其实迁移的收尾阶段才是决定成败的最后一环。
首要是持续观察期。建议在切换后的一周内,每天跑一次全量对账(行数+关键字段+关键业务统计值),同时盯着主从或双写保留期的同步延迟,以及慢查询、错误日志这类基础指标。一旦发现任何异常,优先准备回滚,而不是试图在高温下排查。
其次是老库的优雅下线。我见过有人在迁移完成后第二天就删了老库,结果新库出现一个隐蔽 bug,想回滚已经没有任何退路。稳妥的做法是:保留期至少一周,期间老库仍然接收反向同步,之后发起下线评审,确认没有业务依赖后再做物理删除或归档。
最后是文档沉淀。把迁移方案、执行脚本、校验脚本、遇到的问题和解决方案整理成内部文档,特别是那些“不是网上能搜到”的经验,比如你们业务的特殊时间字段规则、某个历史表的主键策略、某个同步任务的特殊参数。这些东西不写下来,下次迁移时又会全部踩一遍。
回到开头说的“凌晨两点停机迁移”的话题。不能否认,在特定的业务规模和复杂度下,停机迁移依然是一个可行选项,它的执行成本和对团队的要求都低得多。但如果你面对的是核心在线业务,如果业务方明确要求可用性,那“不停机”就不再是一个可选项,而是硬约束。
不停机数据迁移这件事,本质上是把原来“一个晚上的风险”拆成了“一段时间的持续小风险”。方案设计阶段多花点时间,执行阶段多留点余量,校验环节多花点耐心,切换时刻多准备几条退路——这套方法论和认真劲,才是迁移真正靠谱的保险丝。
