分库分表后,怎么设计,可以降低数据迁移的难度?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
架构前瞻性:面试官想知道你是不是有 “为未来设计” 的意识。一次分到位 vs 不断扩容,差别就在这里。
-
分片键选择的功底:分片键选错,后面所有迁移都是灾难。这是考察你对业务的理解深度。
-
数据迁移的工程经验:双写、灰度、对账、回滚,这套组合拳是真刀真枪干过的才能讲清楚。
核心答案
降低迁移难度,我从两个阶段来回答:
设计阶段(治本):
| 设计原则 | 作用 |
|---|---|
| 一次性分够(如 16 库 × 16 表 = 256 表) | 避免二次扩容 |
选不易变的字段作为分片键(如 user_id) |
避免分片键变化导致搬迁 |
| 路由规则用 “虚拟槽” 或一致性哈希 | 扩容时只迁部分数据 |
| 表结构和分片键解耦 | 便于后续调整路由 |
迁移阶段(治标):
| 迁移手段 | 作用 |
|---|---|
| 全量 + 增量同步(Canal/DataX) | 不停服迁移 |
| 双写 + 对账 | 数据一致性保障 |
| 灰度切流(5% → 50% → 100%) | 风险可控 |
| 完整回滚预案 | 出问题能退回 |
下面挨个展开。
深度解析
一、设计阶段:分片键和分片规则是关键
1. 分片键选不好,迁移就是地狱
我见过一个反面案例:某项目用 phone 字段做分片键(按手机号尾号分表),结果后来业务要支持换绑手机号,一个用户改一次手机号,数据要在两个表之间搬一次,整个人都不好了。
正确做法:选业务上几乎不会变的字段。比如 user_id、order_id 这种主键性质的。
2. 一次性分够 vs 多次扩容
这是最关键的一点。很多人第一次分库分表,舍不得分太多,搞个 4 库 × 4 表 = 16 表,结果一两年后又得扩容,迁移一遍数据。
推荐方案:一次分到位。比如 16 库 × 16 表 = 256 个表,足够撑很多年。表多了不影响性能(每个表小,查询反而快),只是逻辑上多一点。
但要注意:表太多也有成本,比如元数据管理、连接数压力。256 是个比较常见的平衡点。
3. 虚拟槽(Slot)方案
这个思路借鉴自 Redis Cluster。Redis Cluster 有 16384 个 slot,数据先 hash 到 slot,slot 再映射到物理节点。扩容时只需把部分 slot 迁移到新节点,业务无感知。
上图是虚拟槽的核心思路:
- 第一步:业务数据通过 hash 函数映射到固定的 “虚拟槽”,槽的数量是固定的(比如 16384)
- 第二步:每个槽归属一个物理节点
- 第三步:扩容时,只调整 “槽 → 节点” 的映射,业务侧的路由逻辑(hash → slot)完全不变
举个具体例子:原来 16384 个 slot 分布在 3 个节点上,扩容到 4 个节点时,只需把一部分 slot 从老节点挪到新节点,迁移量大约是 1/4。如果直接用 hash(user_id) % N,扩容从 3 到 4 时几乎 100% 的数据都要重 hash,那就是全量迁移了。
ShardingSphere 的 HintShardingStrategy 或者自己实现一套 slot 路由都可以。
二、迁移阶段:双写 + 灰度切流是标准方案
如果设计阶段没做好,或者业务真的涨到必须扩容,那就得上双写迁移了。这是大厂里最常用的不停机迁移方案。
上图的迁移流程分为 7 个阶段:
- 阶段 1:全量同步——把老库的历史数据全量搬到新库,工具常用 DataX、Sqoop 或者自己写的脚本
- 阶段 2:增量同步——全量期间产生的新数据,通过 Canal 监听老库 binlog,实时同步到新库
- 阶段 3:应用双写——改造代码,业务写入时同时写老库和新库(先写老库,再写新库,新库失败只记日志不影响主流程)
- 阶段 4:数据对账——定时跑对账脚本,比对新老库数据一致性
- 阶段 5:灰度切流——按用户 ID 取模,先切 5% 流量到新库,没问题再 50%,最后 100%
- 阶段 6:观察期——保留双写 1-2 周,监控报警
- 阶段 7:下线老库——确认稳定后,停止双写,下线老库
每一步都不能省,尤其对账和回滚预案,是这套方案能扛住事故的关键。
双写的代码示例
双写的伪代码大概长这样:
@Transactional
public void createOrder(Order order) {
// 1. 写老库(主流程)
oldOrderMapper.insert(order);
// 2. 写新库(异步 / 同步都行)
try {
newOrderMapper.insert(order);
} catch (Exception e) {
// 新库失败不影响主流程,记日志后续补偿
log.error("新库写入失败, orderNo={}", order.getOrderNo(), e);
// 落库到补偿表,定时任务重试
compensationMapper.insert(new Compensation(order.getOrderNo(), "NEW_DB_WRITE"));
}
}
几个细节要注意:
- 新库写失败不能影响老库:老库是真实业务数据,新库可以慢慢补
- 必须有补偿机制:失败的操作要能重试
- 读流量切换前,新老库必须完全一致
三、灰度切流的具体做法
切流不能一刀切,要按用户 ID 灰度:
public Order getOrder(String userId, String orderNo) {
// 灰度判定:比如配置中心控制比例
int grayRatio = configService.getGrayRatio(); // 比如 5、50、100
// 用户 ID 取模,决定读新老库
boolean readNew = Math.abs(userId.hashCode()) % 100 < grayRatio;
if (readNew) {
return newOrderMapper.selectByOrderNo(orderNo);
}
return oldOrderMapper.selectByOrderNo(orderNo);
}
灰度比例的递进:5% → 10% → 30% → 50% → 80% → 100%,每一步观察 1-2 天,没问题再继续。
面试高频追问
-
追问一:双写过程中如果老库写成功,新库写失败怎么办?
这是最常见的问题。新库失败不影响主流程,但要把失败记录落到补偿表,定时任务去重试。如果一直失败,对账脚本会发现差异并报警。
-
追问二:双写期间数据不一致怎么发现?
跑对账脚本。可以按时间维度(比如每 5 分钟一次)批量比对,发现不一致就修复并报警。对账的粒度要根据业务定,订单类数据必须强一致,日志类数据可以容忍少量差异。
-
追问三:为什么不直接停机迁移?
停机迁移简单粗暴,但影响业务。互联网公司基本都是 7×24 小时服务,停机一两小时业务损失可能上百万。所以双写不停机方案才是主流。
-
追问四:分片键选错了,能补救吗?
能,但代价很大。基本就是双写迁移重新走一遍:新建一套分片规则正确的库,双写对账,切流下线。所以分片键一定要在前期想清楚。
常见面试变体
- “分库分表后如何平滑扩容?”
- “如何不停机做数据迁移?”
- “双写方案中如何保证数据一致性?”
- “ShardingSphere 如何实现分片路由?”
记忆口诀
设计阶段四件套:一次分够、键选稳、虚拟槽、留余地。
迁移阶段七步走:全量 → 增量 → 双写 → 对账 → 灰度 → 观察 → 下线。
总结
降低分库分表的迁移难度,核心就两句话:设计阶段一次分够、分片键选稳、用虚拟槽留扩容余地;迁移阶段坚持双写 + 对账 + 灰度切流,绝不停机一刀切。把这些讲出来,面试官会知道你是真踩过坑的。
