MySQL 5.6前IN子查询性能极差,因优化器不支持semijoin重写,强制嵌套循环执行,每行重复执行子查询并频繁创建磁盘临时表。

MySQL 5.6 之前子查询性能差,根本不是写法问题,而是优化器根本不支持语义等价重写 —— 它把 IN 子查询当成黑盒,每行都硬执行一次。
为什么 WHERE id IN (SELECT user_id FROM logs) 在 5.6 前会慢得离谱
MySQL 5.6 以前没有 semijoin 优化路径,遇到 IN 子查询时,优化器不会尝试将其转为连接操作。它默认走「嵌套循环」:外层每取一行,就完整执行一遍子查询(包括全表扫描、临时表创建、去重、回表匹配),哪怕子查询结果完全不依赖外层字段。
- 子查询结果不缓存,重复执行次数 = 外层结果集行数
- 即使子查询只返回 10 行,外层有 10 万行,就要执行 10 万次子查询
- 临时表默认用 MEMORY 引擎,但一旦超出
tmp_table_size或含 BLOB/TEXT 字段,就会退化为 MyISAM 临时磁盘表,I/O 爆增 -
EXPLAIN中你会看到select_type = DEPENDENT SUBQUERY,这不是“相关子查询”,是优化器放弃治疗的标记
怎么确认你正踩在 5.6 前的坑里
别猜版本,直接看执行计划和行为:
面向 CRM 系统的 MySQL 8 数据库模式设计。当用户需要为客户关系管理(CRM)系统设计、评审、优化或生成数据库模式时,应使用此项技能。
- 执行
SELECT VERSION(),确认是5.5.x或更早(5.6.0是分水岭) - 运行
EXPLAIN SELECT ... WHERE id IN (SELECT ...),若select_type列全是DEPENDENT SUBQUERY,且子查询Extra含Using temporary,基本锁定 - 对比改写为
JOIN后的执行时间:如果快 10 倍以上,说明原写法被优化器彻底误判 - 检查
SHOW STATUS LIKE 'Created_tmp_disk_tables',高增长意味着频繁落盘临时表
必须手动重写的三个安全替代方案
不能等优化器,得自己拆解逻辑。以下方案按优先级排序,适用于绝大多数 IN / NOT IN 场景:
- 用
INNER JOIN替代IN:SELECT u.* FROM users u INNER JOIN logs l ON u.id = l.user_id→ 注意去重:加DISTINCT u.*或用GROUP BY u.id,否则可能因一对多膨胀结果 - 用
LEFT JOIN + IS NULL替代NOT IN:SELECT u.* FROM users u LEFT JOIN logs l ON u.id = l.user_id WHERE l.user_id IS NULL→ 避开NOT IN对NULL的语义陷阱(只要子查询含任意NULL,整个条件恒为FALSE) - 显式建临时表 + 索引(兜底):
CREATE TEMPORARY TABLE tmp_user_ids AS SELECT DISTINCT user_id FROM logs;ALTER TABLE tmp_user_ids ADD INDEX(user_id);SELECT * FROM users WHERE id IN (SELECT user_id FROM tmp_user_ids);→ 临时表名建议带CONNECTION_ID()防并发冲突,如tmp_user_ids_12345
容易被忽略的兼容性雷区
重写不是无脑替换,这些细节决定是否真提速:
-
IN子查询自动去重,JOIN不会 —— 若原逻辑依赖去重,漏加DISTINCT或GROUP BY会导致结果错 -
NOT IN和NOT EXISTS行为一致,但LEFT JOIN + IS NULL要求关联字段非空;若logs.user_id允许NULL,需先WHERE l.user_id IS NOT NULL - 临时表方案中,
CREATE TEMPORARY TABLE只对当前连接可见,但高并发下CONNECTION_ID()动态命名仍比固定名tmp_ids更稳妥 - 所有重写都绕不开索引:确保
logs.user_id有单独索引,否则JOIN或临时表查询仍走全表扫描
最麻烦的不是写法,是验证——重写后必须用 EXPLAIN 确认 type 是 ref 或 eq_ref,且没再出现 Using temporary。否则,你只是换了个姿势踩坑。


















