← 返回 MYSQL 列表

索引深度原理

索引深度原理

一、B+ 树结构

1.1 B+ 树特征

MySQL InnoDB 引擎使用 B+ 树作为索引结构,其核心特征:

  • 非叶子节点只存储键值(索引列)和指向子节点的指针,不存数据
  • 叶子节点存储完整的主键值或数据行(取决于聚簇还是非聚簇)
  • 叶子节点之间通过双向链表连接,支持范围查询
  • 同一层节点通过单向链表连接
                     [50]
                   /     \
               [20,30]    [70,80]
              /   |   \    /   |   \
          [1~19] [21~29] [31~49] [51~69] [71~79] [81~99]
          <-- 叶子节点之间双向链表连接 -->

1.2 B+ 树 vs B 树 vs 哈希索引

特性 B+ 树 B 树 Hash 索引
非叶子节点存数据 /
范围查询 高效(链表) 需要中序遍历 不支持
等值查询 O(log n) O(log n) O(1)
排序支持 直接支持 需额外排序 不支持
最左前缀匹配 支持 支持 不支持
适用场景 绝大多数场景 已淘汰 Memory 引擎、自适应哈希

为什么 MySQL 选择 B+ 树而不是 B 树?

  1. IO 次数更少:非叶子节点不存数据,同一节点能存储更多键值,树高度更低(一般 3~4 层)
  2. 范围查询高效:叶子节点双向链表,直接遍历即可
  3. 查询稳定:任何查询都要走到叶子节点,IO 次数相对稳定

1.3 B+ 树层高计算

sql
-- 假设:
-- 页大小:16KB
-- 主键 BIGINT:8 字节
-- 指针:6 字节
-- 每个非叶子节点可存:16KB / (8+6) ≈ 1170 条
-- 叶子节点每条数据约 1KB,每页存 16 条

-- 层高 3 的 B+ 树可存:1170 * 1170 * 16 ≈ 2190 万条数据
-- 层高 4 的 B+ 树可存:1170 * 1170 * 1170 * 16 ≈ 256 亿条数据

-- 结论:3~4 层 B+ 树就能支撑绝大多数业务表

二、聚簇索引与非聚簇索引

2.1 聚簇索引(Clustered Index)

InnoDB 中,聚簇索引就是表数据的物理存储顺序。

情况 聚簇索引选择
有主键 主键作为聚簇索引
无主键但有唯一非空索引 第一个唯一非空索引作为聚簇索引
都没有 InnoDB 自动生成 6 字节的 ROWID

聚簇索引的特点

  • 叶子节点存储完整的数据行
  • 一张表只能有一个聚簇索引
  • 数据按主键顺序物理存储
sql
-- InnoDB 表结构
CREATE TABLE users (
    id INT PRIMARY KEY,        -- 聚簇索引:叶子节点存储 id,name,email 等完整数据
    name VARCHAR(50),
    email VARCHAR(100)
);
聚簇索引结构:
    Non-leaf: [1] -> [10] -> [20]
    Leaf:     [1, '张三', 'zhang@...'] <-> [2, '李四', 'li@...'] <-> ...

2.2 非聚簇索引(Secondary Index / 二级索引)

非聚簇索引的叶子节点存储的是主键值,不是数据行。

sql
-- 创建二级索引
CREATE INDEX idx_name ON users(name);
非聚簇索引结构(idx_name):
    Non-leaf: ['李'] -> ['王'] -> ['张']
    Leaf:     ['李四', 2] <-> ['张三', 1] <-> ['王五', 3]
                                        ↑        ↑
                                       name    主键id

2.3 回表查询(Bookmark Lookup)

sql
-- 通过二级索引查询
SELECT * FROM users WHERE name = '张三';

-- 执行步骤:
-- 1. 从 idx_name 索引树找到 '张三' 对应的主键值 id=1
-- 2. 用主键 id=1 到聚簇索引树查找完整数据行
-- 这就是"回表"
查询流程:
   idx_name树         聚簇索引树
   '张三' -> id=1  →  id=1 -> 完整数据行
                        ↑
                      回表查询

三、联合索引的最左前缀原则

3.1 原理

联合索引 (a, b, c) 的 B+ 树按 (a, b, c) 的顺序排序:

  • 先按 a 排序
  • a 相同的情况下按 b 排序
  • a、b 都相同的情况下按 c 排序
sql
CREATE INDEX idx_a_b_c ON t (a, b, c);
索引排序逻辑:
  (1, 1, 1) -> (1, 1, 2) -> (1, 2, 1) -> (1, 2, 2) ->
  (2, 1, 1) -> (2, 1, 2) -> (2, 2, 1) -> (2, 2, 2)

3.2 匹配规则

sql
-- 能用到索引的场景:
WHERE a = 1                     -- 用到 a
WHERE a = 1 AND b = 2           -- 用到 a,b
WHERE a = 1 AND b = 2 AND c = 3 -- 用到 a,b,c
WHERE a = 1 ORDER BY b          -- 用到 a(排序用到 b)
WHERE a IN (1,2) AND b = 1      -- 用到 a,b(IN 会优化)

-- 不能用到索引的场景:
WHERE b = 2                     -- 跳过了 a
WHERE c = 3                     -- 跳过了 a,b
WHERE b = 2 AND c = 3           -- 跳过了 a

-- 部分用到索引的场景:
WHERE a = 1 AND c = 3           -- 只用到 a(b 被跳过,c 用不到)
WHERE a > 1 AND b = 2           -- 只用到 a(范围查询后失效)

3.3 实战:索引设计顺序

sql
-- 业务查询模式
-- Q1: SELECT * FROM orders WHERE user_id = ? AND status = ?
-- Q2: SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC
-- Q3: SELECT * FROM orders WHERE status = ? AND create_time > ?

-- 索引设计方案
-- 联合索引 A: idx_user_status (user_id, status)  -- 覆盖 Q1
-- 联合索引 B: idx_user_create (user_id, create_time)  -- 覆盖 Q2
-- 联合索引 C: idx_status_create (status, create_time)  -- 覆盖 Q3

口诀:等值在前、排序在后、区分度高在前、范围查询放最后。


四、覆盖索引与索引下推

4.1 覆盖索引(Covering Index)

当查询所需的所有字段都在索引中时,无需回表。

sql
-- 索引:idx_name_age (name, age)

-- 需要回表(因为 * 包含未索引字段)
SELECT * FROM users WHERE name = '张三';

-- 覆盖索引(无需回表,所有字段都在索引中)
SELECT name, age FROM users WHERE name = '张三';
覆盖索引流程:
   idx_name_age树
   '张三' -> (name='张三', age=25, id=1)
   直接返回,无需访问聚簇索引

非覆盖索引流程:
   idx_name_age树 -> 找到 id=1
   -> 再到聚簇索引树查找完整数据 -> 回表

4.2 索引下推(Index Condition Pushdown, ICP)

MySQL 5.6+ 优化:在索引遍历过程中,提前过滤不满足条件的记录,减少回表次数。

sql
-- 联合索引 idx_name_age (name, age)

-- 没有ICP时:
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;
-- 1. 通过 name LIKE '张%' 找到所有满足条件的索引记录
-- 2. 回表,拿到完整数据行
-- 3. 在服务层过滤 age = 25

-- 启用ICP后:
-- 1. 通过 name LIKE '张%' 遍历索引
-- 2. 在索引遍历过程中,直接判断 age = 25 是否满足
-- 3. 只回表 age=25 的记录

ICP 适用条件

  • 只能用于二级索引
  • 条件中只能用索引包含的字段做过滤
  • 不支持下推条件中包含子查询

4.3 自适应哈希索引(Adaptive Hash Index, AHI)

InnoDB 自动监控索引查询,如果发现某些索引值频繁被等值查询访问,会在 B+ 树索引之上自动建立哈希索引。

sql
-- 查看 AHI 状态
SHOW ENGINE INNODB STATUS \G;  -- 查看 Hash searches/s 等
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';  -- 默认 ON

AHI 的优缺点:

优点 缺点
等值查询 O(1) 快速定位 占用缓冲池内存
减少 B+ 树寻路次数 写操作时需要维护哈希索引
对热点数据自动优化 不可控,偶发性能抖动

五、索引失效场景

5.1 常见失效场景及示例

sql
-- 1. 对索引列使用函数
CREATE INDEX idx_create_time ON orders(create_time);
WHERE DATE(create_time) = '2024-01-01'        -- 索引失效
-- 优化:
WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'

-- 2. 隐式类型转换
CREATE INDEX idx_phone ON users(phone);  -- phone 是 VARCHAR
WHERE phone = 13812345678                     -- 索引失效(数字转字符串)
-- 优化:
WHERE phone = '13812345678'

-- 3. LIKE 以通配符开头
CREATE INDEX idx_name ON users(name);
WHERE name LIKE '%张三%'                      -- 索引失效
-- 优化:
WHERE name LIKE '张三%'                       -- 可以用到索引

-- 4. 联合索引违反最左前缀
CREATE INDEX idx_a_b_c ON t (a, b, c);
WHERE b = 1 AND c = 2                         -- 跳过了 a,索引失效

-- 5. 使用 != 或 <>
WHERE status != 1                              -- 索引失效(除外特殊情况)
-- 优化:
WHERE status IN (0, 2, 3)

-- 6. IS NOT NULL 可能导致失效
CREATE INDEX idx_email ON users(email);
WHERE email IS NOT NULL                        -- 可能索引失效
-- 优化:根据实际业务区分度判断

-- 7. OR 条件中有一个字段无索引
CREATE INDEX idx_name ON users(name);
WHERE name = '张三' OR age = 25               -- age 无索引,整个 OR 条件索引失效
-- 优化:给 age 也建索引,或用 UNION
WHERE name = '张三' UNION ALL SELECT * FROM users WHERE age = 25

-- 8. NOT IN / NOT EXISTS
WHERE id NOT IN (1, 2, 3)                      -- 索引失效
-- 优化:用 NOT EXISTS 或业务绕过

5.2 索引失效避坑自查表

操作 是否导致索引失效 正确写法
DATE(col) = '2024-01-01' 范围查询
col = 123(varchar 字段) 带引号
LIKE '%abc' 前缀匹配
联合索引跳过前置列 调整查询或索引
IS NULL 视情况 取决于数据分布
OR 中混用无索引列 建索引或用 UNION
IN / BETWEEN 通常有效 优化器会优化
ORDER BY 字段不在索引中 排序失效 建合适索引
数据量极小(< 1000) 优化器可能放弃索引 正常现象

六、EXPLAIN 执行计划解析

通过 EXPLAIN 可以查看 SQL 的索引使用情况,是分析索引是否生效的核心工具。

6.1 基本用法

sql
-- 查看执行计划
EXPLAIN SELECT * FROM users WHERE id = 1;

-- 详细格式(JSON)
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1\G

-- MySQL 8.0.16+ 实际执行分析
EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;

-- 查看已执行的 SQL 执行计划(performance_schema)
SELECT * FROM performance_schema.events_statements_history
WHERE digest_text LIKE '%SELECT%users%'\G

6.2 各字段含义(索引视角)

以查询 EXPLAIN SELECT * FROM t WHERE a = 1 AND b > 2 ORDER BY c 为例,输出如下:

字段 示例值 索引相关含义
type range 索引访问方式,从好到差:const > eq_ref > ref > range > index > ALL
possible_keys idx_a_b_c 可能用到的索引,如果为 NULL 表示没有可用索引
key idx_a_b_c 实际使用的索引,为 NULL 表示全表扫描
key_len 10 索引使用的字节数,可判断联合索引用了多少列
ref const 索引匹配的列或常量,显示哪些列被用于索引查找
rows 100 InnoDB 预估扫描行数,值越大说明过滤效果越差
filtered 33.33 按表条件过滤后剩余行数百分比
Extra Using index condition 额外信息,包含大量索引使用线索

6.3 type 详解(从索引视角)

sql
-- const:主键或唯一索引等值查询,最多返回一行
-- 说明索引利用最充分,性能最优
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const, key: PRIMARY

-- eq_ref:JOIN 时使用主键或唯一索引关联
-- 说明关联命中索引
EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_id = u.id;
-- 如果 u.id 是主键,type = eq_ref

-- ref:普通索引等值查询
-- 说明索引正确命中,但可能返回多行
EXPLAIN SELECT * FROM employee WHERE dept_id = 1;
-- type: ref, key: idx_dept_id

-- range:索引范围查询(>、<、BETWEEN、IN)
-- 说明命中索引做范围扫描
EXPLAIN SELECT * FROM employee WHERE salary BETWEEN 10000 AND 20000;
-- type: range, key: idx_salary

-- index:全索引扫描
-- 说明遍历了整个索引树,通常比全表扫描稍好
EXPLAIN SELECT COUNT(id) FROM employee;
-- type: index, key: PRIMARY, Extra: Using index

-- ALL:全表扫描(最差)
-- 说明没有使用索引,需要优化
EXPLAIN SELECT * FROM employee WHERE name = '张三';
-- type: ALL(name 无索引时)

type 性能排序ALL < index < range < ref < eq_ref < const < system

sql
-- 实战:通过 type 判断索引质量
-- 优秀:const, eq_ref, ref
-- 一般:range
-- 需优化:index, ALL

-- 例:如果 type 从 ref 降到 ALL,说明索引失效或缺失

6.4 key_len 计算(判断联合索引用了多少列)

key_len 表示 MySQL 在索引中使用的字节数,可以精确判断联合索引实际用了几个字段。

sql
-- 创建测试表
CREATE TABLE t_keylen (
    id INT PRIMARY KEY AUTO_INCREMENT,
    a INT,            -- 4字节 + NULL标记1字节
    b VARCHAR(100),   -- 变长 + NULL标记1字节 + 长度2字节
    c DATETIME,       -- 5字节 + NULL标记1字节
    INDEX idx_a_b_c (a, b, c)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

key_len 计算规则

字段类型 占用字节 说明
INT 4 无符号 +1(NULL)
BIGINT 8 无符号 +1(NULL)
VARCHAR(100) 100 × 字符集字节数 + 2(长度) + 1(NULL)
CHAR(100) 100 × 字符集字节数 + 1(NULL)
DATETIME 5 +1(NULL)
TINYINT 1 +1(NULL)
sql
-- 实战分析 key_len

-- 假设 utf8mb4 字符集(每个字符最多4字节)

-- 场景1:只用到了 a 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1;
-- key_len: 5(INT 4字节 + NULL标记1字节)

-- 场景2:用到了 a, b 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND b = 'hello';
-- key_len: 410(INT 5 + VARCHAR(100)×4+2+1 = 408 + 5 = ?)
-- 实际:a=5 + (b: 100×4+2+1=403) = 408

-- 场景3:用到了 a, b, c 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND b = 'hello' AND c = '2024-01-01';
-- key_len: 414(a=5 + b=403 + c=6 = 414)

-- 场景4:a 范围查询后,b、c 用不到
EXPLAIN SELECT * FROM t_keylen WHERE a > 1 AND b = 'hello';
-- key_len: 5(只用了 a 列)

关键判断

sql
-- 如果 key_len 明显小于联合索引的总长度
-- 说明有部分索引列没有用上(范围查询或跳过了中间列)

-- 例:联合索引 idx_a_b_c 总长假设为 414
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND c = '2024-01-01';
-- key_len: 5(只用到了 a,c 因为跳过了 b 而用不到)

6.5 Extra 字段详解(索引线索)

Extra 值 含义 对索引的指示
Using index 覆盖索引,无需回表 索引设计好,所有查询字段在索引中
Using index condition 索引下推(ICP) 索引层提前过滤,减少回表次数
Using where 从存储引擎层取出数据后在 Server 层过滤 可能索引不完全覆盖条件
Using filesort 需要额外排序,未用到索引排序 ORDER BY 字段不在索引中或顺序不对
Using temporary 需要临时表 常见于 GROUP BY 无索引
Using join buffer JOIN 未用到索引 关联字段缺少索引
Using MRR 多范围读取优化 对二级索引的范围查询进行排序后再回表
sql
-- 1. Using index(最优)
-- 查询的所有字段都在索引中,无需回表
EXPLAIN SELECT id, a, b FROM t_keylen WHERE a = 1;
-- Extra: Using index

-- 2. Using filesort(需优化)
-- ORDER BY 字段不在索引中,MySQL 需要额外排序
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 ORDER BY c;
-- Extra: Using filesort(联合索引是 a,b,c,跳过了 b 导致排序用不到索引)

-- 3. Using temporary(需优化)
-- GROUP BY 没有可用索引时使用临时表
EXPLAIN SELECT dept_id, COUNT(*) FROM employee GROUP BY dept_id;
-- 如果 dept_id 无索引:Extra: Using temporary; Using filesort

-- 4. Using join buffer(需优化)
-- JOIN 关联字段没有索引
EXPLAIN SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id;
-- 如果 o.user_id 无索引:Extra: Using join buffer

常见 Extra 组合解读

Using index                     → 覆盖索引,很好
Using index condition           → 索引下推,不错
Using where; Using index        → 覆盖索引 + Server 层过滤,不错
Using index; Using filesort     → 覆盖索引但排序不行,需优化索引
Using where; Using filesort     → 无覆盖索引 + 文件排序,需优化
Using temporary; Using filesort → 最差,全表扫描 + 临时表,急需优化
Using join buffer               → JOIN 缺索引,需加索引

6.6 通过 EXPLAIN 定位索引问题

sql
-- 准备测试数据
CREATE TABLE emp (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50),
    dept_id INT,
    salary DECIMAL(10,2),
    create_time DATETIME,
    INDEX idx_dept_salary (dept_id, salary)
);

-- 问题1:索引完全未使用(type = ALL)
EXPLAIN SELECT * FROM emp WHERE name = '张三';
-- type: ALL, possible_keys: NULL, key: NULL
-- 诊断:name 字段没有索引,需要加索引

-- 问题2:联合索引只用到了部分列(key_len 偏小)
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 ORDER BY salary;
-- type: ref, key: idx_dept_salary, key_len: 5
-- 诊断:WHERE 用到 dept_id,ORDER BY 用了 salary 排序(索引有序性)
-- key_len=5 只用了 dept_id(INT + NULL),排序不增加 key_len

-- 问题3:索引失效(隐式类型转换)
CREATE INDEX idx_name ON emp(name);
EXPLAIN SELECT * FROM emp WHERE name = 123;
-- type: ALL, key: NULL
-- 诊断:name 是 VARCHAR,传入数字导致隐式类型转换,索引失效

-- 问题4:范围查询导致后续列索引失效
EXPLAIN SELECT * FROM emp WHERE dept_id > 1 AND salary = 10000;
-- type: range, key: idx_dept_salary, key_len: 5
-- 诊断:key_len=5 只用了 dept_id,range 后的 salary 用不到索引

-- 问题5:filesort 需要优化
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 ORDER BY create_time;
-- Extra: Using filesort
-- 诊断:ORDER BY 字段不在索引 idx_dept_salary 中,需要加索引

-- 问题6:覆盖索引效果
EXPLAIN SELECT dept_id, salary FROM emp WHERE dept_id = 1;
-- Extra: Using index
-- 诊断:查询字段都在 idx_dept_salary 中,无需回表,性能好

-- 问题7:索引下推效果
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 AND salary > 8000;
-- Extra: Using index condition
-- 诊断:dept_id 用于索引查找,salary > 8000 在索引层过滤,减少回表

6.7 分析流程总结

拿到一条慢 SQL 后,用 EXPLAIN 分析的步骤:

步骤1:看 type
  → ALL:需要加索引或改 SQL
  → index/range:可以接受
  → ref/eq_ref/const:不错

步骤2:看 key
  → NULL:SQL 没有使用索引,检查 WHERE 条件
  → 有值:确认是否是你期望的索引

步骤3:看 key_len
  → 联合索引时判断实际用了多少列
  → 如果比预期小,检查是否有列被跳过或范围查询

步骤4:看 rows
  → 预估扫描行数,越大越慢
  → 对比实际数据量,判断过滤效率

步骤5:看 Extra
  → Using filesort:ORDER BY 需优化
  → Using temporary:GROUP BY 需优化
  → Using index:覆盖索引,很好
  → Using join buffer:JOIN 缺索引

七、实战案例

sql
-- 业务表:订单表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32),
    user_id BIGINT,
    status TINYINT,
    amount DECIMAL(12,2),
    create_time DATETIME,
    pay_time DATETIME,
    INDEX idx_order_no (order_no)
);

-- 常见查询模式分析:
-- Q1: 用户查询自己的订单列表
SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC;
-- 索引建议:idx_user_create (user_id, create_time)

-- Q2: 按状态查询订单(运营后台)
SELECT * FROM orders WHERE status = ? ORDER BY create_time DESC;
-- 索引建议:idx_status_create (status, create_time)

-- Q3: 按时间范围查询订单
SELECT * FROM orders WHERE create_time BETWEEN ? AND ?;
-- 索引建议:idx_create_time (create_time)

-- Q4: 查询某个用户某段时间的订单
SELECT * FROM orders
WHERE user_id = ? AND create_time BETWEEN ? AND ?;
-- 用了 idx_user_create 即可满足

-- 最终索引方案(避免冗余):
-- 1. idx_user_create (user_id, create_time)  -- 覆盖 Q1, Q4
-- 2. idx_status_create (status, create_time)  -- 覆盖 Q2
-- 3. idx_order_no (order_no)                  -- 唯一订单号查询
-- 不需要单独的 idx_create_time,因为前两个联合索引已覆盖

案例3:大分页优化

sql
-- 传统分页(深度分页时越来越慢)
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- MySQL 会查到 100020 条,丢弃前 100000 条

-- 优化1:子查询延迟关联
SELECT * FROM orders
WHERE id > (
    SELECT id FROM orders ORDER BY id LIMIT 100000, 1
)
LIMIT 20;

-- 优化2:覆盖索引 + JOIN
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) tmp ON o.id = tmp.id;

总结

知识点 核心要点
B+ 树 非叶子节点只存键值、叶子双向链表、3~4 层支撑千万级
聚簇索引 叶子存数据行、一张表一个、主键即数据
二级索引 叶子存主键值、查询需要回表
最左前缀 等值在前、排序在后、范围放最后
覆盖索引 所有字段在索引中,无需回表
索引下推 在索引层提前过滤
索引失效 函数、隐式转换、LIKE 前缀%、违反最左前缀