当单库数据量超过千万级、QPS 超过数千时,单节点数据库会成为系统瓶颈。分库分表(Sharding)是解决单机数据库性能与容量限制的核心方案。本文系统讲解拆分策略、分片键设计、中间件选型和扩容迁移方案。
1. 为什么要分库分表
1.1 单库性能天花板
| 指标 | MySQL 单节点上限 | 实际问题 |
|---|
| 连接数 | ~2000 | 连接数耗尽 |
| 写入 QPS | ~3000/s | 写入瓶颈 |
| 数据量 | ~5000万行 | 查询变慢 |
| 存储容量 | ~2TB | 磁盘上限 |
| 索引大小 | 内存无法缓存 | 频繁 IO |
1.2 分库 vs 分表
┌───────────────────────────────────────────────────────────────┐
│ 垂直拆分(Vertical Sharding) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ user_db │ │ order_db │ │
│ │ ├── users │ │ ├── orders │ │
│ │ ├── profiles│ │ ├── items │ │
│ │ └── settings│ │ └── payments│ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 特点:按业务拆分,不同表放不同库 │
│ 优点:降低单库压力,业务解耦 │
│ 缺点:无法解决单表大数据量问题,跨库 JOIN 困难 │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ 水平拆分(Horizontal Sharding) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ order_db_0 │ │ order_db_1 │ │
│ │ ├── orders │ │ ├── orders │ │
│ │ └── items │ │ └── items │ │
│ │ user_id%2=0 │ │ user_id%2=1 │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 特点:同一表按规则拆分到多个库 │
│ 优点:解决单表容量和性能问题 │
│ 缺点:扩容复杂、分布式事务、全局查询困难 │
└───────────────────────────────────────────────────────────────┘
2. 分片策略
2.1 常见分片算法
| 算法 | 公式 | 优点 | 缺点 | 适用场景 |
|---|
| 取模(Modulo) | shard = key % N | 均匀分布、简单 | 扩容需迁移全部数据 | 固定分片数或哈希桶 |
| 范围(Range) | shard = key / 1000000 | 扩容简单(追加库)、按时间归档 | 热点严重(最新库压力大) | 按时间/ID 范围 |
| 哈希范围 | 一致性哈希 / 虚拟桶 | 扩容平滑、负载均衡 | 实现复杂 | 动态扩容场景 |
| 标签(Tag) | 按业务标签路由 | 灵活、可控 | 需维护映射表 | 多租户/地域分片 |
| 复合分片 | 先范围后取模 | 兼顾两者优点 | 逻辑复杂 | 超大规模系统 |
2.2 取模分片示例
// 用户ID取模分2库4表
public class ShardingStrategy {
public static ShardInfo route(long userId) {
int dbIndex = (int) (userId % 2); // 0 或 1
int tableIndex = (int) ((userId / 2) % 4); // 0,1,2,3
return new ShardInfo(dbIndex, tableIndex);
}
}
// user_id = 10001 → db_1, orders_1
// user_id = 10002 → db_0, orders_2
2.3 范围分片示例
用户ID范围: 库索引:
0 ~ 5,000,000 → db_0
5,000,001 ~ 10,000,000 → db_1
10,000,001 ~ 15,000,000 → db_2
...
优点:扩容只需增加新库,老数据无需迁移
缺点:新注册用户都落在新库,热点倾斜
2.4 混合策略:Range + Modulo
第一层:按时间/范围分库(解决扩容)
第二层:按取模分表(解决单库热点)
示例(订单表):
- 2024年 → db_2024
- 订单表分为 16 张:orders_00 ~ orders_15(按 order_id % 16)
- 2025年 → db_2025(新库)
- 同样有 orders_00 ~ orders_15
3. 分片键(Sharding Key)设计
3.1 分片键选取原则
| 原则 | 说明 | 错误示例 | 正确示例 |
|---|
| 查询频繁 | 最常用 WHERE 条件 | 按 create_time(无条件查询) | 按 user_id |
| 数据均匀 | 避免热点 | 按 status(inactive 极少) | 按 user_id 哈希 |
| 避免跨片 | 核心查询单分片解决 | 按 user_id 查 orders 却按 order_id 分片 | 订单按 user_id 分片 |
| 不可变 | 分片键修改代价大 | 按 email(可能变更) | 按 user_id |
3.2 典型场景的分片键
| 业务场景 | 分片键 | 原因 |
|---|
| 电商订单 | user_id 或 buyer_id | 用户查询自己的订单是最高频操作 |
| 社交消息 | sender_id 或 receiver_id | 按会话分片,双向冗余或聚合 |
| 日志/O2O | created_time | 按时间归档、清理 |
| 多租户 SaaS | tenant_id | 数据隔离 + 按租户路由 |
4. Sharding-JDBC 实战
4.1 引入依赖
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.5.0</version>
</dependency>
4.2 YAML 配置
# application-sharding.yml
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://db0:3306/order_db
username: root
password: xxx
ds_1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://db1:3306/order_db
username: root
password: xxx
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..15}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: t_order_inline
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
db_inline:
type: INLINE
props:
algorithm-expression: ds_${user_id % 2}
t_order_inline:
type: INLINE
props:
algorithm-expression: t_order_${(user_id % 32).intdiv(2)}
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 0
4.3 Java 代码示例
@Configuration
public class ShardingConfig {
@Bean
public DataSource dataSource() throws SQLException {
return YamlShardingSphereDataSourceFactory.createDataSource(
getClass().getResource("/application-sharding.yml")
);
}
}
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepo;
// 分片键自动路由 - 无需关注底层分片
public Order getOrder(Long userId, Long orderId) {
return orderRepo.findByUserIdAndOrderId(userId, orderId);
}
public List<Order> getUserOrders(Long userId) {
// WHERE user_id = ? 自动路由到单个分片
return orderRepo.findByUserId(userId);
}
}
5. 全局 ID 生成
5.1 方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用 |
|---|
| 自增 ID + 步长 | 库A: 1,3,5… 库B: 2,4,6… | 简单 | 扩容困难、ID 不连续 | 小规模 |
| 号段模式 | 批量获取 ID 区间 | 高性能、趋势递增 | 依赖数据库 | 大多数场景 |
| 雪花算法 | 时间戳+机器ID+序列号 | 去中心化、高性能 | 时钟回拨问题 | 高并发 |
| Leaf 号段+Snowflake | 美团方案 | 兼顾两者 | 依赖 ZooKeeper | 超大规模 |
5.2 雪花算法(Snowflake)
┌─────────────────────────────────────────────────────────┐
│ Snowflake ID (64 bits) │
├──────┬──────────────┬────────────┬──────────────────────┤
│ Sign │ Timestamp │ Worker ID │ Sequence │
│ 1 bit│ 41 bits │ 10 bits │ 12 bits │
├──────┼──────────────┼────────────┼──────────────────────┤
│ 0 │ 毫秒时间戳 │ 机器标识 │ 同毫秒内的序列号 │
└──────┴──────────────┴────────────┴──────────────────────┘
Timestamp: 相对于自定义 Epoch 的毫秒偏移(可用约69年)
Worker ID: 支持 1024 个节点
Sequence: 每毫秒可生成 4096 个 ID
public class SnowflakeIdWorker {
private final long workerId;
private final long epoch = 1704067200000L; // 2024-01-01
private long sequence = 0;
private long lastTimestamp = -1;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) timestamp = tilNextMillis();
} else {
sequence = 0;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << 22) | (workerId << 12) | sequence;
}
}
5.3 Leaf 号段模式
-- 号段表
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) PRIMARY KEY,
max_id BIGINT NOT NULL DEFAULT 0,
step INT NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 获取号段(UPDATE + SELECT for Update 保证原子性)
BEGIN;
UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM leaf_alloc WHERE biz_tag = 'order';
COMMIT;
-- 应用获得 [max_id-step+1, max_id] 区间,在内存中分配
6. 扩容与数据迁移
6.1 一致性哈希平滑扩容
初始:4 个节点(虚拟节点数 v=150)
扩容为 6 个节点:
数据迁移量 ≈ 1/4 - 1/6 = 1/12 ≈ 8.3%
优势:只迁移相邻节点的数据,而非全量
6.2 双写迁移方案(不停机)
阶段1:双写
写请求 → 老库 + 新库(异步)
读请求 → 老库
阶段2:历史数据迁移
使用工具(如 DataX/Canal)将老库历史数据灌入新库
校验数据一致性
阶段3:切读
灰度读请求 → 新库
验证正确性后全量切读
阶段4:停双写
写请求 → 仅新库
6.3 数据校验
# pt-table-checksum 校验主从/分片一致性
pt-table-checksum \
--host=db0 --user=root --password=xxx \
--databases=order_db --tables=t_order \
--replicate=percona.checksums
# pt-table-sync 修复差异
pt-table-sync --execute h=db0,u=root,p=xxx h=db1,u=root,p=xxx \
--databases=order_db --tables=t_order
7. 分库分表后的新问题
| 原功能 | 分片后挑战 | 解决方案 |
|---|
| 全局唯一 ID | 自增 ID 失效 | 雪花算法/号段模式 |
| 跨库 JOIN | 不支持 | 字段冗余、宽表、ES 聚合 |
| 跨库事务 | 分布式事务复杂 | TCC/Saga/消息补偿/Seata |
| 全局排序/分页 | 需聚合多库结果 | 禁止深分页、ES/ClickHouse |
| 聚合查询(COUNT/SUM) | 需遍历所有分片 | 定期预计算、流式计算 |
| 外键约束 | 失效 | 应用层保证 |
8. 总结
分库分表是数据库架构演进的重要里程碑:
容量/性能瓶颈
│
▼
┌─────────────────┐
│ 1. 先优化单库 │ ← 索引、SQL、缓存、主从读写分离
│ (成本最低) │
└────────┬────────┘
│ 仍然不够
▼
┌─────────────────┐
│ 2. 垂直拆分 │ ← 按业务分库
│ (解耦+降负载)│
└────────┬────────┘
│ 单表仍然过大
▼
┌─────────────────┐
│ 3. 水平拆分 │ ← Sharding-JDBC / 自研中间件
│ (终极方案) │
└─────────────────┘
核心设计要点:分片键 > 分片算法 > 中间件选型 > 全局 ID > 扩容方案。分库分表不是银弹,会带来分布式事务、全局查询等新的挑战,应在充分榨取单库性能后再引入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。