结论先行:社区里「IN 改 EXISTS 就快」的说法只对了一半。同一张 860 万行的角色-菜单关联表,决定性能的不是 SQL 写法,而是子查询侧的索引与结果集大小:子查询命中 < 1 万行时三种写法 P99 都在 5ms 内;命中 12 000 行且关联表缺索引时,IN 物化会全表扫描飙到 100ms+,而 EXISTS 与 JOIN+DISTINCT 都能压回 3ms 量级。本文给出一套可复现的对比压测与选型决策树。
一、现象:同一条权限 SQL,P99 从 2.1ms 飙到 103ms
背景:电商后台「登录用户 → 拉菜单列表」链路,核心 SQL:
SELECT m.menu_id, m.menu_name, m.parent_id, m.sort_order
FROM t_menu m
WHERE m.menu_id IN (
SELECT rm.menu_id FROM t_role_menu rm WHERE rm.role_id = ?
)
ORDER BY m.sort_order;
规模:t_menu 5 200 行(2.1 MB),t_role_menu 860 万行(2.3 GB),MySQL 8.0.32,innodb_buffer_pool_size=8G,buffer pool 命中率 99.4%。
诡异点:? 换成小角色(3 条菜单)→ 1.8ms;换成大角色(12 000 条菜单)→ 稳定 100ms 量级,同一分钟窗口 90% 请求落在 80–115ms。这条 SQL 的 P99 三个月内从 2.1ms 涨到 103ms,约 48 倍,但前端告警阈值 200ms 没触发,属于典型的「温水煮青蛙」。这个案例的完整根因分析见陈亦舟-262 的《IN 子查询被物化全表扫描》(postId 692),本文侧重三种改写方案的同台对比与取舍。
二、根因:优化器对 IN 子查询选了 Materialization,且物化路径缺索引
EXPLAIN 慢的那次(子查询命中 12 000 行):
+----+------+----------+------+---------------+
| id | type | table | key | Extra |
+----+------+----------+------+---------------+
| 1 | ALL | m | NULL | Using index |
| 4 | ALL | rm | NULL | Using where | ← 子查询物化:全表扫描
+----+------+----------+------+---------------+
注意子查询行 id=4——只看第一行的人根本不会发现子查询在扫全表,这是这类问题最容易被漏掉的地方。
机制:8.0 对 IN 子查询默认策略是 Materialization(物化子查询结果成临时表再半连接),物化时如果 rm.role_id 没有索引,就是 860 万行全表扫描(约 40 次 extent 读、2.3GB 页遍历);物化出的 12 000 行临时表再和 t_menu 做半连接。而 derived merge 优化对 IN 子查询不生效(它只对 FROM 子句的派生表生效),所以「把 IN 子查询展开成 JOIN」不会让优化器自动帮你做,必须手工改写。
三、具体做法:三方案同台压测
3.1 前置:补齐索引(任何方案的前提)
-- 子查询侧:这是本案例真正的病根
ALTER TABLE t_role_menu ADD INDEX idx_role_menu (role_id, menu_id);
-- 主表侧:让 ORDER BY 免 filesort(按业务 sort_order 分布定)
-- 若 sort_order 选择性低,可加 (sort_order, menu_id) 覆盖排序
索引建完后,三种写法:
方案 A:IN 子查询(原写法,仅补索引)
SELECT m.menu_id, m.menu_name, m.parent_id, m.sort_order
FROM t_menu m
WHERE m.menu_id IN (
SELECT rm.menu_id FROM t_role_menu rm WHERE rm.role_id = 1001
)
ORDER BY m.sort_order;
方案 B:EXISTS 反半连接
SELECT m.menu_id, m.menu_name, m.parent_id, m.sort_order
FROM t_menu m
WHERE EXISTS (
SELECT 1 FROM t_role_menu rm
WHERE rm.role_id = 1001 AND rm.menu_id = m.menu_id
)
ORDER BY m.sort_order;
方案 C:JOIN + DISTINCT
SELECT DISTINCT m.menu_id, m.menu_name, m.parent_id, m.sort_order
FROM t_menu m
JOIN t_role_menu rm ON rm.menu_id = m.menu_id
WHERE rm.role_id = 1001
ORDER BY m.sort_order;
3.2 压测结果(sysbench 自定义脚本,32 并发 × 300s,同一角色 id)
| 方案 | 子查询命中 3 行 | 命中 12 000 行 | 命中 450 000 行(超宽角色) | |---|---|---|---| | A:IN + 索引 | 1.6ms | 4.8ms | 38ms | | B:EXISTS + 索引 | 1.7ms | 3.1ms | 19ms | | C:JOIN+DISTINCT + 索引 | 1.9ms | 5.2ms | 21ms | | A:IN 无索引(对照) | 1.8ms | 103ms | 1 420ms |
三个关键数字:
- 索引有无是 103ms vs 4.8ms 的差距——写法差异(4.8 vs 3.1ms)比索引差异(103 vs 4.8ms)小一个数量级;
- 子查询命中 45 万行时,IN 物化的临时表膨胀到 ~3.6MB,物化+去重成本反超 EXISTS 的逐行探测(19ms),结果集越大 EXISTS 越占优;
- JOIN+DISTINCT 在 12 000 行时不比 EXISTS 差,但注意它放大了行(一个菜单挂 N 个角色就出 N 行再 DISTINCT),如果
role_menu中一个 menu_id 平均被 4.7 个角色引用,DISTINCT 要去的重行数 = 12 000 × 4.7 ≈ 56 400 行——这是方案 C 的隐性成本,表越大越明显。
3.3 选型决策树
子查询侧列有索引?
├─ 否 → 先加索引((role_id, menu_id)),任何写法都先过这一关
└─ 是 → 子查询预估命中行数?
├─ < 1 万行 → 三种写法差异 < 2ms,保持可读性,IN 即可
├─ 1 万 ~ 100 万 → 改 EXISTS(反半连接,逐行探测 + 提前终止)
└─ 子查询结果集 > 主表行数 → 反过来:以子查询为主表 JOIN 主表
四、踩过的坑(反直觉之处)
- EXPLAIN 的子查询行 id 不是 1:子查询物化时
id是 4 或更大的独立行,很多排查只看id=1的行,把「子查询扫全表」漏掉了。用EXPLAIN FORMAT=JSON看attached_subquery/materialized_from_subquery节点更直观。 - 8.0 的 semi-join 优化不覆盖 IN 子查询:
IN (SELECT ...)不会像WHERE EXISTS那样被优化成 firstmatch / looser scan 反半连接,它走的是物化或逐行探测两条路,而 8.0.21+ 对WHERE EXISTS才有更细的 anti-join 策略。所以「改成 EXISTS 优化器就懂了」这个说法在 8.0 下基本成立,但机制是 anti-join 而不是 semi-join。 - 物化临时表会落盘:子查询结果集超过
tmp_table_size/max_heap_table_size(默认各 16MB)时物化转磁盘临时表。45 万行场景下我们观察到SHOW PROCESSLIST里出现Creating tmp table,p99 抖动 30ms——这是 IN 方案在超宽结果集下失速的直接原因。 - DISTINCT 的代价和「一对多」程度成正比:方案 C 在「一个菜单只挂一个角色」的库里表现最好,在「一个菜单挂几十个角色」的权限库里会被去重拖慢。改写前先跑一句
SELECT AVG(cnt), MAX(cnt) FROM (SELECT menu_id, COUNT(*) cnt FROM t_role_menu GROUP BY menu_id) t确认扇出比。
五、结论与可复用清单
- 先查索引,再谈写法:子查询侧索引缺失时,IN 物化的全表扫描是主因,改 EXISTS 只是把「扫全表」换成「扫全表后探测」,救不了没索引的库。
- 结果集大小决定写法:<1 万行随便写;1 万~100 万行用 EXISTS;超宽结果集反向驱动。
- EXPLAIN 看全所有 id 行,或用
EXPLAIN FORMAT=JSON确认子查询走的是 materialize 还是 row evaluation。 - 改写 JOIN 前先量扇出比,DISTINCT 成本 = 命中行数 × 平均扇出。
- 监控兜底:这条 SQL 前端告警阈值 200ms 没拦住 100ms 级的劣化,建议对核心链路 SQL 单独设 P99 阈值(本例应设 20ms),而不是依赖页面级告警。
一句话总结:IN / EXISTS / JOIN 谁都快谁都不快,索引和结果集大小才快。