写SQL这几年,有一条线绕不开:一个查询的结果,怎么喂给另一个查询继续用。最直接的办法就是子查询——把SELECT嵌进SELECT里,让数据库替你先把内层算完,再拿结果去套外层。很多人刚接触子查询时会觉得别扭,明明JOIN也能做,为啥要多套一层?但真到了"每类商品里销量最高的是谁"、"薪资高于部门平均值的人有哪些"这类需求,子查询往往是最自然的解法,而且可读性和维护成本经常比一堆JOIN和窗口函数拼出来的方案更友好。
这篇内容适合三类人:刚学完基础SELECT、想突破"单表查询"瓶颈的新手;写过一阵SQL但一遇到嵌套就往临时表里塞数据的开发者;以及被子查询性能坑过、想理清EXISTS和IN到底怎么选的同学。我会从子查询的分类逻辑讲起,把执行机制、性能取舍和踩坑经验一起捋清楚,最后给一个从需求到最终SQL的完整走读案例,帮你建立一条清晰的选择路径。
1. 子查询到底是什么:从"嵌套查询"理解它的本质
先别急着背语法。子查询的本质是"一个查询作为另一个查询的输入",数据库执行时会先算内层,把结果交给外层继续加工。理解这一点,很多看起来绕口的写法就顺了。
1.1 一条SQL里藏着两段SELECT
随便看一个经典例子:
SELECT employee_name, salary FROM employees WHERE department_id = ( SELECT department_id FROM departments WHERE department_name = '研发中心' );外层是"查员工姓名和工资",内层是"找出研发中心的部门ID"。整个语句的执行顺序可以想象成:数据库先跑内层SELECT,得到比如dept_id=3,然后用这个结果代替外层那个括号,等价于执行:
SELECT employee_name, salary FROM employees WHERE department_id = 3;这是最简单的标量子查询。之所以要拆成这样,是因为你一开始并不知道研发中心对应的ID是多少,又不想分两步先在客户端里查一次、再拼接SQL发第二次。子查询把两步合并成一步,交给数据库自己去协调。
1.2 子查询的"分类坐标":按位置与按关联性
要真正驾驭子查询,建议用两个维度给它定位。第一个维度是它出现在SQL的哪个位置,这决定了返回值的形态限制;第二个维度是它是否依赖外层查询的列,这决定了执行方式。
按位置分,常见有四种:
- WHERE中的子查询:最常见的过滤场景,可以用比较运算符(=、>、IN、EXISTS等)。
- FROM中的子查询:把子查询结果当成一张临时表,外层再继续查,也叫派生表。
- SELECT列表中的子查询:每行返回一个值,要求结果只有一行一列。
- HAVING中的子查询:对分组后的结果做过滤时使用。
按关联性分,就是非相关子查询和相关子查询:
- 非相关子查询:内层查询不引用外层任何列,可以先独立执行。上面研发中心的例子就是。
- 相关子查询:内层查询引用了外层的列,等于外层每处理一行,内层就要用这一行的值重新算一次。
我见过不少人把相关子查询当成洪水猛兽,其实它只是执行方式不同。理解它是"逐行驱动的循环",你就能预判它的成本,后面第3部分我会专门展开。
1.3 子查询为什么不是"临时表"的简单替代
有些人习惯把子查询结果先扔进一张临时表,再继续查。这个习惯不差,但临时表有几个隐形成本:一是多了一条DDL/DML语句,事务边界和后续清理都要考虑;二是临时表的统计信息不一定准确,优化器可能做出糟糕的执行计划;三是代码里多出一段建表、灌数、查数、删表的噪音。
子查询的优势在于它是一段内联的逻辑,数据库优化器可以直接看到完整意图,有机会做子查询展开(subquery unnesting)、半连接优化(semi-join)、物化(materialization)等重写,跑起来不一定比临时表差,甚至更好。后面性能章节我会细说优化器到底做了哪些手脚。
2. 子查询的五种实战形态:从WHERE标量到FROM派生表
这个部分我会把常见形态逐个拆开讲,每个形态配合能直接用的业务场景,这样你写的时候可以有据可依。
2.1 标量子查询:返回单个值的关键用法
标量子查询要求子查询返回一行一列,否则会直接报错。最常见的用法是配合比较运算符,以及放在SELECT列表中做列计算。
场景一:查薪资高于公司平均值的员工,同时把平均薪资带出来展示。
SELECT employee_name, department_id, salary, (SELECT AVG(salary) FROM employees) AS company_avg_salary FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);这个例子有个明显重复:AVG(salary)算了两次。如果表很大,这个写法不值得推荐,更高效的做法是先用一条查询把平均值算出来放子查询或变量里;但对于中小表,这个写法足够清晰,优化器通常也会把两个AVG子查询缓存成同一个结果,未必真的全表扫两遍。
场景二:对照另一个表取"单值属性"。
SELECT order_id, order_date, (SELECT customer_name FROM customers WHERE customers.customer_id = orders.customer_id) AS customer_name FROM orders;这种写法本质上是"相关标量子查询",每一行订单都要回查一次客户表。如果orders表很大,性能上通常不如JOIN,但胜在直白,适合快速写报表。
注意:SELECT列表里的标量子查询,如果子查询查不到数据,会返回NULL而不是报错,这跟WHERE中用=比较的行为不一样。具体细节在第4部分踩坑篇讲。
2.2 IN与NOT IN:集合归属判断
当你想知道某个值在不在另一个查询结果集合里,IN是最直观的写法。
SELECT employee_name, department_id FROM employees WHERE department_id IN ( SELECT department_id FROM departments WHERE location = '北京' );这里内层返回的是多个department_id,外层判断每一行员工的部门ID是否属于这个集合。
但IN有个先天问题:如果子查询返回的结果集中包含NULL,而你在用NOT IN,整个查询会返回空集。这是SQL三值逻辑导致的经典坑,第4部分我会专门讲。
2.3 EXISTS与NOT EXISTS:存在性判断更灵活
EXISTS和IN都可以做"在不在集合里"的判断,但语义上有区别——EXISTS只关心"是否存在满足条件的行",不关心结果集里具体是什么。它甚至不需要返回完整的列,一般写成SELECT 1或SELECT *都行。
SELECT employee_name FROM employees e WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.employee_id = e.employee_id AND o.order_date >= '2024-01-01' );很多优化器会把IN改写为EXISTS,或者把EXISTS改写为半连接,所以"谁更快"不完全取决于写法本身,还取决于数据分布和索引。但有一个方向是明确的:当内层集合很大、外层表也很大的时候,EXISTS的逐行判定配合索引往往表现得比IN更稳定,因为EXISTS一旦找到一条满足条件的记录就可以立刻返回TRUE,不必把整个结果集全部构建出来。
2.4 FROM中的派生表:把查询结果当作新表继续查
把子查询放在FROM后面,相当于构造了一张内存中的派生表,然后对它做进一步的筛选和聚合。这在多步计算里非常实用。
例子:先算出各部门平均薪资,再筛出平均薪资高于全公司平均水平的部门。
SELECT dept_avg.department_id, dept_avg.avg_salary FROM ( SELECT department_id, AVG(salary) AS avg_salary FROM employees GROUP BY department_id ) dept_avg WHERE dept_avg.avg_salary > ( SELECT AVG(salary) FROM employees );这里dept_avg是给派生表起的别名,别名必须要有,否则大部分数据库会直接报错或解析不出列名。派生表的特点是一次性使用、外部不可重复引用,这也是它和CTE最大的区别。如果同一个子查询结果要在外层被引用多次,优先考虑公共表表达式。
2.5 行子查询与多列比较:把整行当参数
少数数据库支持行值比较,例如:
SELECT employee_name FROM employees WHERE (department_id, salary) = ( SELECT department_id, MAX(salary) FROM employees WHERE department_id = 10 );这种写法"同时比较两个字段",等价于department_id匹配且salary匹配。不过行子查询的可移植性稍差,不同数据库对行构造器的支持程度不一样,实际项目里我更推荐写成普通的AND条件,可读性更高,排查问题也更直接。
3. 执行机制与性能取舍:优化器在子查询背后做了什么
很多新手写子查询时最担心的就是"慢不慢",尤其听到"相关子查询逐行执行"就开始提心吊胆。这一章我会把执行机制讲清楚,再讲怎么通过改写来规避性能陷阱。
3.1 相关子查询的"逐行驱动"是怎么发生的
相关子查询的执行逻辑可以理解成一个双层循环:
SELECT employee_name, department_id FROM employees e WHERE salary > ( SELECT AVG(salary) FROM employees WHERE department_id = e.department_id );外层每拿到一行员工记录,就把这行的department_id代入内层,内层算出该部门的平均薪资,然后外层再比较这个员工的薪资是否大于该值。如果employees表有1万行、50个部门,理论上内层最多要执行1万次,但如果数据库把内层的"按部门平均值"物化缓存成一张50行的临时结果,实际代价会大幅下降。
这就是优化的关键:相关子查询不等于一定慢,真正决定性能的是内层能不能快速得到结果。如果内层查询能用上索引,或者被优化器物化,逐行执行的开销可控;如果内层需要全表扫描一次,外层每行都触发一次全表扫描,那才是真正的灾难。
3.2 优化器的三种常见处理手段
现代数据库对子查询的优化能力已经很强,主要手段有三种:
- 子查询展开:把子查询改写为JOIN。比如
x IN (SELECT y FROM t2)可能被改写成半连接x = t2.y,这样优化器可以用哈希连接、索引连接等策略,而不是简单嵌套循环。 - 物化:非相关子查询结果较小且被多次使用时,优化器可能把结果缓存成一张内存临时表。
- 半连接与反连接:EXISTS、IN对应的连接变体,只关心"匹配与否",一旦找到匹配即可停止继续扫描。
这也是为什么我建议你在排查慢SQL时先看执行计划,不要只看SQL本身下结论。同一条子查询在不同数据库版本里可能有完全不同的执行策略。
3.3 EXISTS与IN、JOIN的取舍:从数据和语义两个维度看
围绕"子查询慢不慢"的最大争议就是EXISTS、IN、JOIN三者的对比。我直接给结论,配合一张表方便你对照:
| 对比维度 | IN | EXISTS | JOIN |
|---|---|---|---|
| 是否去重 | 自动去重,结果集不含重复 | 天然按外表行判断,不产生重复 | 可能产生重复,需要DISTINCT或处理好连接条件 |
| NULL处理 | NOT IN遇到NULL风险大 | 不受NULL干扰 | 内连接会忽略NULL |
| 内层集合很大时 | 可能先构建完整集合 | 边扫边判断,命中即止 | 依赖连接算法和索引 |
| 可读性 | 最直观 | 稍绕 | 大多数人熟悉 |
| 典型场景 | 值在给定集合中 | 相关条件存在性判断 | 需要同时取两边字段时 |
我个人的选择逻辑是这样的:如果子查询结果集很小(几十到几千行),IN写得最舒服;如果外层表很大、内层条件复杂且带索引,EXISTS容错性更好;如果外层还需要展示内层表的字段,直接用JOIN,不要为了"用子查询而子查询"。记住三条原则:
- 子查询的返回值是否真的只用于过滤?如果是,EXISTS/JOIN通常更合适。
- 是否存在去重风险?JOIN可能因一对多产生重复,子查询没有这个烦恼。
- 优化器是否可能把子查询改写为连接?真的不确定时,跑EXPLAIN看执行计划。
3.4 什么时候用子查询而不是JOIN:一个反直觉的例子
有句话说"能用JOIN就别用子查询",其实不全面。举个例子:查"从未下过单的客户",你有两种写法:
-- 写法A:NOT EXISTS SELECT customer_name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ); -- 写法B:LEFT JOIN + IS NULL SELECT customer_name FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.order_id IS NULL;两种写法的执行计划往往很接近,数据库都会优化成反连接。但如果orders表存在大量重复的customer_id,LEFT JOIN写法容易让中间结果膨胀,而NOT EXISTS由于"存在即停止"的特性,更不容易被重复数据带偏。这个例子说明:当连接可能导致结果集膨胀时,子查询写法反而更安全。
3.5 公共表表达式和子查询的替代关系
CTE和子查询的最大区别是命名与复用。子查询在SQL里是匿名的,一个括号只能用一次;CTE可以定义在语句开头,后面反复引用,还能递归。当你的查询逻辑有三层以上的嵌套时,CTE的可读性碾压子查询。
WITH dept_avg AS ( SELECT department_id, AVG(salary) AS avg_salary FROM employees GROUP BY department_id ) SELECT e.employee_name, e.salary FROM employees e JOIN dept_avg d ON d.department_id = e.department_id WHERE e.salary > d.avg_salary;这段逻辑用子查询也可以写,但嵌套层数多了以后,括号的配对本身就是一种心智负担。我的习惯是:单层嵌套用子查询,超过两层或需要复用时优先CTE。
4. 子查询七大踩坑实录:这些问题我都在生产环境里遇到过
写子查询最大的成本不在"写对",而在"写对了却踩了逻辑坑"。这一部分我把真实工作里反复出现的几类问题梳理出来,每一类都给出现象、原因和修正方案。
4.1 NOT IN遇上NULL,结果变成空集
这是子查询里最著名的隐藏炸弹。
SELECT employee_name FROM employees WHERE department_id NOT IN ( SELECT department_id FROM departments WHERE manager_id IS NOT NULL );如果departments表中存在manager_id为NULL的部门,那么子查询结果集里就有一个NULL。SQL的NOT IN逻辑是"不等于集合中的每一个值",而NULL既不是等于也不是不等于,比较结果既不是TRUE也不是FALSE,而是UNKNOWN。最终所有行都被过滤掉了。
修正方案有两种:
-- 方案A:先排除NULL WHERE department_id NOT IN ( SELECT department_id FROM departments WHERE manager_id IS NOT NULL AND department_id IS NOT NULL ); -- 方案B:改用NOT EXISTS WHERE NOT EXISTS ( SELECT 1 FROM departments d WHERE d.department_id = employees.department_id AND d.manager_id IS NOT NULL );我强烈建议在做"排除"语义时优先使用NOT EXISTS,因为它天然规避NULL问题,逻辑也更贴近"逐行判断"。
4.2 FROM子查询忘记别名
这是一个每次培训都会遇到的报错。SQL语法要求FROM后面出现的派生表必须有别名,哪怕是SELECT * FROM (SELECT ... ),最后一个右括号后面也必须跟着一个表别名。不写别名,PostgreSQL会直接报"subquery in FROM must have an alias"。
-- 错误写法 SELECT dept_id, cnt FROM (SELECT department_id AS dept_id, COUNT(*) AS cnt FROM employees GROUP BY department_id); -- 正确写法 SELECT dept_id, cnt FROM (SELECT department_id AS dept_id, COUNT(*) AS cnt FROM employees GROUP BY department_id) t;这个别名也会成为外层引用列的前缀,比如t.dept_id。说实话这个限制对新手有点不友好,但遵守它反而让代码更清楚,至少一眼知道这个临时查询结果在当时叫什么角色。
4.3 标量子查询返回多行
如果子查询预期返回一行一列,但实际返回多行,数据库会直接报错。比如:
SELECT employee_name, (SELECT department_name FROM departments WHERE department_id = employees.department_id AND location = '北京') AS dept_name FROM employees;如果同一个部门在北京有多个记录,子查询就返回多行,整个查询崩溃。遇到这类问题,先确认子查询的关联条件是否足够唯一;如果业务上可能一对多,你就得考虑改用JOIN,或者在子查询里加上LIMIT 1,但加LIMIT前一定要思考业务语义——随便取一行是否真的符合需求。
4.4 相关子查询的列名歧义
相关子查询里最常见的错误是内层与外层列名互相覆盖。比如:
SELECT employee_name, salary FROM employees e WHERE EXISTS ( SELECT 1 FROM transfer_history t WHERE t.employee_id = employee_id );这里内层WHERE里的employee_id到底指t.employee_id还是外层e.employee_id?在列名解析规则下,内层优先匹配内层表的列,所以它等价于t.employee_id = t.employee_id,条件恒为真或恒为假,逻辑完全跑偏。
修正办法是给所有表起清晰别名,并显式带前缀:
SELECT employee_name, salary FROM employees e WHERE EXISTS ( SELECT 1 FROM transfer_history t WHERE t.employee_id = e.employee_id );这个坑在表字段重名时尤其致命,因为数据库不会报错,只会静默返回错误结果。排查思路就是看执行计划或实际结果是否符合预期,一旦发现异常,第一反应查列名绑定。
4.5 嵌套层数过深导致的可读性灾难
子查询可以无限嵌套,但人的脑容量有限。三层以上的括号嵌套会让代码变得很难维护,尤其过几个月你自己回来看,光是数括号都会头皮发麻。
比如下面这种写法,虽然能跑,但我极不推荐:
SELECT employee_name FROM employees WHERE department_id IN ( SELECT department_id FROM departments WHERE location IN ( SELECT location FROM company_branches WHERE branch_head_count > 100 ) );这种逻辑完全可以用JOIN改写,或者拆成CTE,代码会清晰得多。子查询的价值是让相关逻辑内聚,而不是制造金字塔。真实项目里我看到过一份"七层嵌套"的报表SQL,最终排查性能问题时发现中间两层子查询完全没有参与计算,纯粹是因为前面的开发者一层层往外套出来的。删掉冗余层级后,查询时间从几十秒降到几百毫秒。
4.6 子查询结果集过大与IN的性能灾害
当子查询返回的结果集达到几十万行时,IN子句有时候会让优化器选择全量物化,内存消耗巨大,甚至导致磁盘临时文件。这种情况下,优先改写为EXISTS或JOIN,让优化器走半连接;
还有一种常见做法是给子查询加过滤条件缩小集合范围,但这不是万能药,最根本的还是要从数据模型上保证过滤条件存在索引。
我给一个经验值:如果子查询结果集预计超过全表数据的10%,IN和JOIN的性能差别通常不大,优化器会自动选择更好的连接策略;如果结果集很小,IN的物化成本低,反而很快。所以"大集合用EXISTS,小集合用IN"这个经验在多数场景成立。
4.7 隐式类型转换让索引失效
子查询内外层的字段类型不一致时,数据库可能做隐式转换,导致索引失效。比较常见的是字符类型和数字类型做比较。
SELECT * FROM orders WHERE customer_id IN (SELECT customer_id FROM customers WHERE level = 1);如果customers的customer_id是VARCHAR,orders的customer_id是INT,比较时数据库可能要类型转换,可能造成索引无法使用。解决办法是统一字段类型,或在子查询里显式CAST。判断方法同样是看执行计划,注意是否有类型转换操作符出现。
5. 一个完整实战走读:从需求到最终SQL的优化全过程
前面讲了很多理论和零散案例,最后用一个完整的业务需求把所有知识点串一遍。这个需求是典型的"报告开发"场景。
5.1 业务需求与初始实现
需求是:找出每个部门里薪资最高的员工,显示员工姓名、部门名称和薪资。首先想到的是"按部门分组取MAX(salary),再反查员工信息",这正好是子查询的经典用途。
第一版我可能会这样写:
SELECT e.employee_name, d.department_name, e.salary FROM employees e JOIN departments d ON d.department_id = e.department_id WHERE (e.department_id, e.salary) IN ( SELECT department_id, MAX(salary) FROM employees GROUP BY department_id );这个写法用了行值比较来解决"既要部门匹配,又要薪资等于部门最高"。它足够简洁,在MySQL和PostgreSQL都能跑。但我马上会想到一个问题:如果同一个部门有多人薪资一样且都是最高,这个查询会把他们全部返回,是否符合需求?如果产品希望"每个部门只要一个人",这个写法就不满足。
5.2 逐步验证与执行计划分析
先假设"允许并列最高",这个查询逻辑没错。但我还是决定看下执行计划,因为employees表很大,group by子查询在IN中使用时,优化器需要物化"部门+最高薪资"这张临时表,然后再做半连接判断。
实测执行计划里确实出现了物化节点,临时表有几千行,这个规模完全可以接受。最终SQL在500万行员工数据上跑出了1.2秒的成绩。但我注意嵌套循环的驱动顺序是employees先扫,再探测物化表,如果过滤条件能先缩小员工范围,性能会更好。
于是我把外层加了一个筛选条件,比如只查在职状态为1的员工:
SELECT e.employee_name, d.department_name, e.salary FROM employees e JOIN departments d ON d.department_id = e.department_id WHERE e.is_active = 1 AND (e.department_id, e.salary) IN ( SELECT department_id, MAX(salary) FROM employees WHERE is_active = 1 GROUP BY department_id );执行时间下降到0.4秒左右。这个优化思路不是改子查询本身,而是让内外层都有更精确的过滤条件,减少无效计算。
5.3 用相关子查询重写后对比
同一需求我也写了一个相关子查询版本:
SELECT e.employee_name, d.department_name, e.salary FROM employees e JOIN departments d ON d.department_id = e.department_id WHERE e.salary = ( SELECT MAX(salary) FROM employees e2 WHERE e2.department_id = e.department_id AND e2.is_active = 1 );这个版本逻辑更贴近自然语言:"薪资等于自己部门的最高薪资"。执行计划里内层相关子查询每行都执行,但如果department_id上有索引,并且每个部门人数可控,速度也不差。实测在相同数据量下,这一版跑了1.5秒,略慢于优化过的IN版本,但代码可读性明显更高。
所以最终怎么选?我的结论是:如果部门数量少、员工量大,且经常需要这种"组内最高"计算,更推荐用窗口函数(ROW_NUMBER())一次性搞定,子查询适合快速实现和逻辑验证,窗口函数适合做生产化的固定报表。完全没必要在"一定用子查询还是一定用JOIN"上站队,三者可以互为备选。
5.4 从案例中提炼的三条经验
这个案例最有价值的不是最终SQL,而是排查过程。我从里面提炼出三条值得留在脑子里的经验:
- 先确定业务语义,再选择技术形态。"是否允许并列最高"、"结果集是否要唯一",这类问题不搞清楚,写出来的SQL方向可能就偏了。
- 所有性能判断都要依托执行计划。同一逻辑可能被优化器重写为完全不同的执行路径,别凭经验硬猜。
- 子查询、JOIN、窗口函数是同一问题的三种切面。优先选择易于验证和维护的写法,性能问题在明确瓶颈后再针对性优化,不要一开始就追求炫技。
写到最后想交代的几件事
关于子查询,我始终觉得它不是一个"高级技巧",而是SQL语言里最基础的组合思维。真正的高级感不来自嵌套多少层,而来自你知道在什么场景下停手——该拆成CTE就拆,该换JOIN就换,该用窗口函数就别硬套。
回看这几年的踩坑经历,最深刻的体会是:子查询的错误多数是"逻辑正确但语义跑偏",所以写完SQL别急着跑结果,先顺着业务逻辑读一遍,再到执行计划里确认访问路径。特别是涉及NULL、去重、多列关联这三个点时,真正常出问题的地方不在语法本身,而在数据分布比你想象的更复杂。
最后分享一个我自己的小习惯:新建的SQL文件,我会先写注释描述最终要返回的每一列,再写WHERE里重要的业务限定。这样即便子查询嵌套了三层,回来改需求时也只需要从外往内逐层拆解,不会迷路。这个习惯我建议你也试试,尤其是刚上手子查询的阶段,它能帮你在"能跑通"和"敢改它"之间建立起真正的自信心。