应避免直接用子查询写复杂过滤,因其易成性能黑洞;优先用EXISTS替代IN、提取重复子查询为派生表、警惕NOT IN的NULL陷阱和全表扫描,所有子查询须经EXPLAIN验证。

直接用子查询写复杂过滤,多数时候是在给自己埋坑。MySQL 对相关子查询的执行机制决定了它容易变成性能黑洞——外层每查一行,内层就重跑一遍,百万级数据下慢得肉眼可见。
用 EXISTS 替代 IN 避免全表扫描
当子查询用于判断“是否存在匹配记录”时,IN 会先物化全部结果(哪怕只用判断真假),而 EXISTS 只需找到第一条匹配就终止,且能走索引。
- 低效写法:
SELECT * FROM users WHERE id IN (SELECT user_id FROM logs WHERE event = 'login')—— 子查询可能返回几万行,全塞进临时表再比对 - 高效写法:
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM logs l WHERE l.user_id = u.id AND l.event = 'login')—— 每个u.id只查一次,命中索引就停 - 必须确保
logs.user_id有索引,否则EXISTS也退化为全表扫描
把重复子查询提成派生表或临时表
如果同一个子查询在主查询里被引用多次(比如在 SELECT、WHERE、JOIN 中都出现),MySQL 默认会反复执行,而不是缓存结果。
- 危险写法:
SELECT name, (SELECT COUNT(*) FROM orders WHERE user_id = users.id), (SELECT AVG(amount) FROM orders WHERE user_id = users.id) FROM users—— 每个用户都查两遍orders - 安全做法:改用派生表一次性聚合:
SELECT u.name, o.cnt, o.avg_amt FROM users u LEFT JOIN (SELECT user_id, COUNT(*) AS cnt, AVG(amount) AS avg_amt FROM orders GROUP BY user_id) o ON u.id = o.user_id - 若派生表结果很大(>
tmp_table_size),考虑用CREATE TEMPORARY TABLE并手动加索引,避免磁盘临时表
警惕 NOT IN 的 NULL 陷阱和性能崩塌
NOT IN 在子查询结果含 NULL 时永远返回空集,这是 SQL 标准行为,但常被忽略;更严重的是,它几乎无法利用索引,极易触发全表扫描。
- 错误场景:
SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM logins)—— 若logins.user_id有NULL,整个结果为空;即使没NULL,优化器也大概率放弃索引 - 正确替代:
SELECT u.id FROM users u LEFT JOIN logins l ON u.id = l.user_id WHERE l.user_id IS NULL—— 明确语义,可走logins(user_id)索引 - 或者用
NOT EXISTS:SELECT u.id FROM users u WHERE NOT EXISTS (SELECT 1 FROM logins l WHERE l.user_id = u.id)—— 同样规避NULL问题,且支持索引
真正难的不是写出能跑的子查询,而是判断它是否在“假装高效”。EXPLAIN 里的 dependent subquery 和 Using temporary 是红灯,不是装饰。别信直觉,每个子查询都该跑一遍 EXPLAIN FORMAT=JSON 看实际执行路径。


















