返回 筑基・数据元府藏真
分库分表设计与实践
博主
大约 21 分钟
分库分表设计与实践
问题引入:单表数据量过亿
三年前,我们的订单系统还是一个单库单表的架构。随着业务快速增长,订单表的数据量很快突破了1亿条,到了第三年,数据量已经达到了惊人的15亿条。这时我们遇到了一系列严重的问题:
场景:订单表数据量达到15亿
问题统计:
- 查询性能:简单主键查询从10ms上升到500ms
- 索引失效:大量查询被迫全表扫描
- 备份时间:完整备份需要36小时
- 恢复时间:数据恢复需要48小时
- DDL操作:添加索引需要锁表12小时
- 磁盘空间:单表占用2TB空间
- 并发能力:QPS从5000下降到800
具体故障案例:
1. 2023年双11,订单查询接口超时,导致大量用户无法查看订单
2. 2024年春节,数据库备份失败,因为备份时间超过了维护窗口
3. 2024年3月,添加新字段的DDL操作导致服务中断2小时
-- 问题查询示例
-- 1. 主键查询变慢
SELECT * FROM orders WHERE order_id = 123456789; -- 500ms
-- 2. 索引失效,全表扫描
SELECT * FROM orders WHERE user_id = 10001
ORDER BY create_time DESC LIMIT 10; -- 30秒
-- 3. 统计查询几乎无法执行
SELECT COUNT(*) FROM orders WHERE status = 'PAID'; -- 超时
-- 4. 备份命令
mysqldump -u root -p orders > orders.sql -- 36小时
根本原因:
- 单表数据量过大,B+树层级加深
- 索引文件过大,内存无法缓存
- 表锁竞争严重,并发能力下降
- 维护操作(备份、DDL)耗时过长
现象描述:大数据量下的数据库问题
案例1:查询性能急剧下降
场景:订单主键查询
数据量与查询性能关系:
┌─────────────────────────────────────────────────────────────┐
│ 数据量 查询时间 B+树层级 索引大小 │
├─────────────────────────────────────────────────────────────┤
│ 100万 5ms 2层 20MB │
│ 1000万 10ms 3层 200MB │
│ 1亿 50ms 3层 2GB │
│ 5亿 200ms 4层 10GB │
│ 10亿 500ms 4层 20GB │
│ 15亿 800ms 4层 30GB │
└─────────────────────────────────────────────────────────────┘
问题分析:
- B+树从3层增加到4层,每次查询多一次磁盘IO
- 索引大小30GB,无法全部加载到内存
- 大量查询需要磁盘随机读取
影响:用户体验差,系统响应慢。
案例2:DDL操作锁表时间过长
场景:添加新字段
-- 添加一个字段
ALTER TABLE orders ADD COLUMN source VARCHAR(20);
-- 执行时间线
T+0h 开始执行ALTER
T+2h 创建临时表完成
T+4h 数据拷贝50%
T+8h 数据拷贝完成
T+10h 重建索引
T+12h 完成,切换表
-- 期间:
-- - 表被锁定,所有写操作阻塞
-- - 读操作性能下降
-- - 连接池耗尽
-- - 服务几乎不可用
锁表期间的影响:
┌─────────────────────────────────────────────────────────────┐
│ DDL操作期间系统状态 │
├─────────────────────────────────────────────────────────────┤
│ 时间 写请求 读请求 连接池 错误率 │
├─────────────────────────────────────────────────────────────┤
│ T+0h 阻塞 正常 50% 0% │
│ T+2h 阻塞 变慢 70% 5% │
│ T+6h 阻塞 很慢 90% 20% │
│ T+10h 阻塞 几乎不可用 100% 50% │
│ T+12h 恢复 恢复 恢复 恢复 │
└─────────────────────────────────────────────────────────────┘
案例3:备份恢复困难
场景:数据库备份与恢复
# 备份命令
mysqldump -u root -p --single-transaction orders > orders_backup.sql
# 备份时间统计
数据量 备份时间 备份文件大小
100GB 2小时 30GB
500GB 10小时 150GB
1TB 20小时 300GB
2TB 36小时 600GB
# 恢复命令
mysql -u root -p orders < orders_backup.sql
# 恢复时间通常是备份时间的1.5-2倍
2TB数据恢复需要:54-72小时
影响:
- 备份窗口不足,无法每日全量备份
- 恢复时间过长,RTO无法满足业务要求
- 备份文件过大,存储成本增加
原因分析:单表大数据量瓶颈
1. InnoDB存储结构限制
InnoDB B+树结构:
┌─────────────────────────────────────────────────────────────┐
│ B+树索引结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 根节点 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 键值: [10, 20, 30, 40, 50, ...] │ │
│ │ 指针: [▼, ▼, ▼, ▼, ▼, ▼] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 中间节点(非叶子节点) │ │
│ │ 每个节点约16KB,存储约1000个键值 │ │
│ │ 1000万数据:2层 │ │
│ │ 10亿数据:3层 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 叶子节点(存储实际数据) │ │
│ │ 每个节点16KB,存储约200行数据 │ │
│ │ 15亿数据需要约750万个叶子节点 │ │
│ │ 叶子节点总大小:约120GB │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 查询过程: │
│ 1. 根节点 → 中间节点 → 叶子节点(3次IO) │
│ 2. 如果索引不能全部缓存到内存,需要磁盘读取 │
│ 3. 15亿数据的索引约30GB,超过一般服务器的内存 │
│ │
└─────────────────────────────────────────────────────────────┘
2. 性能瓶颈分析
| 瓶颈类型 | 具体表现 | 影响程度 |
|---|---|---|
| 磁盘IO | 索引无法缓存,大量随机读 | 严重 |
| 内存压力 | Buffer Pool命中率下降 | 严重 |
| 锁竞争 | 表锁、行锁竞争加剧 | 中等 |
| 维护成本 | 备份、DDL操作耗时过长 | 严重 |
| 扩展性 | 单表无法水平扩展 | 严重 |
3. 分库分表的必要性
分库分表目标:
┌─────────────────────────────────────────────────────────────┐
│ 单库单表 分库分表 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 15亿数据 │ → │ 1亿数据 │ │ 1亿数据 │ ... │
│ │ 1个表 │ │ 表1 │ │ 表2 │ │
│ │ 2TB │ │ 16张表 │ │ 16张表 │ │
│ │ 30GB索引│ │ 32库 │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ 优势: │
│ 1. 单表数据量减少到1亿以下,查询性能提升10倍 │
│ 2. 索引大小减少到2GB,可以完全缓存到内存 │
│ 3. 可以水平扩展,支持更大规模数据 │
│ 4. 维护操作(备份、DDL)时间大幅减少 │
│ 5. 故障隔离,单点故障影响范围减小 │
└─────────────────────────────────────────────────────────────┘
解决方案:分库分表设计
1. 分片策略详解
1.1 哈希分片(Hash Sharding)
哈希分片原理:
┌─────────────────────────────────────────────────────────────┐
│ 分片键值 哈希计算 分片结果 目标分片 │
├─────────────────────────────────────────────────────────────┤
│ user_id=1 hash(1)=xx % 16 = 1 shard_01 │
│ user_id=2 hash(2)=xx % 16 = 2 shard_02 │
│ user_id=3 hash(3)=xx % 16 = 3 shard_03 │
│ ... │
│ user_id=16 hash(16)=xx % 16 = 0 shard_00 │
│ user_id=17 hash(17)=xx % 16 = 1 shard_01 │
└─────────────────────────────────────────────────────────────┘
优点:
- 数据分布均匀,避免热点
- 实现简单,性能高
缺点:
- 范围查询需要扫描所有分片
- 扩容时需要迁移数据
适用场景:
- 用户ID、订单ID等均匀分布的字段
- 点查询为主的场景
// 哈希分片算法
public class HashShardingStrategy {
private int shardCount;
public HashShardingStrategy(int shardCount) {
this.shardCount = shardCount;
}
public int getShardIndex(Object shardKey) {
int hash = shardKey.hashCode();
// 处理负数
return Math.abs(hash) % shardCount;
}
// 一致性哈希(扩容时减少数据迁移)
public int getShardIndexConsistent(Object shardKey, int virtualNodes) {
// 使用TreeMap实现一致性哈希环
TreeMap<Integer, Integer> hashRing = buildHashRing(virtualNodes);
int hash = shardKey.hashCode();
Map.Entry<Integer, Integer> entry = hashRing.ceilingEntry(hash);
return entry != null ? entry.getValue() : hashRing.firstEntry().getValue();
}
}
1.2 范围分片(Range Sharding)
范围分片原理:
┌─────────────────────────────────────────────────────────────┐
│ 时间范围 分片 │
├─────────────────────────────────────────────────────────────┤
│ 2023-01 ~ 2023-03 → order_2023_q1 │
│ 2023-04 ~ 2023-06 → order_2023_q2 │
│ 2023-07 ~ 2023-09 → order_2023_q3 │
│ 2023-10 ~ 2023-12 → order_2023_q4 │
│ 2024-01 ~ 2024-03 → order_2024_q1 │
└─────────────────────────────────────────────────────────────┘
优点:
- 支持高效的范围查询
- 扩容简单,新增分片即可
- 可以按时间归档历史数据
缺点:
- 可能存在热点(最新数据集中在最后一个分片)
- 需要预先规划分片范围
适用场景:
- 时间序列数据(订单、日志)
- 范围查询频繁的场景
// 范围分片算法
public class RangeShardingStrategy {
private TreeMap<Comparable, String> rangeMap;
public RangeShardingStrategy() {
rangeMap = new TreeMap<>();
// 初始化分片范围
rangeMap.put("2023-01-01", "order_2023_q1");
rangeMap.put("2023-04-01", "order_2023_q2");
rangeMap.put("2023-07-01", "order_2023_q3");
rangeMap.put("2023-10-01", "order_2023_q4");
rangeMap.put("2024-01-01", "order_2024_q1");
}
public String getShardName(Comparable shardKey) {
Map.Entry<Comparable, String> entry = rangeMap.floorEntry(shardKey);
return entry != null ? entry.getValue() : rangeMap.firstEntry().getValue();
}
}
1.3 列表分片(List Sharding)
列表分片原理:
┌─────────────────────────────────────────────────────────────┐
│ 地区 分片 │
├─────────────────────────────────────────────────────────────┤
│ 北京、天津、河北 → shard_north │
│ 上海、江苏、浙江 → shard_east │
│ 广东、福建、海南 → shard_south │
│ 四川、重庆、云南 → shard_southwest │
│ 其他 → shard_default │
└─────────────────────────────────────────────────────────────┘
优点:
- 灵活控制数据分布
- 可以按业务需求分配
缺点:
- 需要维护映射关系
- 数据可能不均匀
适用场景:
- 按地区分片
- 按业务类型分片
1.4 复合分片(Composite Sharding)
复合分片原理(先范围后哈希):
第一层:按时间范围分库
┌─────────────────────────────────────────────────────────────┐
│ 时间范围 数据库 │
├─────────────────────────────────────────────────────────────┤
│ 2024-01 ~ 2024-06 → db_2024_h1 │
│ 2024-07 ~ 2024-12 → db_2024_h2 │
└─────────────────────────────────────────────────────────────┘
第二层:按用户ID哈希分表
┌─────────────────────────────────────────────────────────────┐
│ 在每个数据库内,再分16张表 │
│ order_0, order_1, ..., order_15 │
└─────────────────────────────────────────────────────────────┘
最终分片:
user_id=10001, create_time=2024-03-15
→ db_2024_h1.order_1 (10001 % 16 = 1)
优点:
- 结合范围和哈希的优势
- 支持时间范围查询
- 避免单库热点
适用场景:
- 大规模分布式系统
- 需要同时支持范围查询和水平扩展
2. 分片键选择原则
分片键选择决策树:
┌─────────────────────────────────────┐
│ 业务场景分析 │
└─────────────────┬───────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
↓ ↓ ↓
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 点查询为主 │ │ 范围查询为主 │ │ 混合查询 │
│ 用户查询 │ │ 时间范围 │ │ 多维查询 │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
↓ ↓ ↓
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 哈希分片 │ │ 范围分片 │ │ 复合分片 │
│ 用户ID │ │ 时间字段 │ │ 时间+用户ID │
└───────────────┘ └───────────────┘ └───────────────┘
分片键选择原则:
-
高频查询字段
- 选择查询最频繁的字段作为分片键
- 确保大部分查询可以路由到单个分片
-
数据分布均匀
- 避免数据倾斜
- 避免热点问题
-
避免跨分片查询
- 尽量减少需要扫描多个分片的查询
- 避免跨分片JOIN
-
考虑未来扩容
- 预留足够的分片数量
- 考虑一致性哈希减少扩容迁移
3. ShardingSphere配置详解
3.1 基础配置
# application.yml
spring:
shardingsphere:
# 数据源配置
datasource:
names: ds0, ds1, ds2, ds3
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://host0:3306/db0?useSSL=false
username: root
password: password
maximum-pool-size: 50
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://host1:3306/db1?useSSL=false
username: root
password: password
maximum-pool-size: 50
ds2:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://host2:3306/db2?useSSL=false
username: root
password: password
maximum-pool-size: 50
ds3:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://host3:3306/db3?useSSL=false
username: root
password: password
maximum-pool-size: 50
# 规则配置
rules:
sharding:
tables:
# 订单表分片配置
orders:
# 实际数据节点:ds0.orders_0 ~ ds3.orders_15
actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
# 表分片策略
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order-table-hash
# 库分片策略
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-db-hash
# 主键生成策略
key-generate-strategy:
column: order_id
key-generator-name: snowflake
# 用户表(全局表,每个库都有一份)
users:
actual-data-nodes: ds$->{0..3}.users
# 分片算法
sharding-algorithms:
# 订单表哈希算法
order-table-hash:
type: INLINE
props:
algorithm-expression: orders_$->{order_id % 16}
# 用户库哈希算法
user-db-hash:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 4}
# 一致性哈希算法(用于扩容)
consistent-hash:
type: CLASS_BASED
props:
strategy: STANDARD
algorithm-class-name: com.example.sharding.ConsistentHashAlgorithm
# 主键生成器
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 0
max-vibration-offset: 1
# 广播表(全局表)
broadcast-tables:
- users
- regions
- product_categories
# 属性配置
props:
sql-show: true # 显示实际执行的SQL
sql-simple: true # 简化SQL输出
executor-size: 16 # 执行线程池大小
max-connections-size-per-query: 1 # 每个查询最大连接数
3.2 自定义分片算法
// 自定义复合分片算法
@Component
public class CompositeShardingAlgorithm implements StandardShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Long> shardingValue) {
Long value = shardingValue.getValue();
String logicTableName = shardingValue.getLogicTableName();
// 高4位:库索引(0-15)
// 低4位:表索引(0-15)
int dbIndex = (int) ((value >> 4) & 0x0F);
int tableIndex = (int) (value & 0x0F);
// 构建实际表名
return logicTableName + "_" + tableIndex;
}
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames,
RangeShardingValue<Long> shardingValue) {
// 范围查询需要扫描所有分片
return availableTargetNames;
}
@Override
public void init() {
// 初始化
}
@Override
public String getType() {
return "COMPOSITE";
}
}
4. 分布式ID生成
4.1 雪花算法(Snowflake)
@Component
public class SnowflakeIdGenerator {
// 起始时间戳(2024-01-01)
private final long EPOCH = 1704067200000L;
// 各部分位数
private final long WORKER_ID_BITS = 5L; // 工作机器ID占5位
private final long DATACENTER_ID_BITS = 5L; // 数据中心ID占5位
private final long SEQUENCE_BITS = 12L; // 序列号占12位
// 最大值
private final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
private final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS);
private final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
// 位移
private final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("Worker ID out of range");
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException("Datacenter ID out of range");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}
if (timestamp == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 序列号溢出,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 不同毫秒,序列号重置
sequence = 0L;
}
lastTimestamp = timestamp;
// 组合ID
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
4.2 分片ID生成器(嵌入分片信息)
@Component
public class ShardingIdGenerator {
@Autowired
private SnowflakeIdGenerator snowflake;
/**
* 生成包含分片信息的ID
*
* ID结构(64位):
* - 高4位:分片键(库索引)
* - 接下来4位:分片键(表索引)
* - 低56位:雪花算法ID
*/
public long generateId(Long userId) {
// 计算分片键
int dbIndex = (int) (userId % 16); // 16个库
int tableIndex = (int) (userId % 16); // 每个库16张表
// 生成雪花ID
long snowflakeId = snowflake.nextId();
// 组合ID
long shardKey = ((long) dbIndex << 4) | tableIndex;
return (shardKey << 56) | (snowflakeId & 0x00FFFFFFFFFFFFFFL);
}
/**
* 从ID中提取分片信息
*/
public ShardInfo extractShardInfo(Long orderId) {
int dbIndex = (int) ((orderId >> 60) & 0x0F);
int tableIndex = (int) ((orderId >> 56) & 0x0F);
return new ShardInfo(dbIndex, tableIndex);
}
@Data
public static class ShardInfo {
private final int dbIndex;
private final int tableIndex;
}
}
5. 数据迁移方案
5.1 双写迁移方案
双写迁移流程:
阶段1:双写准备
┌─────────────────────────────────────────────────────────────┐
│ 应用层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 写入操作 │ │
│ │ ↓ │ │
│ │ 双写(旧库+新库) │ │
│ │ ↓ │ │
│ │ 读旧库 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 旧库(单库单表) 新库(分库分表) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ orders │ │ orders_0 │ │
│ │ 15亿数据 │ │ orders_1 │ │
│ └─────────────┘ │ ... │ │
│ │ orders_15 │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
阶段2:历史数据迁移
- 使用canal或 Maxwell 同步历史数据
- 校验数据一致性
阶段3:切读
- 灰度切换读操作到新库
- 监控对比新旧库结果
阶段4:停双写,只写新库
- 确认无问题后,停止写旧库
- 保留旧库作为备份
@Service
public class DualWriteMigrationService {
@Autowired
private OrderMapper oldOrderMapper; // 旧库Mapper
@Autowired
private OrderMapper newOrderMapper; // 新库Mapper(ShardingSphere)
@Autowired
private MigrationConfig migrationConfig;
/**
* 双写创建订单
*/
public Order createOrder(Order order) {
// 生成新库ID(包含分片信息)
Long newOrderId = shardingIdGenerator.generateId(order.getUserId());
order.setOrderId(newOrderId);
// 写新库
newOrderMapper.insert(order);
// 异步写旧库(不阻塞主流程)
if (migrationConfig.isDualWriteEnabled()) {
asyncWriteToOld(order);
}
return order;
}
/**
* 读操作(根据灰度配置决定读哪个库)
*/
public Order getOrder(Long orderId) {
if (migrationConfig.isReadFromNew(orderId)) {
// 读新库
return newOrderMapper.selectById(orderId);
} else {
// 读旧库
return oldOrderMapper.selectById(orderId);
}
}
@Async
protected void asyncWriteToOld(Order order) {
try {
oldOrderMapper.insert(order);
} catch (Exception e) {
log.error("写旧库失败", e);
// 记录失败,后续补偿
}
}
}
5.2 数据校验
@Component
public class DataConsistencyChecker {
/**
* 校验新旧库数据一致性
*/
public void checkConsistency(Long startId, Long endId) {
for (Long id = startId; id <= endId; id++) {
Order oldOrder = oldOrderMapper.selectById(id);
Order newOrder = newOrderMapper.selectById(id);
if (oldOrder == null && newOrder == null) {
continue; // 都为空,一致
}
if (oldOrder == null || newOrder == null) {
log.error("数据不一致: ID={}, 旧库={}, 新库={}",
id, oldOrder, newOrder);
continue;
}
if (!oldOrder.equals(newOrder)) {
log.error("数据不一致: ID={}, 差异={}",
id, DiffUtils.diff(oldOrder, newOrder));
}
}
}
}
实战案例:订单系统分库分表改造
改造前架构
改造前(单库单表):
┌─────────────────────────────────────────────────────────────┐
│ 数据库:db_order │
│ 表:orders(15亿数据,2TB) │
│ │
│ 问题: │
│ - 查询慢(主键查询500ms) │
│ - 无法水平扩展 │
│ - 维护困难(备份36小时) │
└─────────────────────────────────────────────────────────────┘
改造后架构
改造后(16库 * 16表 = 256分片):
┌─────────────────────────────────────────────────────────────┐
│ 数据库集群:16个库 │
│ 每个库表数:16张表 │
│ 总分片数:256 │
│ 单分片数据量:约600万 │
│ 单分片大小:约8GB │
│ │
│ 分片策略: │
│ - 库分片:user_id % 16 │
│ - 表分片:order_id % 16 │
│ │
│ 效果: │
│ - 查询快(主键查询10ms) │
│ - 可水平扩展 │
│ - 维护简单(单分片备份10分钟) │
└─────────────────────────────────────────────────────────────┘
核心代码实现
@Service
public class ShardingOrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private ShardingIdGenerator idGenerator;
/**
* 创建订单(自动分片)
*/
@Transactional
public Order createOrder(OrderRequest request) {
// 生成订单ID(包含分片信息)
Long orderId = idGenerator.generateId(request.getUserId());
Order order = new Order();
order.setOrderId(orderId);
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.CREATED);
order.setCreateTime(new Date());
// 自动路由到对应分片
orderMapper.insert(order);
return order;
}
/**
* 根据订单ID查询(自动路由)
*/
public Order getOrderById(Long orderId) {
// ShardingSphere自动解析orderId中的分片信息
return orderMapper.selectById(orderId);
}
/**
* 根据用户ID查询订单列表(需要路由到指定分片)
*/
public List<Order> getOrdersByUserId(Long userId, int page, int size) {
// 计算分片
int dbIndex = (int) (userId % 16);
// 使用Hint强制路由到指定分片
HintManager hintManager = HintManager.getInstance();
hintManager.setDatabaseShardingValue(dbIndex);
try {
return orderMapper.selectByUserId(userId, (page - 1) * size, size);
} finally {
hintManager.close();
}
}
/**
* 分页查询所有订单(需要扫描所有分片)
*/
public PageResult<Order> getAllOrders(int page, int size) {
// 这种查询需要扫描所有分片,性能较差
// 建议:
// 1. 限制查询条件
// 2. 使用ES等搜索引擎
// 3. 预聚合数据到宽表
// 示例:按时间范围查询
Date startTime = DateUtils.addDays(new Date(), -7);
Date endTime = new Date();
return orderMapper.selectByTimeRange(startTime, endTime, (page - 1) * size, size);
}
}
跨分片查询解决方案
@Service
public class CrossShardQueryService {
@Autowired
private ElasticsearchClient esClient;
/**
* 方案1:使用Elasticsearch做全局搜索
*/
public PageResult<Order> searchOrders(OrderSearchRequest request) {
// 构建ES查询
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
if (request.getUserId() != null) {
boolQuery.must(TermQuery.of(t -> t.field("userId").value(request.getUserId()))._toQuery());
}
if (request.getStatus() != null) {
boolQuery.must(TermQuery.of(t -> t.field("status").value(request.getStatus()))._toQuery());
}
if (request.getStartTime() != null && request.getEndTime() != null) {
boolQuery.must(RangeQuery.of(r -> r.field("createTime")
.gte(JsonData.of(request.getStartTime()))
.lte(JsonData.of(request.getEndTime())))._toQuery());
}
SearchResponse<OrderDoc> response = esClient.search(s -> s
.index("orders")
.query(boolQuery.build()._toQuery())
.from((request.getPage() - 1) * request.getSize())
.size(request.getSize())
.sort(SortOptions.of(so -> so.field(f -> f.field("createTime").order(SortOrder.Desc))))
, OrderDoc.class);
// 获取订单ID列表
List<Long> orderIds = response.hits().hits().stream()
.map(hit -> hit.source().getOrderId())
.collect(Collectors.toList());
// 根据ID查询详情(自动路由到对应分片)
List<Order> orders = orderIds.stream()
.map(orderMapper::selectById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new PageResult<>(orders, response.hits().total().value(), request.getPage(), request.getSize());
}
/**
* 方案2:使用宽表预聚合
*/
public OrderStatistics getOrderStatistics(Long userId) {
// 查询预聚合的统计表(单表,按用户ID分片)
return orderStatisticsMapper.selectByUserId(userId);
}
}
性能测试数据
1. 分库分表前后性能对比
| 指标 | 改造前(单表15亿) | 改造后(256分片) | 提升 |
|---|---|---|---|
| 主键查询 | 500ms | 10ms | 50x |
| 用户订单列表 | 30s | 50ms | 600x |
| 写入TPS | 500 | 8,000 | 16x |
| 备份时间 | 36小时 | 10分钟/分片 | 216x |
| 恢复时间 | 72小时 | 15分钟/分片 | 288x |
| DDL时间 | 12小时 | 30秒 | 1440x |
2. 分片数量与性能关系
| 分片数 | 单分片数据量 | 查询延迟 | 维护成本 |
|---|---|---|---|
| 16 | 1亿 | 50ms | 低 |
| 64 | 2500万 | 20ms | 中 |
| 256 | 600万 | 10ms | 高 |
| 1024 | 150万 | 5ms | 很高 |
3. 扩容性能测试
| 扩容方式 | 数据迁移量 | 停机时间 | 复杂度 |
|---|---|---|---|
| 双倍扩容 | 50% | 0(在线) | 中 |
| 一致性哈希 | <10% | 0(在线) | 高 |
| 重新分片 | 100% | 需要 | 低 |
经验总结
✅ 最佳实践
-
预估3-5年数据量
- 根据业务增长预测数据量
- 预留足够的分片数量
- 考虑未来扩容方案
-
分片键选择查询最频繁的字段
- 确保大部分查询可以路由到单个分片
- 避免跨分片查询
- 考虑数据分布均匀性
-
避免跨分片查询
- 使用ES等搜索引擎做全局查询
- 构建宽表预聚合数据
- 应用层聚合结果
-
数据迁移双写方案
- 先双写,再迁移历史数据
- 灰度切读,验证数据一致性
- 最后停双写
-
全局表(广播表)
- 小数据量表在每个分片都存一份
- 避免跨分片JOIN
- 注意数据同步延迟
❌ 常见错误
-
分片键选择不当
// 错误:使用自增ID作为分片键 // 导致数据倾斜,热点问题 // 正确:使用用户ID作为分片键 // 数据均匀分布 -
频繁跨分片JOIN
-- 错误:跨分片JOIN SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id -- orders和users在不同分片,需要跨分片查询 -- 正确:将users设为广播表 -- 每个分片都有users表,避免跨分片JOIN -
分片数过少,无法扩容
# 错误:只分4个片 actual-data-nodes: ds$->{0..3}.orders_$->{0..3} # 正确:预留足够分片 actual-data-nodes: ds$->{0..15}.orders_$->{0..15} -
忽略分布式事务
// 错误:跨分片事务 @Transactional public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) { // fromUser和toUser在不同分片 // 普通事务无法保证一致性 } // 正确:使用分布式事务框架 @GlobalTransactional public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) { // 使用Seata等分布式事务框架 }
决策树:分库分表策略选择
┌─────────────────────────────────────┐
│ 数据规模评估 │
└─────────────────┬───────────────────┘
│
┌─────────────────────────┼─────────────────────────┐
↓ ↓ ↓
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ < 1000万 │ │ 1000万-1亿 │ │ > 1亿 │
│ 小数据量 │ │ 中等数据量 │ │ 大数据量 │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
↓ ↓ ↓
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 单表即可 │ │ 分表不分库 │ │ 分库分表 │
│ 优化索引 │ │ 16-64张表 │ │ 16库*16表 │
└───────────────┘ └───────────────┘ └───────────────┘
检查清单
分库分表设计检查清单
- 是否预估了3-5年数据量?
- 分片键选择是否合理?
- 分片数量是否足够?
- 是否有跨分片查询?
- 是否有分布式事务?
- 数据迁移方案是否完善?
- 是否有全局表(广播表)?
- 是否有ID生成方案?
- 是否有数据一致性校验?
- 是否有监控告警?
分库分表运维检查清单
- 各分片数据量是否均衡?
- 是否有热点分片?
- 跨分片查询是否过多?
- 分片连接池是否正常?
- 数据备份是否正常?
- 扩容方案是否测试?
- 故障转移是否可用?
- 监控告警是否正常?
系列上一篇:主从复制与读写分离实战
系列下一篇:数据库缓存架构:Redis与数据库一致性
知识点测试
读完文章了?来测试一下你对知识点的掌握程度吧!
评论区
使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。
如果评论系统无法加载,请确保:
- 您的网络可以访问 GitHub
- giscus GitHub App 已安装到仓库
- 仓库已启用 Discussions 功能