分库分表后,怎么设计,可以降低数据迁移的难度?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 架构前瞻性:面试官想知道你是不是有 “为未来设计” 的意识。一次分到位 vs 不断扩容,差别就在这里。

  2. 分片键选择的功底:分片键选错,后面所有迁移都是灾难。这是考察你对业务的理解深度。

  3. 数据迁移的工程经验:双写、灰度、对账、回滚,这套组合拳是真刀真枪干过的才能讲清楚。

核心答案

降低迁移难度,我从两个阶段来回答:

设计阶段(治本)

设计原则 作用
一次性分够(如 16 库 × 16 表 = 256 表) 避免二次扩容
选不易变的字段作为分片键(如 user_id 避免分片键变化导致搬迁
路由规则用 “虚拟槽” 或一致性哈希 扩容时只迁部分数据
表结构和分片键解耦 便于后续调整路由

迁移阶段(治标)

迁移手段 作用
全量 + 增量同步(Canal/DataX) 不停服迁移
双写 + 对账 数据一致性保障
灰度切流(5% → 50% → 100%) 风险可控
完整回滚预案 出问题能退回

下面挨个展开。

深度解析

一、设计阶段:分片键和分片规则是关键

1. 分片键选不好,迁移就是地狱

我见过一个反面案例:某项目用 phone 字段做分片键(按手机号尾号分表),结果后来业务要支持换绑手机号,一个用户改一次手机号,数据要在两个表之间搬一次,整个人都不好了。

正确做法:选业务上几乎不会变的字段。比如 user_idorder_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 天,没问题再继续。

灰度切流
灰度切流

面试高频追问

  1. 追问一:双写过程中如果老库写成功,新库写失败怎么办?

    这是最常见的问题。新库失败不影响主流程,但要把失败记录落到补偿表,定时任务去重试。如果一直失败,对账脚本会发现差异并报警。

  2. 追问二:双写期间数据不一致怎么发现?

    跑对账脚本。可以按时间维度(比如每 5 分钟一次)批量比对,发现不一致就修复并报警。对账的粒度要根据业务定,订单类数据必须强一致,日志类数据可以容忍少量差异。

  3. 追问三:为什么不直接停机迁移?

    停机迁移简单粗暴,但影响业务。互联网公司基本都是 7×24 小时服务,停机一两小时业务损失可能上百万。所以双写不停机方案才是主流。

  4. 追问四:分片键选错了,能补救吗?

    能,但代价很大。基本就是双写迁移重新走一遍:新建一套分片规则正确的库,双写对账,切流下线。所以分片键一定要在前期想清楚

常见面试变体

  • “分库分表后如何平滑扩容?”
  • “如何不停机做数据迁移?”
  • “双写方案中如何保证数据一致性?”
  • “ShardingSphere 如何实现分片路由?”

记忆口诀

设计阶段四件套:一次分够、键选稳、虚拟槽、留余地。

迁移阶段七步走:全量 → 增量 → 双写 → 对账 → 灰度 → 观察 → 下线。

总结

降低分库分表的迁移难度,核心就两句话:设计阶段一次分够、分片键选稳、用虚拟槽留扩容余地;迁移阶段坚持双写 + 对账 + 灰度切流,绝不停机一刀切。把这些讲出来,面试官会知道你是真踩过坑的。