分库分表实战:如何用双写+数据校验实现零停机迁移(附完整代码示例)
分库分表实战:如何用双写+数据校验实现零停机迁移(附完整代码示例)
最近在重构一个用户量激增的老系统,数据库单表已经突破了五千万行,查询响应时间开始变得不稳定。团队决定引入分库分表架构,但最头疼的问题来了:如何在不影响线上业务、不停机的情况下,把海量数据从单库单表平滑迁移到新的分片集群?这可不是简单的数据拷贝,而是要保证迁移过程中每一笔订单、每一个用户状态都不能出错。
经过几轮方案对比和实际踩坑,我们最终选择了双写配合数据校验的迁移路径。这套方案听起来复杂,但拆解开来其实每一步都有明确的工程化做法。今天我就把自己在实战中积累的完整思路、关键代码和那些容易忽略的细节分享出来,希望能帮你绕过我们曾经掉进去的那些“坑”。
1. 迁移方案全景与核心挑战
在动手写代码之前,我们必须先想清楚整个迁移过程的宏观视图和可能遇到的“暗礁”。零停机迁移的核心目标,是在用户无感知的情况下,完成数据存储架构的升级。这不仅仅是技术问题,更是一个涉及数据一致性、业务连续性和风险控制的系统工程。
为什么双写是更优解? 对比传统的停机迁移,双写方案最大的优势在于“在线”和“可逆”。整个迁移周期内,旧库始终承担着线上流量,新库则像一个影子系统,逐步追赶并最终与旧库保持同步。即使中途出现问题,我们也可以随时回退,只需关闭对新库的写入即可,业务影响面极小。
迁移过程中需要应对的几个核心挑战:
- 写放大与性能损耗:所有写操作都需要同时落盘两次,对应用性能和数据库连接池都会带来额外压力。
- 数据覆盖与冲突:迁移工具在后台搬运历史数据,而线上业务同时在双写新旧库,如何避免老数据覆盖新数据?
- 一致性校验的精度与效率:如何快速、准确地对比两个巨大数据集的一致性?全表扫描对比显然不现实。
- 切换时机的判断:数据“完全一致”是一个理想状态,在持续写入的系统中,我们如何定义并确认这个时刻,从而安全切换流量?
为了更清晰地规划各阶段任务,我们可以用下面这个表格来梳理迁移的生命周期和每个阶段的核心工作与风险点:
| 迁移阶段 | 主要任务 | 数据流向 | 业务读写源 | 核心风险与应对 |
|---|---|---|---|---|
| 第一阶段:双写开启 | 1. 应用代码改造,支持双写逻辑。 2. 部署新分库分表集群。 3. 上线双写代码,开启对新库的同步写入。 | 写:旧库 + 新库 读:仅旧库 | 旧库 | 双写代码BUG导致旧库写入失败;新库写入性能拖慢整体响应。需灰度发布并严密监控。 |
| 第二阶段:历史数据迁移 | 1. 开发并运行数据迁移工具,按策略搬运旧数据至新库。 2. 监控迁移进度与数据一致性。 | 写:旧库 + 新库 读:仅旧库 迁移工具:旧库 -> 新库 | 旧库 | 迁移工具与双写并发导致数据冲突或丢失。需设计幂等和基于时间戳的覆盖策略。 |
| 第三阶段:数据校验与追平 | 1. 运行校验工具,比对新旧库数据差异。 2. 针对差异数据,进行修复或重新迁移。 | 写:旧库 + 新库 读:仅旧库 校验工具:旧库 <-> 新库 | 旧库 | 校验本身消耗大量数据库资源;差异修复可能引入新差异。需限流、分批进行。 |
| 第四阶段:流量切换 | 1. 短暂暂停双写,确保一个静态数据视图。 2. 进行最终一致性校验。 3. 将应用读流量切换至新库。 4. 观察稳定后,下线旧库写入,完成迁移。 | 写:旧库 + 新库 -> 仅新库 读:旧库 -> 新库 | 旧库 -> 新库 | 切换瞬间可能出现数据不一致或查询异常。需在低峰期操作,并准备快速回滚方案。 |
这个全景图是我们后续所有技术讨论的基石。接下来,我们就深入每个阶段,看看具体怎么落地。
2. 双写逻辑的设计与实现:避免数据覆盖的陷阱
双写,顾名思义就是在业务代码中,对一次数据变更操作,同时向旧库和新库发起写入。听起来简单,但魔鬼藏在细节里。一个粗糙的双写实现,很可能导致数据错乱。
我们的核心设计原则是:以旧库为唯一可信源,新库的写入必须保证幂等,且绝不能以旧数据覆盖新数据。
2.1 双写代码的嵌入模式
我们不在每一个DAO(数据访问对象)方法里手动写两遍SQL,那会使得代码难以维护。更优雅的方式是利用框架的拦截机制,例如在MyBatis中,我们可以通过自定义插件(Interceptor)来统一处理。
下面是一个简化版的双写拦截器核心逻辑。它会在每次执行update或insert操作时,自动并行执行一次针对新库的相同操作。
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class DualWriteInterceptor implements Interceptor {
private SqlSessionTemplate newDbSqlSessionTemplate; // 新库的SqlSession
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 1. 先执行原方法(写旧库)
Object result = invocation.proceed();
int oldDbAffectedRows = (int) result;
// 2. 判断是否是写操作,且需要双写的表
if (isDualWriteRequired(ms)) {
// 3. 异步或同步写新库(根据业务容忍度决定)
writeToNewDatabase(ms, parameter);
}
return oldDbAffectedRows; // 始终返回旧库的影响行数
}
private void writeToNewDatabase(MappedStatement ms, Object parameter) {
try {
// 关键:这里需要根据分片规则,重新计算SQL应该在哪个新库、哪张新表执行
// 例如,根据parameter中的userId,计算targetDataSourceKey和actualTableName
ShardContext context = calculateShardContext(parameter);
// 动态切换到对应的新库数据源
DynamicDataSource.setDataSourceKey(context.getDataSourceKey());
// 修改MappedStatement中的SQL ID或SQL本身,指向实际的分表名
MappedStatement newMs = createNewMappedStatement(ms, context.getActualTableName());
// 执行写入
newDbSqlSessionTemplate.update(newMs, parameter);
} catch (Exception e) {
// **双写降级策略:新库写入失败,不能影响旧库主流程**
// 记录日志、发送告警,将失败信息放入补偿队列,后续由单独任务重试
log.error("Dual write to new database failed, will retry later.", e);
alarmService.sendAlert(e);
retryQueue.push(new RetryTask(ms, parameter));
} finally {
DynamicDataSource.clearDataSourceKey();
}
}
}
注意:上面的
writeToNewDatabase方法中,异常处理部分至关重要。必须确保新库的写入失败不会回滚或影响旧库的事务。这是双写方案能作为“降级盾牌”的前提。
2.2 解决“数据覆盖”问题的关键:乐观锁与时间戳
在历史数据迁移工具运行的同时,线上业务在持续双写。这会产生一个经典问题:如果迁移工具读取了一条记录,正准备写入新库时,这条记录被业务更新了,怎么办?
我们的策略是,所有表必须有一个标识数据版本的字段,比如update_time(更新时间戳)或version(版本号)。在写入新库时,采用“仅当新库不存在或数据更旧时才更新”的策略。
假设我们使用update_time,那么迁移工具的核心SQL逻辑应该是这样的:
-- 迁移工具执行的SQL逻辑 (伪代码)
INSERT INTO new_db.new_table (id, name, amount, update_time)
SELECT id, name, amount, update_time
FROM old_db.old_table AS old
WHERE old.id = ? -- 按ID分批迁移
ON DUPLICATE KEY UPDATE
-- 仅当旧数据中的update_time更晚(更新)时,才覆盖新库中的数据
name = IF(old.update_time > new_table.update_time, old.name, new_table.name),
amount = IF(old.update_time > new_table.update_time, old.amount, new_table.amount),
update_time = IF(old.update_time > new_table.update_time, old.update_time, new_table.update_time);
这个ON DUPLICATE KEY UPDATE ... IF逻辑确保了数据的最终流向是从“最后修改”的源头流向另一端,避免了用旧版本覆盖新版本。
3. 高效数据迁移工具的设计与性能调优
历史数据迁移工具(常被称为“导数工具”)是体力活,但干得好不好直接决定了整个迁移周期的长短和风险窗口的大小。一个高效的迁移工具需要具备:分批、限流、幂等、可监控、可重试等特性。
3.1 工具的核心架构
我们不推荐用一条巨大的INSERT INTO ... SELECT语句,它可能锁表、产生大事务、难以中断和重试。更推荐以下架构:
控制台(配置、启停)
|
v
调度器 (按主键范围分批)
|
v
数据提取器 (从旧库按批查询)
|
v
数据转换器 (应用分片规则,计算目标库表)
|
v
数据写入器 (写入新库,含冲突处理逻辑)
|
v
进度记录器 & 监控上报
我们用一段Spring Boot + MyBatis的示例代码来展示一个批次任务的处理单元:
@Component
public class DataMigrationTask {
@Autowired
private OldDbMapper oldDbMapper;
@Autowired
private NewDbMapper newDbMapper; // 这是一个能根据分片规则路由的Mapper
/**
* 迁移一个批次的数据
* @param minId 批次起始ID
* @param maxId 批次结束ID
* @param batchSize 每批查询条数
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 不开启事务,避免长事务
public void migrateBatch(Long minId, Long maxId, int batchSize) {
long currentStart = minId;
while (currentStart <= maxId) {
long currentEnd = Math.min(currentStart + batchSize - 1, maxId);
// 1. 从旧库批量查询
List<Order> oldOrders = oldDbMapper.selectByIdRange(currentStart, currentEnd);
if (!CollectionUtils.isEmpty(oldOrders)) {
// 2. 批量写入新库(内部处理分片逻辑和冲突解决)
newDbMapper.batchInsertWithConflictResolve(oldOrders);
}
// 3. 记录进度
log.info("Migrated batch from {} to {}, total {} records.", currentStart, currentEnd, oldOrders.size());
progressRecorder.record(currentEnd);
// 4. 限流,避免拖垮数据库
Thread.sleep(50); // 每批之间短暂休眠
currentStart = currentEnd + 1;
}
}
}
3.2 性能优化技巧
- 批次大小(Batch Size):需要平衡。太小则网络往返开销大;太大则内存占用高,且单批失败影响面大。建议从500-1000开始测试调整。
- 并发度控制:可以启动多个迁移任务,分别处理不同的主键区间,但并发数需要根据数据库的IOPS和CPU负载来调整。
# 例如,用shell脚本启动多个任务处理不同范围 java -jar migrator.jar --range=1-1000000 & java -jar migrator.jar --range=1000001-2000000 & ... - 索引利用:迁移查询的
WHERE条件(如id BETWEEN ? AND ?)必须命中旧表的主键或高效索引,避免全表扫描。 - 无事务或小事务:不要在迁移工具中开启包含整个批次的大事务,这会导致undo log膨胀。可以像上面代码一样不使用事务,或者每处理N条(如100条)提交一次。
4. 数据校验的最佳实践:超越简单的COUNT(*)
当迁移工具跑完后,我们如何确信新旧库的数据完全一致?SELECT COUNT(*)显然不够,因为数量相同不代表内容相同。我们需要一种既能保证高可靠性,又不会对数据库造成巨大压力的校验方法。
4.1 分而治之的校验策略
我们采用分片校验代替全量比对。基本思路是:
- 将表数据按照主键范围分成多个小块(Chunk)。
- 对每一个小块,同时从新旧库查询出来。
- 在应用内存中计算该块数据的“指纹”(如CRC32、MD5或更复杂的哈希),而不是对比每一行数据。
- 比较两个指纹是否一致。
public class DataValidator {
public boolean validateChunk(String tableName, long chunkStartId, long chunkEndId) {
// 1. 从旧库计算指定数据块的指纹
String oldFingerprint = oldDbMapper.calculateFingerprint(tableName, chunkStartId, chunkEndId, "CRC32");
// 2. 从新库计算相同数据块的指纹
// 注意:新库的数据可能分布在多个分表,需要先根据分片规则确定目标表,或使用跨分表查询的视图/中间件
String newFingerprint = newDbMapper.calculateFingerprintForShards(tableName, chunkStartId, chunkEndId, "CRC32");
// 3. 比较
return oldFingerprint.equals(newFingerprint);
}
}
数据库层面的指纹计算函数(以MySQL CRC32为例):
-- 旧库单表计算指纹
SELECT CONCAT(COUNT(*), '-', BIT_XOR(CRC32(CONCAT_WS('|', id, name, amount, update_time))))
FROM old_table
WHERE id BETWEEN ? AND ?;
-- 新库需要聚合所有分表的结果(假设有2个分表)
SELECT CONCAT(SUM(cnt), '-', BIT_XOR(overall_xor))
FROM (
SELECT COUNT(*) as cnt, BIT_XOR(CRC32(CONCAT_WS('|', id, name, amount, update_time))) as overall_xor
FROM new_table_0
WHERE id BETWEEN ? AND ?
UNION ALL
SELECT COUNT(*), BIT_XOR(CRC32(CONCAT_WS('|', id, name, amount, update_time)))
FROM new_table_1
WHERE id BETWEEN ? AND ?
) AS combined;
BIT_XOR(CRC32(...))是一种常用的快速一致性校验方法。COUNT(*)保证了数量一致,BIT_XOR操作保证了在顺序不确定的情况下,所有行的内容哈希值的综合特征一致。如果任何一个数据行有差异,最终结果极大概率会不同。
4.2 校验不一致的处理流程
当发现某个数据块的指纹不一致时,我们需要进一步定位到具体的差异行。这时不能再依赖哈希,需要逐行比对。
- 记录不一致的块:将不一致的
chunkStartId和chunkEndId记录到问题列表。 - 启动精细比对:针对问题块,分别从新旧库取出所有数据,按主键排序后在内存中逐行对比字段。
- 生成修复脚本:将差异结果(是缺失、多余还是内容不同)生成具体的SQL修复脚本(
INSERT、UPDATE或DELETE)。 - 执行修复:在业务低峰期,执行修复脚本。修复逻辑同样需要遵循**“以旧库为准”和“基于时间戳覆盖”**的原则。
- 重新校验:修复完成后,再次对该数据块进行指纹校验,直到通过为止。
提示:校验和修复过程也应该是可暂停、可重试的。并且,在最终切换流量前,建议在业务静止窗口(如凌晨)进行多轮全量校验,以确保数据同步的最终状态。
5. 安全切换流量的操作手册与回滚预案
这是整个迁移过程的“临门一脚”,也是最紧张的时刻。切换的核心是:将应用的读流量从旧库逐步、安全地导向新库。
5.1 切换前的最终检查清单
在决定切换的当天,请对照此清单逐项确认:
- [ ] 数据校验已完成:至少完成两轮全量分块校验,且不一致块数为0。
- [ ] 性能基线已建立:新库集群在模拟压力测试下的QPS、响应时间、连接数等指标正常,并与旧库基线对比无显著劣化。
- [ ] 监控告警已就绪:新库的各项监控(慢查询、错误数、CPU/内存/磁盘)已接入告警平台,且团队熟悉查看方式。
- [ ] 回滚方案已演练:团队明确知道如何快速将读流量切回旧库(通常是修改配置中心的一个开关并重启应用)。
- [ ] 业务低峰期:选择流量最低的时间段(如凌晨2点-4点)进行操作。
- [ ] 相关人员在线:研发、DBA、运维核心人员保持在线,随时响应。
5.2 渐进式切换流程
我们采用“先读后写”的渐进式切换,而非一次性切断。
-
第一步:暂停双写(短暂静止)。通过配置中心下发指令,让所有应用实例暂停对新库的写入(双写逻辑中增加一个开关)。这个动作非常快,目的是为了在切换读流量的瞬间,新旧库的数据是绝对静止的,避免出现“边切边跑”的复杂状态。此暂停时间应控制在分钟级别。
# 配置中心下发 dual-write: enabled: false # 关闭双写 -
第二步:最终一致性快照校验。在双写暂停后,立即对核心表(如用户账户表、订单主表)进行一次快速的
COUNT和关键聚合查询比对,作为切换前的最终确认。 -
第三步:开启读新库的灰度流量。修改应用配置,让一小部分(例如5%)的读请求(可通过用户ID哈希)路由到新库。同时,保持所有写请求和大部分读请求仍在旧库。
// 在数据源路由逻辑中增加灰度开关 if (isReadOperation && isGrayUser(userId)) { return newDbDataSource; // 5%的灰度用户读新库 } else { return oldDbDataSource; // 其他所有流量读旧库 } -
第四步:观察与放大。严密监控这5%流量的业务成功率、响应时间和新库的负载。观察30分钟到1小时。如果一切正常,逐步将读新库的比例提升至20%、50%、100%。每提升一个阶段,都需观察一段时间。
-
第五步:写流量切换与旧库下线。当100%的读流量都稳定在新库上运行至少半天后,进行最终切换:
- 将写流量也完全指向新库(此时双写开关仍关闭,因为旧库不再接收写入了)。
- 停用指向旧库的数据源配置。
- 旧库进入只读归档状态,保留一段时间(如一周)以备不时之需,切勿立即删除。
5.3 必须准备的“红色按钮”——回滚预案
无论准备多充分,都必须有回滚计划。回滚的核心是速度。
- 回滚触发条件:新库出现数据错误、关键业务查询超时、核心指标异常飙升。
- 回滚操作:
- 立即将配置中心的读路由比例调回0%(全部读旧库)。
- 重新开启双写开关(让旧库重新同步在切换期间新库写入的数据,这要求旧库在切换期间未被修改,且新库的写操作有日志可以追溯回放)。
- 排查问题,待修复后重新规划切换。
整个切换过程就像驾驶一架飞机在飞行中更换引擎,双写和灰度切换就是我们最可靠的安全带和备用降落伞。在实际操作中,我们团队按照上述流程,在一个千万级用户的生产系统上,用了两个周末的凌晨窗口,最终平稳完成了迁移。期间因为一个非核心索引缺失导致部分查询变慢,得益于灰度流量,我们第一时间发现并修复,用户完全没有感知。
更多推荐



所有评论(0)