问题
MySQL索引存储?索引的失效场景
答案
一、MySQL索引存储
1. 索引存储结构:B+树
为什么选择B+树?
与其他数据结构对比:
| 数据结构 | 查询复杂度 | 缺点 | 是否适合 |
|---|---|---|---|
| 数组 | O(n) | 查询慢 | ❌ |
| 哈希表 | O(1) | 不支持范围查询 | ❌ |
| 二叉搜索树 | O(log n) | 树高过高,IO次数多 | ❌ |
| 红黑树 | O(log n) | 树高过高,IO次数多 | ❌ |
| B树 | O(log n) | 非叶子节点存数据,范围查询慢 | ⚠️ |
| B+树 | O(log n) | 叶子节点存数据,支持范围查询,IO少 | ✅ |
B+树的关键特性
结构特点:
- 非叶子节点只存索引键,不存数据
- 所有数据都在叶子节点
- 叶子节点通过指针连接,形成有序链表
优势:
- IO次数少:树高通常3-4层,最多3-4次IO就能定位数据
- 范围查询高效:叶子节点是有序链表,顺序扫描即可
- 查询稳定:所有查询都到达叶子节点,复杂度稳定
示例:假设索引键是INT(4字节),数据页16KB
非叶子节点可存储的键数量 ≈ 16KB / 4B = 4000个
3层B+树可以存储:4000 × 4000 × 16 ≈ 2.56亿条记录
2. 聚簇索引(Clustered Index)
定义
聚簇索引:索引的叶子节点存储的是完整的行数据。
InnoDB的主键索引就是聚簇索引。
结构示意
[10, 20, 30] -- 非叶子节点(只存主键值)
/ | \
[1,5,9] [11,15,19] [21,25,29] -- 非叶子节点
/ | \
[完整行1] [完整行5] [完整行9] -- 叶子节点(存储完整数据)
↔ ↔ ↔ -- 双向链表
特点
优点:
- 查询快:主键查询一次索引树遍历即可获得完整数据
- 范围查询高效:叶子节点是有序链表,顺序扫描即可
缺点:
- 插入慢:需要维护数据的物理顺序,可能导致页分裂
- 占用空间大:叶子节点存储完整数据
InnoDB的规则:
- 如果定义了主键,主键索引就是聚簇索引
- 如果没有主键,选择第一个非空唯一索引作为聚簇索引
- 如果都没有,InnoDB会创建隐藏的
row_id作为聚簇索引
3. 非聚簇索引(Secondary Index / 二级索引)
定义
非聚簇索引:索引的叶子节点存储的是主键值,而不是完整数据。
InnoDB的所有非主键索引都是非聚簇索引。
结构示意
-- 假设在name列上建立索引
['Jack', 'Tom', 'Zoe'] -- 非叶子节点
/ | \
['Alice', 'Bob'] ['Jack', 'Lucy'] ['Tom', 'Zoe']
| | |
[主键1] [主键5] [主键10] -- 叶子节点(存主键值)
回表查询
流程:
- 在非聚簇索引树中查找,得到主键值
- 拿着主键值到聚簇索引树中查找完整数据
-- 假设name有索引
SELECT * FROM users WHERE name = 'Tom';
-- 执行过程:
-- 1. 在name索引树中查找'Tom',得到主键id=10
-- 2. 在主键索引树中查找id=10,得到完整行数据
性能影响:回表需要额外的IO,如果回表次数多,性能显著下降。
4. 覆盖索引(Covering Index)
定义
覆盖索引:查询的所有列都在索引中,无需回表。
-- 创建联合索引
CREATE INDEX idx_name_age ON users(name, age);
-- 覆盖索引(只查询索引列)
SELECT name, age FROM users WHERE name = 'Tom';
-- Extra: Using index(说明使用了覆盖索引)
-- 非覆盖索引(查询了索引外的列)
SELECT * FROM users WHERE name = 'Tom';
-- 需要回表获取其他列
优势:
- 避免回表,减少IO
- 性能大幅提升(可能快10倍以上)
5. MyISAM的索引存储
MyISAM使用的是非聚簇索引:
- 主键索引和非主键索引结构相同
- 索引的叶子节点存储的是数据文件的地址(指针)
- 数据和索引分别存储在不同文件中(.MYD和.MYI)
-- MyISAM索引结构
[10, 20, 30]
/ | \
[1,5,9] [11,15,19] [21,25,29]
/ | \
[地址A] [地址B] [地址C] -- 叶子节点存储数据文件地址
二、索引失效场景
1. 使用函数或表达式
场景:在索引列上使用函数
-- 索引失效
SELECT * FROM users WHERE YEAR(create_time) = 2024;
SELECT * FROM users WHERE UPPER(name) = 'TOM';
SELECT * FROM users WHERE age + 1 = 26;
原因:索引存储的是原始值,函数计算后的值无法利用索引。
解决方案:
-- 正确写法1:范围查询
SELECT * FROM users
WHERE create_time >= '2024-01-01'
AND create_time < '2025-01-01';
-- 正确写法2:函数索引(MySQL 8.0+)
CREATE INDEX idx_year ON users((YEAR(create_time)));
2. 隐式类型转换
场景:字符串和数字比较
-- 假设phone是VARCHAR类型
CREATE INDEX idx_phone ON users(phone);
-- 索引失效
SELECT * FROM users WHERE phone = 123456;
-- 原因:MySQL会将字符串转为数字,相当于
SELECT * FROM users WHERE CAST(phone AS SIGNED) = 123456;
规则:
- 字符串列 = 数字 → 索引失效(字符串会被转换)
- 数字列 = 字符串 → 索引有效(字符串会被转换为数字,列本身不变)
解决方案:
-- 正确写法
SELECT * FROM users WHERE phone = '123456';
3. 前导模糊查询
场景:LIKE以%开头
-- 索引失效
SELECT * FROM users WHERE name LIKE '%Tom%';
SELECT * FROM users WHERE name LIKE '%Tom';
-- 索引有效
SELECT * FROM users WHERE name LIKE 'Tom%';
原因:B+树索引是有序的,只能支持前缀匹配,无法支持后缀或中间匹配。
解决方案:
-- 方案1:改为前缀匹配
WHERE name LIKE 'Tom%'
-- 方案2:使用全文索引(适合文本搜索)
CREATE FULLTEXT INDEX idx_name_fulltext ON users(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('Tom');
-- 方案3:使用Elasticsearch等搜索引擎
4. 联合索引不遵循最左前缀
场景:跳过联合索引的最左列
CREATE INDEX idx_abc ON users(a, b, c);
-- 索引失效
SELECT * FROM users WHERE b = 2 AND c = 3;
SELECT * FROM users WHERE c = 3;
-- 索引有效
SELECT * FROM users WHERE a = 1;
SELECT * FROM users WHERE a = 1 AND b = 2;
SELECT * FROM users WHERE a = 1 AND b = 2 AND c = 3;
原因:联合索引按照(a, b, c)顺序建立B+树,必须从a开始匹配。
解决方案:
-- 方案1:调整索引顺序(根据查询需求)
CREATE INDEX idx_bca ON users(b, c, a);
-- 方案2:创建单独索引
CREATE INDEX idx_b ON users(b);
CREATE INDEX idx_c ON users(c);
5. 范围查询后的列无法使用索引
场景:范围查询中断索引使用
CREATE INDEX idx_abc ON users(a, b, c);
-- b是范围查询,c无法使用索引
SELECT * FROM users WHERE a = 1 AND b > 10 AND c = 3;
-- 索引使用:a, b(c用不上)
原因:范围查询后,索引的有序性被破坏。
解决方案:
-- 调整索引顺序(等值查询在前)
CREATE INDEX idx_acb ON users(a, c, b);
-- WHERE a = 1 AND c = 3 AND b > 10
-- 索引使用:a, c, b(完整)
6. 使用OR导致索引失效
场景:OR连接的条件中有列没有索引
CREATE INDEX idx_age ON users(age);
-- 索引失效(name没有索引)
SELECT * FROM users WHERE age = 25 OR name = 'Tom';
原因:如果name没有索引,MySQL可能选择全表扫描(因为OR的一侧无法使用索引)。
解决方案:
-- 方案1:为name创建索引
CREATE INDEX idx_name ON users(name);
-- MySQL会使用索引合并(Index Merge)
-- 方案2:改写为UNION
SELECT * FROM users WHERE age = 25
UNION
SELECT * FROM users WHERE name = 'Tom';
7. 不等于操作符
场景:使用!= 或 <>
-- 可能导致索引失效
SELECT * FROM users WHERE age != 25;
SELECT * FROM users WHERE age <> 25;
原因:不等于操作符可能导致优化器认为扫描范围过大,选择全表扫描。
是否失效取决于:
- 数据分布(如果!=的范围很小,可能使用索引)
- 表大小
- MySQL版本和优化器配置
解决方案:
-- 改写为范围查询
SELECT * FROM users WHERE age < 25 OR age > 25;
-- 或拆分为两个查询
SELECT * FROM users WHERE age < 25
UNION ALL
SELECT * FROM users WHERE age > 25;
8. IS NULL / IS NOT NULL
场景:判断NULL值
-- 可能导致索引失效
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
规则(与数据分布有关):
- IS NULL:如果NULL值很少,可能使用索引
- IS NOT NULL:如果NULL值很多,可能全表扫描
解决方案:
-- 避免NULL值,使用默认值
ALTER TABLE users MODIFY email VARCHAR(100) NOT NULL DEFAULT '';
-- 或使用特殊值代替NULL
WHERE email != '' AND email IS NOT NULL
9. 使用NOT IN / NOT EXISTS
场景:NOT IN子查询
-- 通常导致索引失效
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
原因:NOT IN的执行效率很低,MySQL通常选择全表扫描。
解决方案:
-- 改写为LEFT JOIN
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.user_id IS NULL;
-- 或使用NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
10. 数据量过小
场景:表数据量很少
-- 表只有100行数据
SELECT * FROM small_table WHERE id = 10;
原因:当数据量很小时,全表扫描比索引查询更快(索引需要额外的IO)。
优化器选择:自动选择全表扫描。
无需优化:这是正常的优化器行为。
索引失效总结表
| 场景 | 示例 | 是否失效 | 解决方案 |
|---|---|---|---|
| 函数计算 | WHERE YEAR(time)=2024 | ✅ | 改写为范围查询 |
| 隐式转换 | WHERE varchar_col = 123 | ✅ | 使用正确类型 |
| 前导模糊 | WHERE name LIKE '%Tom' | ✅ | 改为'Tom%'或全文索引 |
| 跳过最左列 | WHERE b=2(索引是a,b,c) | ✅ | 调整索引或查询 |
| 范围中断 | WHERE a=1 AND b>10 AND c=3 | ⚠️ | c失效,调整列顺序 |
| OR无索引 | WHERE indexed_col=1 OR no_index_col=2 | ✅ | 为另一列创建索引 |
| 不等于 | WHERE age != 25 | ⚠️ | 取决于数据分布 |
| IS NOT NULL | WHERE col IS NOT NULL | ⚠️ | 避免NULL值 |
| NOT IN | WHERE id NOT IN (...) | ✅ | 改写为LEFT JOIN |
诊断方法
-- 使用EXPLAIN查看是否使用索引
EXPLAIN SELECT * FROM users WHERE ...;
-- 关键字段:
-- type: ALL表示全表扫描(索引失效)
-- possible_keys: 可能使用的索引
-- key: 实际使用的索引(NULL表示未使用)
-- rows: 扫描行数(越少越好)
-- Extra: Using index(覆盖索引),Using where(需要过滤)
-- 使用SHOW WARNINGS查看优化后的SQL
EXPLAIN ...;
SHOW WARNINGS;
面试总结
简洁版回答:
MySQL索引存储:
- InnoDB使用B+树:所有数据在叶子节点,支持范围查询,IO少(3-4层)
- 聚簇索引:主键索引,叶子节点存完整数据
- 非聚簇索引:二级索引,叶子节点存主键值,需要回表
- 覆盖索引:查询列都在索引中,避免回表,性能最优
索引失效场景(重点):
- 函数计算:
WHERE YEAR(time)=2024 - 隐式转换:字符串列与数字比较
- 前导模糊:
LIKE '%xxx' - 违反最左前缀:联合索引跳过最左列
- 范围查询:中断后续列的索引使用
- OR无索引:OR的一侧无索引
- 不等于/IS NOT NULL:可能全表扫描
优化原则:避免在索引列上做计算、使用正确类型、遵循最左前缀、用EXPLAIN验证。