为什么你的SQL查询慢?这7个执行计划陷阱90%的人都踩过

为什么你的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 conditiontype: 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 没有索引,数据库会:

  1. 全表扫描
  2. 把所有数据排序
  3. 取前 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 可以让优化器选择更好的连接顺序和索引策略,避免物化带来的额外开销。


总结:一张表帮你自查

陷阱 核心问题 自查关键词
隐式类型转换 索引列被函数包裹 EXPLAINtype: ALL
OR 条件 多列 OR 导致索引失效 改写为 UNION
索引跳跃扫描 前导列基数高,Skip Scan 代价大 调整联合索引列顺序
LIMIT 无索引 filesort 排序开销大 EXPLAINUsing filesort
统计信息过期 优化器基于错误数据做决策 定期 ANALYZE TABLE
深分页 OFFSET 扫描并丢弃大量前置数据 改用游标分页
子查询物化 临时表无法走索引 改写为 JOIN

最后说一句

优化 SQL 的核心不是背规则,而是 看懂执行计划

养成习惯:写完 SQL,先 EXPLAIN 一下。看看它到底打算怎么跑,和实际预期是否一致。大多数性能问题,在执行计划里都写得清清楚楚。

相关推荐
考虑考虑2 小时前
docker compose V2版本新属性
运维·后端·自动化运维
GreenTea4 小时前
7000 万 QPS、500 PB:OpenAI 如何用一个 Python 存储平台撑住 10 亿用户
后端·架构
Flynt4 小时前
Java 27 升级实测:默认值动得比新特性多,有个老参数会让 JVM 直接起不来
java·jvm·后端
Bs_MoneyMagnet4 小时前
基于springboot+vue的个人健康管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·vue3·springboot3·计算机毕业设计
GreenTea4 小时前
OpenAI Agents API 上手实测:一次调用把整个 agent loop 甩给 OpenAI
前端·后端·算法
Bs_MoneyMagnet7 小时前
基于springboot+vue的心理咨询预约与随访平台的设计与实现 源码+文档
vue.js·spring boot·后端·spring·毕业设计·旅游·计算机毕业设计
IT_陈寒8 小时前
Vue的响应式更新把我坑惨了,原来问题出在这
前端·人工智能·后端
陌シ未央ゞ8 小时前
基于BM25算法和RRF实现的混合索引(java版)
人工智能·spring boot·后端·算法
第五页的你9 小时前
SpringBoot基础设施配置(Redis序列化,JJWT新版)
后端
吃饱了得干活9 小时前
RabbitMQ 原理解析(下):存储、集群与可靠投递
后端·rabbitmq