先确认:8000 万行的表到底慢在哪
有人来问我,他们订单表 8000 万行,“是不是该分库分表了”。我让他先发一条生产上最慢的 SQL 过来,长这样:
SELECT id, order_no, user_id, amount, create_time
FROM t_order
WHERE user_id = 1008611
ORDER BY create_time DESC
LIMIT 20;
EXPLAIN 的结果:
id | select_type | table | type | key | rows | Extra
1 | SIMPLE | t_order | ref | idx_user_id | 3821 | Using where; Using filesort
rows=3821,Extra 里挂着 Using filesort。索引只建了 (user_id),MySQL 把这 3800 多行全捞出来,再在内存里排序。这条 SQL 实测 380ms 上下(示例环境,具体数字看你的机器和 buffer pool 命中率)。
改成联合索引:
ALTER TABLE t_order ADD INDEX idx_user_create (user_id, create_time);
再跑,type 还是 ref,但 rows 变成 20,filesort 没了,耗时掉到 3ms 以内。什么都没拆,问题就解决了。
顺手算一下 8000 万行的 B+ 树有多深。bigint 主键 8 字节 + 6 字节页号 = 14 字节,一页 16KB 能放约 1170 个索引项,三层就是 1170³ ≈ 16 亿。也就是说 8000 万行的主键等值查询,最多 3 次页访问。全都在 buffer pool 里的话,就是三次内存跳转。
数据量大本身不会让查询变慢。随机 IO、回表、大范围扫描才会。
所以分表之前,先干这几件事:
- 慢查询日志里 top 20 的 SQL 逐条看执行计划,别凭感觉
- 联合索引顺序对不对(等值列在前,范围列和排序列在后)
- text / json 大字段是不是跟主表挤在一起,能不能拆到子表
- 历史数据占比多少,能不能归档出去
见过最典型的一个例子:1.2 亿行的日志表,慢查询是 WHERE DATE(create_time) = '2024-05-01',函数作用在索引列上,索引直接失效。改成 create_time >= '2024-05-01' AND create_time < '2024-05-02' 就好了,跟分库分表毫无关系。
分片键选错,后面全是补丁
如果确认要拆,第一件事是选分片键。而且基本只有一次机会——上线之后想换分片键,等于把所有数据重搬一遍。
拿订单表举例,至少有三类查询在抢这个位置:
| 查询方 | 典型 SQL | 希望的分片键 |
|---|---|---|
| C 端用户 | WHERE user_id = ? ORDER BY create_time DESC |
user_id |
| B 端商家 | WHERE merchant_id = ? AND status = ? |
merchant_id |
| 客服/风控 | WHERE order_no = ? |
order_no |
只能选一个。选 user_id,商家后台的查询就广播到所有分片;选 order_no,C 端"我的订单"就广播。
最常见的错误写法,是拿自增主键或订单号取模分片,然后业务里 90% 的查询都带 user_id:
-- 分片规则:order_id % 16
-- 但业务查询长这样:
SELECT * FROM t_order WHERE user_id = 1008611 ORDER BY create_time DESC LIMIT 20;
中间件(ShardingSphere、MyCat、Vitess 都一样)看到 WHERE 里没有分片键,只能把 SQL 改写成 LIMIT 0, 20 下发到全部 16 个分片,再在内存里归并。一次查询变成 16 次。QPS 一上来,先炸的是连接池。
解法是让订单号自己带上 user_id 的"基因",这样按订单号查和按 user_id 查,都能定位到同一个分片。
基因法:让订单号自己说出该去哪个分片
思路很土:从 user_id 里抠几位,塞进 order_no 的低位。因为取模用的就是低位,只要 order_no 的低位和 user_id 的低位一致,两者必然落在同一个分片。
public final class OrderNoGenerator {
private static final int SHARD_BITS = 4; // 16 个分片
private static final int SHARD_MASK = (1 << SHARD_BITS) - 1; // 0b1111 = 15
private static final long EPOCH = 1_700_000_000_000L; // 2023-11-15,别用 1970
private static final int MACHINE_BITS = 10; // 1024 台机器
private static final int SEQ_BITS = 8; // 每毫秒 256 个
private static final int SEQ_MASK = (1 << SEQ_BITS) - 1;
private final long machineId;
private long lastMs = -1L;
private long seq = 0L;
public OrderNoGenerator(long machineId) {
if (machineId < 0 || machineId >= (1L << MACHINE_BITS)) {
throw new IllegalArgumentException("machineId 超出范围: " + machineId);
}
this.machineId = machineId;
}
/**
* 位布局(共 63 位,最高位留 0 保证是正数):
* [41 位毫秒时间戳][4 位 user 基因][10 位机器号][8 位序列]
*/
public synchronized long next(long userId) {
long now = System.currentTimeMillis();
if (now < lastMs) {
// 时钟回拨。直接抛,别自作聪明用 lastMs 兜底,那会生成重复订单号
throw new IllegalStateException("时钟回拨 " + (lastMs - now) + " ms");
}
if (now == lastMs) {
seq = (seq + 1) & SEQ_MASK;
if (seq == 0) { // 本毫秒 256 个用完了
while (now <= lastMs) {
now = System.currentTimeMillis();
}
}
} else {
seq = 0;
}
lastMs = now;
return ((now - EPOCH) << (SHARD_BITS + MACHINE_BITS + SEQ_BITS))
| ((userId & SHARD_MASK) << (MACHINE_BITS + SEQ_BITS))
| (machineId << SEQ_BITS)
| seq;
}
/** 从订单号直接算分片号,不需要查任何映射表 */
public static int shardOf(long orderNo) {
return (int) ((orderNo >>> (MACHINE_BITS + SEQ_BITS)) & SHARD_MASK);
}
public static void main(String[] args) {
OrderNoGenerator gen = new OrderNoGenerator(7);
long userId = 1008611L;
long orderNo = gen.next(userId);
System.out.println(orderNo); // 19 位长整型
System.out.println(shardOf(orderNo)); // 和下面这行相等
System.out.println((int) (userId & SHARD_MASK));
}
}
(now - EPOCH) 占 41 位,能用 69 年,够用。整条链路没有一次额外查询。
接入 ShardingSphere 只需要写一个标准分片算法(这里用 5.5.x 的接口,5.4 及以前是 init() 无参,签名改过,编译不过就照你本地版本调整):
public final class OrderNoShardingAlgorithm implements StandardShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> available, PreciseShardingValue<Long> sv) {
int shard = OrderNoGenerator.shardOf(sv.getValue());
String target = "t_order_" + shard;
if (!available.contains(target)) {
throw new IllegalStateException("分片不存在: " + target);
}
return target;
}
@Override
public Collection<String> doSharding(Collection<String> available, RangeShardingValue<Long> sv) {
// 范围查询(BETWEEN、>)没法定位单分片,只能广播。这点必须在需求评审时讲清楚
return available;
}
@Override
public void init(Properties props) { }
@Override
public String getType() { return "ORDER_NO_GENE"; }
}
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.0.11:3306/order_db?useSSL=false
username: app
password: ${DB_PASSWORD}
ds1:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.0.12:3306/order_db?useSSL=false
username: app
password: ${DB_PASSWORD}
rules:
sharding:
tables:
t_order:
# 2 库 x 8 表 = 16 个分片。库路由复用同 4 位基因里的第 4 位,
# 这里只演示表策略,库策略按同样思路配一个算法即可
actual-data-nodes: ds${0..1}.t_order_${0..7}
table-strategy:
standard:
sharding-column: order_no
sharding-algorithm-name: order-no-gene
sharding-algorithms:
order-no-gene:
type: CLASS_BASED
props:
strategy: STANDARD
algorithmClassName: com.example.sharding.OrderNoShardingAlgorithm
三个必须注意的边界:
- 没有 user_id 的订单(游客下单、后台补单),基因位是 0,全落到 0 号分片。要么给这类订单分配一个虚拟 user_id,要么单独建一张不分片的兜底表。
- user_id 是字符串或 UUID 的,先做一次稳定哈希再取低位。别用
String.hashCode()——跨语言不一致,将来做数据迁移时会踩。用 murmur3 或者自己写个 FNV。 - 别把序列号放低位。有人把订单号设计成
yyyyMMddHHmmss + 序列,低 4 位是序列号,看着分布还行,但同一毫秒内序列是 0~255 循环,0 号分片永远拿到最多的写入。这就是写倾斜,热点全压在 0 号库上。
分片之后,这些 SQL 会突然变得很贵
深分页
-- 反例
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100000, 20;
ShardingSphere 会把它改写成 LIMIT 0, 100020 发到 16 个分片。每个分片最多吐 10 万行进内存,16 份就是 160 万行,再归并排序取前 20 条。轻则 GC 抖动,重则 OOM。而且越往后翻越慢,第 1000 页比第 10 页慢两个数量级。
改成游标分页(键集分页):
-- 第一页
SELECT id, order_no, user_id, amount, create_time
FROM t_order
ORDER BY create_time DESC, order_no DESC
LIMIT 20;
-- 后续页:把上一页最后一条的 (create_time, order_no) 带回来
SELECT id, order_no, user_id, amount, create_time
FROM t_order
WHERE (create_time, order_no) < ('2024-05-01 10:00:00', 1839201740190834701)
ORDER BY create_time DESC, order_no DESC
LIMIT 20;
注意这里必须用 (create_time, order_no) 做联合游标。只按 create_time 会漏数据:同一秒里有 3 笔订单,游标停在第一笔,后面两笔永远翻不到。这是个很容易上线三个月后才发现的坑。
代价是用户不能跳到第 500 页。产品上要么去掉页码,要么单独用 ES 做查询侧,MySQL 只负责按主键取数。
count(*)
SELECT COUNT(*) FROM t_order WHERE user_id = 1008611; -- 带分片键,1 个分片,随便跑
SELECT COUNT(*) FROM t_order WHERE status = 1; -- 不带分片键,16 个分片各扫一遍
第二条会并行打到 16 个分片。单表 500 万行、status 上没索引的话,就是 16 次全表扫描。运营每天早上跑一次,数据库就要告警一次。
要精确值就建汇总表定时刷(分钟级延迟),要实时就看近似值(Redis HyperLogLog,标准误差 0.81%,给运营看足够)。分片之后还想要实时精确 count,那是给自己找事。
JOIN
订单表在 0 号分片、订单明细在 5 号分片,t_order JOIN t_order_detail 就成了跨 16 片的 join,中间件要么直接报错,要么在内存里做笛卡尔积。
正确做法是明细表也用同一个分片键(user_id,或者基因后的 order_no),保证同一订单的主表和明细永远在同一个分片,退化成单机 join。别用 detail_id 给明细表分片,那是自找的。
GROUP BY、DISTINCT 同理,数据得全部拉回内存归并,分片数越多越贵。
事务和扩容:两个绕不开的硬骨头
本地事务直接失效
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // 按 user_id 分片
stockMapper.decrease(order.getSkuId(), order.getCount()); // 按 sku_id 分片
}
这两个操作必然在不同库,@Transactional 这时候就是个摆设,一个成功一个失败,没有任何回滚。
两条路:要么让它们同分片(库存跟 sku 绑死,做不到),要么放弃强一致,走本地消息表:
CREATE TABLE t_order_outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no BIGINT NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0=待投递 1=已投递 2=失败超限
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_order (order_no),
KEY idx_status_retry (status, next_retry_at)
);
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // 分片库
outboxMapper.insert(buildOutbox(order)); // 同一个本地事务,要么都成要么都不成
}
投递端按 status = 0 AND next_retry_at <= NOW() 拉,指数退避(1s / 5s / 30s / 5min),超过 N 次标失败转人工。代价是几百毫秒到几秒的不一致窗口,库存可能短暂超卖,得靠定时对账兜底。
扩容不是加机器那么简单
取模 16 分片,数据涨到要扩到 32,id % 32 和 id % 16 的结果不一样,几乎每条数据都要搬。就算用双倍扩容(16→32),每个老分片也是一半留一半搬,照样全量过一遍。
稳一点的做法是预分片:一开始就划 1024 个逻辑槽,物理上只有 16 个库,槽到库的映射放配置中心。
public final class SlotRouter {
private static final int SLOT_COUNT = 1024; // 写死在代码里,这辈子不改
/** 从配置中心加载,长度固定 1024,值域是物理分片号 */
private volatile int[] slotToShard;
public int shardOf(long userId) {
int slot = Math.floorMod((int) (userId ^ (userId >>> 32)), SLOT_COUNT);
return slotToShard[slot];
}
}
扩容时只改映射数组,把 1024 个槽里的一部分从旧分片挪到新分片,双写 → 增量同步 → 校验 → 切映射 → 再删老数据。迁移量减半,而且可以按槽灰度。代价就是多一次数组查找,纳秒级,可以忽略。
运维成本没人提前说
- DDL:16 个分片就是 16 次
ALTER TABLE,中间件帮你转发,但 MDL 锁和主从延迟一样逃不掉 - 备份:原来一条 mysqldump,现在 16 个实例分别备,还得保证备份点一致,不然恢复出来是脏的
- 监控:慢查询、连接数、主从延迟,颗粒度从 1 变 16,告警规则全部重写
这些东西方案评审时基本没人提,上线后全砸在 DBA 和值班同学身上。
那到底什么时候才该分
给个粗略的判断表,不是教条,按自己的机器和业务压测结果来:
| 现状 | 先试什么 | 什么时候才轮到分库分表 |
|---|---|---|
| 单表 500 万~2000 万,查询慢 | 联合索引、避免回表、去掉列上的函数 | 优化完还是慢 |
| 单表 2000 万~5000 万,历史数据占大头 | 冷热分离,归档到历史库或 ClickHouse | 热数据本身超过 5000 万 |
| 读多写少,主库 CPU 高 | 读写分离 + 缓存 | 写 QPS 也顶到单机上限 |
| 写入量大,表持续增长 | 先看能不能按月分表或分区表 | 分区表维护成本超过收益 |
判断依据从来不是"数据量到了多少",而是:单表优化到极限之后,写入 QPS 或单表容量仍然是瓶颈,并且业务能接受跨分片查询带来的限制。
如果只是查得慢,先去把索引和 SQL 捋一遍。8000 万行的表上加个联合索引,380ms 变 3ms 这种事我见过太多次,分库分表的评审会根本不用开。