子查询大概是新手从“会写SQL”到“敢碰复杂业务报表”之间,最过瘾的一道分水岭。没接触它之前,遇到“按某个汇总结果再筛选一次”的需求,我脑子里只有两种方案:要么把数据分两次查出来,回到代码里用 if 拼;要么硬写多层 JOIN,绕得自己都头晕。直到第一次把一整段 SELECT 包在括号里直接作为过滤条件,那种“一次把活干完”的顺畅感,让我彻底改变了写查询的习惯。
子查询,说穿了就是嵌套在其他查询语句中的完整 SELECT 语句。它的核心价值在于把复杂问题拆解成“先算什么,再算什么”的清晰步骤,让数据库帮你按顺序完成中间计算,而不是把所有逻辑都压在外层查询里硬刚。这篇内容我准备把子查询的四种基本形态、高频业务场景、执行原理和性能优化一次性讲透,顺便把新手最容易踩的几个大坑提前踩平。适合已经掌握 SELECT、JOIN、WHERE,但总觉得复杂查询写不顺手的开发者阅读。
1. 子查询的整体设计思路与基本形态
1.1 子查询到底解决了什么问题
先做个类比。你要在一栋办公楼里找一位访客,正常人不会挨个房间敲门问“是你吗”,而是先到前台查访客登记表,再拿着名字去对应楼层找。子查询就是那张登记表。数据库在执行时,会先算括号里的那段 SELECT,得出一份临时结果,然后外层查询再拿着这份结果继续操作。
它解决的,本质上就是“先有中间结论,再做下一步判断”的问题。实际开发中,我总结出三个最典型的信号,出现任何一个都该往子查询方向想:
第一,你要比较的对象不是一个固定值,而是“另一个查询算出来的值”。比如“找出工资高于全公司平均水平的人”,“全公司平均水平”就是个动态值,今天和明天不一样,必须现算。
第二,你要排除或匹配的不是单个值,而是一组值。比如“找出没有分配任何项目的员工”,这个“有项目的员工”天然就是一组 ID,没法直接写死。
第三,你需要的表结构没法直接 JOIN,因为它在 JOIN 之前还得先聚合、去重、或者变形。最典型的就是“取每个部门工资最高的员工的完整信息”,你没法直接 JOIN 部门表拿到这个结果,得先把每个部门的最高工资算出来,作为一张临时表再关联。
1.2 子查询的四种基本形态
子查询按返回结果可以分成四类,理解了这个分类,遇到任何嵌套查询都能立刻判断它该出现在哪里、能干什么。
| 形态 | 返回内容 | 常见位置 | 典型用途 |
|---|---|---|---|
| 标量子查询 | 一行一列,也就是单个值 | SELECT 子句、WHERE 比较符右侧 | 拿某个算出来的数和其他值比大小 |
| 行子查询 | 单行多列 | WHERE 子句跟行构造器配合 | 一次比较多个字段的组合条件 |
| 列子查询 | 一列多行 | WHERE 子句配合 IN、ANY、ALL | 拿一组数据做包含、排除判断 |
| 表子查询 | 多行多列,也就是虚拟表 | FROM 子句,必须取别名 | 先加工出一张中间表,再做关联查询 |
这四类不是互相排斥的,同一个查询里完全可以嵌套多层,内层可能是标量,外层拿它当表用。关键是你得先能判断“我现在这一步需要的是什么形状的数据”,再决定用哪种写法。
2. 核心语法拆解与实操要点
2.1 标量子查询:把查询结果当常量用
标量子查询是最简单也最常用的形态,它的返回值是“一个值”,所以你可以把它写在任何需要一个表达式的地方。比如 SELECT 后面:
SELECT emp_id, name, salary, (SELECT AVG(salary) FROM employees) AS company_avg_salary FROM employees;这个查询会在每一行结果后面都附上公司平均工资,方便你直观对比。注意这里有个细节:标量子查询必须且只能返回一行一列,如果底层数据有问题,查出了两行,整个 SQL 会直接报错,这一点后面讲坑的时候再说。
标量子查询也经常放在 WHERE 里做动态比较,典型写法是:“找到工资高于全公司平均工资的员工”。
SELECT emp_id, name, salary FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);这种写法有个很大的好处:你不用先手动跑一遍 AVG 查询、把结果抄回来、再写死到第二遍 SQL 里。只要员工数据有变化,子查询每次执行都会重新计算,维护成本低得多。
2.2 列子查询:IN、ANY、ALL 三兄弟
当子查询返回“一列多行”时,最常用的搭档是 IN 和 NOT IN,用来做包含判断。比如“找出销售部和技术部的所有员工”:
SELECT emp_id, name, dept_id FROM employees WHERE dept_id IN ( SELECT dept_id FROM departments WHERE dept_name IN ('销售部', '技术部') );这个逻辑非常直白:先查出符合条件的部门 ID 列表,再让外层员工表去匹配。写起来像聊天一样自然,这也是很多新手上手子查询的第一个写法。
除了 IN,还有两个兄弟 ANY 和 ALL,它们在需要“跟一组值逐个大比拼”时特别好用。比如“找出工资比技术部任何一个员工都高的人”:
SELECT emp_id, name, salary FROM employees WHERE salary > ANY ( SELECT salary FROM employees WHERE dept_id = (SELECT dept_id FROM departments WHERE dept_name = '技术部') );ANY 的意思是“大于其中任意一个就行”,等价于“大于这一组里最小的那个”。ALL 则要求“大于全部”,等价于“大于这一组里最大的那个”。这两个关键字平时用得不那么多,但遇到“跟一组表数据比较最值”的场景时,比先算出 MIN/MAX 再比较要直观得多。
2.3 相关子查询:内外联动的逐行判断
标量和列子查询都是“先算完内层,再算外层”,但相关子查询不一样,它的内层查询依赖外层当前行的某个值,必须一行一行地联动计算。最经典的例子就是“找出每个部门中,工资高于本部门平均工资的员工”:
SELECT e.emp_id, e.name, e.dept_id, e.salary FROM employees e WHERE e.salary > ( SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id );你看内层查询里出现了e.dept_id,这个 e 是外层表的别名。数据库的逻辑是:先拿出外层第一行员工,把他的 dept_id 传给内层,算出这个部门的平均工资,然后比较;再拿第二行,重复同样的动作。有多少个员工,内层 AVG 就执行多少次。
这种写法的优点是非常清晰地表达了“每一行都跟自己所在部门比”的意图,代码容易读、容易维护。代价是性能可能变差,尤其当外层表有几万行、部门表又没建好索引时,简直是灾难。后面性能部分我会专门讲怎么治它。
2.4 表子查询:FROM 后面的虚拟表
表子查询就是写在 FROM 子句里的完整 SELECT,查询结果被当作一张临时表来使用。它最大的优势是可以在 JOIN 之前先做缩小范围、聚合、去重等操作,避免大表直接关联导致中间结果爆炸。
SELECT d.dept_name, avg_salary_info.avg_salary FROM departments d JOIN ( SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id ) AS avg_salary_info ON d.dept_id = avg_salary_info.dept_id;这里我先把员工的工资按部门聚合,得到一个“部门平均工资表”,再跟部门表做 JOIN。如果先 JOIN 再 GROUP BY,数据库要先把所有员工跟部门匹配出来,再分组聚合,数据量大时差别非常明显。
表子查询有两个硬性要求:必须被放在括号里,而且必须取别名。很多新手漏了 AS 别名直接报语法错误,这正是因为数据库没法引用一张无名虚拟表。另一个注意点是表子查询本身尽量只保留需要的列,不要 SELECT *,虚拟表太宽会让外层查询的每一列都参与运算,白白增加内存和 IO 开销。
3. 真实业务场景实操全记录
3.1 场景一:每个部门的高薪员工名单
这个场景我在多个项目里都写过,需求描述基本一致:老板想知道每个部门里,哪些员工的工资比本部门平均水平高。先看最直观的相关子查询写法:
SELECT dept_id, emp_id, name, salary FROM employees e WHERE salary > ( SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id ) ORDER BY dept_id, salary DESC;它的可读性确实好,但如果你负责的系统数据量在几十万行以上,就要小心了。实际优化时,我更喜欢改成先聚合、再 JOIN 的写法:
SELECT e.dept_id, e.emp_id, e.name, e.salary FROM employees e JOIN ( SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id ) dept_avg ON e.dept_id = dept_avg.dept_id WHERE e.salary > dept_avg.avg_salary ORDER BY e.dept_id, e.salary DESC;两种写法逻辑结果完全一样,但第二种只需要对员工表扫描一次,然后按部门聚合一次,之后再跟聚合结果关联一次。第一种写法在没有物化相关子查询能力的数据库上,相当于把数据库当循环用,每一行员工都要触发一次部门聚合。实测数据量到十万行时,第二种写法往往快一个数量级以上。
3.2 场景二:找出“没有分配到任何项目”的员工
项目表和员工项目关联表是经典的一对多关系,需求是找出“孤零零”的员工。新手最常见的写法是 NOT IN:
SELECT emp_id, name FROM employees WHERE emp_id NOT IN ( SELECT emp_id FROM employee_projects );如果 employee_projects 表里的 emp_id 列没有 NULL,这种写法没问题。但现实中项目表很可能录入过不完整的数据,emp_id 一旦出现 NULL,整个 NOT IN 的结果就会让所有员工消失。原因是 SQL 对 NULL 的处理遵循“未知”逻辑:emp_id NOT IN (1, 2, NULL)会被转换成emp_id != 1 AND emp_id != 2 AND emp_id != NULL,最后的!= NULL结果既不是真也不是假,而是“未知”,整条判断就变成未知,行被过滤掉了。
所以碰到这种“不存在”语义的需求,我会优先用 NOT EXISTS:
SELECT emp_id, name FROM employees e WHERE NOT EXISTS ( SELECT 1 FROM employee_projects ep WHERE ep.emp_id = e.emp_id );EXISTS 和 NOT EXISTS 只关心“内层有没有行”,不关心返回什么列,也不受 NULL 影响。这个坑我用一次就长记性了,现在看到 NOT IN 手都会先抖一下。
3.3 场景三:按工资排名取部门前三
“每个部门工资最高的三个人”属于经典 Top N 问题,用简单子查询也能写出来,原理是统计“比当前员工工资高的人数”,如果这个人数小于 3,那这个人就在部门前三里:
SELECT e.dept_id, e.emp_id, e.name, e.salary FROM employees e WHERE ( SELECT COUNT(*) FROM employees e2 WHERE e2.dept_id = e.dept_id AND e2.salary > e.salary ) < 3 ORDER BY e.dept_id, e.salary DESC;这段 SQL 每次都要数一遍同部门里有多少人工资更高,逻辑非常清晰,适合数据量小的系统。如果部门人数很多,可以改成表子查询配合窗口函数,比如在支持窗口函数的数据库里:
SELECT dept_id, emp_id, name, salary FROM ( SELECT dept_id, emp_id, name, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employees ) ranked WHERE rn <= 3;这个写法我先在子查询里用窗口函数给每个部门内部排好序,然后外层再过滤排名。它的好处是各种数据库优化器往往能把窗口函数执行得更高效,而且排名规则更加可控,并列、跳过名次之类的需求都能扩展。实际开发中我会优先用窗口函数版本,只有老系统不支持时才退回 COUNT 版本。
3.4 场景四:一次比较多个字段的“组合条件”
有时候筛选条件不是单个字段,而是多个字段的组合必须同时匹配。比如员工表和员工历史表都有“部门 + 职级”两个字段,需求是找出“当前部门和职级跟入职时完全一致”的员工。这种场景用行子查询最顺手:
SELECT emp_id, name FROM employees WHERE (dept_id, job_level) = ( SELECT dept_id, job_level FROM employee_history WHERE employee_history.emp_id = employees.emp_id AND is_latest = 1 );括号里用行构造器把两个字段打包,跟子查询返回的一行多列做整体比较,SQL 会逐一比较每个字段,必须全部相等才算匹配。这种写法的可读性比写成dept_id = (...) AND job_level = (...)两条子查询明显好,而且只需要一个子查询,性能上也省一次执行。需要注意的是行比较必须保证两边字段数量一致、数据类型兼容,否则数据库直接报结构错误。
4. 执行原理与性能调优
4.1 子查询究竟是怎么被执行的
理解执行过程,你才能预测一条子查询到底快不快。先讲最简单的非相关子查询,比如“工资高于全公司平均值”:
SELECT * FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);数据库通常会先执行内层查询,算出平均工资,把这个值当成一个常量,然后再去扫描员工表做比较。内层只算一次,成本非常低。
但相关子查询完全不同,每处理外层一行数据,都要重新执行一次内层查询。比如“工资高于本部门平均”那个例子,外层有十万员工,内层 AVG 查询理论上就要跑十万次。如果部门表上 dept_id 没有索引,每次内层都要全表扫描级联,性能直接崩盘。
还有FROM 子句里的表子查询,大部分数据库优化器会把结果先物化成一张临时表,再参与外层连接。物化的好处是内层只执行一次,但如果这张虚拟表数据很大,又没有建立合适的统计信息,优化器可能猜错行数,选错 JOIN 顺序,结果照样慢。这也是为什么我强调“表子查询里尽量只留需要的列,能提前 WHERE 就提前 WHERE”。
4.2 用 EXPLAIN 看清执行计划的真实面目
与其靠猜,不如直接看执行计划。几乎所有主流数据库都支持EXPLAIN关键字,在查询前加上它,数据库会告诉你它打算怎么执行这条 SQL:
EXPLAIN SELECT emp_id, name, salary FROM employees e WHERE salary > ( SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id );看执行计划时我一般重点关注三件事。
第一,有没有出现类似DEPENDENT SUBQUERY的标记,这通常表示相关子查询要被逐行执行,是性能风险信号。
第二,内层查询扫描的方式是ALL(全表扫描)还是INDEX(索引扫描),如果内层走全表扫描,那就意味着每执行一次外层行都要扫一遍整张表。
第三,临时表的预估行数,表子查询物化出来的虚拟表如果预估行数跟实际偏差很大,很可能导致优化器选错连接算法,这时候要检查统计信息是否更新。
只有把执行计划当常规武器用,才能真正判断一条子查询是该留着、该改写、还是该建索引。
4.3 子查询慢的时候,怎么“抢救”
子查询的性能问题,我总结了一套递进式的抢救路线。
第一步,先确认子查询的类型和关联方式。如果内层查询和外层没关联,也就是非相关子查询,它通常只执行一次,一般不会成为瓶颈。
第二步,如果确认是相关子查询,优先给被关联的字段建索引。比如上面的dept_id,内层查询每执行一次都要按 dept_id 过滤,建立索引后,每次过滤从全表扫描 O(N) 变成索引查找 O(log N),收益立竿见影。
第三步,尝试改写。相关子查询经常可以被改写成 JOIN 加上一个聚合子查询,在 FROM 子句里先聚合好,再连接。原因很简单,把逐行执行的逻辑改成“一次聚合、一次连接”,优化空间大得多。
第四步,考虑把子查询拆成两段在应用层处理。有些查询真的复杂到优化器已经无能为力,比如三层嵌套 + 两个相关条件,强行让数据库一步到位,反而不如先查出中间结果、缓存到内存、再查第二次。做报表的时候,这种“拆”的思路往往比硬调 SQL 更符合业务场景。
5. 高频踩坑与排查经验
5.1 报错:子查询返回了多行数据
这是新手最常见的报错,信息通常类似“Subquery returns more than 1 row”,我看了不下几十次。它一定发生在标量子查询场景,因为你把它当“单个值”用了,但实际查出来了多行。
背后原因通常是业务数据本来就有重复。比如“找出每个员工所在部门的部门经理”,如果你用SELECT manager_name FROM departments WHERE dept_id = e.dept_id,而 departments 表里同一个 dept_id 存在多条记录,就会报错。排查思路是:单独运行内层子查询,看看返回行数,然后想办法去重,比如加DISTINCT、LIMIT 1,或者明确加上更严格的过滤条件。
5.2 NOT IN 遇到 NULL,结果凭空消失
前面场景二已经演示过这个问题,我再补一个更隐蔽的变体。如果列表里有 NULL,用 NOT IN 的结果不是报错,而是“悄悄变少”,少了哪些你根本无感知。反而是数据量越小越难发现,等报表对不上数才追查回来。
我现在的原则是:只要判断“某个值是否不在某个集合里”,一律优先写 NOT EXISTS;写 IN 的时候,先确认子查询结果里不会含 NULL。别再指望每个写 SQL 的人都能记住三值逻辑,写稳妥的代码比写风骚的代码更重要。
5.3 关联子查询慢到让人怀疑人生
有一次我帮同事排查一条查询,他需要对一张五十万行的员工表做“每个部门工资排名”,用到相关子查询:
SELECT e1.* FROM employees e1 WHERE ( SELECT COUNT(*) FROM employees e2 WHERE e2.dept_id = e1.dept_id AND e2.salary > e1.salary ) < 5;查询跑了接近三分钟,原因非常典型:e1 表逐行触发 e2 的过滤,而 e2 的 dept_id 刚好没有索引,每次都是全表扫描。我先在 dept_id 和 salary 上建了联合索引,查询时间从三分钟降到十几秒;随后按前面的方法改成窗口函数的表子查询,又降到一秒以内。这个故事足以说明,遇到慢查询先看执行计划、先看索引,不要盲目怀疑数据库性能。
5.4 子查询和 JOIN,到底怎么选
我经常被问到“同样一个结果,用 JOIN 能写,用子查询也能写,哪个更好”。这个问题的标准答案要看场景,我做了一张决策表:
| 判断维度 | 偏向子查询 | 偏向 JOIN |
|---|---|---|
| 可读性 | 逻辑分层清晰,适合“先算中间值” | 多表关联关系直观,适合“边关联边过滤” |
| 性能 | 非相关子查询可能只执行一次,开销可控 | JOIN 可让优化器有更多连接顺序选择 |
| 去重需求 | 聚合类子查询天然缩窄范围 | 需要小心一对多导致结果翻倍 |
| 复杂关联条件 | 适合做“存在性判断” | 适合处理多列关联的复杂业务表关系 |
我的推荐是:能写成非相关子查询的,就写子查询,因为清晰;判断“存在/不存在”的时候,优先 EXISTS/NOT EXISTS;要返回多张表的字段组合,尤其是业务上需要关联展示时,老老实实 JOIN;两者都能实现且性能接近时,选你三个月后回来看还能一眼读懂的那种写法。
个人体会是,很多开发者在性能和安全之间反复纠结,真正常踩的坑其实不是“写错”,而是“写完不验证”。每次写完子查询,我都建议把内层单独拎出来跑一遍,确认返回的行数、类型、NULL 情况都符合预期,再放回外层整体执行。这个习惯帮我挡掉了大量线上事故,比任何调优技巧都实在。
最后再分享一个小习惯:我常用“临时项目 X”这套员工、部门、项目表数据练手,反复试 IN、EXISTS、行构造器、窗口函数这几种写法,对比它们的执行计划。数据库这个东西,光看是看不熟的,亲手把每一种形态跑几遍、踩几个坑,下次遇到真正业务需求,你的第一反应才是对的。