MySQL 索引失效的 12 种场景与慢查询优化实战

“我明明建了索引,为什么还是全表扫描?”这是 MySQL 优化里最常遇到的问题。索引失效的原因很多,但归根结底就两大类:写法让索引没法用,或者优化器算完账觉得不用更快。本文把两类都拆开讲。

一、先搞懂 InnoDB 的索引结构

不理解 B+Tree,就记不住那些失效规则,只能死记硬背。

1.1 为什么是 B+Tree 而不是 BTree

对比项 BTree B+Tree
数据存储位置 每个节点都存数据 只有叶子节点存数据
叶子节点连接 无 有双向链表相连
单点查询性能 可能更快(根附近就命中) 稳定,都要走到叶子
范围查询 需要中序遍历 沿链表顺序扫描,极快
非叶子节点容量 小(要存数据) 大(只存键值),树更矮

B+Tree 的优势集中在一句话上:非叶子节点不存数据,所以一个页能装下更多键值,树高更矮,磁盘 IO 次数更少。

粗略估算:InnoDB 默认页大小 16 KB,主键为 bigint(8 字节)+ 指针(6 字节)约 14 字节,一个非叶子页能放约 1170 个键值。

  • 树高 2 层:约 1170 × 16 ≈ 1.8 万行
  • 树高 3 层:约 1170 × 1170 × 16 ≈ 2190 万行
  • 树高 4 层:约 256 亿行

两千万数据只需 3 次磁盘 IO,这就是索引快的根本原因。

1.2 聚簇索引与二级索引

InnoDB 的主键索引就是聚簇索引 —— 叶子节点存的是整行数据。而二级索引(普通索引、唯一索引)的叶子节点存的只是主键值。

这带来一个关键概念:回表。

1
2
-- name 上有二级索引
SELECT * FROM user WHERE name = '张三';

执行过程分两步:

  1. 在 idx_name 这棵 B+Tree 上找到 name='张三' 对应的主键 id
  2. 拿着这个 id 回到聚簇索引再查一次,取出完整行数据

第二步就是回表,多一次 B+Tree 查找。

1.3 覆盖索引:避免回表

如果查询的字段全都在索引里,就不需要回表:

1
2
3
4
5
-- 建立联合索引 (name, age)
CREATE INDEX idx_name_age ON user(name, age);

-- 只查 name 和 age,索引里全都有,Extra 显示 Using index
SELECT name, age FROM user WHERE name = '张三';

覆盖索引是最容易被忽略的优化手段。把 SELECT * 改成只查需要的字段,配合合理的联合索引,往往能让查询快一个数量级,而且这属于零成本的改动。

二、12 种索引失效场景

以下示例基于这张表:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`phone` varchar(20) NOT NULL,
`age` int NOT NULL,
`city` varchar(20) NOT NULL,
`status` tinyint NOT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_name_age_city` (`name`, `age`, `city`),
KEY `idx_phone` (`phone`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

场景 1:违反最左前缀法则

联合索引 (name, age, city) 的排序逻辑是先按 name 排,name 相同再按 age 排,age 也相同才按 city 排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- ✅ 走索引,用到 name
SELECT * FROM user WHERE name = '张三';

-- ✅ 走索引,用到 name + age
SELECT * FROM user WHERE name = '张三' AND age = 28;

-- ✅ 走索引,三个都用上
SELECT * FROM user WHERE name = '张三' AND age = 28 AND city = '杭州';

-- ❌ 跳过 name 直接从 age 查,索引失效
SELECT * FROM user WHERE age = 28;

-- ❌ 跳过 age,name 之后的 city 用不上
SELECT * FROM user WHERE name = '张三' AND city = '杭州';

记忆方法:联合索引就像查字典,必须先知道第一个字母才能往后翻。不过 MySQL 8.0 引入了索引跳跃扫描(Index Skip Scan),在首列区分度极低时优化器可能跳过它。但别依赖这个特性,建索引时还是按最左前缀来设计。

场景 2:索引列上使用函数

1
2
3
4
5
6
7
-- ❌ 对索引列做函数运算,B+Tree 的有序性被破坏
SELECT * FROM user WHERE YEAR(create_time) = 2026;

-- ✅ 改写成范围查询
SELECT * FROM user
WHERE create_time >= '2026-01-01 00:00:00'
AND create_time < '2027-01-01 00:00:00';

其他常见写法:

1
2
3
4
5
6
7
8
9
10
11
-- ❌
SELECT * FROM user WHERE SUBSTRING(phone, 1, 3) = '138';
-- ✅
SELECT * FROM user WHERE phone LIKE '138%';

-- ❌
SELECT * FROM user WHERE DATE(create_time) = '2026-08-31';
-- ✅
SELECT * FROM user
WHERE create_time >= '2026-08-31 00:00:00'
AND create_time < '2026-09-01 00:00:00';

场景 3:索引列参与表达式计算

1
2
3
4
5
-- ❌ age 参与了运算
SELECT * FROM user WHERE age + 1 = 29;

-- ✅ 把计算挪到右边
SELECT * FROM user WHERE age = 29 - 1;

场景 4:隐式类型转换

这是最隐蔽的一种,SQL 看着完全没问题,但索引就是不走。

1
2
3
4
5
6
-- phone 是 varchar,这里却用数字比较
-- ❌ MySQL 会对整列做 CAST 转换,等价于在索引列上加函数
SELECT * FROM user WHERE phone = 13800138000;

-- ✅ 类型保持一致
SELECT * FROM user WHERE phone = '13800138000';

规则总结:

字段类型 传入类型 是否走索引
varchar 字符串 ✅ 走
varchar 数字 ❌ 不走,字段被隐式转换
int 数字 ✅ 走
int 字符串 ✅ 走,转换发生在常量侧,不影响索引

注意最后一行的不对称性:字符串字段传数字会失效,数字字段传字符串却没问题。因为 MySQL 的转换规则是”把字符串转成数字”,数字字段传字符串时,转换的是常量而不是索引列。

场景 5:LIKE 以 % 开头

1
2
3
4
5
6
7
8
-- ✅ 前缀匹配,能用到索引的有序性
SELECT * FROM user WHERE name LIKE '张%';

-- ❌ 前缀不确定,无法从根节点开始定位
SELECT * FROM user WHERE name LIKE '%三';

-- ❌ 同上
SELECT * FROM user WHERE name LIKE '%张%';

如果业务必须支持前后模糊搜索,就不要硬扛索引了,用全文索引:

1
2
3
4
5
-- 建全文索引
ALTER TABLE user ADD FULLTEXT INDEX ft_name (name);

-- 使用全文检索
SELECT * FROM user WHERE MATCH(name) AGAINST('张三' IN BOOLEAN MODE);

或者上 Elasticsearch,这才是模糊搜索的正解。

场景 6:OR 连接了非索引列

1
2
3
4
5
6
7
8
9
10
11
-- status 没有索引,优化器只能全表扫(否则要扫两遍再合并)
-- ❌
SELECT * FROM user WHERE name = '张三' OR status = 1;

-- ✅ 方案一:给 status 也建索引
ALTER TABLE user ADD INDEX idx_status (status);

-- ✅ 方案二:拆成两个查询用 UNION
SELECT * FROM user WHERE name = '张三'
UNION ALL
SELECT * FROM user WHERE status = 1;

场景 7:!= 、<>、NOT IN

1
2
3
4
5
6
-- ❌ 否定条件的匹配范围太广,优化器倾向全表
SELECT * FROM user WHERE status != 1;
SELECT * FROM user WHERE age NOT IN (20, 30);

-- ✅ 改写为正向范围,前提是能表达
SELECT * FROM user WHERE status IN (0, 2, 3);

并非绝对失效 —— 如果 status != 1 命中的行数极少,优化器仍可能走索引。判断依据始终是 explain 的实际输出。

场景 8:IS NULL / IS NOT NULL

是否能用索引,取决于该列的空值比例,这是个动态决策:

1
2
3
4
5
-- 如果 name 列绝大部分是 NULL,这个查询命中极少,会走索引
SELECT * FROM user WHERE name IS NULL;

-- IS NOT NULL 命中比例高,通常全表扫
SELECT * FROM user WHERE name IS NOT NULL;

优化方向是让字段 NOT NULL 并给默认值,从根本上消除 NULL 判断:

1
2
ALTER TABLE user
MODIFY COLUMN name varchar(50) NOT NULL DEFAULT '';

场景 9:范围查询右侧的列失效

联合索引中,某一列用了范围查询之后,它右边的所有列都无法再用于精确定位。

1
2
3
-- 索引 (name, age, city)
-- age 用了范围,city 用不上,key_len 会暴露这一点
SELECT * FROM user WHERE name = '张三' AND age > 20 AND city = '杭州';

因为 age > 20 匹配到的是一批值,这批值里 city 是无序的,无法继续用索引定位。

优化方案:把范围列放到联合索引的最后一位。

1
2
3
4
5
-- 调整索引顺序,让等值列在前
CREATE INDEX idx_name_city_age ON user(name, city, age);

-- 这样 name 和 city 都能精确定位,age 做范围
SELECT * FROM user WHERE name = '张三' AND city = '杭州' AND age > 20;

场景 10:JOIN 时字符集不一致

跨表关联时,如果两个关联字段的字符集或排序规则不同,MySQL 会对字段做转换,等同于加函数。

1
2
3
4
5
6
-- t1.phone 是 utf8mb4,t2.phone 是 utf8
-- ❌ 关联字段字符集不一致,索引失效
SELECT * FROM t1 JOIN t2 ON t1.phone = t2.phone;

-- ✅ 统一字符集
ALTER TABLE t2 MODIFY phone varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

排查方法:

1
2
SHOW FULL COLUMNS FROM t1 LIKE 'phone';
SHOW FULL COLUMNS FROM t2 LIKE 'phone';

场景 11:优化器主动放弃索引

有时候索引完全可用,但优化器算完账觉得全表更快,于是弃用。

1
2
3
-- 假设 status=1 占了全表 90% 的行
-- 优化器判断:走索引要回表 90 万次,不如直接顺序扫全表
SELECT * FROM user WHERE status = 1;

原因就是回表代价:二级索引取出主键后还要回聚簇索引取完整行,随机 IO。当命中比例超过约 20%~30%,随机 IO 的开销就超过顺序扫全表了。

优化方案一:用覆盖索引消除回表

1
2
3
CREATE INDEX idx_status_name ON user(status, name);
-- 只查索引内的字段,不回表,优化器就愿意走索引
SELECT name FROM user WHERE status = 1;

优化方案二:强制走索引(谨慎使用)

1
SELECT * FROM user FORCE INDEX (idx_status) WHERE status = 1;

FORCE INDEX 是把优化器的决策权抢过来,属于硬编码。数据分布一变,它可能从”优化”变成”劣化”。只在明确知道数据分布且长期稳定时使用,并且要加注释说明原因。

场景 12:索引选择性太差

在区分度极低的列上建索引,本身就没什么意义。

1
2
3
-- 假设 gender 只有 男/女 两个值,10 万行里男女各 5 万
-- 这个索引几乎不会被引擎选中
CREATE INDEX idx_gender ON user(gender);

用下面的公式计算索引选择性,越接近 1 越好:

1
2
3
4
5
SELECT
COUNT(DISTINCT gender) / COUNT(*) AS gender_selectivity,
COUNT(DISTINCT phone) / COUNT(*) AS phone_selectivity,
COUNT(DISTINCT name) / COUNT(*) AS name_selectivity
FROM user;
字段 选择性 建议
id(主键) 1.0000 最优
phone 0.9980 适合建索引
name 0.8500 可以建
city 0.0200 单独建意义不大
gender 0.00002 不要建

低选择性的列不是不能出现在索引里,而是应该作为联合索引的后缀列,配合高选择性列一起用,比如 (city, name)。

三、explain 执行计划怎么看

3.1 常用列解读

1
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 28;
列 含义 关注点
type 访问类型 最关键,见下表
key 实际使用的索引 为 NULL 表示未走索引
key_len 实际用到的索引字节数 判断联合索引用了几列
rows 预计扫描行数 越小越好
Extra 附加信息 看是否有 Using filesort / Using temporary

3.2 type 性能排序

从好到坏:

1
system > const > eq_ref > ref > range > index > ALL
类型 含义 出现场景
system 表只有一行 极少见
const 主键或唯一索引等值查询,最多一行 WHERE id = 1
eq_ref 唯一索引关联,每行只匹配一条 JOIN 主键
ref 普通索引等值查询 WHERE name = '张三'
range 索引范围扫描 WHERE age > 20
index 全索引扫描 覆盖索引但无条件
ALL 全表扫描 需要优化

优化目标是至少达到 range,最好是 ref 或 const。看到 ALL 就要警惕。

3.3 Extra 常见提示

提示 含义 严重性
Using index 覆盖索引,未回表 🟢 优秀
Using where 在存储引擎返回后再次过滤 🟡 正常
Using index condition 索引下推(ICP),减少回表 🟢 良好
Using filesort 无法用索引排序,需额外排序 🔴 需优化
Using temporary 创建了临时表 🔴 需优化
Using join buffer JOIN 未走索引 🔴 需优化

3.4 用 key_len 判断联合索引用了几列

1
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 28;

假设 name 是 varchar(50) utf8mb4,则 50 × 4 + 2 = 202 字节;age 是 int 为 4 字节。

  • key_len = 202 → 只用了 name
  • key_len = 206 → 用了 name + age
  • key_len = 288 → 用了 name + age + city

key_len 是判断”联合索引到底生效了几列”最直接的方法。

四、慢查询定位

4.1 开启慢查询日志

1
2
3
4
5
6
7
8
-- 查看当前配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 临时开启(重启失效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

永久生效要改配置文件:

1
2
3
4
5
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1

4.2 分析慢日志

1
2
3
4
5
# MySQL 自带工具,按耗时排序取前 10
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# 按出现次数排序
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

生产环境更推荐 Percona Toolkit:

1
2
# 生成完整分析报告
pt-query-digest /var/log/mysql/slow.log > slow_report.txt

4.3 实时抓问题 SQL

1
2
3
4
5
-- 查看当前正在执行的慢查询
SELECT id, user, host, db, command, time, state, LEFT(info, 100) AS sql_text
FROM information_schema.processlist
WHERE command != 'Sleep' AND time > 2
ORDER BY time DESC;

五、实战案例

案例一:千万级分页优化

问题 SQL:

1
2
-- 耗时 12 秒
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;

原因:MySQL 必须先扫描并丢弃前 100 万行,才能取到想要的 20 行。

优化方案:延迟关联,先用覆盖索引定位主键,再回表取数据。

1
2
3
4
5
6
7
-- 耗时 0.3 秒
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY create_time DESC
LIMIT 1000000, 20
) t ON o.id = t.id;

更彻底的方案:游标分页,彻底告别深翻页。

1
2
3
4
5
6
-- 已知上一页最后一条的 create_time 和 id
SELECT * FROM orders
WHERE create_time < '2026-08-01 10:00:00'
OR (create_time = '2026-08-01 10:00:00' AND id < 9527)
ORDER BY create_time DESC, id DESC
LIMIT 20;

配合索引 (create_time, id),无论翻到第几页都是毫秒级。

案例二:隐式字符集转换导致全表扫描

现象:一个 JOIN 查询突然从 50ms 变成 30 秒。

1
2
3
4
SELECT u.name, o.amount
FROM user u
JOIN orders o ON u.phone = o.phone
WHERE u.city = '杭州';

explain 显示 orders 表的 type = ALL。查字符集发现:

1
2
-- user.phone:   utf8mb4_general_ci
-- orders.phone: utf8_general_ci

修复:

1
2
ALTER TABLE orders
MODIFY phone varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

修复后 type 变为 ref,耗时回到 40ms。

案例三:filesort 消除

问题 SQL:

1
2
-- Extra: Using where; Using filesort
SELECT * FROM orders WHERE user_id = 1001 ORDER BY create_time DESC LIMIT 10;

原因:idx_user_id 只按 user_id 排序,同一 user_id 下的 create_time 是无序的,只能临时排序。

修复:建立联合索引,让排序天然有序。

1
2
3
4
-- 删除冗余的单列索引
DROP INDEX idx_user_id ON orders;
-- 建立联合索引,等值列在前、排序列在后
CREATE INDEX idx_user_create ON orders(user_id, create_time);

修复后 Extra 变为 Using index condition,filesort 消失。

六、索引设计最佳实践

  1. 优先使用自增主键 —— 顺序写入避免页分裂,且主键长度越小,二级索引越省空间
  2. 联合索引优于多个单列索引 —— MySQL 通常只会选择其中一个索引,联合索引效率更高
  3. 区分度高的列放前面 —— 但等值查询列一律优先于范围查询列
  4. 控制索引数量 —— 每个索引都是一棵 B+Tree,写操作要同步维护,索引多了写入会变慢
  5. 善用覆盖索引 —— 把查询高频的字段纳入联合索引,消除回表
  6. 避免冗余索引 —— 有了 (a, b, c),(a) 和 (a, b) 就是冗余的
  7. 字段尽量 NOT NULL —— 省一个字节的 NULL 标记位,同时避免 NULL 判断导致的失效
  8. 长字符串用前缀索引 —— CREATE INDEX idx ON t(email(20)),但要权衡区分度
  9. 定期清理无用索引 —— 查询 sys.schema_unused_indexes 找出从没被用过的索引
  10. 上线前必看 explain —— 数据量和线上不一致时,本地测试的执行计划没有参考价值

总结:索引失效看似规则繁多,其实都逃不出两条根因 —— 写法破坏了 B+Tree 的有序性(函数、类型不匹配、% 开头),或者回表代价让优化器觉得不划算(低选择性、大范围命中)。排查时不要猜,一律 EXPLAIN 说话,重点盯 type、key_len、rows、Extra 这四列。