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采用传统的触发器机制:
- 创建一个与原表结构相同的新表(“影子表”)
- 在原表上创建INSERT、UPDATE、DELETE触发器,将变更同步到影子表
- 分批将原表数据复制到影子表
- 通过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的工作流程:
- 连接到从库,读取binlog流
- 创建影子表,分批复制数据
- 异步将binlog中的变更应用到影子表
- 提供交互式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小时不间断访问、不能有显著停机。解决方案是"复制优先"策略:
- 使用DistCp进行批量数据复制(Hadoop自带的分布式复制工具)
- 开发自定义的审计日志插件,记录所有文件变更
- 实时复制系统持续同步增量变更,保持目标集群最多落后源集群几小时
- 切换时短暂停止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的核心是:
- 新增字段/表,不修改现有结构
- 双写到新旧两处
- 切换读路径到新位置
- 停止写旧位置
- 清理旧字段/表
任何一步出问题,都可以回退到上一步的状态。
测试迁移本身
大多数团队会测试业务逻辑,但很少有人测试迁移脚本。数据迁移的测试应该包括:
- 用生产数据的匿名化副本进行完整演练
- 验证迁移后数据的行数、校验和、业务指标
- 模拟各种失败场景(网络中断、数据库崩溃、进程被杀)
- 测试回滚流程是否真的能恢复到迁移前状态
时间估算要翻倍
数据迁移几乎总是比预期更耗时。Bloor Research的数据显示,超过80%的迁移项目延期。一个经验法则是:把初始估算乘以2,然后再加上缓冲。
Facebook迁移数十PB数据时,专门开发了新的复制系统来处理规模问题。Stripe的订阅迁移规划了数月,执行了数月。没有"快速迁移"这回事。
不要迁移垃圾
Target Canada的教训:迁移是清理数据的最佳时机。在迁移前进行数据质量评估,识别并修复错误数据、重复数据、过期数据。否则,你只是在把垃圾从一个地方搬到另一个地方。
结语
数据迁移失败率居高不下,根本原因不在于技术难度——gh-ost、CDC、Outbox等工具已经相当成熟——而在于对复杂性的系统性低估。迁移涉及数据一致性、分布式事务、性能优化、组织协调等多个维度,任何一个环节的疏忽都可能导致灾难性后果。
Stripe的经验表明,成功的迁移不需要什么万能灵药:渐进式策略、持续验证、明确的主库角色、可回滚的每一步,这些看似保守的做法恰恰是最可靠的路径。而Target Canada和Queensland Health的失败则提醒我们:急于求成、忽视数据质量、跳过测试,只会让迁移成为下一个商业灾难。
数据迁移的本质,是在不停机的系统中更换地基。这需要工程师既要有对分布式系统的深刻理解,也要有对风险的充分敬畏。83%的失败率不是宿命,而是对准备不足者的惩罚。
参考资料
- Gartner Research on Data Migration Failure Rates
- Bloor Research Data Migration Customer Survey (2007, 2011)
- Confluent: “Understanding the Dual-Write Problem and Its Solutions”
- Stripe Engineering Blog: “Online migrations at scale”
- Facebook Engineering: “Moving an Elephant: Large Scale Hadoop Data Migration at Facebook”
- GitHub: gh-ost - Online Schema Migration Tool for MySQL
- Percona: pt-online-schema-change Documentation
- Bytebase: “gh-ost vs pt-online-schema-change in 2025”
- Martin Fowler: “Patterns of Distributed Systems - Two-Phase Commit”
- pgroll: “The three levels of a database rollback strategy”
- Microservices.io: “Pattern: Saga”
- Google Cloud: “Online Database Migration by Dual-Write: This is not for Everyone”
- Hopp Tech: “Failed Data Migration Projects and the Lessons Learned”