mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
7680 字
20 分钟
MySQL 索引原理与失效分析
2024-05-26

InnoDB 架构与实现中,我们提到 InnoDB 是索引组织表,数据本身就存在聚簇索引的 B+ 树里。那张图里的”二级索引叶子存主键值”、“回表”到底是什么操作,索引建了却没用上又是怎么回事,本文把这些问题讲透。

一张表可能有上亿行,如果每次查询都从头扫到尾,性能不可接受。索引用额外的空间换取查询时间的急剧缩短。本文从 B+ 树索引的根本原理出发,覆盖聚簇索引与二级索引、覆盖索引与索引下推,然后重点展开索引失效的七大场景,每个场景给出 EXPLAIN 诊断示例,最后落到索引设计模式与运维排查。

前置知识#

Important
  • 了解 B+ 树的基本结构,便于理解聚簇索引与二级索引的回表机制,InnoDB 架构与实现中详细拆解了 InnoDB 的页结构与行格式

  • 基本的 SQL 查询语法

一、为什么需要索引#

1.1 全表扫描的代价#

假设一张 orders 表有 1 亿行数据,每行约 200 字节,数据文件约 20 GB。执行以下查询:

SELECT * FROM orders WHERE user_id = 42;

如果没有索引,InnoDB 只能从聚簇索引的第一页开始,逐行检查 user_id 是否等于 42。这就是全表扫描(Full Table Scan),时间复杂度 O(n),需要读取全部数据页。

Note

全表扫描并非总是最差选择。当表很小、或查询需要返回大部分行时,全表扫描反而比索引查找更快,因为索引查找需要额外的随机 I/O。优化器会根据统计信息自动选择,关于优化器如何选择执行计划,将在查询优化与慢排查中展开。

1.2 索引加速的原理#

索引是一种空间换时间的数据结构:在原始数据之外,维护一份有序的、更小的副本,使得查找不必遍历全部数据。

维度全表扫描索引查找
时间复杂度O(n)O(log n) 或 O(1)
I/O 模式顺序读(大量页)随机读(少量页)
1 亿行约需读取~20 GB 数据34 次磁盘 I/O(B+ 树 3 层)
适用场景返回大量行、小表精确匹配、范围查询、排序
额外开销占用存储空间、写入时需维护

以 B+ 树为例,1 亿行数据只需 34 层即可容纳,每次查找最多 34 次磁盘 I/O,从 20 GB 的顺序扫描骤降到个位数的随机读取。

1.3 索引的分类体系#

不同查询场景需要不同类型的索引。以下是 MySQL 中索引的完整分类:

graph TB INDEX["MySQL 索引"] INDEX --> BY_STRUCTURE["按数据结构"] INDEX --> BY_PHYSICAL["按物理存储"] INDEX --> BY_LOGIC["按逻辑语义"] BY_STRUCTURE --> BPTREE["B+ 树索引"] BY_STRUCTURE --> HASH["哈希索引"] BY_STRUCTURE --> FTS["全文索引(倒排)"] BY_STRUCTURE --> RTREE["空间索引(R 树)"] BY_PHYSICAL --> CLUSTERED["聚簇索引"] BY_PHYSICAL --> NON_CLUSTERED["二级索引(非聚簇)"] BY_LOGIC --> PRIMARY["主键索引"] BY_LOGIC --> UNIQUE["唯一索引"] BY_LOGIC --> COMPOSITE["联合索引"] BY_LOGIC --> COVERING["覆盖索引"] BY_LOGIC --> PREFIX["前缀索引"] style INDEX fill:#e3f2fd,stroke:#1565c0 style BY_STRUCTURE fill:#e8f5e9,stroke:#2e7d32 style BY_PHYSICAL fill:#fff3e0,stroke:#e65100 style BY_LOGIC fill:#fce4ec,stroke:#c62828

MySQL 不支持位图索引和部分索引(PostgreSQL 才有),表达式索引在 8.0 才支持函数索引。接下来按数据结构逐一深入。

二、B+ 树索引#

B+ 树是关系型数据库中最广泛使用的索引结构。MySQL InnoDB 的默认索引就是 B+ 树。

2.1 B+ 树的结构#

B+ 树是一种多路平衡搜索树,其核心特征是:所有数据都存储在叶子节点,内部节点只存键值用于路由。叶子节点通过双向链表串联,支持高效的范围扫描。

graph TB subgraph INTERNAL["内部节点(只存键值,用于路由)"] ROOT["[30 | 60]"] end subgraph LEAF["叶子节点(存数据,双向链表串联)"] L1["[10|20|30]"] L2["[35|45|60]"] L3["[70|85|99]"] end ROOT -->|"key &lt; 30"| L1 ROOT -->|"30 ≤ key &lt; 60"| L2 ROOT -->|"key ≥ 60"| L3 L1 <-->|"双向链表"| L2 L2 <-->|"双向链表"| L3 style ROOT fill:#bbdefb,stroke:#1565c0 style L1 fill:#c8e6c9,stroke:#2e7d32 style L2 fill:#c8e6c9,stroke:#2e7d32 style L3 fill:#c8e6c9,stroke:#2e7d32

B+ 树的关键参数:

参数含义典型值
阶(Order)每个节点最多拥有的子节点数InnoDB 约 1200(16 KB 页 / 12 字节键)
层数根到叶子的路径长度3~4 层可容纳数亿行
叶子链表叶子节点间的双向链表支持范围扫描的 O(k) 遍历
填充因子节点实际使用率通常 50%~100%,影响分裂频率
Tip

B+ 树的层数与数据量呈对数关系。假设每个内部节点有 1000 个子节点指针,3 层 B+ 树可容纳 10^9(10 亿)行,4 层可容纳 10^12(万亿)行。数据量从百万增长到十亿,查找开销只增加一次 I/O。

2.2 B+ 树的容量估算#

上面的结论给出了一个数字(3 层可存 10 亿行),但没有解释这个数字怎么来的。这一节把估算过程拆开,让你能对任意表估算出它需要几层 B+ 树,以及为什么生产环境的 B+ 树一般只有 3~4 层。

估算的出发点是 InnoDB 的页大小。InnoDB 以页为磁盘 I/O 的最小单位,默认 16 KB。B+ 树的每个节点就是一个页,所以问题归结为:一个页能存多少个键(内部节点),或多少条记录(叶子节点)

内部节点能存多少个键#

内部节点不存数据,只存键值和指向子节点的指针。InnoDB 里每个指针称为一个页指针,记录子页的页号。一条索引项的体积约为:

键值大小 + 子页指针(4~6 字节)

以常见的二级索引 idx_user_id 为例,键是 BIGINT 类型的 user_id,占 8 字节,加上 6 字节的页指针,单条约 14 字节。一个 16 KB 的页去掉页头页尾开销后可用约 16,000 字节,能容纳:

16000 / 14 ≈ 1142 个键

也就是说每个内部节点约有 1100~1200 个分支。这就是上表”阶”那一列的典型值 1200 的来源,也是前面 tip 里”1000 个子节点”取整的依据。主键索引的键更小(聚簇索引内部节点存主键,如果主键是 BIGINT 同样 8 字节),分支数在同一量级。

叶子节点能存多少条记录#

叶子节点的容量取决于存什么。聚簇索引的叶子存完整行数据,二级索引的叶子存索引键加主键。两者体积差很多:

聚簇索引叶子:完整行(假设 200 字节)+ 事务信息约 23 字节 ≈ 223 字节/行
二级索引叶子:索引键 8 字节 + 主键 8 字节 ≈ 16 字节/行

同样一个 16 KB 的页:

聚簇索引叶子:16000 / 223 ≈ 71 条/页
二级索引叶子:16000 / 16 ≈ 1000 条/页

三层能存多少行#

B+ 树的容量是逐层相乘。层数 L、每层分支数 B、叶子单页记录数 R 的关系近似为:

总行数 ≈ B^(L-1) × R

带入聚簇索引的数字(B ≈ 1200,R ≈ 71):

1 层(只有根):71 行
2 层:1200 × 71 ≈ 8.5 万行
3 层:1200² × 71 ≈ 1 亿行
4 层:1200³ × 71 ≈ 1225 亿行

带入二级索引的数字(B ≈ 1200,R ≈ 1000):

3 层:1200² × 1000 ≈ 14 亿行

这就是为什么前面说”3 层 B+ 树可容纳数亿行”。聚簇索引因为叶子存整行,单页记录少,3 层约 1 亿行;二级索引叶子只存键,3 层能到十几亿行。生产库的单表数据量绝大多数在千万到亿级,正好落在 3 层 B+ 树的容量范围内,所以 InnoDB 的 B+ 树通常只有 3 层,极少到 4 层。

Note

上面的数字是理想情况。实际页不会完全填满:插入导致页分裂时分裂为两半,删除导致页合并前会有半空页,加上 InnoDB 在每个 B-tree 页硬编码保留 1/16 空间给未来的插入,真实容量要打个 50%~70% 的折扣。即便如此,3 层 B+ 树存下千万级行是绰绰有余的。

Note

innodb_fill_factor(默认 100,范围 10~100)定义的是排序索引构建(sorted index build,如 CREATE INDEX 批量建索引)时每个 B-tree 页的填充百分比,并不影响运行时普通插入的填充率。即便设为 100,InnoDB 仍会在聚簇索引页内置保留 1/16 空间用于后续增长,这个 1/16 是硬编码行为,与该参数无关。

查找只取决于层数,不取决于行数#

容量估算最直接的结论是查找成本。一次点查的 I/O 次数等于层数:从根节点开始,每深入一层读一个页,最后在叶子读到数据。3 层 B+ 树就是 3 次磁盘 I/O。

表从 100 万行长到 1 亿行,行数涨了 100 倍,但层数从 3 层变成 3 层(仍在 3 层容量范围内),查找 I/O 不变。这是 B+ 树对数复杂度的实际意义:数据量线性增长,查找成本对数增长,在工程上几乎等于不增长。

Tip

生产环境若发现单表 B+ 树层数明显超过 3~4 层(可借助 innodb_index_statsn_leaf_pagessize 估算索引页规模,间接推断树高),通常是主键设计出了问题:主键过大导致单页键数变少、分支因子塌缩。主键选 BIGINT 而非字符串,选自增列而非 UUID,根本原因就是控制主键体积以维持高分支因子。

2.3 聚簇索引 vs 二级索引#

**聚簇索引(Clustered Index)**将索引与数据合二为一,叶子节点直接存储完整的行数据。一张表只能有一个聚簇索引,因为数据只能按一种方式物理排列。

二级索引(Secondary Index)的叶子节点不存储行数据,而是存储主键值。通过二级索引查找数据时,需要先在二级索引中找到主键,再回到聚簇索引中查找完整行,这个过程称为回表

-- InnoDB 中,主键就是聚簇索引
CREATE TABLE users (
id BIGINT PRIMARY KEY, -- 聚簇索引
email VARCHAR(255),
name VARCHAR(100),
age INT,
INDEX idx_email (email) -- 二级索引,叶子存 id 值
);
-- 通过二级索引查找需要回表
SELECT * FROM users WHERE email = 'alice@example.com';
-- 步骤:idx_email 找到 id=5,再回聚簇索引找 id=5 的完整行
维度聚簇索引二级索引
叶子节点存储完整行数据主键值
每表数量1 个多个
范围查询高效(数据物理有序)需回表,可能大量随机 I/O
插入顺序按主键有序插入最优随机插入,可能页分裂
存储开销无额外索引空间需额外存储索引 + 主键
Warning

如果二级索引的回表操作量很大(例如范围查询返回大量行),优化器可能放弃索引而选择全表扫描。这就是”索引存在但不被使用”的常见原因之一,回表成本超过了全表扫描成本。详见7.7 节的优化器放弃索引场景。

聚簇索引与二级索引的物理结构、回表机制在 InnoDB 架构与实现中有更详细的拆解,包括页结构、行格式和 Change Buffer 对二级索引写入的优化。

2.4 主键索引与唯一索引#

前面用”聚簇索引 vs 二级索引”分了物理存储,但日常建表时说的是”主键索引""唯一索引”这种按逻辑语义命名的索引。这两套分类不矛盾:主键索引和唯一索引是从约束语义角度看的,落实到物理结构仍是 B+ 树,要么是聚簇索引,要么是二级索引。

2.4.1 主键索引就是聚簇索引#

InnoDB 是索引组织表,数据按主键顺序存在聚簇索引的 B+ 树里。所以主键索引就是聚簇索引本身,二者是同一个东西的两个叫法:从约束语义看叫主键(保证非空且唯一),从存储结构看叫聚簇索引(数据就挂在它的叶子节点上)。

这带来一个直接结论:一张表只能有一个主键索引,因为聚簇索引只能有一个。建表时 PRIMARY KEY 指定的列就是聚簇索引的键。

-- 主键索引 = 聚簇索引
CREATE TABLE users (
id BIGINT PRIMARY KEY, -- 这一列既是主键约束,也是聚簇索引的键
email VARCHAR(255),
name VARCHAR(100)
);

如果建表时没定义主键,InnoDB 会找一个非空唯一索引当聚簇索引;都没有就用隐藏的 DB_ROW_ID 列造一个隐藏聚簇索引。隐藏主键对业务不可见、不可引用,也无法用它做关联查询,生产中应显式定义主键,别留给 InnoDB 自己造。

主键选型直接影响聚簇索引的效率。因为聚簇索引叶子存整行,插入顺序决定页是否分裂:自增主键顺序写入,新行总追加到最后一页,几乎不分裂;UUID 主键随机写入,新行落在任意页,频繁触发页分裂和页移动,写入吞吐骤降。这也是前面容量估算小节强调”主键选 BIGINT 自增而非 UUID”的根因。

2.4.2 唯一索引:B+ 树加一层唯一性约束#

唯一索引(UNIQUE)在物理结构上和普通二级索引没有差别,都是一棵 B+ 树,叶子存索引键和主键值,查询时同样要回表。差别只有一点:唯一索引在建索引时强制值唯一,插入重复值会报错。

维度唯一索引普通二级索引
物理结构B+ 树B+ 树
值是否唯一必须,插入重复报错不要求
查询路径相同(可能回表)相同(可能回表)
加锁行为(等值命中)退化为 Record Lock,不锁间隙加 Next-Key Lock,锁间隙

最后这行加锁差异是唯一索引和普通索引在生产中最实质的区别。等值查询命中唯一索引时,InnoDB 知道最多只有一条匹配记录,不需要防止幻读(不可能插入第二条相同的值),所以退化为 Record Lock,只锁命中那一条记录,不锁间隙。普通索引命中时,可能有多个相同值的记录,要防止间隙内插入新记录,会加 Next-Key Lock 锁住前后间隙。唯一的代价就是锁范围更大、并发更低。加锁规则的完整展开见 MySQL 锁机制与死锁的加锁规则四场景。

Note

唯一索引因为要维护唯一性约束,插入时多一步检查:先查索引里有没有相同值,没有才写入。InnoDB 通过把待插入记录加共享锁读一下来检查,所以唯一索引在并发插入热点值时更容易冲突。自增主键本身也是唯一索引,但因为是自增、值不冲突,没有这个热点问题。

Tip

主键索引和唯一索引的选择:能做主键的列就别只建唯一索引,因为主键索引即聚簇、无回表代价,且 InnoDB 隐式把主键当聚簇索引的键。业务唯一标识(如用户邮箱、订单号)适合建唯一索引保证约束,但不建议直接拿它当主键,否则聚簇索引按邮箱字符串排序,插入页分裂严重。常见做法是加一个无业务含义的自增主键,业务唯一列另建唯一索引。

2.5 联合索引与最左前缀#

联合索引是在多个列上建立的 B+ 树索引,其排序规则为:先按第一列排序,第一列相同则按第二列排序,依此类推。这决定了联合索引的最左前缀原则,查询条件必须从索引的最左列开始,才能有效利用索引。

-- 联合索引
CREATE INDEX idx_status_created ON orders (status, created_at);
-- 可以使用索引(最左前缀匹配)
SELECT * FROM orders WHERE status = 'shipped';
SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2026-01-01';
-- 无法使用索引(跳过了最左列 status)
SELECT * FROM orders WHERE created_at > '2026-01-01';
-- 部分使用索引(仅使用 status 列,created_at 无法用于过滤)
SELECT * FROM orders WHERE status = 'shipped' AND amount > 100;

联合索引的列顺序直接影响索引的可用性和效率。一般原则是:高选择性(基数大)的列放前面,等值查询的列放前面,范围查询的列放后面

2.6 索引选择性与基数#

**选择性(Selectivity)**衡量索引的区分度,定义为:

-- 计算列的选择性
SELECT COUNT(DISTINCT status) / COUNT(*) AS selectivity FROM orders;

选择性越高,索引过滤效果越好。选择性为 1(如主键、唯一列)时索引效率最高;选择性接近 0(如性别列只有 2 个值)时,索引几乎无法过滤数据。

-- 查看表的索引基数(不同值的数量)
SHOW INDEX FROM orders;
-- 关注 Cardinality 列,它反映优化器对该索引区分度的估算
选择性范围示例索引效果
≈ 1.0主键、唯一 ID极佳,一次定位
0.1 ~ 0.9用户名、邮箱良好,大幅过滤
< 0.1性别、状态差,可能不如全表扫描
≈ 0常量列无效,索引无意义

三、哈希索引与自适应哈希索引#

3.1 哈希索引的特点#

哈希索引使用哈希表实现,对等值查询具有 O(1) 的时间复杂度。其原理是:对索引列的值计算哈希,将哈希值映射到哈希槽,槽内存储指向数据行的指针。

MySQL 中,Memory 引擎原生支持哈希索引,但 InnoDB 不支持用户手动创建哈希索引。InnoDB 提供的是自适应哈希索引(Adaptive Hash Index, AHI),由引擎自动管理。

哈希索引的核心问题是数据无序,哈希函数将相邻的键值映射到完全不同的槽位,因此无法支持范围查询、排序和前缀匹配。

能力B+ 树索引哈希索引
等值查询O(log n)O(1)
范围查询O(log n + k)不支持
排序天然有序无序
最左前缀支持不支持
模糊匹配支持前缀 LIKE不支持

3.2 InnoDB 自适应哈希索引#

InnoDB 的 AHI 是一种自动优化机制:当 InnoDB 监控到某些 B+ 树索引页被频繁访问时,会在内存中自动为这些页构建哈希索引,将 O(log n) 的 B+ 树查找加速为 O(1) 的哈希查找。

-- 查看自适应哈希索引状态
SHOW ENGINE INNODB STATUS\G
-- 关注 "INSERT BUFFER AND ADAPTIVE HASH INDEX" 段
-- 开启/关闭自适应哈希索引
SET GLOBAL innodb_adaptive_hash_index = ON;

AHI 的适用场景与限制:

  • 适用:高并发等值查询、B+ 树非叶子页被反复访问
  • 不适用:范围查询为主、查询模式频繁变化
  • 风险:AHI 的维护需要加锁,在高并发写入场景下可能成为争用热点

四、全文索引与空间索引#

4.1 全文索引#

全文索引的核心是倒排索引(Inverted Index),不是”文档到词”的正向映射,而是”词到文档列表”的反向映射。

倒排索引的构建过程:

  1. 分词:将文档拆分为词元(Token),中文需要 ngram 分词器
  2. 归一化:词干提取、大小写统一、同义词映射
  3. 构建倒排表:每个词指向包含该词的文档列表(Posting List)
  4. 记录位置:可选地记录词在文档中的位置,支持短语查询
-- MySQL 全文索引(中文需要 ngram 分词器)
CREATE TABLE articles (
id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
FULLTEXT INDEX ft_content (title, content) WITH PARSER ngram
);
-- 全文搜索
SELECT title,
MATCH(title, content) AGAINST('数据库 索引' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH(title, content) AGAINST('数据库 索引' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC;

4.2 空间索引#

空间数据(地理坐标、几何图形)的查询模式与普通数据截然不同,“查找距离我 3 公里内的所有餐厅”涉及二维范围搜索,B+ 树无法高效处理。

R 树(R-Tree)是空间索引的经典数据结构,其核心思想是最小外接矩形(MBR):将空间对象用矩形包围,内部节点存储子矩形的 MBR,查询时通过矩形相交判断快速剪枝。

-- MySQL 空间索引
CREATE TABLE locations (
id INT PRIMARY KEY,
name VARCHAR(100),
point GEOMETRY NOT NULL SRID 4326,
SPATIAL INDEX idx_point (point)
);
-- 空间范围查询
SELECT name, ST_Distance_Sphere(point, ST_GeomFromText('POINT(31.23 121.47)', 4326)) AS dist
FROM locations
WHERE ST_Contains(
ST_Buffer(ST_GeomFromText('POINT(31.23 121.47)', 4326), 0.03),
point
);
Note

SRID 4326(WGS 84)遵循 EPSG 的轴序,POINT 第一个坐标是纬度(范围 [-90,90]),第二个是经度。上海的坐标是北纬 31.23°、东经 121.47°,因此写成 POINT(31.23 121.47)。若把经度放在第一位,会被当作纬度校验,超过 90 报 ERROR 3617 Latitude is out of range

五、索引优化技巧#

5.1 覆盖索引:避免回表#

当查询所需的所有列都包含在索引中时,InnoDB 可以直接从索引返回结果,无需回表,这就是覆盖索引

-- 联合索引
CREATE INDEX idx_user_email_name ON users (email, name);
-- 覆盖索引:查询只需要 email 和 name,索引中都有
SELECT email, name FROM users WHERE email = 'alice@example.com';
-- 非覆盖索引:还需要 age,必须回表
SELECT email, name, age FROM users WHERE email = 'alice@example.com';

覆盖索引的判断方法:在 EXPLAIN 输出中,Extra 列出现 Using index 即表示使用了覆盖索引。

-- MySQL EXPLAIN 验证覆盖索引
EXPLAIN SELECT email, name FROM users WHERE email = 'alice@example.com';
-- Extra: Using index 表示覆盖索引生效,无需回表

5.2 索引下推(Index Condition Pushdown, ICP)#

索引下推是 MySQL 5.6 引入的优化:将部分 WHERE 条件在索引遍历时就进行过滤,而非等到回表后再过滤。

-- 联合索引 (email, name)
-- 查询条件使用了 email(索引第一列)和 name(索引第二列)
SELECT * FROM users WHERE email LIKE 'ali%' AND name LIKE '%ce';

无 ICP:先通过 email LIKE 'ali%' 从索引找到所有匹配行,回表获取完整行,再过滤 name LIKE '%ce'

有 ICP:在索引遍历时就检查 name LIKE '%ce',只对同时满足两个条件的行回表

-- 查看 ICP 是否生效
EXPLAIN SELECT * FROM users WHERE email LIKE 'ali%' AND name LIKE '%ce';
-- Extra: Using index condition 表示索引下推生效

5.3 前缀索引#

对于长字符串列(如 VARCHAR(500) 的 URL),完整列作为索引键会占用大量空间。前缀索引只取列的前 N 个字符作为索引键:

-- 前缀索引:只取 URL 前 20 个字符
CREATE INDEX idx_url_prefix ON pages (url(20));
-- 选择前缀长度的原则:选择性接近完整列即可
SELECT
COUNT(DISTINCT url) / COUNT(*) AS full_selectivity,
COUNT(DISTINCT LEFT(url, 10)) / COUNT(*) AS prefix_10,
COUNT(DISTINCT LEFT(url, 20)) / COUNT(*) AS prefix_20,
COUNT(DISTINCT LEFT(url, 30)) / COUNT(*) AS prefix_30
FROM pages;
-- 选择使选择性接近 full_selectivity 的最短前缀

前缀索引的局限:无法用于覆盖索引(因为索引中不存完整值)、无法用于 ORDER BY / GROUP BY

六、索引设计模式#

6.1 该不该加索引#

索引不是越多越好。每个索引都是一棵独立的 B+ 树,占存储,还要在每次 INSERT / UPDATE / DELETE 时同步维护,写吞吐随索引数量下降。判断一列该不该加索引,要综合五个因素,单看任何一个都会误判。

因素倾向加索引倾向不加
选择性高(区分度大,选择性 > 0.1)低(如性别、状态,选择性 < 0.1)
查询频率高频查询的 WHERE / ORDER BY / JOIN 列极少查询的列
读写比读多写少写多读少(索引维护代价超过查询收益)
表规模大表(无索引要全表扫描,代价高)小表(几百行的表,全表扫描更快)
返回行数返回少量行(精确匹配、点查)返回大部分行(走索引反而不如全表扫描)

选择性的计算见 2.6 节,这里只强调它不是唯一标准。一个常见误判是”列选择性高就一定要加索引”,忽略了写多读少的场景:某张日志表写入极频繁、几乎不查询,即便有个选择性很高的 trace_id 列,给它加索引也会让每次插入都维护这棵 B+ 树,写入吞吐明显下降,而查询收益近乎为零。这种列就不该加索引,或只在该列真被查询时再加。

另一个误判是”返回大量行的查询加索引能加速”。索引的价值在于大幅过滤行数。如果一个 WHERE 条件匹配 30% 的行,走索引意味着对这 30% 的行逐个回表(随机 I/O),代价往往高于直接顺序全表扫描。优化器会自己判断并可能放弃索引(见 7.7 节),但设计阶段就该避免在这种列上寄望索引。

-- 判断该不该加索引的检查清单
-- 1. 看选择性
SELECT COUNT(DISTINCT trace_id) / COUNT(*) AS selectivity FROM access_log;
-- 0.95,选择性很高
-- 2. 看这张表的读写情况(sys.schema_index_statistics 或慢查询日志)
-- 如果 access_log 每天写入千万级、查询个位数 → 不加
-- 如果查询频繁、每次按 trace_id 精确查一条 → 加
-- 3. 表规模
SELECT table_rows FROM information_schema.tables
WHERE table_name = 'access_log';
-- 千万行级别 → 值得为高频精确查询加索引

一句话原则:索引为高频的点查和窄范围查询服务。满足”高频 + 高选择性 + 返回少量行”才值得加,写多读少或返回大量行的场景保持无索引反而更好。判断清楚加不加之后,才是怎么排顺序、要不要覆盖、单列还是联合,下面几节展开。

6.2 联合索引字段顺序#

联合索引的列顺序直接影响索引的可用性和效率。核心原则:

  1. 等值条件列在前,范围条件列在后:范围查询后的列无法利用索引
  2. 高选择性列在前(在等值条件列之间):提升过滤效果
  3. 考虑查询频率:最常查询的列组合优先
-- 场景:订单表有 status、user_id、created_at 三列
-- 查询模式 1:WHERE user_id = ? AND status = ?(高频)
-- 查询模式 2:WHERE user_id = ? AND created_at > ?(中频)
-- 查询模式 3:WHERE status = ? AND created_at > ?(低频)
-- 最优索引:user_id 在前(等值+高频),status 在中(等值),created_at 在后(范围)
CREATE INDEX idx_uid_status_created ON orders (user_id, status, created_at);
-- 这个索引可以覆盖查询模式 1 和 2
-- 查询模式 3 需要单独的索引
CREATE INDEX idx_status_created ON orders (status, created_at);

6.3 覆盖索引选型#

覆盖索引通过把查询需要的列都放进索引,避免回表。代价是索引变大、写入变慢。选型时权衡两点:

  • 高频查询且只需少量列:值得建覆盖索引。例如查询用户列表只取 idname,在 (name) 上建索引即可覆盖
  • 需要回表的列太多:不适合覆盖索引,索引会过于臃肿。考虑只把过滤条件列建索引,接受回表代价
-- 高频查询:只取 email 和 name
SELECT email, name FROM users WHERE email = 'alice@example.com';
-- 覆盖索引:把查询列都纳入索引
CREATE INDEX idx_email_name ON users (email, name);
-- Extra: Using index,无需回表

6.4 多列索引策略#

面对多列查询,是建一个联合索引还是多个单列索引?答案取决于查询模式:

flowchart TD START["多列查询场景"] --> Q1{"查询模式是否固定?"} Q1 -->|"是:总是按相同列组合查询"| UNION["建一个联合索引<br/>(最左前缀覆盖多种查询)"] Q1 -->|"否:列组合多变"| Q2{"是否有高频组合?"} Q2 -->|"是"| BOTH["联合索引覆盖高频组合<br/>+ 单列索引覆盖低频查询"] Q2 -->|"否"| MULTI["多个单列索引<br/>让优化器做索引合并"] MULTI --> WARN["索引合并效率通常<br/>不如联合索引"] BOTH --> CHECK["验证:EXPLAIN 确认<br/>索引被正确使用"] style START fill:#e3f2fd,stroke:#1565c0 style UNION fill:#c8e6c9,stroke:#2e7d32 style BOTH fill:#fff3e0,stroke:#e65100 style MULTI fill:#fce4ec,stroke:#c62828 style WARN fill:#ffcdd2,stroke:#c62828

七、索引失效分析#

索引存在但未被使用,是数据库性能问题中最常见的陷阱之一。以下是七种典型失效场景,每种给出错误写法、正确写法和 EXPLAIN 诊断。

7.1 函数/运算导致失效#

对索引列使用函数或算术运算,会导致优化器无法使用索引。B+ 树按列的原始值排序,函数变换后的结果不再有序,索引失去定位能力。

-- 错误:对 created_at 使用函数
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- 正确:使用范围查询
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- 错误:对 user_id 进行算术运算
SELECT * FROM orders WHERE user_id + 1 = 43;
-- 正确:将运算移到等号另一侧
SELECT * FROM orders WHERE user_id = 42;

EXPLAIN 诊断示例:

EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL (全表扫描)
-- possible_keys: NULL
-- key: NULL (未使用索引)
-- Extra: Using where
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- type: range (范围扫描)
-- key: idx_created_at (使用索引)
-- Extra: Using index condition

MySQL 5.7 及之前版本对函数索引不支持,8.0 起可以通过创建函数索引解决:

-- MySQL 8.0+ 函数索引
CREATE INDEX idx_year_created ON orders ((YEAR(created_at)));

7.2 隐式类型转换#

当查询条件的值类型与列类型不匹配时,MySQL 会进行隐式类型转换,可能导致索引失效。

-- 假设 user_id 是 VARCHAR 类型
-- 错误:传入整数,MySQL 将 user_id 隐式转为数字
SELECT * FROM users WHERE user_id = 123;
-- 正确:传入字符串
SELECT * FROM users WHERE user_id = '123';
-- 假设 phone 是 VARCHAR 类型
-- 错误:传入数字
SELECT * FROM users WHERE phone = 13800138000;
-- 正确:传入字符串
SELECT * FROM users WHERE phone = '13800138000';

EXPLAIN 诊断示例:

-- user_id 为 VARCHAR,传入整数
EXPLAIN SELECT * FROM users WHERE user_id = 123;
-- type: ALL (全表扫描,索引失效)
-- key: NULL
-- 传入字符串
EXPLAIN SELECT * FROM users WHERE user_id = '123';
-- type: ref (索引查找)
-- key: idx_user_id
Note

MySQL 的隐式转换规则是:将字符串转为数字进行比较。这意味着对 VARCHAR 列传入整数时,MySQL 会对列值做类型转换(而非对常量做转换),列值经过函数式转换后索引失效。反过来,对 INT 列传入字符串 '123',MySQL 只对常量做转换,索引仍然有效。

7.3 最左前缀违反#

联合索引的最左前缀原则要求查询条件必须从索引的最左列开始。跳过最左列,或中间列缺失,都会导致索引无法使用或只能部分使用。

-- 索引:idx_status_created (status, created_at)
-- 错误:跳过 status,直接查 created_at
SELECT * FROM orders WHERE created_at > '2026-01-01';
-- 正确:包含最左列 status
SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2026-01-01';
-- 错误:status 用范围查询,created_at 无法利用索引排序
SELECT * FROM orders WHERE status > 'pending' ORDER BY created_at;
-- 正确:status 用等值查询,created_at 可利用索引排序
SELECT * FROM orders WHERE status = 'shipped' ORDER BY created_at;

EXPLAIN 诊断示例:

-- 跳过最左列
EXPLAIN SELECT * FROM orders WHERE created_at > '2026-01-01';
-- type: ALL
-- key: NULL (未使用联合索引)
-- 包含最左列
EXPLAIN SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2026-01-01';
-- type: range
-- key: idx_status_created
-- Extra: Using index condition

7.4 OR 条件#

OR 连接的条件中,如果有一个条件列没有索引,整个 OR 子句都无法使用索引。优化器对 OR 的处理是:要么两侧都能走索引(Index Merge),要么全表扫描。

-- 假设 email 有索引,name 没有索引
-- 错误:name 无索引导致整个 OR 无法使用索引
SELECT * FROM users WHERE email = 'alice@example.com' OR name = 'Alice';
-- 正确方案 1:为 name 也建索引
CREATE INDEX idx_name ON users (name);
SELECT * FROM users WHERE email = 'alice@example.com' OR name = 'Alice';
-- 优化器可能使用索引合并(Index Merge)
-- 正确方案 2:用 UNION 替代 OR
SELECT * FROM users WHERE email = 'alice@example.com'
UNION
SELECT * FROM users WHERE name = 'Alice';

EXPLAIN 诊断示例:

-- name 无索引时
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com' OR name = 'Alice';
-- type: ALL (全表扫描)
-- key: NULL
-- name 建索引后
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com' OR name = 'Alice';
-- type: index_merge (索引合并)
-- key: idx_email,idx_name
-- Extra: Using union(idx_email,idx_name); Using where

7.5 LIKE 左模糊#

LIKE 查询以通配符 % 开头时,B+ 树无法利用有序性进行定位。因为 B+ 树按前缀排序,左模糊意味着任何前缀都可能匹配,只能全表扫描。

-- 错误:前缀通配符,无法使用索引
SELECT * FROM users WHERE name LIKE '%lice';
-- 正确:前缀匹配,可以使用索引
SELECT * FROM users WHERE name LIKE 'ali%';
-- 如需全文搜索,使用全文索引
SELECT * FROM users WHERE MATCH(name) AGAINST('lice');

EXPLAIN 诊断示例:

-- 左模糊
EXPLAIN SELECT * FROM users WHERE name LIKE '%lice';
-- type: ALL
-- key: NULL (索引失效)
-- 前缀匹配
EXPLAIN SELECT * FROM users WHERE name LIKE 'ali%';
-- type: range
-- key: idx_name (索引生效)
-- Extra: Using index condition

7.6 != / <> 选择率过低#

!=<> 操作符意味着”排除某些值”,优化器评估时,如果匹配的行数占比较大(即选择率低),会认为走索引 + 回表的代价高于全表扫描,从而放弃索引。

-- 假设 status 有 3 个值:pending、shipped、cancelled
-- pending 占 80%,shipped 占 15%,cancelled 占 5%
-- 错误:!= 过滤掉的行太少,优化器倾向全表扫描
SELECT * FROM orders WHERE status != 'pending';
-- 正确:用 IN 列举需要的值,优化器能更精确评估
SELECT * FROM orders WHERE status IN ('shipped', 'cancelled');

EXPLAIN 诊断示例:

EXPLAIN SELECT * FROM orders WHERE status != 'pending';
-- type: ALL (选择率低,优化器放弃索引)
-- key: NULL
-- rows: 1000000 (估算扫描全表)
EXPLAIN SELECT * FROM orders WHERE status IN ('shipped', 'cancelled');
-- type: range (选择率高,索引生效)
-- key: idx_status
-- rows: 200000 (估算扫描 20%)
Note

!=<> 并非必然导致索引失效,关键在于选择率。如果 != 排除的是大多数行(例如 status != 'pending' 且 pending 占 95%),优化器仍可能走索引。判断依据是 EXPLAIN 中的 rows 估算值,而非操作符本身。

7.7 优化器放弃索引(回表代价过高)#

即使查询条件能走索引,如果回表代价过高,优化器也可能放弃索引选择全表扫描。典型场景:通过二级索引查到大量主键,再逐个回表取完整行,随机 I/O 代价远超顺序全表扫描。

-- 假设 orders 表 1000 万行,status = 'shipped' 占 60%(600 万行)
CREATE INDEX idx_status ON orders (status);
-- 优化器可能放弃索引:600 万次回表的随机 I/O 远超全表扫描
SELECT * FROM orders WHERE status = 'shipped';
-- 强制使用索引(不推荐,仅用于诊断)
SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'shipped';

EXPLAIN 诊断示例:

EXPLAIN SELECT * FROM orders WHERE status = 'shipped';
-- type: ALL (优化器评估回表代价过高,放弃索引)
-- key: NULL
-- rows: 6000000 (估算匹配 600 万行)
-- 强制索引
EXPLAIN SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'shipped';
-- type: ref
-- key: idx_status
-- rows: 6000000
-- Extra: Using index condition
-- 实际执行可能比全表扫描更慢,因为大量随机回表

应对策略:

  • 覆盖索引:把查询列纳入索引,避免回表
  • 限制返回行数:加 LIMIT 减少回表次数
  • 细分数据:按状态分表,让每个查询的匹配行数可控

7.8 索引失效决策树#

遇到”索引存在但不被使用”的问题时,按以下决策树逐项排查:

flowchart TD START["索引未被使用"] --> FUNC{"索引列是否<br/>使用了函数/运算?"} FUNC -->|"是"| FIX1["将运算移到等号另一侧<br/>或使用范围查询"] FUNC -->|"否"| TYPE{"是否存在<br/>隐式类型转换?"} TYPE -->|"是"| FIX2["确保查询值类型<br/>与列类型一致"] TYPE -->|"否"| LIKE{"LIKE 是否<br/>以 % 开头?"} LIKE -->|"是"| FIX3["改用前缀匹配<br/>或全文索引"] LIKE -->|"否"| OR{"OR 中是否有<br/>无索引列?"} OR -->|"是"| FIX4["为 OR 列建索引<br/>或改用 UNION"] OR -->|"否"| LEFT{"是否违反<br/>最左前缀?"} LEFT -->|"是"| FIX5["调整查询条件<br/>或索引列顺序"] LEFT -->|"否"| NEQ{"是否使用了<br/>!= / 选择率低?"} NEQ -->|"是"| FIX6["改用 IN 列举<br/>或调整查询逻辑"] NEQ -->|"否"| COST{"优化器判断<br/>回表成本过高?"} COST -->|"是"| FIX7["考虑覆盖索引<br/>或限制返回行数"] COST -->|"否"| OTHER["检查统计信息<br/>是否过期<br/>ANALYZE TABLE"] style START fill:#ffcdd2,stroke:#c62828 style FIX1 fill:#c8e6c9,stroke:#2e7d32 style FIX2 fill:#c8e6c9,stroke:#2e7d32 style FIX3 fill:#c8e6c9,stroke:#2e7d32 style FIX4 fill:#c8e6c9,stroke:#2e7d32 style FIX5 fill:#c8e6c9,stroke:#2e7d32 style FIX6 fill:#c8e6c9,stroke:#2e7d32 style FIX7 fill:#c8e6c9,stroke:#2e7d32 style OTHER fill:#fff9c4,stroke:#f9a825

八、踩坑与运维#

8.1 SHOW INDEX 诊断索引健康度#

SHOW INDEX 是排查索引问题的第一步,关注 Cardinality 列。它反映优化器对该索引列区分度的估算,如果 Cardinality 远低于实际不同值数量,说明统计信息过期,优化器可能做出错误的索引选择。

-- 查看表的所有索引
SHOW INDEX FROM orders;
-- 关注字段:
-- Cardinality:索引基数(不同值数量估算)
-- Non_unique:0=唯一索引,1=非唯一
-- Seq_in_index:联合索引中的列顺序
-- 更新统计信息(MySQL 8.0 采样,不锁表)
ANALYZE TABLE orders;
-- 持久化统计信息(MySQL 8.0+)
SET GLOBAL innodb_stats_persistent = ON;
ALTER TABLE orders STATS_PERSISTENT = 1;

8.2 慢查询中的 Rows_examined#

慢查询日志中,Rows_examined 是判断索引是否生效的关键指标。它表示优化器为返回结果实际扫描的行数。理想情况下 Rows_examined 接近 Rows_sent,如果 Rows_examined 远大于 Rows_sent,说明索引过滤效果差,大量行被扫描后丢弃。

-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1 秒的查询记录
-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';

慢查询日志中的典型记录:

慢查询日志条目示例
00.123456Z
# Query_time: 2.345678 Lock_time: 0.000123 Rows_sent: 50 Rows_examined: 800000
SET timestamp=1720000000;
SELECT * FROM orders WHERE user_id + 1 = 43 LIMIT 50;

Rows_sent: 50Rows_examined: 800000,扫描了 80 万行才返回 50 行,典型的索引失效信号。结合 EXPLAIN 可以定位到 user_id + 1 = 43 的函数运算导致索引失效。

8.3 index_stats 采样统计#

InnoDB 的统计信息存储在 mysql.innodb_index_stats 表中,可以查询具体的索引采样数据:

-- 查看表的索引统计信息
SELECT *
FROM mysql.innodb_index_stats
WHERE database_name = 'testdb' AND table_name = 'orders';
-- 关注:
-- stat_name = 'n_leaf_pages':索引叶子页数
-- stat_name = 'size':索引总页数
-- stat_name = 'n_diff_pfxNN':联合索引前缀的基数估算
-- 调整采样页数
-- 持久化统计(innodb_stats_persistent 开启时生效,默认采样 20 页)
SET GLOBAL innodb_stats_persistent_sample_pages = 100;
-- 非持久化统计(innodb_stats_persistent 关闭时生效,默认采样 8 页)
SET GLOBAL innodb_stats_transient_sample_pages = 100;
-- 采样页数越多,统计越准,但 ANALYZE TABLE 越慢
-- 查看采样设置
SHOW VARIABLES LIKE 'innodb_stats%_sample_pages';
Note

早期版本(5.6 之前)用 innodb_stats_sample_pages 控制采样页数,MySQL 5.6 起按统计是否持久化拆为两个变量:innodb_stats_persistent_sample_pages(持久化分支,默认 20)和 innodb_stats_transient_sample_pages(非持久化分支,默认 8,取代了旧的 innodb_stats_sample_pages)。在 MySQL 8.0 上执行 SET GLOBAL innodb_stats_sample_pages 会报未知变量错误。

Warning

统计信息过期是”索引突然不走了”的常见原因。大表数据变动后(批量导入、大量删除),Cardinality 估算可能严重偏离实际值,优化器据此做出错误决策。定期 ANALYZE TABLE 或开启 innodb_stats_auto_recalc 可以缓解。

待补充真实案例:某大表批量导入后索引失效导致慢查询的具体排查过程与数据。

8.4 索引维护的代价#

索引不是免费的。每加一个索引,写入时就要多维护一棵 B+ 树。以下场景需要警惕索引膨胀:

  • 写入密集表:索引越多,INSERT/UPDATE/DELETE 越慢,页分裂越频繁
  • 大量冗余索引:联合索引 (a, b, c) 已经覆盖了 (a)(a, b) 的查询,单独建后两者就是冗余
  • 从未使用的索引:通过 sys.schema_unused_indexes 视图排查,长期不用的索引应删除
-- MySQL 8.0 查询冗余索引
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = 'testdb';
-- 查询从未使用的索引
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'testdb';

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL 索引原理与失效分析
https://blog.souloss.cn/posts/middleware/db/mysql-index-and-failure/
作者
Souloss
发布于
2024-05-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时