主题
分库分表扩容实战:双写、数据迁移与不停机切流
本文是分布式系统系列的第 24 篇,侧重工程实践。前置参考:31. 数据分片与一致性哈希、04. 一致性哈希。 适合 P7/P8 面试场景:怎么设计一个不停机扩容方案。
分库分表之后,扩容才是真挑战
"分库分表"四个字听起来就是建表时设个分片键、配个 ShardingSphere 完事。但业务跑半年后,数据量翻了三倍,原来 4 个库扛不住了——这时候的扩容才是真正的工程难题。
一致性哈希在缓存层挺好用,MySQL 分库分表场景下却不太顶用。分库分表用的不是 key 映射到虚拟节点的模式,而是硬编码的库表路由规则。比如 order_id % 4 决定去哪个库,扩容到 8 个库后变成 % 8,绝大多数已有数据的路由结果都变了——这不是"迁一部分数据"的问题,是几乎全量数据都要搬。
三种扩容思路,各有各的代价
双倍扩容:取模公式的 trick
把 4 库扩成 8 库,取模公式从 % 4 变成 % 8。但有个数学规律可以利用:hash(key) % 8 的结果要么和 % 4 一样,要么等于 % 4 + 4。
比如 hash("order_100") % 4 = 2,% 8 的结果要么是 2 要么是 6。这意味着每个旧库的数据只需要拆成两半,一半留在原库,一半迁到新库,不需要跨库迁移。
这个 trick 对 n → 2n 的扩容有效,但跳着扩就不行(4→12 没法复用)。而且数据库层面"拆成两半"不是读一半数据贴标签就能做的——你得按分片键范围或 hash 值把数据筛出来,耗时长、IO 重。
双写迁移:最稳妥但最复杂
生产环境用得最多的方案:新旧两套分片同时接受写入,等数据一致后再切流。
状态机大概是这样的:
text
第一阶段:双写 + 全量迁移
┌──────────────┐ ┌──────────────┐
│ 应用层 │ │ │
│ 写旧库 │ │ 写新库 │
│ 读旧库 │ │ (异步开始) │
└──────┬───────┘ └──────────────┘
│ ▲
│ ┌────────┴────────┐
│ │ 全量迁移进程 │
│ │ SELECT 旧库 │
│ │ → INSERT 新库 │
│ └─────────────────┘
│
▼
┌──────────────┐
│ 旧分片库 │
└──────────────┘
第二阶段:增量追平 + 校验
┌──────────────┐ ┌──────────────┐
│ 应用层 │ │ │
│ 写旧库 │ │ 写新库 │
│ 读旧库 │ │ (继续写入) │
└──────┬───────┘ └──────────────┘
│ │
└────────┬────────┘
▼
┌──────────────────┐
│ 增量校验进程 │
│ checksum 比对 │
│ + 修复差异行 │
└──────────────────┘
第三阶段:切读 + 灰度
┌──────────────┐ ┌──────────────┐
│ 应用层 │ │ │
│ 写双库 │ │ 读新库 │
│ (灰度 1%) │ │ (灰度 1%) │
└──────────────┘ └──────────────┘
第四阶段:摘旧写
┌──────────────┐ ┌──────────────┐
│ 应用层 │ │ │
│ 写新库 │ │ 读新库 │
│ (全量) │ │ (全量) │
└──────────────┘ └──────────────┘每个阶段怎么切、切多少、出问题怎么回滚,是这套方案的核心设计。
binlog 订阅迁移:降低对应用层的侵入
双写需要在应用层改代码——每条写操作都要多写一份。如果应用代码不好改(比如第三方系统),或者不想在业务代码里掺这种逻辑,可以用 binlog 订阅。
思路:消费旧库的 binlog,解析成 SQL 然后回放到新库。Canal、Otter、DRC 都是干这个的。
应用层无感知,代价是:
- binlog 和 SQL 的映射不是 1:1。一条
INSERT INTO ... VALUES (...)可能刚好对应新库的某个分片,但UPDATE ... WHERE如果没带分片键,要在新库全分片扫一遍。 - 延迟不可控。binlog 消费端如果挂了,新库的数据会落后,切流窗口期拉长。
- DDL 同步是噩梦。旧库加字段 → binlog 里有 DDL → 新库也得加,但分片后的表结构可能不完全一样。
迁移期间的一致性问题
双写方案最核心的坑是:应用层双写不能保证原子性。写旧库成功、写新库失败,两边数据就对不上了。
解决方案是"校验 + 补偿":
sql
-- 阶段一:全量迁移对每条记录做 checksum
SELECT id, CRC32(CONCAT(col1, col2, ...)) AS checksum
FROM old_order_0;
-- 阶段二:增量校验,比对每分钟的写入
-- 新库侧执行同样的行
SELECT id, CRC32(CONCAT(col1, col2, ...)) AS checksum
FROM new_order_0;
-- 发现差异 → 以旧库为准修复新库校验是个工程活。全量校验通常用 CRC32 或 MD5 对整行做 hash,比对两边。但千万级数据量下,全量校验跑几个小时很正常,这期间增量写入还在继续。所以校验必须是分段进行的:先比对旧数据,再比对增量窗口。
更实用的做法是:定时跑增量校验任务,只校验最近一段时间(比如 5 分钟)内发生过变更的数据。通过业务字段(update_time)或 binlog 位点来圈定范围。
灰度切流:从 1% 到 100% 的决策树
数据追平到可接受程度后,开始切读流量。不是啪的一下全切,而是按用户维度灰度:
yaml
# 灰度路由配置
gray_rules:
- uid_mod: 0 # uid 尾号为 0
read_source: new # 读新库
write_source: both # 写双库
- uid_mod: 1-19 # uid 尾号 1-19
read_source: old # 读旧库
write_source: both # 写双库
- uid_mod: 20-99 # 其余
read_source: old
write_source: old灰度比例递增:1% → 5% → 20% → 50% → 100%。每个阶段观察多久?看业务容忍度,一般至少观察 1-2 个业务高峰周期。
灰度的回滚要跟灰度一样快。如果 20% 切流后发现问题,路由配置回滚到 5% 甚至 1%,数据层面不需要回滚——因为双写一直在跑,新库的数据是完整的,只是读流量切多切少的问题。
什么时候该摘旧写
灰度切读 100% 后,旧库还在接收写入。这一步是安全冗余,但也意味着维护成本翻倍(两套存储都要管)。
摘旧写的时机:
- 新库持续运行 1-2 周,无异常报警
- 数据一致性校验连续 3 天零差异
- 旧库的写入延迟监控稳定(没有因为写双库导致的性能退化)
摘的时候,摘的是"写旧库",不是"删旧库"。旧库保留只读一段时间(比如 1 个月),作为最后的回滚选项。
与 ShardingSphere 弹性扩容的关系
ShardingSphere 5.x 的弹性扩容(scaling)内置了这套逻辑:数据搬迁、binlog 同步、一致性校验。但它简化了灰度切流部分——默认是"搬完数据后一次性切",对延迟敏感的业务不够友好。
如果自己实现的方案比 ShardingSphere 的 scaling 更灵活,无非是两点:
- 灰度逻辑是自己的:路由规则放在配置中心,实时刷新,不需要重启应用
- 校验粒度更细:按分片键范围拆成小任务,逐个校验,并行执行,随时可观察进度
总结
分库分表扩容,80% 的难度不在技术方案,在可观测性和回滚能力。方案好不好,不在能不能跑通,在跑到一半断电了怎么恢复、灰度到 50% 发现有问题怎么回退、校验发现数据不一致怎么修复。
面试问到这个话题,把迁移时序状态机、灰度比例递增策略、校验补偿机制讲清楚,比堆方案名称更有用。
参考
- ShardingSphere Scaling 源码分析
- Canal 原理:binlog 订阅与解析
- Otter 双向同步与一致性保证
- 《数据密集型应用系统设计》第 6 章:分区与再平衡