容量规划与扩容策略
如何预估分库分表数量
分库分表并非盲目拆分,需要基于业务数据特征进行科学预估。拆分数量过少,未来仍需二次扩容;拆分过多,则浪费资源且增加运维复杂度。
分表数量计算公式
核心公式:
分表数量 = (存量数据总量 + 预估年增长量 × 保留年限) / 单表极限容量
=> 向上取最接近的2的幂参数说明:
- 单表极限容量:InnoDB引擎理论上单表2000万行性能稳定(阿里规范建议500万,实际可适当放宽)
- 存量数据总量:当前系统已有数据量
- 预估年增长量:基于业务发展预测的年度新增数据量
- 保留年限:业务要求的数据保留周期
实例计算
场景假设:
- 当前存量:2000万条记录
- 年增长量:500万条
- 保留年限:10年
- 单表极限:2000万条
计算过程:
分表数量 = (2000万 + 500万 × 10) / 2000万
= (2000万 + 5000万) / 2000万
= 7000万 / 2000万
= 3.5
=> 向上取2的幂 = 4推荐分4张表。
graph TB
A[数据容量评估] --> B[存量数据]
A --> C[年增长量]
A --> D[保留年限]
B --> E[总数据量预估]
C --> E
D --> E
E --> F[单表极限]
F --> G[分表数量]为什么选择2的幂
优势一:优化为位运算
取模运算可优化为高效的位运算:
// 传统取模运算
int tableIndex = productId % 8;
// 优化为位运算(当分表数为2的幂时)
int tableIndex = productId & (8 - 1); // 等价于 productId & 7位运算性能远超取模运算,参考HashMap的实现原理。
优势二:均匀分配多库
当同时分库分表时,2的幂能确保分表均匀分布到各个库:
**示例:**128张表,16个库
- 每个库:128 ÷ 16 = 8张表(完全均匀)
**对比:**如果分120张表,15个库
- 每个库:120 ÷ 15 = 8张表(均匀)
- 但120不是2的幂,后续扩容成本高
graph TB
A[128张表] --> B[16个库]
B --> C[每库8张表]
D[120张表] --> E[15个库]
E --> F[每库8张表]优势三:简化扩容迁移
从4张表扩容到8张表时,只需迁移50%数据。
扩容前路由规则:
int tableIndex = userId % 4; // 结果: 0, 1, 2, 3- table_00: userId % 4 == 0
- table_01: userId % 4 == 1
- table_02: userId % 4 == 2
- table_03: userId % 4 == 3
扩容后路由规则:
int tableIndex = userId % 8; // 结果: 0, 1, 2, 3, 4, 5, 6, 7数据迁移方案:
- 原table_00中,userId % 8 == 0的数据保持不动
- 原table_00中,userId % 8 == 4的数据迁移到新table_04
- 原table_01中,userId % 8 == 1的数据保持不动
- 原table_01中,userId % 8 == 5的数据迁移到新table_05
- ...以此类推
graph LR
A[原table_00] --> B[新table_00]
A --> C[新table_04]
D[原table_01] --> E[新table_01]
D --> F[新table_05]对比非2的幂:
5张表扩容到9张表,几乎所有数据都需要重新计算路由并迁移,成本极高。
分库数量估算
分库数量取决于并发压力,但通常与分表数量保持倍数关系。
经验公式:
分库数量 = 分表数量 / 8常见组合:
| 分表数量 | 分库数量 | 每库分表数 |
|---|---|---|
| 128 | 16 | 8 |
| 512 | 64 | 8 |
| 1024 | 128 | 8 |
| 64 | 8 | 8 |
特例:
- 分表数 < 8:建议分库数 = 分表数(如4库4表)
- 并发极高:可适当增加分库数(如8库64表)
graph TB
A[分库分表组合] --> B[128库1024表]
A --> C[64库512表]
A --> D[16库128表]
A --> E[8库64表]
A --> F[4库4表]分库分表后表不够怎么办
即使前期规划充分,业务爆发式增长仍可能导致分表容量不足。此时有三种应对策略:
策略一:提前预留冗余
**最佳实践:**初次分表时预留充足buffer,避免频繁扩容。
生产环境常见配置:256、512、1024张表,远超当前需求,但为未来留出空间。
graph LR
A[初次分表] --> B[选择策略]
B --> C[满足当前需求]
B --> D[大幅预留]
C --> E[频繁扩容]
D --> F[长期稳定]策略二:数据归档
将历史冷数据迁移到归档库或对象存储,减轻热数据表压力。
归档方案:
// 归档策略:6个月前的订单迁移到归档库
public void archiveOldOrders() {
Date archiveDate = DateUtils.addMonths(new Date(), -6);
// 查询需归档数据
List<Order> oldOrders = orderMapper.selectBefore(archiveDate);
// 写入归档库
archiveOrderMapper.batchInsert(oldOrders);
// 删除原表数据
orderMapper.deleteBefore(archiveDate);
}归档库选型:
- MySQL归档库:低配实例,冷查询场景
- HBase:海量历史数据,低成本存储
- OSS对象存储:极低成本,仅存储不查询
graph LR
A[热数据表] --> B[业务查询]
C[归档库] --> D[偶尔查询]
E[对象存储] --> F[仅存储]策略三:二次分表
容量彻底不足时,只能重新分表并迁移数据。
关键步骤:
- 设计新的分表策略(如128张扩容到256张)
- 创建新的物理表结构
- 修改路由算法
- 执行数据迁移
- 灰度切换流量
- 清理旧表
降低迁移成本的设计:
采用一致性哈希算法,扩容时仅部分数据需迁移:
// 一致性哈希扩容示例
ConsistentHash oldHash = new ConsistentHash();
oldHash.addNode("table_00");
oldHash.addNode("table_01");
oldHash.addNode("table_02");
oldHash.addNode("table_03");
// 扩容:新增4个节点
ConsistentHash newHash = new ConsistentHash();
newHash.addNode("table_00");
newHash.addNode("table_01");
newHash.addNode("table_02");
newHash.addNode("table_03");
newHash.addNode("table_04"); // 新增
newHash.addNode("table_05"); // 新增
newHash.addNode("table_06"); // 新增
newHash.addNode("table_07"); // 新增
// 仅受影响的数据需要迁移graph TB
A[二次分表] --> B[选择2的幂扩容]
A --> C[采用一致性哈希]
A --> D[平滑数据迁移]平滑数据迁移方案
二次分表的核心难点是如何无损、无缝迁移数据。
迁移策略:双写+校验
阶段一:双写阶段
graph LR
A[应用层] --> B[写入旧表]
A --> C[同步写入新表]
D[读操作] --> B新数据同时写入新旧表,读操作仍从旧表读取,确保业务稳定。
// 双写实现
@Transactional
public void createOrder(Order order) {
// 写旧表
oldOrderMapper.insert(order);
// 异步写新表(失败不影响主流程)
try {
newOrderMapper.insert(order);
} catch (Exception e) {
log.error("写入新表失败,订单号:{}", order.getOrderNo(), e);
// 记录失败,后续补偿
}
}阶段二:存量数据迁移
graph TB
A[存量数据迁移] --> B[分批查询旧表]
B --> C[计算新表路由]
C --> D[写入新表]
D --> E[数据校验]
E --> F[是否一致]
F --> G[标记完成]
F --> H[记录差异]// 存量数据迁移
public void migrateHistoryData() {
int pageSize = 1000;
int offset = 0;
while (true) {
List<Order> orders = oldOrderMapper.selectPage(offset, pageSize);
if (orders.isEmpty()) {
break;
}
for (Order order : orders) {
// 写入新表
newOrderMapper.insert(order);
// 校验数据一致性
Order newOrder = newOrderMapper.selectByOrderNo(order.getOrderNo());
if (!order.equals(newOrder)) {
log.error("数据不一致,订单号:{}", order.getOrderNo());
}
}
offset += pageSize;
}
}阶段三:灰度切流
graph LR
A[读流量] --> B[灰度比例]
B --> C[读旧表]
B --> D[读新表]
E[监控指标] --> F[错误率正常]
F --> G[提升新表比例]
F --> H[回滚旧表]逐步将读流量从旧表切换到新表:
// 灰度读取
public Order getOrder(String orderNo) {
int grayRatio = configService.getGrayRatio(); // 从配置中心读取
if (RandomUtils.nextInt(100) < grayRatio) {
// 读新表
return newOrderMapper.selectByOrderNo(orderNo);
} else {
// 读旧表
return oldOrderMapper.selectByOrderNo(orderNo);
}
}阶段四:完全切换
新表稳定运行一段时间后,完全切换到新表,下线旧表。
使用一致性哈希降低迁移成本
一致性哈希在节点变化时,仅影响部分数据:
原理:
- 哈希环首尾相接(0 ~ 2^32-1)
- 数据和节点都映射到环上
- 数据存储在顺时针方向最近的节点
- 新增节点只影响逆时针方向第一个节点的数据
graph TB
A[一致性哈希环] --> B[新增节点]
B --> C[部分数据受影响]
C --> D[其余数据无需迁移]
E[传统取模] --> F[新增节点]
F --> G[大量数据重新路由]扩容示例:
4个节点扩容到8个节点:
- 一致性哈希:平均约50%数据需迁移
- 传统取模:约75%数据需迁移
// 一致性哈希配置
public class ShardingConfig {
private ConsistentHash consistentHash = new ConsistentHash();
public void init() {
// 初始4个节点
consistentHash.addNode("db_00");
consistentHash.addNode("db_01");
consistentHash.addNode("db_02");
consistentHash.addNode("db_03");
}
public void expand() {
// 扩容到8个节点
consistentHash.addNode("db_04");
consistentHash.addNode("db_05");
consistentHash.addNode("db_06");
consistentHash.addNode("db_07");
}
public String getNode(String orderId) {
return consistentHash.getNode(orderId);
}
}分库分表后的挑战
分库分表虽然解决了性能瓶颈,但也引入了新的复杂性。
必须携带分片键
所有读写操作必须包含分片键,否则只能执行全表扫描。
单表时代:
SELECT * FROM t_order WHERE order_status = 'PAID';分表后:
-- 必须带分片键
SELECT * FROM t_order WHERE customer_id = 12345 AND order_status = 'PAID';不带分片键则需扫描所有物理表,性能极差:
-- 扫描256张表
SELECT * FROM t_order_0000 WHERE order_status = 'PAID'
UNION ALL
SELECT * FROM t_order_0001 WHERE order_status = 'PAID'
...
UNION ALL
SELECT * FROM t_order_0255 WHERE order_status = 'PAID';graph TB
A[查询请求] --> B{是否带分片键}
B -->|是| C[定位单表<br/>高效查询]
B -->|否| D[扫描所有表<br/>性能极差]
style C fill:#90EE90
style D fill:#FFD93D跨库事务问题
分库后无法使用数据库原生事务保证跨库操作的ACID特性。
解决方案:
- 分布式事务:Seata、TCC、Saga等
- 最终一致性:本地消息表、事务消息
- 业务补偿:定时任务扫描并修复不一致
graph LR
A[跨库写入] --> B[订单库<br/>创建订单]
A --> C[库存库<br/>扣减库存]
D[分布式事务保障] --> E[Seata AT模式]
D --> F[本地消息表]
D --> G[RocketMQ事务消息]
style E fill:#90EE90
style F fill:#87CEEB
style G fill:#FFD93D分页排序失效
跨表分页和全局排序变得复杂,后续章节详细讨论。
全局唯一ID
自增主键在分表后会冲突,需要引入分布式ID生成方案,后续章节详细讨论。
更新: 2025-12-04 17:41:58
原文: https://www.yuque.com/u22210564/zoxfmt/doc-17-04