1. 一个诡异的慢查询:子查询秒回,主查询却卡死
先还原一下我遇到这个问题的现场。线上有个报表接口,某一天突然开始频繁超时,监控平台连续告警。我把出问题的SQL捞出来,单独执行最内层的SELECT id FROM orders WHERE user_id = 123 AND status = 1,日志显示耗时只有0.05秒不到,走了索引,结果集也就几百行。可一旦把它作为IN子查询合并到完整的业务SQL里,整个查询直接飙到30秒甚至更久,把数据库连接池都拖垮了。
类似的情况我相信不少人都碰到过:**单看子查询没问题,单看主查询的过滤条件也没问题,两个拼到一起就变成了性能灾难。**如果此时你只盯着EXPLAIN看,往往只能看到驱动表、索引、rows这些指标,很难一眼定位到真正的瓶颈。
这其实是个非常典型的问题——不是数据量太大,也不是索引缺失,而是SQL优化器在处理IN子查询时做出的执行计划选择,和你直觉中认为的“应该怎么执行”完全不一样。这篇文章我会从优化器的底层决策逻辑讲起,结合几个我实际排查过的案例,把这个坑彻底拆开,顺便把一类类似的“局部快、整体慢”问题一起解决掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么你的直觉在MySQL优化器面前不成立
很多写SQL的人对IN子查询的执行逻辑都有一个朴素的预期:“MySQL应该先执行子查询,拿到结果集合,再把这个集合当作条件去过滤外层查询。”听起来非常合理,对吧?如果真是这样,那子查询本身快,整个查询应该也快才对。
但问题的关键在于,MySQL优化器很少按字面顺序执行SQL,IN子查询从语义到执行计划之间还隔着好几层改写逻辑。
2.1 优化器把IN子查询改写成什么了
MySQL处理IN (subquery)会依据当前版本、表的索引结构、数据分布情况,自动选择一个执行策略。常用的几条路线大致是这样:
- 半连接物化:把子查询结果去重后,物化成一张内部临时表,再和外层表做连接。
- 半连接拉平:把子查询改成
JOIN,直接和外层表做连接操作。 - 转换为EXISTS:把
IN子查询改写成逐行关联的EXISTS执行方式,相当于外层表每取一行,就去子查询里查一次。 - 物化后加索引:如果物化临时表较大,MySQL会在临时表的关联列上自动建索引。
每种策略都有各自的适用条件。MySQL不是按“最优”来选,而是按cost估算的“较优”来选。出问题的情况通常就出在:优化器选了那条你和数据分布都预料不到的路线。
2.2 一个最常见的反直觉案例
我再还原一个简化过的例子。
sql复制SELECT *
FROM user_order_log l
WHERE l.user_id IN (
SELECT u.id
FROM user u
WHERE u.level = 3
);
单独执行:
sql复制SELECT u.id FROM user u WHERE u.level = 3;
可能0.02秒就出来了,level字段有索引,命中的用户也才几百个。
整体执行却很慢,EXPLAIN里能看到类似这样的现象:user表走了全表扫描,user_order_log表的user_id索引明明存在却没被选上,反而是全表扫或者走了奇怪的Using where。
为什么?
因为MySQL优化器在估算成本时,发现user_order_log表很大,但“表数据行数”和“实际匹配 IN 列表的行数”之间存在巨大偏差。它认为:与其先执行子查询拿几百个id再去一条条回表索引查找,不如直接全表扫一遍user_order_log,然后逐行判断是否在子查询结果集里。如果这个“判断是否在集合里”的操作因为数据分布不均,比如大量日志都属于这批用户,全表扫描成本就会被严重低估。
2.3 单条快、合并慢的直接原因总结
归纳下来,IN单查快、合查慢,本质上逃不出这几类原因:
| 原因类别 | 具体表现 | 触发场景 |
|---|---|---|
| 执行策略转换 | 被改写成EXISTS或全表关联 |
子查询结果集偏大或MySQL觉得物化不划算 |
| 统计信息失真 | 优化器估算的行数和实际差距大 | analyze table未更新、数据剧烈波动 |
| 驱动顺序变化 | 小表没当驱动表, |
