某电商平台的MySQL订单表达到7亿行时,出现了致命问题:一条简单的 SELECT * FROM orders WHERE user_id = xxx LIMIT 10 查询竟然耗时12秒。B+树索引深度达到5层,磁盘IO暴增;单表超200GB,备份时间窗突破6小时;写并发量达8000QPS,主从延迟高达15分钟,。

如果你正在负责一个日订单量百万级、存量数据即将破亿的系统,这篇文章应该能帮到你。我会从分片策略设计、订单号生成(基因法) 、商家多维度查询、技术选型到平滑迁移,把整个分库分表的实战思路讲清楚。
很多同学面试时被问到“什么时候分库分表”,张口就来:“阿里开发手册说单表超过500万行就要分。”
这其实有点教条了。现在的硬件(SSD + 大内存),单表跑到1000万数据,索引建好了照样飞快。
真正逼你分库分表的,通常不是“存储容量”,而是“连接数”和“维护成本” :
实战建议:通常在800万-1000万行区间开始规划分库分表,优先考虑硬件升级和索引优化,实在扛不住了再拆。
分片键的选择决定了数据如何分布,是整个方案设计的基石。选错了,后面全是坑。
分片键选择三原则:
| 原则 | 说明 | 反例 |
|---|---|---|
| 离散性 | 避免数据热点 | 用status(订单状态)分片,大量订单都是“已完成” |
| 业务相关性 | 80%的查询需携带该字段 | 用几乎不出现的字段分片 |
| 稳定性 | 值不随业务变更 | 用手机号分片(用户可能换号) |
对于电商订单系统,首选分片键是 user_id(用户ID) 。原因很简单:
user_id分布均匀,不会出现数据倾斜user_id采用哈希取模策略,这是最成熟、最常用的方案。
复制代码// 分库:user_id 对库数量取模
int dbIndex = hash(user_id) % 数据库数量;// 分表:user_id 对总表数取模,再除以库数量
int tableIndex = hash(user_id) / 数据库数量 % 单库表数量;
不要只盯着当前的1亿条数据。需要预估未来3-5年的数据增长量,并据此设计总的分片数。
计算公式:
复制代码总数据量 = 存量数据 + (日增量 × 预估天数)
总分片数 = 数据库数 × 每库表数 ≥ 总数据量 / 单表推荐容量
单表容量建议:控制在500万-1000万行,或数据文件大小在2GB-10GB以内。
实战案例:某订单系统当前8000万条数据,预期3年后达到5亿条:
关键技巧:2的幂次方。分库数和单库分表数建议设计为2的幂次方(如8、16、32、64、128)。这样在未来需要扩容时,可以通过翻倍扩容的方式,最大限度地减少数据迁移量。
使用ShardingSphere-JDBC,配置如下:
Maven依赖:
复制代码<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.3.0</version>
</dependency>
YAML配置(4库 × 16表):
复制代码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
分片算法实现:
复制代码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;
}
}
用户查询订单主要有两种方式:
user_id,能直接定位分片 order_id,无法定位分片 如果直接用order_id查询,因为分片键是user_id,系统根本不知道这条订单在哪个分片上,只能广播到所有分片并行查询(全库表扫描),然后聚合结果。数据量一大,这操作分分钟把数据库打崩。
“基因法” 是解决此问题的经典方案。核心思想是:在生成订单号时,把用户ID的部分信息(“基因”)嵌入进去。
这样,当通过订单号查询时,系统可以从订单号中解析出“基因”,反推出该订单属于哪个用户,从而准确计算出分片位置。
实现方式:改造雪花算法(Snowflake)。
标准雪花算法的64位ID结构是:符号位(1) + 时间戳(41) + 机器ID(10) + 序列号(12)。
我们把它改成:符号位(1) + 时间戳(41) + 分片基因(12) + 序列号(10)。
代码实现:
复制代码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位
}
}
路由逻辑:
复制代码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直接定位分片。
这是分库分表设计中最容易被忽略、也最致命的问题。
面试中经常出现这样的场景:
这一问,直接问到了分库分表的本质:分库分表不仅仅是“把数据切开”,难点永远在于“切开后怎么聚合” 。
这是目前大厂最标准的做法。
架构图:
复制代码订单写入 → MySQL(按user_id分片)→ Canal监听binlog → 同步 → Elasticsearch(按seller_id索引)
↓
商家查询 → 先查ES(按seller_id + 各种条件)→ 返回order_id列表 → 再查MySQL获取详情
核心组件:
seller_id建立索引,支持任意复杂的组合查询。ES索引设计:
复制代码{
"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,可以用“空间换时间”的思路。
做法:建立一张以seller_id为分片键的订单索引表。
user_id分片,用户查订单,快如闪电seller_id分片写入策略:用户下单写入主库后,异步把数据同步一份到商家索引表。
️ 一致性保障:使用RocketMQ事务消息或本地事务表 + 定时轮询模式:
注意:这张表只保留热数据(如近6个月),冷数据走离线导出。
如果商家的查询是“统计报表”性质的(如月度销售额汇总、类目占比),这些操作极其消耗CPU,千万不要放在在线事务库中。
做法:将订单数据同步到列式存储数据库(如ClickHouse) 或离线数据仓库(Hive) 中。
策略:
| 方案 | 适用场景 | 复杂度 | 实时性 | 推荐度 |
|---|---|---|---|---|
| ES异构 | 复杂组合查询、模糊搜索 | 中 | 毫秒级延迟 | ⭐⭐⭐⭐⭐ |
| 异构索引表 | 固定字段查询、资源有限 | 低 | 准实时 | ⭐⭐⭐ |
| 离线数仓 | 报表统计、大数据分析 | 高 | T+1或分钟级 | ⭐⭐⭐⭐ |
无论是ES还是MySQL,LIMIT 100000, 20都会导致性能崩溃。
解决方案:
分库分表后,原本的单库事务可能演变为分布式事务。
应对策略:
复制代码@GlobalTransactional
public void processPayment(Order order) {
// 扣减买家余额
accountService.debit(order.getBuyerId(), order.getAmount());
// 增加卖家余额
accountService.credit(order.getSellerId(), order.getAmount());
// 更新订单状态
orderService.updateStatus(order.getId(), "PAID");
}
分库分表后,跨库JOIN是性能杀手。
优化前(跨库JOIN):
复制代码SELECT o.*, u.nick FROM t_order o JOIN t_user u ON o.buyer_id = u.id
优化后(分两次查询,应用层组装):
复制代码-- 第一次:查订单
SELECT * FROM t_order WHERE buyer_id = ?
-- 第二次:根据user_id批量查用户
SELECT * FROM t_user WHERE id IN (?, ?, ?)
从单库迁移到分库分表,绝对不能停机。推荐双写方案。
第一阶段:双写
第二阶段:数据校验
第三阶段:灰度切流
第四阶段:下线旧库
| 组件 | 推荐方案 | 说明 |
|---|---|---|
| 分库分表中间件 | Apache ShardingSphere | 生态完善,文档丰富,社区活跃 |
| 分布式ID | 改进型雪花算法(含基因) | 高性能、趋势递增、自带路由信息 |
| 数据异构 | Canal + Elasticsearch | 大厂标配,解耦彻底 |
| 分布式事务 | Seata | 阿里巴巴开源,与ShardingSphere集成良好 |
| 数据迁移 | DataX | 阿里巴巴开源,批量迁移效率高 |
分库分表是一项复杂的架构改造,总结一下核心要点:
user_id,哈希取模,分片数取2的幂次方最后提醒一句:不要企图在分库分表的主库上用IN查询去遍历所有分片来聚合数据。这种方式在数据量过亿后,一旦并发稍高,数据库连接池瞬间就会被打满,直接导致服务雪崩。
让“买家实时查询”走分库分表主链路,“商家复杂查询”走ES异构数据链路——两类存储物理隔离,各司其职。