MySQL 索引优化:结构、最左前缀与执行计划分析
本文目录
索引是 MySQL 调优的第一杠杆,也是被误解最多的部分——加一个索引能让查询快 100 倍,也能让写入慢 3 倍、让一条本可走索引的查询悄悄全表扫描。这篇文章从 B+ 树结构讲到 EXPLAIN 逐列解读,再用同一张示例表贯穿所有场景。所有结论均以 InnoDB、MySQL 8.x 为准,可在 dev.mysql.com 官方文档对照。
先给出贯穿全文的示例表:
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL COMMENT '0待支付 1已支付 2已发货 3已完成',
amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL,
note VARCHAR(255) DEFAULT NULL,
KEY idx_user_status_created (user_id, status, created_at)
) ENGINE=InnoDB;
为什么是 B+ 树
InnoDB 选 B+ 树不是历史偶然,可以逐个排除其他候选:
| 结构 | 强项 | 为什么不适合磁盘存储 |
|---|---|---|
| 哈希表 | 等值查询 O(1) | 不支持范围(BETWEEN、>、ORDER BY),内存哈希更没有页的概念 |
| 二叉/红黑树 | 内存均衡 | 树太深:百万行要 20 层左右,每层都是一次磁盘随机 IO |
| 跳表 | 实现简单、内存友好 | 层数与二叉树同量级,磁盘页利用效率不如 B+ 树 |
| B+ 树 | 树矮胖、叶子链表 | 内部节点只存键不存数据,单页(16KB)能塞上百个键,3–4 层覆盖千万行 |
B+ 树的两个关键设计:非叶子节点不存行数据只存索引键,扇出极大,InnoDB 默认页 16KB,三四层树高就能索引千万级行——即一次主键点查最多 3–4 次页访问;叶子节点之间有双向链表,范围查询定位到起点后沿链表顺序扫即可,天然契合 ORDER BY 与 BETWEEN。对 SSD 时代磁盘随机读已没那么可怕,但"减少页访问次数"仍是索引设计的核心目标。
聚簇索引与二级索引:回表的代价
InnoDB 的表本身就是按主键组织的 B+ 树(聚簇索引),行数据就在主键树的叶子页上。二级索引(如上面的 idx_user_status_created)是独立的树,叶子节点存的是索引列值 + 主键值。
-- 查询一:走二级索引,拿到 (user_id, status, created_at, id)
SELECT * FROM orders WHERE user_id = 42;
-- 拿到的只有索引列和主键 id,要得到 amount、note,还要拿 id 回聚簇树再查一遍:回表
-- 若命中 1000 行,就是 1000 次回表(页大多在缓冲池里时不算贵,但仍是放大系数)
-- 查询二:只查索引里已有的列
SELECT id, status, created_at FROM orders WHERE user_id = 42;
-- 索引树里全都有,不用回表:覆盖索引,EXPLAIN 的 Extra 会显示 Using index
推论:SELECT * 不是风格问题,是性能问题——它让所有走二级索引的查询都退化为"索引扫描 + 逐行回表",且表加列后还会引入更深的功能依赖(列改名、类型改)。同时主键设计影响所有二级索引的大小:主键越短,每个二级索引叶子越小,用随机 UUID 做主键既让二级索引膨胀,也让聚簇页插入点分散(页分裂频繁),大表主键推荐自增 ID 或有序 ID 方案。
最左前缀:联合索引的列顺序
联合索引 (user_id, status, created_at) 在 B+ 树里是按"先 user_id,再 status,最后 created_at"排序的三元组。查询能不能用它,取决于条件是否从最左列连续命中:
-- 命中:从 user_id 开始连续
WHERE user_id = 42 -- 用 1 列
WHERE user_id = 42 AND status = 1 -- 用 2 列
WHERE user_id = 42 AND status = 1 AND created_at > '2026-10-01' -- 3 列(created_at 走范围)
-- 不完整但部分命中
WHERE user_id = 42 AND created_at > '2026-10-01' -- 只稳定用 user_id 一列
-- 不命中:缺最左列
WHERE status = 1 -- 无法使用该索引
WHERE created_at > '2026-10-01' -- 无法使用该索引
列顺序的判断标准不是"哪个列查询多",而是等值条件列在前、范围条件列在后,因为范围条件之后的列无法继续参与定位(下一节的截断)。如果 status 单独查询也高频,正确的做法不是调整这个索引,而是另建 KEY idx_status (status)——一个索引服务一个查询簇,不要试图用一个联合索引覆盖所有形态。
范围截断与索引下推(ICP)
联合索引遇到范围条件就"截断":范围列本身还能用于扫描过滤,但其后的列只剩过滤价值。MySQL 5.6 引入的索引下推(Index Condition Pushdown)把一部分过滤下推到存储引擎层做:
-- 索引 (user_id, status, created_at)
SELECT * FROM orders
WHERE user_id = 42 AND created_at > '2026-10-01 00:00:00';
-- 无 ICP:server 层先用 user_id=42 定位,逐行回表读整行,再由 server 判断 created_at
-- 有 ICP:在索引树上就用 created_at 过滤,不满足的行直接跳过,回表次数大幅减少
EXPLAIN 里 ICP 的标志是 Extra 出现 Using index condition。经验法则:放范围列到联合索引最后一位,让截断截在末尾,前面的等值列都物尽其用;确有多个范围列时只能保证一个范围列走索引,剩下的靠覆盖或改写查询(比如把时间范围离散成日期枚举再等值匹配)。
覆盖索引:让查询止步于索引树
覆盖索引指查询所需的所有列都在索引里,免回表。它是最被低估的优化:不算改 schema、不算大改查询,通常只是改 SELECT 列清单或加一个专门索引:
-- 列表页只需要这三列 + 主键 id(索引里天然有)
SELECT id, status, created_at
FROM orders
WHERE user_id = 42 AND status = 1
ORDER BY created_at DESC
LIMIT 20;
-- 完美命中 idx_user_status_created:过滤、排序、取数全在索引树完成,零回表
| 手段 | 效果 |
|---|---|
| 去掉不需要的列(消灭 SELECT *) | 减少回表概率 |
| 把高频展示列追加进联合索引尾部 | 列表查询全覆盖 |
| 只用聚合函数查索引列(COUNT/SUM) | 免回表 |
边界:覆盖索引是把读的成本转嫁给写——索引更宽,写入时维护更贵、缓冲池占用更多,列数控制在 4–5 列以内,VARCHAR(255) 这类宽列进索引前要掂量。反例教训:某接口为了覆盖把 note VARCHAR(255) 也塞进联合索引,写入抖动立刻上升,得不偿失。
EXPLAIN:type 等级、rows 与 Extra
EXPLAIN 是索引优化的唯一裁判。先看 type 列(访问类型,从好到坏):
| type | 含义 | 出现场景 |
|---|---|---|
| system / const | 主键或唯一索引等值,最多一行 | WHERE id = 1 |
| eq_ref | 连接时被驱动表走主键/唯一键 | JOIN 的理想形态 |
| ref | 普通二级索引等值 | WHERE user_id = 42 |
| range | 索引范围扫描 | >、BETWEEN、IN |
| index | 扫整棵索引树(不回表但全扫) | 统计类、只查索引列时可见 |
| ALL | 全表扫描 | 索引失效或没有索引 |
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 1\G
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ref
possible_keys: idx_user_status_created
key: idx_user_status_created
key_len: 9
ref: const,const
rows: 87
filtered: 100.00
Extra: NULL
key_len = 9 可以核对联合索引的命中深度:BIGINT NOT NULL 占 8 字节 + TINYINT NOT NULL 占 1 字节,说明两列等值都用上了。
逐列看法:key 是实际用的索引,key_len 能反推用了联合索引的前几列(每列按类型有固定字节数,如 BIGINT 8 字节、DATETIME 5 字节,可累加核对);rows 是优化器预估的扫描行数,是数值不是精确值,但数量级可信;Extra 才是判断"索引有没有充分发挥"的关键:
| Extra | 含义 | 应对 |
|---|---|---|
| Using index | 覆盖索引,免回表 | 好状态 |
| Using index condition | ICP 生效 | 好状态 |
| Using where | server 层过滤 | 留意过滤量是否大 |
| Using filesort | 排序没走索引,额外排序 | 高频查询要修(下一节) |
| Using temporary | 建了临时表(常见于 GROUP BY/DISTINCT) | 高频查询要修 |
| Using index; Using where | 覆盖 + 过滤 | 通常是好状态 |
实践顺序:先看 type 是不是 ALL(索引没走上),再看 rows 数量级(预估扫描量),最后看 Extra 有没有 filesort/temporary(索引用上了但没用够)。三步各修各的问题,不要混在一起猜。
索引失效:函数、隐式转换、前导 % 与 or 陷阱
-- 失效一:对索引列用函数
WHERE YEAR(created_at) = 2026 -- 索引失效
-- 改写为可走索引的范围
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
-- 失效二:隐式类型转换
WHERE user_id = '42' -- 数字列给字符串,实际是数字列,等值常量被转换成数字,可以走索引
WHERE note = 42 -- 字符串列给数字,是把列往数字转,函数作用在列上,索引失效
-- 习惯:常量类型与列类型严格一致,SQL 评审里专查这一条
-- 失效三:前导通配符
WHERE note LIKE '%关键词%' -- 无法走普通索引
WHERE note LIKE '关键词%' -- 可以走索引(前缀确定)
-- 失效四:or 陷阱
WHERE user_id = 42 OR note = 'abc' -- note 无索引时,整条退化为全表扫描
-- 两个条件都有索引时可用 index merge,但稳定性不如拆成两条 UNION 查询
or 的正确理解:只要 or 的一侧无索引可用,整条查询就全表扫描;两侧都有索引时优化器可能选 index merge(Using intersect/Using union),但 index merge 在复杂条件下不一定比 UNION 更优,关键查询可对照两种写法的 rows 实测。另外 NOT IN、!= 通常也无法有效走索引——它们本质是"除了这些值之外的所有行",等于范围巨大。判断失效不要背清单,直接 EXPLAIN 看 type 是否退化为 ALL,再逐条改写。
ORDER BY 与索引
索引本身就是有序的,所以"能用索引完成排序"就无需 filesort:
-- 命中:ORDER BY 与联合索引 (user_id, status, created_at) 顺序一致
SELECT * FROM orders WHERE user_id = 42 AND status = 1 ORDER BY created_at DESC LIMIT 20;
-- 失效:ORDER BY 跳过了 status
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC;
-- filesort:排序列不在过滤条件的连续前缀之后
-- 失效:ORDER BY 方向与索引序不一致(同为升序或同为降序才可以)
SELECT * FROM orders WHERE user_id = 42 AND status = 1 ORDER BY created_at ASC, status DESC;
规律与最左前缀同构:ORDER BY 的列必须紧跟 WHERE 里已命中的等值前缀,且列的排序方向要一致(MySQL 8.0 支持降序索引 DESC,混合方向的排序可用 ORDER BY a ASC, b DESC 的降序索引定义匹配)。带 LIMIT 的分页查询尤其受益:filesort 需要物化所有中间结果再截断,走索引则可以"边扫边停",20 行就翻 20 个叶子节点。
深分页:limit 100000 的三种解法
-- 问题形态:越翻越慢
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 100000, 20;
-- 要扫描并丢弃前 100000 行(回表或索引跳读都在做无用功),第 1000 页延迟成秒级
| 方案 | 写法 | 适用 |
|---|---|---|
| 游标(keyset)分页 | WHERE (user_id, created_at) < (42, 上页末条时间) ... LIMIT 20 | 对外接口、无跳页需求 |
| 延迟关联 | 先查主键再回表 | 必须支持跳页 |
| 限制最大页深 | 前端只暴露前 N 页 | 与产品规则配合 |
-- 游标分页:以唯一序(主键兜底)为游标
SELECT * FROM orders
WHERE user_id = 42
AND (created_at, id) < ('2026-10-20 12:00:00', 998877)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 命中索引直接从游标位置向后读 20 行,延迟与页码无关
-- 延迟关联:子查询在索引内完成 offset 丢弃,回表只发生 20 次
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 100000, 20
) t ON o.id = t.id;
游标分页是最优解但要付出产品代价:不能跳页、刷新位置难维持(游标是快照,新数据进来位置会漂移,这是所有分页稳定性方案都要面对的核心议题)。延迟关联是兼容传统分页的折衷,原理就一句话:让"丢弃 100000 行"发生在索引树内部,别让它牵着回表跑。
索引维护成本与 cardinality
索引不是免费的。每次写入要同步维护所有相关索引树(页分裂、页合并),索引也占用缓冲池与磁盘。评估索引合理性看两个数:
-- 单个索引的统计
SHOW INDEX FROM orders;
-- 更全的统计(含 data_length / index_length 的库表占用)
SELECT * FROM information_schema.TABLES
WHERE table_schema = 'example_db' AND table_name = 'orders';
- cardinality(基数):索引列中不重复值的估计数量。
user_id基数高(几乎每行不同),过滤性好;status只有 4 个值,基数 4,选择性约 1/4,单独建索引价值很低——优化器在 status 上过滤后仍要扫 25% 的行,它甚至可能放弃该索引直接全表。 - 统计信息代价:cardinality 是采样估计,写入频繁时可能失真,
ANALYZE TABLE orders可手动刷新;优化器基于失真的统计做 join 顺序选择,是"统计变了执行计划就变"的偶发慢查询来源。
索引治理经验值:单表索引数量 5–6 个以内(结合写入放大评估)、没有查询支撑的索引删除(sys.schema_unused_indexes 可辅助)、上线前用 EXPLAIN 验证每条核心 SQL 的访问类型。删不用的索引是低风险的(可以随时加回),留错索引是持续的隐性税。
注意事项与边界
- 本文示例均以 InnoDB 为准;MEMORY 引擎默认哈希索引、MyISAM 无聚簇概念,结论不可平移。
- EXPLAIN 的 rows 是估算值,两套计划 rows 差 10% 不必下结论,差 10 倍才值得改写。
- 优化器版本差异:MySQL 8.0 的 hash join、降序索引、invisible index(
ALTER TABLE ... ALTER INDEX ... INVISIBLE)给调优提供了试错手段,invisible index 可以"灰度删除"。 - 批量写入前可临时删索引再重建,但要评估期间该表的查询形态——OLTP 高峰期别做。
FORCE INDEX是救火手段不是设计手段:写死索引会随数据分布变化而劣化,留给"优化器明显选错且等不及统计修复"的场景。- 所有数据量效果表述(千万行、3–4 层树高)是量级经验,具体取决于行宽与页填充,用
SHOW TABLE STATUS核对自己的真实表。 - 本文示例库名 example_db、数据均为演示值,请替换为你自己的环境。
总结
索引优化可以收敛成一条主线:按查询设计索引(等值在前、范围在后、ORDER BY 接续前缀),用 EXPLAIN 三步验证(type 不退 ALL、rows 数量级合理、Extra 无 filesort/temporary),按成本治理(覆盖换性能、基数低的别单独建、无用的删掉)。把每个索引都绑定到具体的查询语句上,索引清单才不会长成一团没人敢动的铁锈。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。