为什么你的SQL查询慢?这7个执行计划陷阱90%的人都踩过
你有没有遇到过这种情况:
明明给字段建了索引,执行计划却偏偏走全表扫描;
SQL 在生产环境慢得离谱,在测试环境却飞快;
加了 LIMIT 100,查询还是要跑十几秒......
问题往往不在 SQL 本身,而在执行计划(Execution Plan) 。
执行计划是数据库优化器给出的"查询路线"。它一旦选错,再好的 SQL 也会跑成灾难。
今天我们就来拆解 7 个最常见的执行计划陷阱,每一个都可能让你的查询慢上十倍甚至百倍。
陷阱一:隐式类型转换,索引直接失效
这是最经典、也最隐蔽的陷阱。
sql
-- user_id 是 VARCHAR 类型,但查询时传了数字
SELECT * FROM orders WHERE user_id = 12345;
你以为走了索引?实际上数据库会做隐式类型转换:
sql
-- 优化器实际执行的逻辑类似这样
SELECT * FROM orders WHERE CAST(user_id AS UNSIGNED) = 12345;
对索引列做了函数运算,索引直接失效,全表扫描。
怎么发现?看执行计划里有没有 Using index condition 或 type: ALL。
正确写法:
ini
SELECT * FROM orders WHERE user_id = '12345';
排查技巧: 执行 EXPLAIN 后,关注 type 列。如果是 ALL(全表扫描)或 index(全索引扫描),而你的 possible_keys 里有可用索引,大概率是类型不匹配。
陷阱二:OR 条件让优化器"摆烂"
ini
SELECT * FROM orders
WHERE user_id = '12345'
OR status = 'pending';
user_id 有索引,status 也有索引。你觉得优化器会怎么选?
答案是:它可能两个都不用,直接全表扫描。
原因是 MySQL 的优化器在面对 OR 条件时,如果涉及多个不同字段,很难合并两个索引的范围扫描,往往会选择代价最低的全表扫描。
优化方案:用 UNION 拆开
sql
SELECT * FROM orders WHERE user_id = '12345'
UNION
SELECT * FROM orders WHERE status = 'pending';
这样每个子查询都能独立使用各自的索引,性能差距可能从 几秒到几毫秒。
陷阱三:索引跳跃扫描(Index Skip Scan)的假象
MySQL 8.0 引入了 Index Skip Scan,听起来很美好------当联合索引的前导列不在 WHERE 条件中时,优化器可以"跳过"它。
但现实很骨感:
sql
-- 联合索引是 (gender, age)
SELECT * FROM users WHERE age = 25;
优化器确实可能用 Skip Scan,但它的原理是 按前导列分组遍历 。如果 gender 的基数很低(比如只有 M/F),那还行;但如果前导列基数高,Skip Scan 的开销可能比全表扫描还大。
正确做法:调整索引顺序,把高区分度的列放前面
scss
-- 改为 (age, gender)
CREATE INDEX idx_age_gender ON users(age, gender);
陷阱四:LIMIT 的"伪优化"
很多人以为加了 LIMIT 查询就会快:
sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100;
如果 created_at 没有索引,数据库会:
- 全表扫描
- 把所有数据排序
- 取前 100 条
排序(filesort)的开销可能比扫描本身还大。
优化方案:给排序列建索引
scss
CREATE INDEX idx_created_at ON orders(created_at);
这样优化器可以直接走索引的倒序扫描,取满 100 条就停,根本不需要排序。
陷阱五:统计信息过期,优化器"瞎选"
这是生产环境最常见的"灵异事件":
同样的 SQL,昨天还跑得好好的,今天突然慢了。
原因很可能是 统计信息过期。
当表的数据分布发生剧烈变化(比如一次性删除了大量数据、批量插入),优化器的统计信息没有及时更新,导致它错误地认为:
- 某个条件能过滤掉 99% 的数据(实际只过滤了 10%)
- 某个索引的区分度很高(实际已经很低)
结果就是选了一个看似便宜、实际很贵的执行计划。
解决方式:
sql
-- MySQL
ANALYZE TABLE orders;
-- PostgreSQL
ANALYZE orders;
另外,对于 MySQL 的 InnoDB,注意 innodb_stats_auto_recalc 是否开启。如果关闭了,统计信息不会自动更新。
陷阱六:深分页的" OFFSET 陷阱"
vbnet
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
这行 SQL 的问题在于:数据库必须先读取并丢弃前 1,000,000 条记录。
即使走了索引,它也要遍历索引的前 100 万条,然后才能拿到你要的 20 条。
优化方案:游标分页(Keyset Pagination)
sql
-- 假设上次查到的最后一条 id 是 1000000
SELECT * FROM orders
WHERE id > 1000000
ORDER BY id
LIMIT 20;
这样优化器可以直接从 id = 1000000 的位置开始扫描,跳过前面的所有数据,性能提升是数量级的。
陷阱七:子查询被"物化",临时表爆炸
sql
SELECT * FROM orders
WHERE user_id IN (
SELECT user_id FROM vip_users WHERE level > 5
);
在某些 MySQL 版本中,优化器会把子查询 物化(Materialize) 成一个临时表,然后再做 JOIN。如果 vip_users 数据量大,这个临时表会非常大,而且无法使用索引。
优化方案:改写成 JOIN
sql
SELECT o.*
FROM orders o
INNER JOIN vip_users v ON o.user_id = v.user_id
WHERE v.level > 5;
JOIN 可以让优化器选择更好的连接顺序和索引策略,避免物化带来的额外开销。
总结:一张表帮你自查
| 陷阱 | 核心问题 | 自查关键词 |
|---|---|---|
| 隐式类型转换 | 索引列被函数包裹 | EXPLAIN 中 type: ALL |
| OR 条件 | 多列 OR 导致索引失效 | 改写为 UNION |
| 索引跳跃扫描 | 前导列基数高,Skip Scan 代价大 | 调整联合索引列顺序 |
| LIMIT 无索引 | filesort 排序开销大 | EXPLAIN 中 Using filesort |
| 统计信息过期 | 优化器基于错误数据做决策 | 定期 ANALYZE TABLE |
| 深分页 OFFSET | 扫描并丢弃大量前置数据 | 改用游标分页 |
| 子查询物化 | 临时表无法走索引 | 改写为 JOIN |
最后说一句
优化 SQL 的核心不是背规则,而是 看懂执行计划。
养成习惯:写完 SQL,先 EXPLAIN 一下。看看它到底打算怎么跑,和实际预期是否一致。大多数性能问题,在执行计划里都写得清清楚楚。