这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 MySQL 的 InnoDB 索引是 B+Tree。理解 B+Tree,就能理解为什么主键要递增、为什么联合索引遵守最左前缀、为什么二级索引会回表、为什么范围查询可能影响排序。
9.1 为什么需要索引
没有索引时,查找一个订单只能全表扫描:
10 万行:最多比较 10 万次
1000 万行:最多比较 1000 万次
B+Tree 将有序数据按页组织,从根到叶逐层缩小范围。100 万行数据通常只需要几次页访问。
真实次数取决于行大小、页填充率、缓冲命中和数据分布,但数量级差异非常明显。
9.2 B+Tree 的结构
InnoDB 默认页大小为 16KB:
SHOW VARIABLES LIKE 'innodb_page_size';
B+Tree 结构:
Root Page
|
+-- Internal Page:只存键和子页指针
|
+-- Leaf Page:存索引键和行信息
同一层叶子节点之间用双向链表连接
特点:
- 非叶子节点只负责路由;
- 叶子节点保存全部键值;
- 叶子节点有序;
- 范围扫描可以沿链表继续读取;
- 树高通常为 2 到 4 层。
9.3 聚簇索引
InnoDB 表本身就是按主键组织的 B+Tree,称为聚簇索引。
PRIMARY KEY B+Tree
叶子节点:
主键值 + 完整行数据
如果没有显式主键:
- 优先使用第一个非 NULL 唯一索引作为聚簇索引;
- 如果没有合适的唯一索引,InnoDB 生成隐藏的
ROW_ID。
隐藏 ROW_ID 不利于治理,业务表应显式定义主键。
主键查询
SELECT *
FROM orders
WHERE id = 1;
沿着主键 B+Tree 定位叶子页,直接拿到完整行,不需要回表。
9.4 二级索引
二级索引也称为辅助索引。它的叶子节点保存索引列和主键值:
INDEX(user_id, created_at)
叶子节点:
user_id + created_at + id
查询:
SELECT *
FROM orders
WHERE user_id = 1001;
执行过程:
1. 在 idx_user_created 中定位 user_id=1001;
2. 从叶子节点拿到主键 id;
3. 回到主键索引查完整行;
4. 继续扫描直到 user_id 条件不满足。
第 3 步就是回表。若结果很多,回表成本会明显上升。
覆盖索引
SELECT id, user_id, created_at
FROM orders
WHERE user_id = 1001;
二级索引叶子已经包含 user_id、created_at 和主键 id,无需回表,执行计划通常显示 Using index。
覆盖索引不是一种独立语法,而是查询所需列恰好被索引包含的结果。
9.5 最左前缀原则
联合索引:
KEY idx_user_status_created (user_id, status, created_at)
可以支持:
user_id
user_id + status
user_id + status + created_at
user_id + status + created_at 范围
不能直接支持:
status
created_at
status + created_at
因为 B+Tree 先按 user_id 排序;只有 user_id 相同,才按 status 排序;只有前两列相同,才按 created_at 排序。
MySQL 8.0.13+ 有 Index Skip Scan,某些前导列取值很少的场景可以跳跃扫描,但不能把它当作通用设计依据。
9.6 索引列顺序与范围条件
联合索引 (a, b, c) 中:
WHERE a = 1 AND b = 2 AND c >= 10
通常 a、b 可以精确匹配,c 走范围。
WHERE a >= 1 AND b = 2
a 是范围后,b 在 B+Tree 全局有序性上无法继续精确定位。优化器仍可能使用 b 过滤,但效率取决于数据分布。
设计建议:
- 等值条件列放前面;
- 范围条件列尽量放后面;
- 排序列放在范围列之后要谨慎;
- 高选择性列优先,但要结合固定查询;
- 不要只看单列区分度,要看组合查询路径。
9.7 页分裂与页合并
页分裂
当一页写满后插入新行,InnoDB 会分配新页并移动部分记录:
原页 100% 满
-> 分裂成两个约 50% 满的页
-> 父节点增加路由项
随机主键会让新数据落在已有页之间,导致更多页分裂:
- 写入放大;
- 页空间利用率下降;
- 树高增长;
- Buffer Pool 命中率下降;
- 二级索引同样受影响。
趋势递增主键让新数据集中追加到最右侧页,分裂更少。
页合并
删除数据使页填充率低于阈值时,InnoDB 可能合并页:
低填充页 -> 与相邻页合并 -> 释放空页
删除大量数据后磁盘空间未必立刻返还给操作系统,通常以可复用页的形式留在表空间中。
9.8 索引基数与统计信息
查看索引基数:
SHOW INDEX FROM orders;
SELECT
table_name,
index_name,
column_name,
cardinality
FROM information_schema.STATISTICS
WHERE table_schema = 'shop'
AND table_name = 'orders';
基数表示索引列不同值的估算数量。基数越高,通常选择性越好。
粗略选择性:
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
COUNT(DISTINCT user_id) / COUNT(*) AS user_selectivity
FROM orders;
注意:
- 基数是估算值;
- 低基数不等于索引一定没用;
- 状态列配合时间列可能有效;
- 高基数列也要匹配查询条件;
- 数据分布倾斜时平均值会误导判断。
更新统计信息:
ANALYZE TABLE orders;
MySQL 8.0 支持直方图,帮助优化器了解非索引列分布:
ANALYZE TABLE orders
UPDATE HISTOGRAM ON status, merchant_id WITH 100 BUCKETS;
查看:
SELECT * FROM information_schema.COLUMN_STATISTICS
WHERE table_name = 'orders'\G
9.9 行格式与大字段
查看表行格式:
SELECT NAME, ROW_FORMAT
FROM information_schema.INNODB_TABLES
WHERE NAME LIKE '%/orders';
常见行格式:
| 格式 | 说明 |
|---|---|
COMPACT |
传统紧凑格式 |
DYNAMIC |
MySQL 5.7+ 常用,长变长列可溢出 |
COMPRESSED |
压缩行格式,需要评估 CPU 和页匹配 |
大字段可能存储在溢出页:
聚簇索引叶子保存前缀 + 指向溢出页的指针
影响:
- 单页可容纳行数减少;
- 索引树变高;
- 查询大字段增加随机 IO;
- 内存命中率下降。
大字段治理:
- 不查询不必要的
TEXT/BLOB; - 高频字段和低频大字段分表;
- 图片和文件放对象存储;
- 历史详情归档;
- 使用覆盖索引避免读取大字段所在页。
9.10 Change Buffer
Change Buffer 用于二级索引写优化。当修改的二级索引页不在 Buffer Pool 中时,InnoDB 可以先把变更缓存起来,避免立即读入原索引页。
查看:
SHOW VARIABLES LIKE 'innodb_change_buffering';
SHOW ENGINE INNODB STATUS\G
适合:
- 二级索引多;
- 写多读少;
- 页不在内存概率高;
- 非唯一索引。
不适合:
- 唯一索引必须读取页判断唯一性;
- 写后马上读取;
- 索引页基本常驻内存;
- 高压场景需要简化不确定因素。
9.11 Adaptive Hash Index
InnoDB 会监控热点页访问模式,自动为热点索引页构建自适应哈希:
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';
SHOW ENGINE INNODB STATUS\G
它不是用户可显式创建的哈希索引,也不是通用加速器。高并发读写下可能引入锁竞争,排障时可以谨慎关闭测试:
SET GLOBAL innodb_adaptive_hash_index = OFF;
生产修改全局参数必须评估影响范围并保留回滚方案。
9.12 主键为什么推荐 BIGINT AUTO_INCREMENT
常见理由:
- 趋势递增,减少随机插入和页分裂;
- 固定 8 字节,键比较简单;
- 二级索引叶子保存主键,主键越小越省空间;
- 便于范围划分、归档和游标分页;
- 便于监控自增水位。
随机 UUID 的问题:
- 插入位置随机;
- 空间占用更大;
- 缓存局部性差;
- 二级索引体积膨胀;
- 页分裂更多。
如果需要对外隐藏规律,可以保留内部递增主键,另建随机 public_id 唯一索引。
9.13 索引的代价
索引不是免费的:
| 操作 | 受影响 |
|---|---|
| INSERT | 每个索引都可能写入 |
| UPDATE | 修改索引列时更新索引 |
| DELETE | 标记删除并维护索引 |
| 磁盘 | 索引占用空间 |
| 内存 | 索引页竞争 Buffer Pool |
| 优化器 | 候选索引越多选择越复杂 |
| DDL | 索引越多变更越慢 |
| 备份 | 数据文件更大 |
经验上,单表索引数量应控制在必要范围内,并为每个索引回答:
1. 支撑哪个查询;
2. 预计减少多少扫描;
3. 写入代价多少;
4. 是否能覆盖高频查询;
5. 未来是否可下线;
6. 由谁负责验证;
7. 由谁负责删除。
本章小结
InnoDB 的聚簇索引按主键组织完整行,二级索引叶子保存索引列和主键。B+Tree 的有序结构解释了最左前缀、范围扫描、回表、覆盖索引和页分裂。主键应尽量小且趋势递增,索引设计要同时考虑查询收益和写入、存储、DDL、内存成本。
思考题
- 为什么二级索引叶子节点保存主键而不是完整行地址?
- 覆盖索引为什么可以减少回表?
- 联合索引
(a,b,c)为什么不能直接支持只按c查询? - 随机 UUID 主键为什么会增加页分裂?
- 为什么索引数量不是越多越好?