MySQL 索引入门:从一次慢查询说起
从图书管理系统里一条越来越慢的查询出发理解 MySQL 索引:EXPLAIN 的关键字段、B+ 树为什么适合范围查询、最左前缀、回表与覆盖索引及失效场景。
这篇文章的起点是一个很具体的问题。我的图书管理系统里有一张 borrow_records 表,用来记录谁在什么时候借了哪本书。项目刚做完的时候只有几十条测试数据,一切都很流畅。后来我把几百条历史数据导进去,借阅记录页面的加载时间从感觉不到变成了两秒多。
一个只有几百行的表,查询要两秒,这显然不正常。这篇文章就是我把这个问题查清楚的过程,以及顺带搞明白的索引知识。
从 EXPLAIN 开始
我用的查询是“查某个读者的借阅历史,按借阅日期倒序”:
SELECT * FROM borrow_records
WHERE reader_id = 12
ORDER BY borrow_date DESC;
在 SQL 前面加一个 EXPLAIN,就能看到 MySQL 打算怎么执行这条语句:
EXPLAIN SELECT * FROM borrow_records
WHERE reader_id = 12 ORDER BY borrow_date DESC;
输出里我一开始只看得懂两列,后来才明白每一列在说什么:
| 列名 | 含义 | 我在乎的值 |
|---|---|---|
type |
访问类型,越靠上越好 | const > eq_ref > ref > range > index > ALL |
key |
实际用到的索引 | 为 NULL 就是没走索引 |
rows |
预估要扫描的行数 | 越接近实际返回行数越好 |
Extra |
额外信息 | Using filesort、Using temporary 是警告信号 |
我那次的结果是 type: ALL、key: NULL、rows: 1687、Extra: Using where; Using filesort。翻译过来就是:全表扫了 1687 行,一行行筛出 reader_id = 12 的,然后把这几十条结果全部扔进内存里重新排序。
两秒的耗时里,大部分花在排序上,因为 ORDER BY borrow_date DESC 没有索引可用。
B+ 树索引为什么快
加索引之前我想先搞明白它到底是怎么工作的。MySQL 的 InnoDB 默认用 B+ 树做索引,我可以这样理解它:
把数据按索引列排好序,然后建成一棵多层的树。树的非叶子节点只存“路由信息”(每个孩子节点里键的范围),真正的数据全在叶子节点上,而且叶子节点之间是用双向链表串起来的。
这个结构带来两个直接好处。
一是查单个值快。要在 1687 行里找 reader_id = 12,全表扫描要比较 1687 次;B+ 树大概三层,比较 3~4 次就能定位。这是等值查询。
二是范围查询快。如果需要 borrow_date BETWEEN '2025-10-01' AND '2025-10-31',B+ 树先花 O(log n) 找到 10 月 1 日的位置,然后顺着叶子节点的链表往后走就行,不需要回到根节点重新查找。这是 B+ 树比哈希索引强的地方——哈希索引只能等值查,一遇范围就退化成全表扫。
联合索引与最左前缀
知道了原理,我给借阅记录表加了一个联合索引:
ALTER TABLE borrow_records
ADD INDEX idx_reader_date (reader_id, borrow_date);
联合索引的排序规则是:先按第一列排,第一列相同的再按第二列排。这就像电话簿先按姓排、同姓的再按名排。所以它能被利用的条件是“从最左列开始连续匹配”——也就是最左前缀原则。
拿 idx_reader_date (reader_id, borrow_date) 举例:
| 查询条件 | 能否用上索引 | 原因 |
|---|---|---|
WHERE reader_id = 12 |
✅ 用上 | 匹配最左列 |
WHERE reader_id = 12 AND borrow_date > '2025-10-01' |
✅ 用上 | 两列都匹配,且范围条件在最后 |
WHERE reader_id = 12 ORDER BY borrow_date DESC |
✅ 用上 | 索引本身有序,省掉了 filesort |
WHERE borrow_date > '2025-10-01' |
❌ 用不上 | 跳过了最左列 |
WHERE borrow_date = '2025-10-01' AND reader_id = 12 |
✅ 用上 | 顺序无关,优化器会重排 |
有一点要注意:如果写成 WHERE reader_id > 12 AND borrow_date = '2025-10-01',那么只有 reader_id 这一列能用于索引定位,borrow_date 无法再用于缩小扫描范围,只能在回表后过滤。范围条件之后的列会失效,这是最左前缀原则里最容易踩的一条。
加上索引之后我重新 EXPLAIN,type 变成了 ref,key 是 idx_reader_date,rows 从 1687 降到 3,Extra 里的 Using filesort 也消失了。耗时从两秒多掉到十几毫秒。
回表与覆盖索引
InnoDB 的二级索引(我们平时建的普通索引)叶子节点里存的是索引列的值 + 主键值,不存整行数据。所以用二级索引查到主键后,还要拿主键去主键索引(聚簇索引)里把整行取出来,这个动作叫回表。
回表意味着两次 B+ 树查找。如果结果集很大,回表的成本会很明显。
覆盖索引的意思就是:查询需要的所有列,索引里已经全有了,不需要回表。比如:
-- 需要回表:SELECT * 要取所有列
EXPLAIN SELECT * FROM borrow_records WHERE reader_id = 12;
-- 覆盖索引:两列都在 idx_reader_date 里
EXPLAIN SELECT reader_id, borrow_date FROM borrow_records
WHERE reader_id = 12;
第二条的 Extra 里会出现 Using index,这就是“用上了覆盖索引”的标志。
实战里的用法是:如果某个列表页只需要展示固定的几列,就尽量让这几列组成一个联合索引,然后查询时只 SELECT 这几列,别图省事写 SELECT *。
索引不是越多越好
知道索引有用之后,我有过一段“看到查询慢就加索引”的时期,这是错的。
每个索引都是一棵独立的 B+ 树,每一次 INSERT / UPDATE / DELETE 都要同步维护所有相关的索引。借阅记录表如果是高频写入的表,索引加多了,写入就会变慢。再加上索引本身占磁盘、占内存缓冲池。
我后来给自己的判断标准是:先看这条查询有多频繁、有多慢,再决定值不值得为它加一个索引。
常见的索引失效场景
这几条我在项目里或看别人代码时都见过:
对列做函数运算。 WHERE DATE(borrow_date) = '2025-10-01' 用不上 borrow_date 的索引,因为索引里存的是原始值,不是 DATE() 的结果。改成范围写法:WHERE borrow_date >= '2025-10-01' AND borrow_date < '2025-10-02'。
隐式类型转换。 card_no 是 VARCHAR,但查询写成 WHERE card_no = 20250001(数字),MySQL 会把列转成数字再比较,索引就失效了。参数该加引号就加引号。
以 % 开头的 LIKE。 WHERE title LIKE '%数据库%' 无法用索引,因为 B+ 树是按前缀有序的,前缀不确定就没法定位。LIKE '数据库%' 可以走索引。
OR 混用。 WHERE reader_id = 12 OR title = 'xxx',如果 title 上没有索引,优化器可能干脆放弃索引走全表扫。可以拆成两条查询用 UNION 合并。
我给自己定的索引检查清单
WHERE、ORDER BY、JOIN ON里出现的列,是索引的候选。- 加索引前先
EXPLAIN,记下type/rows/Extra三个值。 - 写联合索引时,把等值条件的列放前面,范围条件的列放最后。
- 列表页查询尽量做成覆盖索引,能用
SELECT明确列就不写SELECT *。 - 加完索引再
EXPLAIN一次,确认key变了、rows降了。 - 写写入频繁的表,新增索引前先问一句“这个写入代价我付得起吗”。
- 上线一段时间后回头看,长期没被
key命中的索引考虑删掉。
索引这块我现在的理解还很基础,比如优化器的成本估算、索引下推这些还没有深入。但至少那条两秒的查询我已经能自己解释清楚为什么慢、以及为什么加了索引就快了——对一个本科阶段的学习者来说,这个程度是我比较踏实的状态。