跳到主要内容

索引原理与优化

问题

MySQL 索引的底层数据结构是什么?聚簇索引和非聚簇索引有什么区别?什么情况下索引会失效?

答案

一、InnoDB 索引结构 — B+ 树

InnoDB 所有索引都基于 B+ 树 实现。一棵典型的 B+ 树:

核心特点

  • 非叶子节点 只存 key,不存数据 → 每个页能存更多 key → 树更矮
  • 叶子节点存 完整数据,且通过 双向链表 连接 → 范围查询高效
  • 树高通常 3~4 层,即千万级数据只需 3~4 次磁盘 IO
为什么 3 层就够?

InnoDB 的数据页大小为 16KB。假设主键为 bigint(8B),指针 6B,则非叶子节点每页存 16KB / 14B ≈ 1170 个 key。

  • 2 层:1170 × 16 个叶子 ≈ 18,000 行
  • 3 层:1170 × 1170 × 16 ≈ 2 千万行
  • 4 层:≈ 200 亿行

绝大多数表 3 层 B+ 树就够了,也就是 3 次 IO 即可定位任意一行

二、聚簇索引 vs 非聚簇索引

这是 InnoDB 索引体系中 最核心的概念

聚簇索引(主键索引)

  • 叶子节点存储 完整数据行
  • 每张表 有且只有一棵 聚簇索引
  • 数据按主键顺序物理存储

主键选择规则

  1. 有显式 PRIMARY KEY → 用它
  2. 没有 → 找第一个所有列都 NOT NULL 的 UNIQUE 索引
  3. 都没有 → InnoDB 自动生成一个 6 字节的隐藏 row_id

非聚簇索引(二级索引 / 辅助索引)

  • 叶子节点存储 索引列的值 + 主键值
  • 不存储完整数据行

回表查询

通过二级索引查询时,如果需要的列不在索引中:

-- 走 idx_name 索引
SELECT * FROM users WHERE name = '张三';
1. 在 idx_name 索引中找到 name='张三' → 得到 id=5
2. 用 id=5 去聚簇索引中查找完整数据行 ← 这就是"回表"

回表的代价:多一次 B+ 树查找,增加 IO 次数。

覆盖索引 — 避免回表

如果查询的列 都包含在索引中,则不需要回表:

-- 联合索引 idx_name_age(name, age)
SELECT name, age FROM users WHERE name = '张三';
-- 无需回表,索引中已包含 name 和 age(+ 隐含的 id)
-- EXPLAIN 的 Extra 列会显示 "Using index"
覆盖索引是重要优化手段

在设计索引时,尽量让高频查询可以走覆盖索引,避免回表。这是 SQL 优化中投入产出比最高的手段之一。

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

联合索引 (a, b, c) 的 B+ 树按 先 a 排序、a 相同按 b 排序、b 相同按 c 排序 的方式组织:

(1, 1, 1) → (1, 1, 2) → (1, 2, 1) → (2, 1, 1) → (2, 1, 3) → (2, 2, 2)

最左前缀原则

查询条件是否走索引 (a, b, c)说明
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 b = 2❌ 不走索引缺少最左列 a
WHERE a = 1 AND c = 3✅ 用到 ac 无法使用(b 中断了)
WHERE a > 1 AND b = 2✅ 用到 a范围查询后面的列无法使用
WHERE a = 1 ORDER BY b✅ 用到 a, bb 可用于排序,避免 filesort
范围查询会中断联合索引

>, <, BETWEEN, LIKE 'abc%' 等范围条件 之后的列不能使用索引

设计联合索引时:等值条件的列放前面,范围条件的列放后面

四、索引失效的常见场景

场景示例原因
对索引列使用函数WHERE YEAR(create_time) = 2024函数破坏了 B+ 树的有序性
隐式类型转换WHERE phone = 13800138000(phone 是 varchar)MySQL 将 varchar 转为数字比较,等同于对列加了函数
LIKE 左模糊WHERE name LIKE '%三'无法利用 B+ 树的前缀有序性
OR 条件未全覆盖WHERE a = 1 OR b = 2(b 无索引)OR 的任一条件无索引则全表扫描
NOT IN / NOT EXISTS部分情况优化器可能选择全表扫描
数据量小表只有几十行优化器认为全表扫描更快
索引选择性低性别列(只有男/女)区分度太低,不如全表扫描
-- ❌ 索引失效:对列使用函数
SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';

-- ✅ 改写:用范围查询
SELECT * FROM orders
WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';

五、索引设计原则

原则说明
选择性高的列优先COUNT(DISTINCT col) / COUNT(*) 越接近 1 越好
频繁查询的列建索引WHERE、JOIN、ORDER BY 中的列
尽量用联合索引一个联合索引 > 多个单列索引
避免冗余索引(a, b) 已包含 (a) 的功能,不需要额外建 (a)
控制索引数量索引占空间,且 INSERT/UPDATE/DELETE 需要维护索引
短索引优先前缀索引:INDEX idx_email(email(10))

常见面试问题

Q1: 为什么推荐用自增主键?

答案

  1. 插入效率高:自增主键保证新数据总是追加到 B+ 树的最后,不会引起页分裂
  2. 存储紧凑:int/bigint 占 4/8 字节,比 UUID(36 字节字符串)小得多
  3. 二级索引更小:每个二级索引叶子节点都存主键值,主键越短二级索引越小

UUID 做主键的问题

  • 无序插入导致频繁页分裂
  • 占空间大(36 字节 vs 8 字节)
  • 索引效率低

Q2: 什么是索引下推(Index Condition Pushdown)?

答案

MySQL 5.6 引入的优化。以联合索引 (name, age) 为例:

SELECT * FROM users WHERE name LIKE '张%' AND age = 20;
  • 无 ICP:用 name LIKE '张%' 在索引中找到所有姓张的(比如 100 条),逐条回表,回表后再用 age=20 过滤
  • 有 ICP:在索引中找到姓张的后,直接在索引层 用 age=20 过滤(因为联合索引中有 age),过滤后只回表匹配的行(比如 5 条)

ICP 减少了回表次数,EXPLAIN 中 Extra 显示 Using index condition

Q3: 前缀索引的优缺点?

答案

-- 对 email 的前 10 个字符建索引
ALTER TABLE users ADD INDEX idx_email(email(10));

优点:索引更小,存储更省 缺点

  • 无法用于 ORDER BY
  • 无法作为覆盖索引(因为索引中存的不是完整值)
  • 需要根据选择性选择合适的前缀长度

Q4: 一千万数据,如何添加索引不影响线上服务?

答案

MySQL 5.6+ 支持 Online DDL,添加索引期间不阻塞 DML:

ALTER TABLE big_table ADD INDEX idx_name(name), ALGORITHM=INPLACE, LOCK=NONE;
  • ALGORITHM=INPLACE:原地修改,不需要复制整张表
  • LOCK=NONE:不加锁,DML 可以正常执行

对于更老版本或更安全的方案,可用 pt-online-schema-changegh-ost 工具。


相关链接