2013年,Target进军加拿大市场,133家门店同时开业。短短两年后,这家美国零售巨头黯然退出加拿大,累计损失超过21亿美元。根本原因之一是库存数据迁移的灾难性失败——货架上的商品和系统里的记录完全对不上,顾客在结账时发现价格与标签不符,仓库里堆满了系统显示"缺货"的商品。

这不是孤例。Gartner的研究数据显示,83%的数据迁移项目要么彻底失败,要么严重超预算、超进度。Bloor Research的调查更进一步指出,超过80%的数据迁移项目最终延期或被中止。数据迁移看似只是"把数据从A搬到B",实则是一个涉及分布式系统、事务语义、性能工程和组织协作的复杂命题。

双写:看似简单的陷阱

数据迁移最常见的技术方案是"双写":在迁移期间,应用同时向旧数据库和新数据库写入数据,确保两边数据同步。这个方案看起来直观易懂——不就是多写一次吗?

问题在于,双写的"双"字本身就违背了分布式系统的基本约束

考虑一个简单场景:用户创建订单后,系统需要将订单写入MySQL,同时发送消息到Kafka通知下游系统。如果MySQL写入成功但Kafka写入失败,系统就会陷入不一致状态——数据库里有订单记录,但下游系统对此一无所知。这正是Confluent技术博客中详细分析的"Dual-Write Problem"。

双写的本质困境在于:两个独立的存储系统无法共享同一个事务。数据库事务只保证单数据库内的原子性,它无法延伸到外部系统。你无法用MySQL的事务机制来保证Kafka消息的发送。

有人会想到两阶段提交(2PC)。理论上,2PC确实可以协调多个资源管理器完成原子事务。但现实是残酷的:2PC需要所有参与者支持XA协议,需要引入事务协调器,在准备阶段会锁定所有资源——这意味着严重的性能损耗和可用性风险。一旦协调器故障,所有参与者都会被阻塞。Martin Fowler曾直言,2PC在微服务架构中几乎不可用,因为"事务协调器本质上是一个独裁者"。

数据不一致的五种典型场景

双写模式在实际工程中会遇到哪些具体问题?

场景一:部分失败导致的数据分歧

应用执行双写时,第一个数据库写入成功,第二个数据库写入失败。这是最直接的不一致来源。更糟糕的是,失败可能发生在网络层、存储层、甚至磁盘层——每一层都可能产生"半成功"状态。

场景二:并发写导致的顺序错乱

两个用户同时修改同一条记录:用户A的修改先写入旧数据库,用户B的修改先写入新数据库。由于网络延迟差异,两个数据库可能以不同顺序应用这两个修改,导致最终状态不一致。

场景三:回填过程中的增量变更

双写方案通常需要"回填"历史数据——将迁移开始前已存在的数据迁移到新系统。在回填过程中,如果旧数据被更新,而更新尚未同步到新系统,回填的数据就会覆盖掉正确的更新。

场景四:聚合查询的精度陷阱

当部分数据在新系统、部分数据在旧系统时,聚合查询(如统计用户总数)会产生错误结果。Google Cloud的技术文章明确指出,双写变体无法在任意时间点保证单一一致的数据源,这会导致聚合查询在迁移期间返回不准确的值。

场景五:故障恢复后的状态撕裂

如果迁移过程中任一数据库发生故障并触发主从切换,两边的数据状态可能完全撕裂。Facebook在迁移数十PB Hadoop数据时专门强调了这一点:必须保证在任何故障场景下都能追溯数据状态。

三大解决方案的技术权衡

解决双写问题,业界已经形成了几种成熟的模式。

CDC:让数据库自己说话

Change Data Capture(变更数据捕获)的核心思想是:不再依赖应用层同步数据,而是直接监听数据库的变更日志。

大多数数据库都采用预写式日志(WAL)来保证持久性。PostgreSQL有WAL,MySQL有binlog,Oracle有Redo Log。这些日志记录了每一个数据变更操作的完整信息。CDC工具通过实时解析这些日志,将数据变更转化为事件流,从而实现数据同步。

Debezium是CDC领域的代表性工具。它支持PostgreSQL、MySQL、MongoDB、Oracle等多种数据库,能够捕获INSERT、UPDATE、DELETE操作,并将其转换为结构化事件。Confluent的技术文档详细描述了CDC的工作原理:CDC将数据库内部的变更转化为事件流,发布到Kafka等消息系统中,下游系统通过消费这些事件来保持同步。

CDC的优势在于:

  • 零侵入性:不需要修改应用代码
  • 事务顺序保证:日志中的变更顺序与事务提交顺序一致
  • 精确一次语义:每条变更只被捕获一次

但CDC也有局限性:需要数据库开启日志复制、对源数据库有一定性能影响、跨Schema转换需要额外处理。

Transactional Outbox:用一张表解决原子性

Transactional Outbox模式的思想来自分布式系统的经典设计:既然无法让数据库和消息系统共享事务,那就让它们都操作同一个数据库。

具体实现是:在业务数据库中创建一个"发件箱"表。当应用需要写入业务数据并发送消息时,它在同一个数据库事务中完成两件事——写入业务数据、将消息写入发件箱表。由于两者在同一个数据库事务中,原子性由数据库保证。

然后,一个独立的进程或CDC工具读取发件箱表,将消息发送到目标系统。如果发送失败,进程会重试直到成功。每条消息发送成功后,从发件箱表中删除或标记为已发送。

这个模式巧妙地将"跨系统一致性"问题转化为"单系统事务+异步处理"问题。Stripe在迁移订阅数据时大量使用了这种模式——他们称之为"migration event log",用于追踪迁移期间每一个数据库操作的执行状态。

Saga模式:用补偿替代回滚

Saga模式适用于更复杂的跨服务事务场景。它将一个长事务拆分为多个本地事务,每个本地事务完成后触发下一个。如果某个步骤失败,执行"补偿事务"来撤销之前的影响。

比如,创建订单涉及:扣减库存 → 扣款 → 创建订单。如果扣款失败,需要执行补偿事务:恢复库存。Saga模式不追求原子性,而是追求"最终一致性"——所有操作要么全部成功,要么全部被补偿。

微软Azure的架构文档详细描述了Saga模式:每个本地事务更新数据库并触发下一个本地事务。如果某个事务失败,Saga运行补偿事务来撤销之前步骤的影响。Saga模式特别适合微服务架构,因为它不依赖分布式事务协调器。

在线迁移工具:gh-ost vs pt-online-schema-change

当迁移涉及Schema变更时,在线DDL工具成为关键基础设施。MySQL生态中,gh-ost和pt-online-schema-change是两大主流选择。

pt-online-schema-change:基于触发器的经典方案

Percona Toolkit中的pt-online-schema-change采用传统的触发器机制:

  1. 创建一个与原表结构相同的新表(“影子表”)
  2. 在原表上创建INSERT、UPDATE、DELETE触发器,将变更同步到影子表
  3. 分批将原表数据复制到影子表
  4. 通过RENAME TABLE原子切换表名

触发器方案的问题是:所有写操作都会触发额外的触发器执行,对主库性能有明显影响。在高并发写入场景下,触发器可能成为瓶颈。

gh-ost:无触发器的异步方案

GitHub开源的gh-ost采用了完全不同的架构:不使用触发器,而是通过解析binlog来捕获变更

┌─────────────┐     binlog stream     ┌─────────────┐
│   Primary   │ ───────────────────► │   gh-ost    │
│   (Source)  │                       │  (Migration│
└─────────────┘                       │   Engine)  │
      │                               └─────────────┘
      │ copy in chunks                      │
      ▼                                     ▼
┌─────────────┐                     ┌─────────────┐
│  Original   │                     │   Ghost     │
│    Table    │                     │   Table     │
└─────────────┘                     └─────────────┘

gh-ost的工作流程:

  1. 连接到从库,读取binlog流
  2. 创建影子表,分批复制数据
  3. 异步将binlog中的变更应用到影子表
  4. 提供交互式cutover机制,允许精确控制切换时机

gh-ost的优势在于:

  • 无触发器开销:主库不承担额外的触发器执行
  • 可暂停/恢复:迁移过程可以随时暂停和恢复
  • 精确控制:cutover时机可人工干预,避免意外的表锁定
  • 测试友好:可以在从库上运行,验证迁移结果

Bytebase的对比测试表明,在写密集型场景下,gh-ost对主库的影响显著低于pt-online-schema-change。但gh-ost也有局限:不支持外键、要求MySQL 5.7+、需要Row-Based Replication。

真实案例:成功的迁移是怎么做到的

Stripe的四阶段在线迁移

Stripe处理每年万亿级别的支付交易,其订阅数据的迁移经验被广泛引用。他们采用的是经典的四阶段双写模式,但每个阶段都有精细的设计。

第一阶段:双写启动

在订阅服务中同时写入新旧两个数据存储。为了降低风险,他们采用渐进式策略:先对1%的新订阅启用双写,监控指标稳定后逐步提升到100%。同时,他们使用MapReduce离线处理历史数据,避免直接查询生产数据库。

第二阶段:切换读路径

使用GitHub的Scientist库进行A/B测试——同时从新旧存储读取数据,比对结果是否一致。如果不一致,触发告警但继续使用旧存储的结果,确保不影响线上业务。这种"影子读取"模式让他们在不影响用户的前提下验证新存储的正确性。

第三阶段:切换写路径

这是最复杂的阶段。Stripe的订阅逻辑涉及数千行代码,分布在多个微服务中。他们采用增量策略:每次只修改一小部分代码路径,确保新旧存储保持同步。Scientist继续运行,检测任何不一致。

第四阶段:清理旧数据

确认所有读写路径都切换到新存储后,停止向旧存储写入。然后异步清理旧数据,避免一次性大规模删除对数据库的冲击。

整个过程持续数月,但实现了零停机迁移。

Facebook的数十PB数据迁移

2011年,Facebook完成了当时最大规模的数据迁移——将数十PB的Hadoop数据从一个数据中心迁移到另一个。

他们面临的核心挑战是:数据规模巨大、7x24小时不间断访问、不能有显著停机。解决方案是"复制优先"策略:

  1. 使用DistCp进行批量数据复制(Hadoop自带的分布式复制工具)
  2. 开发自定义的审计日志插件,记录所有文件变更
  3. 实时复制系统持续同步增量变更,保持目标集群最多落后源集群几小时
  4. 切换时短暂停止JobTracker,等待复制追赶完成,然后修改DNS切换

关键经验是:快速复制系统是应对问题的安全网。如果发现数据损坏,可以快速重新复制而不会影响整体进度。同时,这套复制系统意外地成为了容灾方案——证明了可以在两个数据中心之间保持多PB数据的准实时同步。

失败案例:前车之鉴

Target Canada:数据不一致引发的商业灾难

Target Canada的失败是数据迁移领域的经典警示案例。他们在进入加拿大市场时,急于在短时间内完成所有门店的开业,数据迁移被压缩到极限时间。

问题从一开始就存在:

数据质量被忽视:迁移的数据中包含大量错误、重复和不完整的记录。这些垃圾数据直接被搬运到新系统,导致库存记录与实际商品对不上。

人员培训不足:员工不知道如何识别和处理数据问题,错误在系统中不断传播。

进度压倒质量:开业的截止日期被优先考虑,数据验证步骤被跳过。

结果是灾难性的:顾客在门店找不到系统显示有货的商品,仓库里堆满了系统显示缺货的商品,结账时价格与标签不符成为常态。最终,Target关闭了所有133家加拿大门店,损失超过20亿加元。

Queensland Health:薪酬系统的崩溃

2010年,澳大利亚昆士兰州卫生署启动薪酬系统升级项目。新系统上线第一个月就产生了超过35,000个薪酬错误——有人被多付,有人被少付,有人根本没拿到工资。

失败的根因:

  • 薪酬数据的复杂性被严重低估
  • 利益相关者未充分参与需求定义
  • 供应商提供的方案不匹配实际需求

项目最终花费超过12亿澳元,成为澳大利亚最臭名昭著的IT灾难之一。

避免成为83%:迁移方法论

综合成功与失败的经验,数据迁移的核心方法论可以总结为以下几点。

明确谁是"主库"

在任何时刻,必须明确哪个数据源是"Source of Truth"。双写期间最危险的状态是:两个数据源都不完整、都不一致。Stripe的做法值得借鉴:在切换读路径之前,旧存储始终是主库;只有在新存储被充分验证后,才切换主库角色。

设计三个层次的回滚方案

pgroll的技术文档提出了数据库回滚策略的三个层次:

Level 0:无回滚策略——任何失败都需要临时修复,风险极高。

Level 1:手动回滚脚本——预先编写回滚SQL,但往往未经测试,失败时可能无法执行。

Level 2:Expand-Contract模式——新增字段而非修改旧字段,保持旧Schema可用直到新Schema完全验证。这是最安全的迁移模式。

Expand-Contract的核心是:

  1. 新增字段/表,不修改现有结构
  2. 双写到新旧两处
  3. 切换读路径到新位置
  4. 停止写旧位置
  5. 清理旧字段/表

任何一步出问题,都可以回退到上一步的状态。

测试迁移本身

大多数团队会测试业务逻辑,但很少有人测试迁移脚本。数据迁移的测试应该包括:

  • 用生产数据的匿名化副本进行完整演练
  • 验证迁移后数据的行数、校验和、业务指标
  • 模拟各种失败场景(网络中断、数据库崩溃、进程被杀)
  • 测试回滚流程是否真的能恢复到迁移前状态

时间估算要翻倍

数据迁移几乎总是比预期更耗时。Bloor Research的数据显示,超过80%的迁移项目延期。一个经验法则是:把初始估算乘以2,然后再加上缓冲。

Facebook迁移数十PB数据时,专门开发了新的复制系统来处理规模问题。Stripe的订阅迁移规划了数月,执行了数月。没有"快速迁移"这回事。

不要迁移垃圾

Target Canada的教训:迁移是清理数据的最佳时机。在迁移前进行数据质量评估,识别并修复错误数据、重复数据、过期数据。否则,你只是在把垃圾从一个地方搬到另一个地方。

结语

数据迁移失败率居高不下,根本原因不在于技术难度——gh-ost、CDC、Outbox等工具已经相当成熟——而在于对复杂性的系统性低估。迁移涉及数据一致性、分布式事务、性能优化、组织协调等多个维度,任何一个环节的疏忽都可能导致灾难性后果。

Stripe的经验表明,成功的迁移不需要什么万能灵药:渐进式策略、持续验证、明确的主库角色、可回滚的每一步,这些看似保守的做法恰恰是最可靠的路径。而Target Canada和Queensland Health的失败则提醒我们:急于求成、忽视数据质量、跳过测试,只会让迁移成为下一个商业灾难。

数据迁移的本质,是在不停机的系统中更换地基。这需要工程师既要有对分布式系统的深刻理解,也要有对风险的充分敬畏。83%的失败率不是宿命,而是对准备不足者的惩罚。


参考资料

  1. Gartner Research on Data Migration Failure Rates
  2. Bloor Research Data Migration Customer Survey (2007, 2011)
  3. Confluent: “Understanding the Dual-Write Problem and Its Solutions”
  4. Stripe Engineering Blog: “Online migrations at scale”
  5. Facebook Engineering: “Moving an Elephant: Large Scale Hadoop Data Migration at Facebook”
  6. GitHub: gh-ost - Online Schema Migration Tool for MySQL
  7. Percona: pt-online-schema-change Documentation
  8. Bytebase: “gh-ost vs pt-online-schema-change in 2025”
  9. Martin Fowler: “Patterns of Distributed Systems - Two-Phase Commit”
  10. pgroll: “The three levels of a database rollback strategy”
  11. Microservices.io: “Pattern: Saga”
  12. Google Cloud: “Online Database Migration by Dual-Write: This is not for Everyone”
  13. Hopp Tech: “Failed Data Migration Projects and the Lessons Learned”