前言
某电商平台的MySQL订单表达到7亿行时,出现了致命问题:一条简单的 SELECT * FROM orders WHERE user_id = xxx LIMIT 10 查询竟然耗时12秒3。B+树索引深度达到5层,磁盘IO暴增;单表超200GB,备份时间窗突破6小时;写并发量达8000QPS,主从延迟高达15分钟4,5。
如果你正在负责一个日订单量百万级、存量数据即将破亿的系统,这篇文章应该能帮到你。我会从分片策略设计 、订单号生成(基因法) 、商家多维度查询 、技术选型 到平滑迁移,把整个分库分表的实战思路讲清楚。
一、什么时候该分库分表?
很多同学面试时被问到"什么时候分库分表",张口就来:"阿里开发手册说单表超过500万行就要分。"
这其实有点教条了。现在的硬件(SSD + 大内存),单表跑到1000万数据,索引建好了照样飞快6。
真正逼你分库分表的,通常不是"存储容量",而是"连接数"和"维护成本" 7:
- 连接数瓶颈:一个MySQL实例的连接数是有限的(通常几千个)。当并发QPS极高时,数据库连接池瞬间被打爆,这时候必须"分库"来分摊并发压力。
- DDL痛苦:给一张5000万行的表加个字段,锁表能锁到你怀疑人生。这时候必须"分表"来降低单表大小。
实战建议 :通常在800万-1000万 行区间开始规划分库分表,优先考虑硬件升级和索引优化,实在扛不住了再拆8。
二、核心设计:分片策略
2.1 分片键怎么选?
分片键的选择决定了数据如何分布,是整个方案设计的基石。选错了,后面全是坑。
| 原则 | 说明 | 反例 |
|---|---|---|
| 离散性 | 避免数据热点 | 用status(订单状态)分片,大量订单都是"已完成" |
| 业务相关性 | 80%的查询需携带该字段 | 用几乎不出现的字段分片 |
| 稳定性 | 值不随业务变更 | 用手机号分片(用户可能换号) |
对于电商订单系统,首选分片键是 user_id(用户ID) 。原因很简单:
- 同一用户的所有订单落在同一个分片上,用户查自己的订单列表------一次查询直接命中,不需要跨库
user_id分布均匀,不会出现数据倾斜- 业务上大部分查询都带
user_id
2.2 分片策略:哈希取模
采用哈希取模策略,这是最成熟、最常用的方案。
java
// 分库:user_id 对库数量取模
int dbIndex = hash(user_id) % 数据库数量;
// 分表:user_id 对总表数取模,再除以库数量
int tableIndex = hash(user_id) / 数据库数量 % 单库表数量;
2.3 容量规划:别只算眼前的账
不要只盯着当前的1亿条数据。需要预估未来3-5年的数据增长量,并据此设计总的分片数。
计算公式:
text
总数据量 = 存量数据 + (日增量 × 预估天数)
总分片数 = 数据库数 × 每库表数 ≥ 总数据量 / 单表推荐容量
单表容量建议 :控制在500万-1000万 行,或数据文件大小在2GB-10GB 以内。
实战案例:某订单系统当前8000万条数据,预期3年后达到5亿条:
- 单表上限:500万条
- 需要分片数:5亿 / 500万 = 100个
- 实际配置:4个库 × 16张表 = 64个分片
- 后续可扩展到8个库 × 16张表
关键技巧:2的幂次方 。分库数和单库分表数建议设计为2的幂次方(如8、16、32、64、128)。这样在未来需要扩容时,可以通过翻倍扩容的方式,最大限度地减少数据迁移量。
2.4 ShardingSphere配置示例
Maven依赖:
xml
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.3.0</version>
</dependency>
YAML配置(4库 × 16表):
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://localhost:3306/order_0
username: root
password: 123456
# ds1, ds2, ds3 同理...
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..3}.t_order_$->{0..15}
table-strategy:
standard:
sharding-column: user_id
precise-algorithm-class-name: com.example.UserIdTableShardingAlgorithm
key-generator:
column: id
type: SNOWFLAKE
分片算法实现:
java
public class UserIdTableShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Long> shardingValue) {
Long userId = shardingValue.getValue();
// 4个库
int dbIndex = (int) (userId % 4);
// 每个库16张表
int tableIndex = (int) (userId / 4 % 16);
return "ds" + dbIndex + ".t_order_" + tableIndex;
}
}
三、订单号查询的难题:"基因法"
3.1 问题在哪?
用户查询订单主要有两种方式:
- 查自己的订单列表 → 用
user_id,能直接定位分片 ✅ - 用订单号精确查询 → 用
order_id,无法定位分片 ❌
如果直接用order_id查询,因为分片键是user_id,系统根本不知道这条订单在哪个分片上,只能广播到所有分片并行查询 (全库表扫描),然后聚合结果。数据量一大,这操作分分钟把数据库打崩。
3.2 解决方案:"基因法"
"基因法" 是解决此问题的经典方案。核心思想是:在生成订单号时,把用户ID的部分信息("基因")嵌入进去 。
这样,当通过订单号查询时,系统可以从订单号中解析出"基因",反推出该订单属于哪个用户,从而准确计算出分片位置。
标准雪花算法的64位ID结构是:符号位(1) + 时间戳(41) + 机器ID(10) + 序列号(12)。
我们把它改成:符号位(1) + 时间戳(41) + 分片基因(12) + 序列号(10)。
代码实现:
java
public class OrderIdGenerator {
// 基因占12位,支持2^12=4096个分片
private static final int GENE_BITS = 12;
private static final long EPOCH = 1288834974657L;
public static long generateId(long userId) {
long timestamp = System.currentTimeMillis() - EPOCH;
// 提取用户ID后12位作为基因
long gene = userId & ((1L << GENE_BITS) - 1);
long sequence = getSequence(); // 获取序列号
// 组装:时间戳左移22位 | 基因左移10位 | 序列号
return (timestamp << 22) | (gene << 10) | sequence;
}
// 从订单ID反推分片位置
public static int getShardKey(long orderId) {
return (int) ((orderId >> 10) & 0xFFF); // 提取中间12位
}
}
路由逻辑:
java
public class OrderShardingRouter {
private static final int DB_COUNT = 4;
private static final int TABLE_COUNT_PER_DB = 16;
public static String route(long orderId) {
int gene = OrderIdGenerator.getShardKey(orderId);
int dbIndex = gene % DB_COUNT;
int tableIndex = gene / DB_COUNT % TABLE_COUNT_PER_DB;
return "order_db_" + dbIndex + ".t_order_" + tableIndex;
}
}
关键突破 :通过基因嵌入,相同用户的订单始终落在同一分片 ,同时支持通过订单ID直接定位分片 。
四、商家查订单怎么办?(核心痛点)
4.1 问题的本质
这是分库分表设计中最容易被忽略、也最致命的问题。
面试官:"你们的订单表有2亿数据,怎么做的分库分表?"
候选人:"按
user_id取模,分了16个库,每个库64张表。"面试官:"那商家(Seller)要查自己店铺的订单列表 怎么办?商家又没有
user_id,按你的分法,商家查一次岂不是要扫描全部1024张表?"
这一问,直接问到了分库分表的本质:分库分表不仅仅是"把数据切开",难点永远在于"切开后怎么聚合" 。
4.2 解法一:数据异构至Elasticsearch(强烈推荐)
这是目前大厂最标准的做法。
架构图:
text
订单写入 → MySQL(按user_id分片)→ Canal监听binlog → 同步 → Elasticsearch(按seller_id索引)
↓
商家查询 → 先查ES(按seller_id + 各种条件)→ 返回order_id列表 → 再查MySQL获取详情
核心组件:
- Canal:阿里巴巴开源的MySQL binlog增量订阅组件。它伪装成MySQL的从库,实时接收主库的binlog,解析出数据变更事件。
- Elasticsearch :海量数据全文检索引擎。将订单数据按
seller_id建立索引,支持任意复杂的组合查询。
ES索引设计:
json
{
"order_index": {
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"seller_id": { "type": "keyword" },
"buyer_id": { "type": "keyword" },
"order_status": { "type": "integer" },
"total_amount": { "type": "double" },
"create_time": { "type": "date" },
"sku_name": { "type": "text", "analyzer": "ik_max_word" }
}
}
}
}
优点:
- 支持任意复杂组合查询、模糊搜索、聚合统计
- 查询响应从秒级降至毫秒级
- 物理隔离,完全不影响主库性能
缺点:
- 引入ES组件,系统复杂度上升
- 有毫秒级的数据同步延迟(业务可接受)
4.3 解法二:异构索引表(轻量方案)
做法 :建立一张以seller_id为分片键的订单索引表。
写入策略 :用户下单写入主库后,异步 把数据同步一份到商家索引表。
⚠️ 一致性保障 :使用RocketMQ事务消息 或本地事务表 + 定时轮询 模式:
- 事务消息:利用MQ的半消息机制,确保"本地订单入库"和"消息发送"要么同时成功,要么同时失败
- 兜底重试:配合定时任务扫描未确认的消息,确保至少投递一次
注意 :这张表只保留热数据(如近6个月),冷数据走离线导出。
4.4 解法三:离线数仓(报表类查询)
如果商家的查询是"统计报表"性质的(如月度销售额汇总、类目占比),这些操作极其消耗CPU,千万不要放在在线事务库中。
做法 :将订单数据同步到列式存储数据库(如ClickHouse) 或离线数据仓库(Hive) 中。
策略:
4.5 三种方案对比
| 方案 | 适用场景 | 复杂度 | 实时性 | 推荐度 |
|---|---|---|---|---|
| ES异构 | 复杂组合查询、模糊搜索 | 中 | 毫秒级延迟 | ⭐⭐⭐⭐⭐ |
| 异构索引表 | 固定字段查询、资源有限 | 低 | 准实时 | ⭐⭐⭐ |
| 离线数仓 | 报表统计、大数据分析 | 高 | T+1或分钟级 | ⭐⭐⭐⭐ |
五、避坑指南
5.1 禁止深分页
无论是ES还是MySQL,LIMIT 100000, 20都会导致性能崩溃。
解决方案:
- 强制要求用户只能翻前100页
- 改用游标(Search After / 下一页Token) 方式翻页
- 默认只查"近3个月"的数据
5.2 分布式事务
分库分表后,原本的单库事务可能演变为分布式事务。
应对策略:
java
@GlobalTransactional
public void processPayment(Order order) {
// 扣减买家余额
accountService.debit(order.getBuyerId(), order.getAmount());
// 增加卖家余额
accountService.credit(order.getSellerId(), order.getAmount());
// 更新订单状态
orderService.updateStatus(order.getId(), "PAID");
}
5.3 禁止跨库JOIN
优化前(跨库JOIN):
sql
SELECT o.*, u.nick FROM t_order o JOIN t_user u ON o.buyer_id = u.id
优化后(分两次查询,应用层组装):
sql
-- 第一次:查订单
SELECT * FROM t_order WHERE buyer_id = ?
-- 第二次:根据user_id批量查用户
SELECT * FROM t_user WHERE id IN (?, ?, ?)
六、平滑迁移:从单库到分库分表
从单库迁移到分库分表,绝对不能停机 。推荐双写方案。
迁移步骤
第一阶段:双写
- 新订单同时写入旧库和新分库分表
- 历史数据通过离线任务(如DataX)批量迁移到新库
第二阶段:数据校验
- 对比新旧库中的数据,确保迁移完整、准确
- 修正不一致的数据
第三阶段:灰度切流
- 先切分少量读流量到新库
- 验证无误后,逐步切换所有读写流量
第四阶段:下线旧库
- 所有流量切换到新架构
- 观察一段时间后,下线旧库
七、技术选型总结
| 组件 | 推荐方案 | 说明 |
|---|---|---|
| 分库分表中间件 | Apache ShardingSphere | 生态完善,文档丰富,社区活跃 |
| 分布式ID | 改进型雪花算法(含基因) | 高性能、趋势递增、自带路由信息 |
| 数据异构 | Canal + Elasticsearch | 大厂标配,解耦彻底 |
| 分布式事务 | Seata | 阿里巴巴开源,与ShardingSphere集成良好 |
| 数据迁移 | DataX | 阿里巴巴开源,批量迁移效率高 |
写在最后
分库分表是一项复杂的架构改造,总结一下核心要点:
- 分片键 :优先选
user_id,哈希取模,分片数取2的幂次方 - 订单号:用"基因法"改造雪花算法,让订单号自带路由信息
- 商家查询 :千万不要扫全表!用ES异构或索引表方案物理隔离
- 事务:能用相同分片键避免的就避免,实在不行上Seata
- 迁移:双写 + 灰度切流,做到用户无感知
最后提醒一句:不要企图在分库分表的主库上用IN查询去遍历所有分片来聚合数据。这种方式在数据量过亿后,一旦并发稍高,数据库连接池瞬间就会被打满,直接导致服务雪崩。
让"买家实时查询"走分库分表主链路,"商家复杂查询"走ES异构数据链路------两类存储物理隔离,各司其职。
💡 如果这篇文章对你有帮助,欢迎点赞、收藏、关注!
有任何问题,欢迎在评论区留言讨论~