在 InnoDB 架构与实现中,我们提到 InnoDB 是索引组织表,数据本身就存在聚簇索引的 B+ 树里。那张图里的”二级索引叶子存主键值”、“回表”到底是什么操作,索引建了却没用上又是怎么回事,本文把这些问题讲透。
一张表可能有上亿行,如果每次查询都从头扫到尾,性能不可接受。索引用额外的空间换取查询时间的急剧缩短。本文从 B+ 树索引的根本原理出发,覆盖聚簇索引与二级索引、覆盖索引与索引下推,然后重点展开索引失效的七大场景,每个场景给出 EXPLAIN 诊断示例,最后落到索引设计模式与运维排查。
前置知识
了解 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),需要读取全部数据页。
全表扫描并非总是最差选择。当表很小、或查询需要返回大部分行时,全表扫描反而比索引查找更快,因为索引查找需要额外的随机 I/O。优化器会根据统计信息自动选择,关于优化器如何选择执行计划,将在查询优化与慢排查中展开。
1.2 索引加速的原理
索引是一种空间换时间的数据结构:在原始数据之外,维护一份有序的、更小的副本,使得查找不必遍历全部数据。
| 维度 | 全表扫描 | 索引查找 |
|---|---|---|
| 时间复杂度 | O(n) | O(log n) 或 O(1) |
| I/O 模式 | 顺序读(大量页) | 随机读(少量页) |
| 1 亿行约需读取 | ~20 GB 数据 | |
| 适用场景 | 返回大量行、小表 | 精确匹配、范围查询、排序 |
| 额外开销 | 无 | 占用存储空间、写入时需维护 |
以 B+ 树为例,1 亿行数据只需 34 层即可容纳,每次查找最多 34 次磁盘 I/O,从 20 GB 的顺序扫描骤降到个位数的随机读取。
1.3 索引的分类体系
不同查询场景需要不同类型的索引。以下是 MySQL 中索引的完整分类:
MySQL 不支持位图索引和部分索引(PostgreSQL 才有),表达式索引在 8.0 才支持函数索引。接下来按数据结构逐一深入。
二、B+ 树索引
B+ 树是关系型数据库中最广泛使用的索引结构。MySQL InnoDB 的默认索引就是 B+ 树。
2.1 B+ 树的结构
B+ 树是一种多路平衡搜索树,其核心特征是:所有数据都存储在叶子节点,内部节点只存键值用于路由。叶子节点通过双向链表串联,支持高效的范围扫描。
B+ 树的关键参数:
| 参数 | 含义 | 典型值 |
|---|---|---|
| 阶(Order) | 每个节点最多拥有的子节点数 | InnoDB 约 1200(16 KB 页 / 12 字节键) |
| 层数 | 根到叶子的路径长度 | 3~4 层可容纳数亿行 |
| 叶子链表 | 叶子节点间的双向链表 | 支持范围扫描的 O(k) 遍历 |
| 填充因子 | 节点实际使用率 | 通常 50%~100%,影响分裂频率 |
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 层。
上面的数字是理想情况。实际页不会完全填满:插入导致页分裂时分裂为两半,删除导致页合并前会有半空页,加上 InnoDB 在每个 B-tree 页硬编码保留 1/16 空间给未来的插入,真实容量要打个 50%~70% 的折扣。即便如此,3 层 B+ 树存下千万级行是绰绰有余的。
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+ 树对数复杂度的实际意义:数据量线性增长,查找成本对数增长,在工程上几乎等于不增长。
生产环境若发现单表 B+ 树层数明显超过 3~4 层(可借助 innodb_index_stats 的 n_leaf_pages、size 估算索引页规模,间接推断树高),通常是主键设计出了问题:主键过大导致单页键数变少、分支因子塌缩。主键选 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 |
| 插入顺序 | 按主键有序插入最优 | 随机插入,可能页分裂 |
| 存储开销 | 无额外索引空间 | 需额外存储索引 + 主键 |
如果二级索引的回表操作量很大(例如范围查询返回大量行),优化器可能放弃索引而选择全表扫描。这就是”索引存在但不被使用”的常见原因之一,回表成本超过了全表扫描成本。详见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 锁机制与死锁的加锁规则四场景。
唯一索引因为要维护唯一性约束,插入时多一步检查:先查索引里有没有相同值,没有才写入。InnoDB 通过把待插入记录加共享锁读一下来检查,所以唯一索引在并发插入热点值时更容易冲突。自增主键本身也是唯一索引,但因为是自增、值不冲突,没有这个热点问题。
主键索引和唯一索引的选择:能做主键的列就别只建唯一索引,因为主键索引即聚簇、无回表代价,且 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),不是”文档到词”的正向映射,而是”词到文档列表”的反向映射。
倒排索引的构建过程:
- 分词:将文档拆分为词元(Token),中文需要 ngram 分词器
- 归一化:词干提取、大小写统一、同义词映射
- 构建倒排表:每个词指向包含该词的文档列表(Posting List)
- 记录位置:可选地记录词在文档中的位置,支持短语查询
-- 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 scoreFROM articlesWHERE 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 distFROM locationsWHERE ST_Contains( ST_Buffer(ST_GeomFromText('POINT(31.23 121.47)', 4326), 0.03), point);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_30FROM 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.tablesWHERE table_name = 'access_log';-- 千万行级别 → 值得为高频精确查询加索引一句话原则:索引为高频的点查和窄范围查询服务。满足”高频 + 高选择性 + 返回少量行”才值得加,写多读少或返回大量行的场景保持无索引反而更好。判断清楚加不加之后,才是怎么排顺序、要不要覆盖、单列还是联合,下面几节展开。
6.2 联合索引字段顺序
联合索引的列顺序直接影响索引的可用性和效率。核心原则:
- 等值条件列在前,范围条件列在后:范围查询后的列无法利用索引
- 高选择性列在前(在等值条件列之间):提升过滤效果
- 考虑查询频率:最常查询的列组合优先
-- 场景:订单表有 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 覆盖索引选型
覆盖索引通过把查询需要的列都放进索引,避免回表。代价是索引变大、写入变慢。选型时权衡两点:
- 高频查询且只需少量列:值得建覆盖索引。例如查询用户列表只取
id和name,在(name)上建索引即可覆盖 - 需要回表的列太多:不适合覆盖索引,索引会过于臃肿。考虑只把过滤条件列建索引,接受回表代价
-- 高频查询:只取 email 和 nameSELECT email, name FROM users WHERE email = 'alice@example.com';
-- 覆盖索引:把查询列都纳入索引CREATE INDEX idx_email_name ON users (email, name);-- Extra: Using index,无需回表6.4 多列索引策略
面对多列查询,是建一个联合索引还是多个单列索引?答案取决于查询模式:
七、索引失效分析
索引存在但未被使用,是数据库性能问题中最常见的陷阱之一。以下是七种典型失效场景,每种给出错误写法、正确写法和 EXPLAIN 诊断。
7.1 函数/运算导致失效
对索引列使用函数或算术运算,会导致优化器无法使用索引。B+ 树按列的原始值排序,函数变换后的结果不再有序,索引失去定位能力。
-- 错误:对 created_at 使用函数SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- 正确:使用范围查询SELECT * FROM ordersWHERE 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 ordersWHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';-- type: range (范围扫描)-- key: idx_created_at (使用索引)-- Extra: Using index conditionMySQL 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_idMySQL 的隐式转换规则是:将字符串转为数字进行比较。这意味着对 VARCHAR 列传入整数时,MySQL 会对列值做类型转换(而非对常量做转换),列值经过函数式转换后索引失效。反过来,对 INT 列传入字符串 '123',MySQL 只对常量做转换,索引仍然有效。
7.3 最左前缀违反
联合索引的最左前缀原则要求查询条件必须从索引的最左列开始。跳过最左列,或中间列缺失,都会导致索引无法使用或只能部分使用。
-- 索引:idx_status_created (status, created_at)
-- 错误:跳过 status,直接查 created_atSELECT * FROM orders WHERE created_at > '2026-01-01';
-- 正确:包含最左列 statusSELECT * 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 condition7.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 替代 ORSELECT * FROM users WHERE email = 'alice@example.com'UNIONSELECT * 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 where7.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 condition7.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%)!= 和 <> 并非必然导致索引失效,关键在于选择率。如果 != 排除的是大多数行(例如 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 索引失效决策树
遇到”索引存在但不被使用”的问题时,按以下决策树逐项排查:
八、踩坑与运维
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';慢查询日志中的典型记录:
慢查询日志条目示例
# Query_time: 2.345678 Lock_time: 0.000123 Rows_sent: 50 Rows_examined: 800000SET timestamp=1720000000;SELECT * FROM orders WHERE user_id + 1 = 43 LIMIT 50;Rows_sent: 50 但 Rows_examined: 800000,扫描了 80 万行才返回 50 行,典型的索引失效信号。结合 EXPLAIN 可以定位到 user_id + 1 = 43 的函数运算导致索引失效。
8.3 index_stats 采样统计
InnoDB 的统计信息存储在 mysql.innodb_index_stats 表中,可以查询具体的索引采样数据:
-- 查看表的索引统计信息SELECT *FROM mysql.innodb_index_statsWHERE 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';早期版本(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 会报未知变量错误。
统计信息过期是”索引突然不走了”的常见原因。大表数据变动后(批量导入、大量删除),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_indexesWHERE table_schema = 'testdb';
-- 查询从未使用的索引SELECT * FROM sys.schema_unused_indexesWHERE object_schema = 'testdb';参考资料
- MySQL 8.0 Reference Manual: Optimization - 官方优化指南,含索引策略与 EXPLAIN 详解
- MySQL 8.0 Reference Manual: InnoDB Indexes - InnoDB 索引类型与聚簇索引说明
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






