分库分表的路由策略 / 扩容方案

一句话速记

分库分表的路由策略主要有:Hash 取模(均匀分布,但扩容需要迁移数据)、范围分片(便于范围查询,但可能冷热不均)、一致性 Hash(扩容只迁移少量数据,但实现复杂)。扩容的核心挑战是数据迁移不停服——常用方案是双写 + 增量迁移:新老两套路由并行,先写新库,然后逐步迁移历史数据,验证一致后切流。

关键细节

1)路由策略对比

Hash 取模

分片键:user_id
表数量:16 张表

路由规则:table_index = user_id % 16

优点:数据均匀分布,简单实现
缺点:
  - 扩容时(16→32)所有数据需要重新路由(user_id % 32 和 % 16 不同)
  - 范围查询需要扫描所有分片(WHERE user_id BETWEEN 1 AND 100)

范围分片

分片键:user_id
规则:
  user_id 1~100w    → shard_0
  user_id 100w+1~200w → shard_1
  user_id 200w+1~300w → shard_2

优点:便于按 ID 范围查询,扩容时只需加新分片
缺点:
  - 热点问题(新用户都在最新分片,冷分片闲置)
  - 需要维护路由表

一致性 Hash

将分片键映射到 0~2^32 的 Hash 环
每个分片节点在环上占多个位置(虚拟节点)
数据 → 找顺时针最近的节点

优点:
  - 增加节点时只影响相邻数据(迁移量 1/N)
  - 节点故障时只有相邻数据受影响

缺点:
  - 实现复杂,虚拟节点数量需调优
  - 数据均匀性不如 Hash 取模

适用场景:缓存分片(Redis Cluster),不适合需要精确路由的 DB

2)业界常用方案:Hash + 提前预分片

核心思想:一开始就分足够多的逻辑分片(如 1024 张逻辑表),
          逻辑分片→物理库的映射关系可以随扩容调整。

初始:user_id % 1024 → 得到 slot(0~1023)
      slot 0~255   → db_0
      slot 256~511 → db_1
      slot 512~767 → db_2
      slot 768~1023 → db_3

扩容时:只需调整 slot→物理库的映射
  例:slot 512~767 再拆分 → db_2a (512~639), db_2b (640~767)
  迁移量:只需迁移 1/4 的数据

框架支持:ShardingSphere(原 Sharding-JDBC)、MyCat、DBLE

3)不停服扩容方案(最重要)

双写迁移方案(业界标准):

Phase 1:准备
  - 搭建新分片集群(新路由规则)
  - 部署双写逻辑(同时写旧库和新库)
  - 注意:先写旧库,旧库成功后写新库(以旧库为主)

Phase 2:存量数据迁移
  - 后台任务按批次迁移历史数据到新库
  - 每批迁移后,对比新旧库数据一致性
  - 迁移期间双写保证增量数据不丢

Phase 3:追及 & 校验
  - 确认历史数据全部迁移完毕
  - 对比新旧库数据一致性(全量校验或抽样)
  - 确认新库无遗漏

Phase 4:切流
  - 读流量先切到新库(灰度,监控延迟/错误率)
  - 读流量正常后,写流量切到新库(以新库为主)
  - 保留旧库双写再观察 1~2 天

Phase 5:清理
  - 停止写旧库
  - 清空旧库数据(或保留用于回滚)

双写实现要点

// 双写的事务处理(通常不能同时在两个库上开事务,要业务层保证)
public void saveOrder(Order order) {
    // Phase 2 期间:先写旧库
    oldShardDataSource.save(order);
    
    try {
        // 异步写新库(不影响主链路响应时间)
        newShardDataSource.saveAsync(order);
    } catch (Exception e) {
        // 新库写失败不阻断业务(降级),记录日志,后续补偿
        log.error("New shard write failed, order={}", order.getId(), e);
        asyncCompensate(order);
    }
}

4)分库分表的常见问题

跨分片查询(最痛点)

-- ❌ 需要全分片扫描(范围查询 + 无分片键)
SELECT * FROM orders WHERE amount > 100 ORDER BY created_at LIMIT 10;
-- → 查所有分片,各取若干行,应用层合并排序(耗时,结果量大)
 
-- ✅ 解法 1:分片键 + 二级索引(ES/搜索引擎)
-- 把 amount、created_at 等非分片键的查询路由到 ES
-- ES 索引所有 order,返回 id 列表,再按 id 去分片取数据
 
-- ✅ 解法 2:宽表(冗余存储)
-- 把常用查询条件冗余到 order 的搜索表(非 MySQL)

全局 ID(分布式主键)

分库分表后,自增主键不能用(每个分片各自自增,会重复)

方案:
  1. UUID → 无序,索引性能差(页分裂),字符串存储大
  2. 雪花算法(Snowflake):时间戳(41bit) + 机器ID(10bit) + 序列号(12bit)
     → 64bit 长整型,有序,高并发(每毫秒 4096 个 ID)
     → 时钟回拨问题(NTP 同步导致时间倒退)
  3. 号段模式(Leaf-segment):号段服务预分配 ID 段(如 1000~2000),用完再取
     → 无网络延迟问题,高可用
  4. Redis INCR → 高性能,但 Redis 宕机风险(需持久化)

分布式事务(跨分片写)

问题:用户下单(写 user 分片)+ 扣库存(写 product 分片),如何保证原子性?

方案:
  TCC(Try-Confirm-Cancel):业务层补偿事务,侵入性强
  Saga:长事务拆分为多个本地事务 + 补偿操作
  消息最终一致性(最常用):
    - 写本地 DB + 本地消息表(同一事务)
    - 消息异步发送到 MQ
    - 消费端处理(幂等)
    → 最终一致,无需跨分片 XA 事务

延伸追问

  • Q:什么情况下考虑分库分表? → 单表超过 2000w 行(InnoDB B+树深度增加,查询变慢);或单库 TPS 超过瓶颈(通常单实例写 QPS 在 3000~5000)。但分库分表代价极大,先考虑:读写分离、索引优化、冷热数据分离(归档)、时序数据换 ClickHouse/TiDB。
  • Q:ShardingSphere 和 MyCat 怎么选? → ShardingSphere(原 Sharding-JDBC)推荐:client-side 模式,无额外代理节点,Java 应用直接引 jar,性能好,维护活跃;MyCat 是 Proxy 模式,语言无关,但多一层网络跳,运维复杂。新项目推荐 ShardingSphere 的 JDBC 模式或考虑 TiDB(分布式 MySQL 兼容数据库,透明分片)。
  • Q:分库分表后如何做聚合统计? → 不在 MySQL 做。通过 Binlog(Canal + Flink)实时同步到 ClickHouse/Doris/OLAP 系统,聚合统计走 OLAP,MySQL 只处理 OLTP 查询。

我的记法

  • Hash 取模:均匀,扩容全量迁移
  • 范围分片:便于范围查,热点问题
  • 提前预分片 + slot 映射:业界推荐(ShardingSphere 模型)
  • 不停服扩容 = 双写 + 增量迁移 + 数据校验 + 灰度切流
  • 分片键选择:最核心的查询维度(通常是 user_id 或 tenant_id)
  • 跨分片查询 → ES 做二级索引
  • 全局 ID → 雪花算法 或 号段模式
  • 一句话:「分库分表先想好分片键,扩容靠双写不停服迁移」

状态

  • 已背速记
  • 能解释双写扩容的 5 个 Phase
  • 能说跨分片查询的解法