MySQL 索引原理与优化:搞懂 B+ 树与索引失效
前言
索引是数据库查询性能的核心,理解和掌握 B+ 树的原理以及索引失效的场景,是每个后端工程师的必修课。很多慢查询问题归根结底都是索引使用不当导致的,本文将系统讲解 MySQL 索引的底层原理与实战优化技巧。
B+ 树:MySQL 索引的基石
MySQL 的 InnoDB 引擎使用 B+ 树作为默认索引数据结构,而非二叉树或哈希表。选择 B+ 树并非偶然,而是经过深思熟虑的工程权衡。
为什么不用二叉树? 二叉树在极端情况下会退化为链表(按顺序插入时),此时查询时间复杂度退化为 O(n),与全表扫描无异。
为什么不用哈希表? 哈希表虽然 O(1) 查询极快,但无法支持范围查询(WHERE id > 10)和排序操作,场景局限性太大。
B+ 树的核心特点:
- <strong>多叉平衡树</strong>:每个节点可以包含多个子节点(由 innodb_page_size 决定),树高通常为 3-4 层,这意味着即便数据量达到千万级,也只需 3-4 次磁盘 I/O 就能定位到目标数据。
- <strong>所有数据都在叶子节点</strong>:非叶子节点只存储索引键和子节点指针,这样的设计使得非叶子节点可以容纳更多索引项,从而显著降低树的高度。
- <strong>叶子节点用双向链表连接</strong>:这使得范围查询(如 BETWEEN 10 AND 100)只需定位起点,然后沿链表顺序遍历,无需回溯。
假设一个高度为 3 的 B+ 树,每页 16KB,每行索引约 50 字节,每页可存约 300 条索引,则:
- 根节点:300 条索引
- 第二层:300 x 300 = 90,000 条索引
- 第三层:90,000 x 300 约等于 27,000,000 条索引
仅需 3 层就能索引千万级数据,这就是 B+ 树强大的扩展能力。
聚簇索引与二级索引
InnoDB 中索引分为两种类型:
聚簇索引(Clustered Index):叶子节点存储完整的行数据。一个表只有一个聚簇索引,即主键索引。如果没有定义主键,InnoDB 会选择第一个 UNIQUE NOT NULL 索引作为聚簇索引,否则自动生成一个隐藏的主键。
二级索引(Secondary Index):叶子节点存储索引键和主键值。查询时先在二级索引中找到主键,再回表查询聚簇索引获取完整数据,这个过程称为回表。
-- 创建表,主键自动成为聚簇索引
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100),
age INT,
created_at DATETIME,
INDEX idx_name (name),
INDEX idx_age_email (age, email)
) ENGINE=InnoDB;
-- 查看表的索引结构
SHOW INDEX FROM users;
-- EXPLAIN 分析查询是否使用索引
EXPLAIN SELECT * FROM users WHERE name = '张三';
-- type: ref 表示使用二级索引
-- key: idx_name 表示使用的索引名
-- rows: 预估扫描行数
-- Extra: Using index condition 表示索引下推优化
联合索引与最左前缀原则
联合索引是指多个列组成的索引,如 (age, email)。理解最左前缀原则是使用联合索引的关键。
最左前缀原则:查询条件必须从联合索引的最左边连续列开始,才能使用该索引。索引 (A, B, C) 等同于创建了 (A)、(A, B)、(A, B, C) 三个索引。
-- 创建联合索引
ALTER TABLE users ADD INDEX idx_age_email (age, email);
-- 能命中索引的查询
SELECT * FROM users WHERE age = 25;
SELECT * FROM users WHERE age = 25 AND email = 'a@b.com';
SELECT * FROM users WHERE age = 25 AND email LIKE 'a%';
-- 无法命中联合索引的查询
SELECT * FROM users WHERE email = 'a@b.com'; -- 跳过了最左列 age
SELECT * FROM users WHERE name = '张三'; -- 不同的索引列
-- 当 age 使用范围查询时,MySQL 只能使用索引的 age 部分
EXPLAIN SELECT * FROM users WHERE age > 20 AND email = 'test@example.com';
索引失效的常见场景
即便字段上建立了索引,如果写法不当,MySQL 查询优化器也会选择放弃索引,执行全表扫描。以下是高频踩坑场景:
-- 场景1:使用函数或运算
SELECT * FROM users WHERE YEAR(created_at) = 2024; -- 失败,字段被包在函数内
SELECT * FROM users WHERE id + 1 = 100; -- 失败,字段参与运算
-- 正确做法:改写为范围查询
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- 场景2:类型转换
SELECT * FROM users WHERE age = '25'; -- 警告,字符串与数字比较
SELECT * FROM users WHERE name = 123; -- 失败,name 是 VARCHAR,传入数字
-- 场景3:LIKE 以通配符开头
SELECT * FROM users WHERE name LIKE '%张三'; -- 失败,前缀通配符无法使用索引
SELECT * FROM users WHERE name LIKE '张%'; -- OK,后缀通配符可以使用索引
-- 场景4:OR 连接不同条件
SELECT * FROM users WHERE age = 25 OR name = '李四';
-- 如果 name 没有索引,整个查询可能走全表扫描
-- 场景5:IS NULL / IS NOT NULL
-- 索引列允许 NULL 时,IS NULL 可能不走索引
-- 场景6:NOT IN / NOT EXISTS / <> 操作符
SELECT * FROM users WHERE age NOT IN (20, 30, 40); -- 失败,通常无法利用索引
SELECT * FROM users WHERE age <> 25;
索引优化实战技巧
-- 1. 覆盖索引:查询的所有列都在索引中,无需回表
SELECT age, email, name FROM users WHERE age = 25; -- OK Index Only Scan
-- 2. 索引下推(ICP):在索引遍历过程中利用索引列过滤数据
SET optimizer_switch = 'index_condition_pushdown=on'; -- 默认开启
SELECT * FROM users WHERE age = 25 AND name LIKE '张%';
-- 不开启 ICP:先按 age=25 找到所有主键,回表后再用 name 过滤
-- 开启 ICP:在索引树遍历时直接用 name 条件过滤,减少回表次数
-- 3. 前缀索引:长字符串列只索引前 N 个字符
ALTER TABLE users ADD INDEX idx_email_prefix (email(10));
-- 注意:前缀索引不支持 ORDER BY 和 GROUP BY
-- 4. 强制使用指定索引
SELECT * FROM users USE INDEX (idx_name) WHERE name = '张三';
SELECT * FROM users FORCE INDEX (idx_name) WHERE name = '张三';
-- 5. 索引维护:避免过度索引
OPTIMIZE TABLE users; -- 重建索引,回收碎片空间
常见问题
Q1:索引越多越好吗?
不是。索引会占用磁盘空间,更重要的是,每次 INSERT/UPDATE/DELETE 操作都需要同时更新所有相关索引,增加了写操作的开销。对于写多读少的场景,过多索引反而会成为性能瓶颈。建议为高频查询条件建立索引,定期使用 SHOW INDEX 检查冗余索引。
Q2:主键为什么建议使用自增整型?
InnoDB 的聚簇索引数据按主键顺序物理存储。自增主键保证新记录始终追加到页面末尾,避免了 B+ 树的节点分裂和页分裂,插入性能最优。使用 UUID 或字符串作为主键会导致数据频繁移动,严重影响插入性能。
Q3:慢查询突然变快是索引生效了吗?
不一定。可能的原因包括:数据被缓存(buffer pool)、统计信息更新后优化器选择了不同执行计划、或者数据量发生了变化。建议使用 EXPLAIN 和 SHOW STATUS 配合分析,不要仅凭响应时间判断。
延伸阅读
- MySQL 慢查询分析与优化实战
- MySQL 事务与隔离级别
- MySQL 官方文档:InnoDB Indexing https://dev.mysql.com/doc/refman/8.0/en/innodb-indexing.html