
MySQL 索引 B+ 树与"查询为什么变慢了":从页分裂到回表代价
关键词:B+ 树、聚簇索引、回表、最左前缀、页分裂、ICP、MRR
一个常被忽略的事实是:MySQL 的查询变慢,绝大多数时候不是"数据变多了",而是"索引没被用上"或"用上了但代价被低估"。InnoDB 的 B+ 树从来不是"有索引就快"的银弹------它是一棵以 16KB 页为节点、以磁盘 I/O 次数为成本的树。理解这棵树的物理形态,比背"最左前缀原则"有用得多。
本系列前面聊过线程池、AQS、CompletableFuture 这些 JVM 侧的并发话题,这一篇换个战场:把视角下沉到存储引擎,看看一条 SELECT 从 SQL 到磁盘页之间到底发生了什么。
一、先纠正一个认知:B+ 树不是"树",是"页的目录"
很多人对 B+ 树的理解停留在教科书图示:一个根节点,往下分叉,叶子节点串成链表。这没错,但漏掉了最关键的物理约束------InnoDB 的每一个 B+ 树节点,就是一个 16KB 的数据页(page)。
这个约束决定了三件事:
1.1 树高决定 I/O 次数
假设主键是 BIGINT(8 字节),InnoDB 的行格式里每条记录还有一个 6 字节的 DB_ROW_ID/事务相关开销,加上页内指针,一个非叶子节点页大约能存上千个 key(具体数量取决于 key 长度和页内目录结构)。那么:
- 树高 3 层:根页 + 中间层 + 叶子层
- 叶子层能容纳的记录数 ≈ 上千 × 上千 × 每页记录数
也就是说,千万级数据量的表,主键等值查询的树高通常也就 3~4 层。这意味着一次主键查询最多 3~4 次页访问。而根节点和中间层节点大概率常驻 Buffer Pool,真正需要磁盘 I/O 的往往只有最后那一次叶子页读取。
这就是为什么"索引让查询变快"------它把全表扫描的 O(N) 次页访问,压缩成了 O(log N) 次。
1.2 页大小是可配置的,但别乱动
innodb_page_size 默认 16KB,可选 4KB/8KB/16KB/32KB/64KB。官方文档明确说明:页大小在初始化实例时设定,之后不可更改(除非重新初始化)。调大页能降低树高,但会加剧写放大和 Buffer Pool 碎片;调小页适合某些 SSD 场景。绝大多数业务没有调它的理由。
参考:MySQL 官方文档 - InnoDB Page Size
1.3 叶子节点存的是"数据",不是"指针"
这是聚簇索引(clustered index)和二级索引(secondary index)的本质区别:
| 索引类型 | 叶子节点内容 | 等值查询后还需要做什么 |
|---|---|---|
| 聚簇索引(主键) | 完整行数据 | 直接返回 |
| 二级索引 | 索引列 + 主键值 | 回表:拿主键再去聚簇索引查一次 |
"回表"这两个字,是无数慢查询的根源。
二、回表:慢查询最隐蔽的成本放大器
假设 order-service 里有一张订单表(仅为讲解示例):
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`status` TINYINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`, `created_at`)
) ENGINE=InnoDB;
执行:
SELECT id, user_id, amount, created_at
FROM t_order
WHERE user_id = 10086
ORDER BY created_at DESC
LIMIT 20;
看起来 idx_user_created 完美匹配 WHERE + ORDER BY,应该很快。但注意 amount 不在索引里------每一行都要回表。
2.1 回表的真实代价
二级索引的叶子节点里只有 user_id、created_at、id。要拿到 amount,必须用 id 回到聚簇索引再查一次。假设这个用户有 10 万条订单:
- 走二级索引:定位到 10 万条索引记录(顺序扫描,很快)
- 回表:10 万次随机主键查找
随机 I/O 是这里的关键词 。二级索引里 id 的分布未必和 created_at 顺序一致,回表时访问的聚簇索引页可能是跳跃的。Buffer Pool 命中还好,一旦未命中就是随机磁盘寻道------这正是"索引明明走了,但还是慢"的典型场景。
2.2 覆盖索引:把回表消掉
如果查询只需要索引里已有的列:
SELECT id, user_id, created_at
FROM t_order
WHERE user_id = 10086
ORDER BY created_at DESC
LIMIT 20;
EXPLAIN 的 Extra 会出现 Using index,即覆盖索引(covering index) ,无需回表。这是优化慢查询性价比最高的手段之一:把 SELECT 列表里的列,尽量塞进联合索引。
但别走极端------索引列越多,索引本身越大,写入越慢。覆盖索引是权衡,不是教条。
三、最左前缀:不是规则,是 B+ 树的物理必然
"最左前缀原则"被讲烂了,但很少有人解释为什么。
3.1 联合索引的排序方式
idx_user_created (user_id, created_at) 在 B+ 树里是这样排序的:
- 先按
user_id升序 user_id相同时,再按created_at升序
所以:
-- 能用索引:user_id 是前缀
WHERE user_id = 10086
-- 能用索引:user_id + created_at 都是前缀
WHERE user_id = 10086 AND created_at > '2024-01-01'
-- 用不了索引定位:跳过了 user_id
WHERE created_at > '2024-01-01'
第三条不是 MySQL "不想用",而是在树里根本找不到起点 ------created_at 在全局不是有序的,只在同一个 user_id 内部有序。
3.2 范围查询会"截断"后面的列
这是最容易被忽略的边界:
-- created_at 是范围,后面的列无法再用于索引定位
WHERE user_id = 10086 AND created_at > '2024-01-01' AND status = 1
status 不在索引里,即便在,created_at 用了范围后,status 也只能在回表后过滤(除非用到索引条件下推,见下节)。这就是为什么联合索引的列顺序要"等值在前、范围在后"。
3.3 一个实操验证方法
不用猜,直接看 EXPLAIN 的 key_len:
EXPLAIN SELECT * FROM t_order
WHERE user_id = 10086 AND created_at > '2024-01-01';
key_len 会告诉你索引实际用了几个字节。BIGINT 是 8 字节(允许 NULL 再加 1),DATETIME 在 MySQL 8.0 是 5 字节 + 小数秒。通过 key_len 反推用到了第几列,比背规则可靠。
四、ICP 与 MRR:两个被低估的优化器武器
4.1 索引条件下推(Index Condition Search, ICP)
MySQL 5.6 引入 ICP。在没有 ICP 时,存储引擎只能把符合索引前缀的记录全部回表,交给 Server 层过滤。有 ICP 后,能在索引层判断的条件下推到存储引擎,减少回表次数。
-- 假设索引 (user_id, created_at)
SELECT * FROM t_order
WHERE user_id = 10086
AND created_at LIKE '2024-01%';
LIKE '2024-01%' 是前缀匹配,可以在索引层判断,ICP 会避免把不匹配的行回表。EXPLAIN 的 Extra 显示 Using index condition。
注意:ICP 不减少索引扫描的行数,只减少回表次数。这是常见误解。
参考:MySQL 官方文档 - Index Condition Pushdown
4.2 多范围读(Multi-Range Read, MRR)
回表是随机 I/O,MRR 的思路是:先把要回表的主键收集起来,排序后再批量回表,把随机 I/O 转成更接近顺序的 I/O。
SET optimizer_switch = 'mrr=on,mrr_cost_based=off';
mrr_cost_based=off 会强制使用 MRR(生产环境慎用,让优化器自己判断通常更好)。MRR 在机械盘上收益明显,在 NVMe SSD 上收益可能有限------因为随机读的惩罚本身就小了很多。
五、页分裂:写入侧被忽视的性能杀手
前面都在讲读。但 B+ 树的写同样值得警惕。
5.1 自增主键为什么"通常"更好
InnoDB 聚簇索引按主键排序。如果主键是单调递增 的(如 AUTO_INCREMENT),新记录永远追加到最右侧叶子页,页写满就申请新页------顺序写入。
如果主键是随机 的(如 UUID、雪花 ID 打乱、业务随机码),新记录会插入到树的中间。当目标页已满,InnoDB 必须页分裂:把一页拆成两页,移动部分记录,并更新父节点的指针。
页分裂的代价:
- 额外的页分配和记录移动
- 父节点可能级联分裂
- 页内空间利用率下降(分裂后两页各约 50% 满)
5.2 一个假设性示意
假设一张表用随机字符串做主键,持续高频插入。随着数据增长,页分裂会让索引的物理空间利用率明显低于顺序插入的场景,Buffer Pool 里能缓存的"有效数据"变少,间接拖慢读。这是设计层面的问题,事后加索引救不回来。
(以上为机制层面的示意性说明,不涉及具体测量数据。)
5.3 但不是所有场景都该用自增
反直觉的地方在于:自增主键在高并发插入时,所有写入都竞争最右侧的页,可能形成热点。有些分布式场景用"趋势递增 + 分片"的策略,正是为了在顺序性和热点之间取平衡。没有银弹。
六、什么时候别用索引 / 别踩的坑
反直觉清单,按"最容易翻车"排序:
- 区分度低的列单独建索引,通常是浪费。 比如
status只有 0/1/2 三个值,优化器很可能直接选择全表扫描------因为走索引回表的代价可能高于扫描。区分度(cardinality)是关键,SHOW INDEX FROM t_order能看到。
WHERE里对索引列做函数运算或隐式类型转换,索引直接失效。WHERE DATE(created_at) = '2024-01-01'、WHERE user_id = '10086'(user_id 是 BIGINT,字符串会触发隐式转换)都是经典翻车点。改成范围写法:created_at >= '2024-01-01' AND created_at < '2024-01-02'。
LIKE '%xxx'前置通配符用不了 B+ 树索引。 这不是 MySQL 的锅,是 B+ 树只能从左往右定位。需要全文检索就上全文索引或外部搜索引擎。
- 索引不是越多越好。 每个二级索引都要在写入时同步维护,还要占 Buffer Pool。一张表挂七八个索引,写入性能会明显下降。
ORDER BY用不上索引时,LIMIT也救不了。 深分页LIMIT 1000000, 20的经典问题:即使走了索引,也要先扫描并丢弃前 100 万行。常见解法是"游标分页"(记住上一页最后一个 id)或延迟关联(先用覆盖索引拿主键,再回表)。
- 别迷信
EXPLAIN的type=ref就万事大吉。 还要看rows(预估扫描行数)、filtered(过滤比例)、Extra。rows很大 + 需要回表,一样会慢。
- 统计信息会过期。 InnoDB 的索引统计信息不是实时的,
ANALYZE TABLE可以手动刷新。优化器基于过期统计选错执行计划,是"昨天还快今天就慢"的常见原因之一。
结语
B+ 树的本质,是用树高换 I/O 次数 、用有序性换范围查询。查询变慢,往往不是树"坏了",而是:
- 回表次数被低估(缺覆盖索引)
- 索引列顺序与查询模式不匹配(最左前缀被破坏)
- 写入模式导致页分裂(随机主键)
- 优化器基于过期统计做了错误决策
下一篇,我会顺着这条线往下挖:EXPLAIN 的输出到底该怎么读,以及 EXPLAIN ANALYZE 在 MySQL 8.0 里能告诉我们什么 EXPLAIN 说不出的东西。如果这篇对你有帮助,欢迎关注系列,我们继续往存储引擎深处走。