工作这么多年,被问最多的 SQL 考点里,“复合查询”绝对能排进前三。很多人一听到这个词,脑子里就浮现出各种 JOIN 和括号套娃,觉得复杂、容易晕。其实你换个角度想,数据库里存那么多表,就是为了把数据拆开存、减少冗余,而查询的时候再把它们按需要“复合”起来拼成一张完整的信息网。这就像是做饭,食材洗干净切好是单表,下锅翻炒做成一盘菜,就是复合查询。今天这篇文章我不聊虚的,我会从业务场景出发,把 MySQL 复合查询最常见的几种形态、写法、底层逻辑和让人头大的性能坑,一条条给你拆清楚。不管你是刚入门写 SQL 的新手,还是被线上慢查询折磨过的老手,这篇文章都值得花十分钟读一遍。
1. 复合查询到底是什么——先建立正确的认知框架
1.1 从业务需求理解复合查询
先想一个问题:你写一条 SQL,select 后面只能跟一张表吗?不是。你从两张表里取数据,这是一种复合;你把三次查询的结果合并成一张结果集,这也是一种复合;你在 where 里嵌套一个查询出来的结果作为条件,这还是一种复合。所以,复合查询本质上是一个“组合”概念,不是某一个单一语法,而是多种查询手段的总和。
我见过太多人把复合查询理解为多表查询,这是不对的。多表查询只是复合查询里最常见的一种形态。复合查询的价值,在于它能帮我们用一条 SQL 完成过去需要好几轮应用层代码才能拼出来的数据逻辑。举个例子,一个电商后台需要展示订单列表,同时显示每个订单对应的用户名和商品名。如果你不用复合查询,你只能先查订单表,拿到 user_id 和 product_id,再循环去用户表、商品表查名字。这种做法在数据量小的时候没感觉,订单一多,几百次查询来回网络开销直接拖垮接口,TP99 从 50ms 涨到 800ms 都有可能。复合查询就是把这几次请求合并成一次数据库往返,让数据库帮你把数据拼好。
从整个系统架构来看,复合查询还承担着一个重要职责:把复杂的业务筛选逻辑尽量下沉到数据库层。往往数据库的索引和优化器比我们在应用里写 for 循环要高效得多。我们不需要怕复合语句复杂,反而应该担心的是,自己对复合查询的掌握太浅,导致一遇到多表关联就只会无脑 left join,埋下性能隐患。
1.2 复合查询的三种基本形态
先给一个整体框架,后面再逐个展开。
- 第一种:多表连接(JOIN)。把两张或多张表的行,按照连接条件拼成一张更宽的结果集。
- 第二种:集合查询(UNION / UNION ALL)。把多条 SQL 的结果纵向合并,变成一个更大的结果集。
- 第三种:子查询(Subquery)。把一条 SQL 的查询结果,作为另一个 SQL 的一部分条件或数据源来使用。
三种形态看下来,你会发现一个有趣的规律:复合并没有创造新的数据,它只是把已经存在的数据,以不同的组织方式重新组合、筛选和呈现。理解了这一点,你就不会再去背什么“先执行哪一步”的八股文,而是能顺着逻辑,你自己都知道结果应该长什么样。
对了,在设计复合查询之前,有一个特别容易被忽略的准备动作:必须清楚每一张表的主键和外键关系,以及字段到底是唯一的还是有重复的。否则你连出来的行数可能是翻倍的,你都不知道怎么排查。后面讲连接的时候会重点说。
2. 多表连接:最刚需的复合查询能力
2.1 连接类型怎么选:内连接、左连接、右连接
做复合查询,百分之七十的场景都是在做连接。连接的核心就是“把多张表的行配对”。怎么配对?由连接条件决定。平时用得最多的三种连接类型:INNER JOIN、LEFT JOIN、RIGHT JOIN。实现上看,它们的本质都是嵌套循环或者哈希匹配,但业务语义差异巨大。
INNER JOIN(内连接)只返回两表中连接条件成立的行,也就是交集。比如查“下了订单的用户”,用户表有 1000 人,订单表有 500 个用户下过单,那么内连接返回最多 500 行(如果一个用户下多单,行数还会更多)。LEFT JOIN(左连接)会返回左表全部行,右表匹配不上就补 NULL。它的业务语义是“以左表为主”,比如查“所有用户以及他们的订单信息”,没下过单的用户也要出现在结果里,订单字段为 NULL。
这里我要说一个很多新手踩过的坑:RIGHT JOIN 和 LEFT JOIN 在绝大多数场景下是可以互换的,只要把表的顺序调换一下。比如a LEFT JOIN b等价于b RIGHT JOIN a。MySQL 的优化器在内部很多时候也会把 RIGHT JOIN 转换成 LEFT JOIN 来执行。所以我个人的习惯是,统一用 LEFT JOIN,代码可读性更高,团队 review 的时候也更容易看出“哪个表是主表”。除非你接手老项目已经有 RIGHT JOIN 的习惯,否则我不建议混着写。
那什么时候用 INNER JOIN,什么时候用 LEFT JOIN?我的建议很简单:先想清楚“你要的结果集以谁为准”。如果你关心的是 A 表所有记录,不管 B 表有没有匹配,默认 LEFT JOIN;如果只有匹配上才要,那 INNER JOIN。别一上来无脑 LEFT JOIN,很多慢查询就是 LEFT JOIN 用多了,导致驱动表变大、扫描行数暴涨。
2.2 连接条件与驱动表的小门道
连接条件一般写在 ON 后面,比如ON o.user_id = u.id。这个条件决定了行与行怎么配对。很多人把 ON 和 WHERE 混为一谈,这是复合查询里最经典的误区。
ON 是连接时用来配对行的条件;WHERE 是连接完成之后,对结果集的过滤条件。放在 INNER JOIN 里,两者的最终结果一样,但放在 LEFT JOIN 里就有大区别。拿订单表 LEFT JOIN 用户表举例:
SELECT u.name, o.order_no FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.status = 1;这条 SQL 会先把订单和用户连接,然后把用户状态不是 1 的行全部过滤掉。一旦 WHERE 里有右表字段的条件,LEFT JOIN 就名存实亡了,效果等同于 INNER JOIN。
如果你希望“所有订单都返回,只是状态非 1 的用户名字显示为空”,那么条件必须放到 ON 里面:
SELECT u.name, o.order_no FROM orders o LEFT JOIN users u ON o.user_id = u.id AND u.status = 1;这个区别我面试的时候几乎必问。记住了这个,你写复合查询的语义就不会歪。
再说驱动表。在 MySQL 传统的嵌套循环连接算法里,优化器会选一张表作为驱动表,先去查驱动表的每一行,再去另一张表匹配。小表驱动大表是主流原则。你用 LEFT JOIN 的时候,一般左表会被作为驱动表,但不绝对,优化器会根据索引、数据分布选择低成本方案。有时候你觉得“我明明把数据量大的表放左边,怎么还是慢”,那是因为优化器做了调整。这时候不要硬刚,打开执行计划看实际谁是驱动表,再通过索引去优化。
2.3 连接查询的字段规划
很多初写复合查询的人,习惯SELECT *,这不只是不优雅,还直接影响性能和结果正确性。两张表各 20 个字段,你 JOIN 一下就出来 40 列。传输层数据量膨胀,而且如果两个表有同名字段,在结果集里还不容易区分。正确做法是显式列出你需要的字段,并对同名字段用表别名区分。
SELECT o.id AS order_id, o.order_no, u.id AS user_id, u.name AS user_name, p.title AS product_title FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN products p ON o.product_id = p.id;表别名的作用不只是省打字,更重要的是让 SQL 自文档化。别人一看o.就知道是 orders 表,u.是 users 表。很多时候线上出问题,排查半天发现是两个表都有id字段,业务代码取到的值张冠李戴。显式列名加别名,从源头避免这种问题。
另外还有一个字段层面的注意点:连接条件的字段类型要保持一致。user_id在 orders 表是 bigint,在 users 表是 varchar,这种情况下 MySQL 会对每行做隐式类型转换,索引直接失效,全表扫描分分钟。这个坑很隐蔽,排查 SQL 慢的时候,第一反应看执行计划,第二反应就是检查字段类型和字符集。
3. 并集查询:UNION 与 UNION ALL 的精准选择
3.1 两者的核心差异
JOIN 是把表横向连起来,UNION 是把查询结果纵向堆起来。举个实际案例:一个后台系统需要同时展示“今日新增用户”和“今日活跃用户”,这两个数据逻辑不同,一个来自用户注册表,一个来自用户行为表,你用一条普通 SQL 很难同时表达两种不同的统计口径,但 UNION 可以帮你把两个结果集拼到同一张表里。
UNION 和 UNION ALL 的唯一区别是:UNION 会对结果去重,UNION ALL 完全合并,不做去重。很多人觉得“去重是好事”,于是不管三七二十一用 UNION。这是有性能代价的。UNION 要去重,意味着 MySQL 必须把两个子结果集都放到临时表,然后做排序或哈希去重,这个过程在数据量大时非常耗时。UNION ALL 没有这个步骤,直接把两个结果集拼接返回,效率高得多。
所以我的选择标准很简单:如果业务上能够确定两个子查询的结果不会重复,或者重复了也无所谓,一律用 UNION ALL。只有像“合并两个历史表中的数据,需要剔除重复主键”这种场景,才考虑用 UNION。
3.2 UNION 的排序、去重与常见坑
UNION 的 ORDER BY 和 LIMIT 怎么用?这里有一个容易出错的细节。如果两条 SQL 各自带 ORDER BY,合并之后再整体排序,你会发现子查询的排序大概率被忽略,除非你配合 LIMIT 使用。
(SELECT id, user_name FROM users_a ORDER BY id DESC LIMIT 10) UNION ALL (SELECT id, user_name FROM users_b ORDER BY id DESC LIMIT 10) ORDER BY id DESC;外层 ORDER BY 会作用于整个合并结果,所以最终顺序不是你想象的那种“先前十条倒序,再后十条倒序”,而是整体排序。如果你只是在每个子查询里加了 ORDER BY,没有 LIMIT,很多版本下这种排序会被优化器直接丢掉。原因很简单:子查询排完序,合并后还是无序的,那排序就没有意义了,优化器认为这是在浪费资源。
另一个常见的坑是,UNION 对列名和列数的要求。两个结果集列数必须一致,列的数据类型要能隐式转换。如果第一个查询的列名是user_name,第二个查询的列名是username,合并结果集的列名以第一个为准,后面的列别名会被忽略。别满世界找为什么第二个别名不生效。
还有一点要特别注意:UNION 会自动去重,去重是以所有列完全一致为标准的。你如果只希望按主键去重,但 SELECT 的列里包含了时间戳这种每次都不一样的字段,那去重就形同虚设。这种情况下老老实实分两步,或者用 GROUP BY 手动指定去重列。
4. 子查询:让逻辑层层递进的利器
4.1 标量子查询与列子查询
把一条查询的结果塞进另一条查询的 WHERE 条件里,这就是子查询。它适合表达“先找出满足条件的集合,再把集合作为过滤条件”的这类逻辑。子查询分好几种形态,我按使用频率逐个说。
标量子查询返回单个值(一行一列),常用于 WHERE 中的比较运算。最简单的例子:
SELECT id, product_name FROM products WHERE price > ( SELECT AVG(price) FROM products );这条 SQL 的含义是查出所有价格高于平均价的商品。这个子查询的结果是单值,所以可以直接和price比较。它的执行逻辑是先执行子查询算出平均价,再执行外层查询。这种“先内后外”的执行方式也是最符合直觉的。
列子查询返回一列多行,配合 IN、ANY、ALL 使用:
SELECT id, user_name FROM users WHERE id IN ( SELECT user_id FROM orders WHERE amount > 1000 );查出所有下过超千元订单的用户。注意,这里如果子查询结果很大,IN 的性能会比较差。MySQL 在某些版本下会把 IN 转换成 semi join 来优化,但前提是相关字段有索引。我实际工作中发现,很多慢查询就是在这里用 IN 接了一个几万行的子查询结果。
4.2 行子查询与 FROM 子句子查询
行子查询返回一行多列,可以同时比较多个字段:
SELECT id, user_name FROM users WHERE (age, city) = (SELECT age, city FROM users WHERE id = 100);这表示查找和用户 100 在同一个城市并且年龄相同的人。这种写法有特定场景,但说实话用得不频繁,因为它完全可以拆成两个独立条件写,可读性还更好。我个人只在临时排查数据时用过,项目代码里几乎不保留这种写法。
真正项目里用得很多的是 FROM 子句子查询,也叫派生表:
SELECT d.dept_name, COUNT(*) AS cnt FROM ( SELECT dept_id, emp_id FROM employees WHERE hire_date > '2024-01-01' ) AS d GROUP BY d.dept_name;FROM 子句的子查询结果会被当成一张临时表,外层再对它进行查询、分组、聚合。这种写法特别适合“先缩小数据范围,再对缩小后的数据做统计”的场景。它对比直接在原表上做聚合的好处是:内层可以先把大量无关数据过滤掉,让外层的 GROUP BY 只针对一小撮有效数据做计算。
在 MySQL 8.0 之前的版本,派生表还有一个优化点:如果外层查询只是简单过滤,优化器可能把派生表合并到外层去执行,这叫 derived_merge。8.0.22 之后则引入了 derived_condition_pushdown,可以把外层条件推到派生表内部,性能表现更好。遇到派生表慢的情况,不要急着改 SQL,先确认 MySQL 版本,再看执行计划。
4.3 子查询与 JOIN 的互转
这是复合查询里非常实用的一招。很多逻辑既可以用子查询写,也可以用 JOIN 写,性能却可能天差地别。
用上面列子查询的例子:
SELECT id, user_name FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);换成 JOIN 写法:
SELECT DISTINCT u.id, u.user_name FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE o.amount > 1000;两种写法语义等价。当 users 表很大、orders 表有大量符合条件的记录时,JOIN 配合索引往往比 IN 子查询更高效,因为优化器可以把连接过程变成一边扫描一边匹配,减少中间结果的物化开销。但 JOIN 也有副作用:如果一位用户下过多个大额订单,JOIN 会产生重复行,必须靠 DISTINCT 去重。DISTINCT 本身也是有成本的,需要排序或者哈希。
所以这就不存在“哪种一定更好”的结论,必须结合数据分布。我的经验是:驱动表和被驱动表都能走索引、且连接列唯一性强的时候,JOIN 优势明显;如果连接列重复度高,JOIN 产出的中间行太多,子查询反而更干净。
不过现在 MySQL 8.0 的优化器对子查询的处理已经非常成熟,很多 IN 子查询会被自动改写成 semi join。因此如果是新项目,我建议优先按逻辑可读性来写,遇到性能问题再 EXPLAIN 分析,不要凭感觉过度优化。
5. 复合查询的经典组合场景
5.1 分页+聚合+连接的三段式查询
实际业务里复合查询极少只用一种能力,往往是 JOIN、子查询、聚合、分页叠加使用。举一个我做过电商后台的真实例子:页面需要展示“销售额排名前 20 的商品分类,并显示每个分类下销量最高的商品名称”。
先拆解逻辑:
- 第一步,按分类聚合出销售额排名。
- 第二步,找出每个分类内销量最高的商品。
- 第三步,把两个结果连接起来。
写成 SQL 大概是这样:
SELECT c.category_name, r.total_amount, p.product_name FROM ( SELECT category_id, SUM(amount) AS total_amount FROM orders GROUP BY category_id ORDER BY total_amount DESC LIMIT 20 ) r LEFT JOIN categories c ON r.category_id = c.id LEFT JOIN products p ON p.id = ( SELECT product_id FROM orders o2 WHERE o2.category_id = r.category_id GROUP BY o2.product_id ORDER BY SUM(o2.amount) DESC LIMIT 1 );这里用到了派生表做预聚合、LIMIT 做排名截断、LEFT JOIN 做维度补全、相关子查询找每个分类的爆款商品。一条 SQL 把几层逻辑全部表达完。这种 SQL 看起来复杂,但它是分步骤组合出来的,只要你心里清晰每一步的产物是什么,就能一层一层写出来。这种能力在复杂报表需求里特别值钱,因为应用层代码做同样的事情要写一大堆循环和多次查询。
5.2 用复合查询完成多表更新与删除
复合查询不光是 SELECT 的专利,UPDATE 和 DELETE 同样可以用 JOIN 或子查询。比如把所有订单总额大于一万的用户,在用户表里的会员等级改成 VIP:
UPDATE users u INNER JOIN ( SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id HAVING total > 10000 ) o ON o.user_id = u.id SET u.level = 'VIP';这里用派生表加 JOIN 指定了要更新的行集合,比WHERE user_id IN (...)在可读性上更直观,且对于大表更新,JOIN 更新通常也能更好地利用索引。DELETE 同理:
DELETE o FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.id IS NULL;这条命令可以删除所有找不到对应用户的孤儿订单。注意 DELETE 后面跟的表别名o,这是告诉 MySQL 你要删哪个表的行。我以前见过有人写成DELETE FROM orders o LEFT JOIN users...,语法直接报错。多表删除的语法细节就是,DELETE 要先声明要操作的表名或别名,再 FROM 关联关系。
6. 性能优化与踩坑实录
6.1 执行计划怎么看
复合查询性能出问题,第一步永远是 EXPLAIN,不要瞎猜。MySQL 8.0 可以对你写的 SELECT 语句直接:
EXPLAIN SELECT ...;重点看几个字段:
- type:从好到差依次是 system、const、eq_ref、ref、range、index、ALL。看到 ALL 就要警惕,意味着全表扫描。
- key:实际用到的索引。
- rows:预估扫描行数。这个数字是优化器的估算,不一定精确,但能反映量级。
- Extra:如果出现 Using filesort 或 Using temporary,注意了,排序和临时表往往是性能瓶颈所在。
举个例子,JOIN 查询右边表连接字段没有索引,type 经常是 ALL,Extra 出现 Using where。你去看表结构,发现连接字段没建索引。这就是最典型的“多表查询慢”的根因。
6.2 常见的性能杀手与分析
我把日常遇到最多的几个复合查询性能问题列出来:
- 无索引连接。两表连接字段不建索引,导致嵌套循环里每一条驱动表记录都要全表扫一次被驱动表。解决方法是连接字段建索引。我以前优化过一条接口 SQL,orders 表 join users 表,加了索引后从 5 秒降到 30ms。
- SELECT 了不必要的列。多表时尤其明显,返回大量文本字段导致网络传输和临时表排序成本上升。
- LEFT JOIN 后 WHERE 条件对右表非空判断。这在 2.2 里讲过,会把 LEFT JOIN 变成 INNER JOIN,但有时候优化器没完全转换,产生不必要的中间结果。
- 对派生表无法利用索引。某些版本的 MySQL 派生表不会自动建索引,外层查询没法高效访问。解决方案是加合适的索引或者改写为 CTE(公共表表达式),后者在某些场景可以复用结果。
- UNION 代替 UNION ALL。数据如果不重,用了 UNION 白白增加去重开销。
另外一个很典型的坑:复合查询中有多个子查询,每个子查询都扫描同一张大表。比如要统计订单总金额、订单总数、最大订单金额,有人写三条子查询:
SELECT (SELECT SUM(amount) FROM orders) AS total_amount, (SELECT COUNT(*) FROM orders) AS total_count, (SELECT MAX(amount) FROM orders) AS max_amount;这种方式在 orders 表很大时会扫描三遍。如果改成一条聚合:
SELECT SUM(amount), COUNT(*), MAX(amount) FROM orders;一次扫描全搞定。这种优化空间,只有你在写复合查询时有“整体扫描成本”的意识才能发现。
还有一个我特别喜欢强调的:多表复合查询时,分页别直接在连接后的结果集上做。比如:
SELECT u.id, u.user_name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id ORDER BY o.create_time DESC LIMIT 10 OFFSET 20;如果 users 和 orders 的关联关系是一对多,那么“第 20 条到第 30 条”到底是按用户算还是按订单算,业务上就容易出歧义,而且数据库要先连接出非常多的中间行,再全局排序和丢弃,成本很高。更稳的做法是:先对主表分页查出主键集合,再用主键集合去关联明细:
SELECT u.id, u.user_name, o.order_no FROM ( SELECT id FROM users ORDER BY id LIMIT 10 OFFSET 20 ) AS pu LEFT JOIN users u ON pu.id = u.id LEFT JOIN orders o ON o.user_id = pu.id;这种思路叫“先窄后宽”,先拿到最小必要行,再做连接,配合索引,性能提升非常明显。
6.3 问题排查速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 结果行数暴增 | 连接条件不唯一 | 分别 count 两张表连接字段的重复值 | ON 条件加上唯一性约束 |
| 查询明显很慢 | 连接字段无索引 | EXPLAIN 查看 type=ALL | 给连接字段建索引 |
| LEFT JOIN 结果少了 | WHERE 过滤了右表字段 | 检查 WHERE 是否对右表有非空判断 | 把条件移到 ON |
| UNION 结果乱了 | 子查询排序被忽略 | 检查是否加了 LIMIT | 外层统一排序 |
| 子查询超时 | 子查询扫描行数过大 | EXPLAIN 查看派生表 rows | 缩小子查询范围,或改写 JOIN |
| 同名字段错乱 | SELECT * 或没加别名 | 检查结果集列名 | 显式列名加别名 |
这张表是我平时排查 SQL 问题的行动指南,建议你收藏。排查的时候,严格按照“先看执行计划,再复现数据,最后改 SQL”这个顺序,不要跳步,否则容易改错方向。
最后分享一个经验心得:复合查询的代码,和普通业务代码一样,要有“可读性”意识。不要追求“一行 SQL 干完所有事”的炫技,那会让后续维护你的人非常痛苦。我见过一个报表 SQL,300 多行嵌套了 7 层子查询,每次需求变更都要两个人一起改一晚上。复合查询的要义是“逻辑清晰、层次分明”:能拆成 CTE 就拆成 CTE,能给每段子查询起清晰的名字就起清晰的名字,宁可 SQL 长一点,也要让下一个接手的人五秒钟看懂意图。毕竟,数据库最不怕的就是多几行语句,业务代码最怕的是看不懂它到底在算什么。