跳到主要内容

索引

讨论范围

除非另有说明,正文讨论的是 MySQL 8.4 与 InnoDB

高频考点

面试中反复出现的,其实是四条因果关系:

  • B+ 树的节点能保存多个键,树高较低;叶子节点有序,因此也适合范围查询。
  • InnoDB 二级索引保存聚簇索引键,本例就是主键 id。查询完整记录时可能回表;索引覆盖查询所需字段时,可以省掉这次读取。
  • 联合索引按照定义顺序排列,由此产生最左前缀。
  • 优化器比较的是执行成本。索引存在,不等于执行时一定会选中它。

1. 索引解决了什么问题

假设有一张用户表:

CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL
) ENGINE = InnoDB;

现在根据邮箱查询用户:

SELECT * FROM users WHERE email = '[email protected]';

email 没有索引时,数据库不知道目标记录位于哪里。对于这条需要返回全部匹配行的查询,通常只能扫描整张表。

创建索引后,数据库多了一条按邮箱组织的访问路径:

CREATE INDEX idx_users_email ON users(email);

索引是数据库为了加快查询而维护的数据结构。它保存索引列的值,以及定位对应记录所需的信息。

这个过程可以类比查字典:先根据有序目录缩小范围,再找到具体内容。区别在于,数据库还要处理磁盘访问、并发和数据更新。字典至少不会在查到一半时突然插入十万条新词。

索引也有成本。它会占用存储空间;执行 INSERTUPDATEDELETE 时,相关索引还要一起更新。索引优化是在读取收益与这些成本之间做取舍,不是给每个字段都配一份目录。

2. InnoDB 怎样通过索引找到一行数据

InnoDB 的行数据保存在 聚簇索引(Clustered Index) 中。表存在主键时,主键就是聚簇索引。没有主键时,InnoDB 会选择第一个所有列都不允许为 NULL 的唯一索引;这样的索引也不存在,才会生成隐藏的行标识。

通过主键查询,找到聚簇索引的叶子节点后,就能读到逻辑上的完整记录。物理存储中,较长的可变长度列还可能使用溢出页:

SELECT * FROM users WHERE id = 10086;

idx_users_email 属于二级索引(Secondary Index)。它的记录包含 email 和对应的主键值。对二级索引中的候选记录继续访问聚簇索引,以取得缺失列的过程称为回表。匹配多行时可能发生多次聚簇索引查找,也可能通过 Multi-Range Read(MRR)等方式批量读取;目标页面位于 Buffer Pool 时,并不会固定产生一次额外的磁盘 I/O。

主键会跟随每条二级索引记录保存。主键越宽,二级索引通常也越宽,占用的存储和缓存空间都会增加。

如果查询只需要 idemail,二级索引已经包含全部结果:

SELECT id, email
FROM users
WHERE email = '[email protected]';

这时通常可以直接从二级索引返回结果,无需为了取得查询列而回表。所谓覆盖索引(Covering Index),描述的是索引覆盖了当前查询需要的字段,不是一种新的索引结构。

几组名称说的不是同一件事

单列索引和联合索引按列数量区分;普通索引和唯一索引按约束区分;聚簇索引和二级索引描述 InnoDB 怎样组织数据。一个联合唯一索引也可以是二级索引。

更多细节可以参考聚簇索引与非聚簇索引

3. 为什么常规索引使用 B+ 树

数据库按页管理数据。在 InnoDB 的 B+ 树中,非叶子节点主要保存键和子页面编号,记录内容集中在叶子节点。同样大小的页面可以容纳更多导航项,分支更多,树高也就更低。一次查找经过的层数少,需要访问的页面通常也更少。

B+ 树的叶子页面按键值排列,并按顺序连接。下面这条范围查询可以先定位 10000,再沿着叶子页面继续读取,而不必为范围中的每个值重新从树根查找:

SELECT *
FROM users
WHERE id BETWEEN 10000 AND 20000;

当查询条件可以和索引键建立可比较的边界时,=><BETWEEN 都可以被优化器转换为索引查找区间,是否采用这条访问路径仍要比较成本。这里的“区间”是优化器内部的查找边界,不等同于 EXPLAINtype = range:等值查询也可能显示为 consteq_refref。在联合索引中,范围条件还会影响后续列怎样参与查找。

插入和删除数据时,B+ 树通过页面分裂、合并等操作维持高度平衡。数据量增长会增加树高,但不会像未平衡二叉搜索树那样,因为插入顺序不同而退化成长链。

在经典 B 树模型中,内部节点也可以承载数据记录,单页能够容纳的导航项通常更少。这个对比用于解释基本结构;实际页面的分支数量还会受到键宽、记录指针和页格式影响。哈希索引适合等值定位,却没有键值顺序,无法直接提供这里需要的范围扫描。B+ 树的价值正在于它同时照顾了页面访问和有序查询。

这里比较的是数据结构。InnoDB 常规的用户索引使用 B+ 树,不要与 MEMORY 引擎支持的 HASH 索引,或 InnoDB 在运行时维护的自适应哈希索引混为一谈。

节点结构、查询和再平衡过程可以继续阅读 B+ 树

4. 联合索引为什么有最左前缀

用户表经常按照状态和创建时间查询:

SELECT *
FROM users
WHERE status = 1
AND created_at >= '2026-01-01';

对应的联合索引可以写成:

CREATE INDEX idx_users_status_created
ON users(status, created_at);

索引先按 status 排列;status 相同时,再按 created_at 排列。因此它可以直接定位 (status),也可以定位 (status, created_at)

如果查询条件只有 created_at,所有状态分组中都可能存在符合条件的记录。数据库无法从联合索引的某一个连续位置开始查找。这就是最左前缀产生的原因。

最左前缀关注索引列的定义顺序,与 WHERE 中条件的书写顺序无关。下面两种写法表达的是同一组条件,优化器可以重新组织它们:

WHERE status = 1 AND created_at >= '2026-01-01'
WHERE created_at >= '2026-01-01' AND status = 1
两个容易混淆的边界

对于索引 (a, b, c),条件 a = 1 AND b > 10 AND c = 5 可以根据 ab 确定连续扫描范围。c 仍可能参与索引条件下推或覆盖查询,但通常不能继续缩短这段范围,因为不同的 b 值下面都会重新排列 c

同样地,a = 1 AND b > 10 ORDER BY c 通常不能直接得到整体按 c 排列的结果。索引中的数据先按不同的 b 值分组,每个分组内部的 c 有序,不代表所有分组合在一起仍按 c 有序。

查询缺少 a 时,无法使用传统的最左前缀定位。MySQL 在少数场景下可能采用 Skip Scan,或者直接扫描整棵索引;执行计划中出现索引名,并不等于完成了常规的前缀查找。

5. 有索引,为什么没有被选中

MySQL 优化器会估算不同执行计划的成本。索引只是候选访问路径;扫描范围太大,或者索引无法建立有效查找范围时,全表扫描反而可能更便宜。估算依赖索引统计信息、直方图等数据,统计信息失真或过期也会影响计划选择。

5.1. 索引里没有计算结果

假设 created_at 已经建立普通索引:

SELECT *
FROM users
WHERE YEAR(created_at) = 2026;

索引保存的是 created_at 原始值,不是 YEAR(created_at)。这条条件很难直接对应到索引中的一段连续范围。

日期条件可以写回原始列:

SELECT *
FROM users
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01';

MySQL 也支持生成列索引和函数索引,但需要显式创建与查询表达式匹配的索引。例如:

CREATE INDEX idx_users_created_year
ON users ((YEAR(created_at)));

普通的 created_at 索引不会自动保存 YEAR(created_at) 的结果。

5.2. 左侧通配符没有确定的起点

SELECT *
FROM users
WHERE email LIKE '%@example.com';

B+ 树中的字符串从左向右排列。条件以 % 开头时,数据库无法确定邮箱的左侧起点,也就无法形成普通的索引范围。对于普通字符串索引,并且没有函数或不利的隐式类型转换时,email LIKE 'hello%' 有明确前缀,可以从 hello 对应的位置开始查找。

5.3. 索引过滤不掉多少数据

假设 users 有 100 万行,其中 90 万行的 status 都是 1

SELECT * FROM users WHERE status = 1;

即使 status 建有二级索引,这条查询仍要读取大部分记录。沿二级索引找到 90 万个主键,再逐行回表,可能比直接扫描聚簇索引更贵。优化器选择全表扫描时,问题出在数据分布和读取成本,不在 = 这个操作符。

这里的数字只是为了说明数据分布怎样影响执行计划。换成 status = 0,同一个索引可能就有完全不同的价值。

6. 从一条查询决定索引怎么建

索引设计需要落到具体查询上。继续使用 users 表,现在要查询最近创建的 20 个正常用户:

SELECT id, email
FROM users
WHERE status = 1
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;

(status, created_at) 与这条查询的过滤和排序顺序一致。status 先完成等值定位,随后在 created_at 的有序范围内倒序读取,得到 20 行后即可停止。

这里把 status 放在左侧,是因为查询先对它做等值限定,随后还需要 created_at 的范围和顺序。联合索引没有一条脱离查询的列顺序口诀;字段区分度、条件组合、排序和其他查询能否复用,都可能改变选择。

如果把 email 也放进索引,形成 (status, created_at, email),这条查询可以避免回表。但它每次最多返回 20 行,较窄的索引只会产生有限次数的回表。为了省掉这部分读取,让插入、删除和涉及相关索引列的更新维护更宽的索引,在这个例子里未必划算。

另一些页面可能完全不带 status,只按 created_at 查询。(status, created_at) 无法为它们提供相同的定位能力。这时是否增加 (created_at),要看这些查询出现的频率和扫描范围。字段列表本身给不出答案,查询才可以。

7. 用 EXPLAIN 检查推断

前面的分析来自索引结构,数据库实际选择了什么,可以从执行计划中看到:

EXPLAIN
SELECT *
FROM users
WHERE status = 1
AND created_at >= '2026-01-01';

初次阅读执行计划,可以先看下面几个字段:

字段说明
typeMySQL 访问数据的方式
possible_keys候选索引
key实际选择的索引
rows预计需要检查的行数
filtered条件过滤后预计保留的数据比例
Extra覆盖索引、额外排序、临时表等补充信息

下面是一种可能的结果。rows 会随数据量和统计信息变化,这里只用来演示读取方式:

typepossible_keyskeykey_lenrowsfilteredExtra
rangeidx_users_status_createdidx_users_status_created61200100.00NULL

type = range 表示 MySQL 使用索引范围访问,key 说明实际选择了哪个索引。示例中的 key_len = 6 需要结合列类型理解:TINYINT 占 1 字节,DATETIME 占 5 字节。这个结果与两列共同构造访问边界相符,只能作为旁证,不能独立证明每一列缩小了多少扫描量。可空列和字符集也会改变长度,因此不能把 key_len 直接当成“使用了几列”。

rows 是优化器估算的检查行数,filtered 是应用其余条件后预计保留的比例。range 只说明访问方式从全表扫描变成了索引范围;范围是否真的足够小,还要比较 rows、表的总行数和实际执行结果。

全表扫描可以直接从 type = ALL 识别。type = index 则通常表示全索引扫描,需要读取该索引的大量乃至全部叶子记录;它虽然出现了索引名,读取范围仍可能很大。即使 key 已经选中索引,rows 很大也说明这条访问路径可能仍然昂贵。

Extra 出现 Using index condition 时,表示 MySQL 可以先在索引层判断部分条件,再决定是否读取完整记录。它描述的是索引条件下推,不是 range 访问方式必然附带的结果。

把几个容易混淆的概念放到同一条读取路径中,关系会更清楚:访问条件先确定索引读取范围;索引条件下推在索引层继续过滤;查询仍缺少列时发生回表;索引已经包含结果列时形成覆盖查询,可以省掉为了取列而进行的回表。

需要实际行数和执行时间时,可以在测试环境使用 EXPLAIN ANALYZE。它会真正执行查询,因此不适合直接拿一条未知成本的 SQL 去生产环境试手气。下面是经过简化的示意输出:

-> Index range scan on users using idx_users_status_created
(cost=250 rows=1200)
(actual time=0.080..2.400 rows=1187 loops=1)

actual time 的两个值分别表示返回第一行和返回全部行的大致时间,单位是毫秒;存在多次循环时,它们是每次循环的平均值。实际处理量可以结合 rows × loops 理解。示例中预计 1200 行,实际每次循环返回 1187 行,估算与实测接近;两者相差很大时,统计信息和条件选择性就是后续检查方向。

从慢查询得到一个可以验证的结论

  1. 还原真正执行的 SQL、参数和数据规模。
  2. EXPLAIN 确认访问方式、索引和预计扫描行数。
  3. 用查询条件、索引顺序和数据分布解释当前计划。
  4. 修改 SQL 或索引后,在接近真实的数据上比较执行计划和耗时。

新增索引没有被选择,主要访问路径也没有改变时,它通常没有解决原来的访问问题。计划发生了变化,也还要看实际扫描行数和耗时;换一条路不等于路程变短。

面试自检

这篇文章覆盖的核心能力,可以压缩成五组问答:

  1. 索引解决什么问题? 通过有序结构减少需要检查的数据,代价是额外的存储和写入维护。
  2. 为什么会回表? 二级索引主要保存索引列和聚簇索引键,查询其他列时还要访问聚簇索引。
  3. 最左前缀从哪里来? 联合索引按照列的定义顺序逐级排列,缺少左侧列就无法直接确定连续起点。
  4. 有索引为什么还会全表扫描? 优化器估算的是总成本,低选择性的索引加上大量回表可能更贵。
  5. 怎样读最小执行计划? type 看访问方式,key 看实际索引,rows 看预计检查量,再用 EXPLAIN ANALYZE 验证实际结果。

8. 扩展阅读

  1. MySQL 8.4:Optimization and Indexes
  2. MySQL 8.4:Clustered and Secondary Indexes
  3. MySQL 8.4:Multiple-Column Indexes
  4. MySQL 8.4:EXPLAIN Statement