MySQL 索引入门:从一次慢查询说起

约 5 分钟

从图书管理系统里一条越来越慢的查询出发理解 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 合并。

我给自己定的索引检查清单

  1. WHERE、ORDER BY、JOIN ON 里出现的列,是索引的候选。
  2. 加索引前先 EXPLAIN,记下 type / rows / Extra 三个值。
  3. 写联合索引时,把等值条件的列放前面,范围条件的列放最后。
  4. 列表页查询尽量做成覆盖索引,能用 SELECT 明确列就不写 SELECT *。
  5. 加完索引再 EXPLAIN 一次,确认 key 变了、rows 降了。
  6. 写写入频繁的表,新增索引前先问一句“这个写入代价我付得起吗”。
  7. 上线一段时间后回头看,长期没被 key 命中的索引考虑删掉。

索引这块我现在的理解还很基础,比如优化器的成本估算、索引下推这些还没有深入。但至少那条两秒的查询我已经能自己解释清楚为什么慢、以及为什么加了索引就快了——对一个本科阶段的学习者来说,这个程度是我比较踏实的状态。