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,最好是 refconst。看到 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;

假设 namevarchar(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 说话,重点盯 typekey_lenrowsExtra 这四列。