☰
MySQL DQL进阶:子查询、联合查询与分页优化实践
2026/10/14 4:30:12 网站建设 项目流程

DQL 系列终于写到了第四篇。前几篇我们把 SELECT 的基础语法、过滤条件、排序、分组聚合都过了一遍,相信你已经能写出一条结构完整的查询。但真要拿 MySQL 去处理业务,前面那些还只是热身。这一篇要聊的子查询、联合查询和分页查询,才是把 DQL 从“能查”推向“会查”的关键一环。

很多新手第一次看到“子查询”三个字就开始头疼,其实它的本质特别朴素:一条 SQL 写不完的逻辑,拆成两步,先查出一个中间结果,再拿这个结果去做下一步筛选。而 UNION 解决的是“把多段结果并在一起展示”的问题,LIMIT 则是所有列表页和报表接口都绕不开的分页机制。这三个点单独看都不难,但组合在一起,几乎能覆盖日常开发里 80% 的复杂查询场景。

这篇我会直接按“是什么、怎么写、为什么这样写、踩过什么坑”的顺序来讲,配合实际可跑通的 SQL 片段,争取让你看完就能直接用到项目里。

1. 子查询:把一条 SQL 的结果当成另一条 SQL 的输入

1.1 子查询的本质与三个常用位置

子查询说白了就是“套娃”。外层是一条 SQL,里面某个位置嵌入了另一条完整的 SELECT,内层查询的结果作为外层的条件、数据源或计算字段。

理解子查询的关键不在于背语法,而在于想清楚一个问题:内层查询到底返回的是什么形态的数据?是单个值、一列值、一行值,还是一整张表?这个形态决定了外层能用什么运算符去接它。

子查询在 DQL 里常见于三个位置:

  • WHERE 子句中:把子查询结果作为过滤条件
  • FROM 子句中:把子查询结果当作一张临时表,也叫派生表
  • SELECT 子句中:把子查询结果作为一个输出列,通常是标量子查询

举个例子,你想查“所有下单金额超过平均金额的客户”,如果不用子查询,你得先执行一条SELECT AVG(amount) FROM orders拿到平均值,再手动把这个数字填到第二条 SQL 里。有了子查询,一条语句就能完成:

SELECT customer_id, customer_name FROM customers WHERE avg_order_amount > ( SELECT AVG(amount) FROM orders );

这里内层的平均值返回的就是一个标量值,外层直接拿它做比较。这个例子虽然简单,但已经把子查询的核心价值体现出来了:把“多步查询”合并成“一条查询”,让数据库帮你按依赖关系自动执行。

1.2 WHERE 子查询的四种形态:标量、列、行、表

按返回结果的不同,WHERE 子查询可以分成四类,每一类对应的操作符都不一样。

第一类是标量子查询,返回一行一列,结果是一个单值,通常配合=、>、<、>=、<=这类比较运算符使用。上面那个平均值例子就属于这一类。需要注意的是,标量子查询如果返回多行,MySQL 会直接报错,所以写之前要确认子查询的结果必然只有一行。

第二类是列子查询,返回一列多行,也就是一个“值的集合”,最常用的操作符是IN。比如查“所有下过单的客户”:

SELECT customer_name FROM customers WHERE customer_id IN ( SELECT customer_id FROM orders );

IN的语义非常好理解,外层拿每一行跟这个集合做匹配,只要在集合里就留下。跟IN配套的还有ANY和ALL,但实际业务里用得相对少。> ANY(子查询)表示“大于其中任意一个”,等价于“大于最小值”;> ALL(子查询)表示“大于全部”,等价于“大于最大值”。

第三类是行子查询,返回一行多列。这种写法适合“多个字段同时匹配”的场景。MySQL 支持行构造器,可以写成:

SELECT * FROM orders WHERE (customer_id, order_amount) = ( SELECT customer_id, MAX(order_amount) FROM orders );

这句话的意思是,找出“某个客户的最大金额那一单”。如果单用列子查询,你得分别判断 customer_id 和 order_amount,写起来更啰嗦。行子查询的注意点在于两边的字段顺序必须一致,数量也必须一致,否则 MySQL 会报错。

第四类是表子查询,返回多行多列。它通常不出现在 WHERE 里配合比较运算符,而是直接放到 FROM 后面当派生表使用,这个我们下面单独说。

1.3 相关子查询与 EXISTS 写法

前面讲的子查询,多数是非相关子查询,也就是内层查询可以独立执行,不依赖外层的任何字段。还有一类是相关子查询,内层查询要引用外层查询的字段,就像“每走一步都得回头看一下外层现在的状态”。

最典型的就是EXISTS查询。比如要查“有超过 500 元订单的客户”:

SELECT customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.amount > 500 );

MySQL 执行相关子查询时,会先把外层的一行拿出来,代入内层,判断内层能不能查出数据。只要查出一条,EXISTS就成立,外层这行保留;然后换下一行继续。所以内层的SELECT 1只是一个占位写法,查不查具体字段都不重要,重要的是“有没有”。

这里有一个很容易踩的坑:NOT IN遇到NULL。当列子查询的结果里包含NULL时,NOT IN很可能会返回空结果,因为x NOT IN (2, NULL)在 SQL 语义里相当于x != 2 AND x != NULL,而任何值与NULL的比较结果都是 UNKNOWN,整行就被过滤掉了。所以遇到可能含 NULL 的数据,建议写成NOT EXISTS,或者先在内层把NULL过滤掉。

1.4 子查询性能:“先执行一次”不等于“只执行一次”

很多人愿意用子查询,是因为它写起来直观。但性能上要分情况讨论。

非相关子查询,像前面IN里的那种,MySQL 通常会先执行内层查询,把结果缓存下来,再拿外层逐行匹配。如果内层返回的结果集很小,性能完全没问题。相关子查询则不一样,外层有多少行,内层就可能执行多少次。比如我举的EXISTS例子,如果外层客户表有 10 万行,内层查询就要被触发 10 万次。这时候如果内层的customer_id上没有索引,性能会非常难看。

所以建议是:能用JOIN表达的关联查询,优先用JOIN写,让优化器有更多选择。比如上面那个“有超过 500 元订单的客户”,用JOIN改写:

SELECT DISTINCT c.customer_name FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.amount > 500;

逻辑完全一致,但优化器可以选择先驱动 orders 表,再回表找客户,执行计划通常比相关子查询更优。这不是说子查询不能用,而是提醒你多查一下执行计划,别让“写法简洁”变成“性能事故”。

2. 联合查询:UNION 和 UNION ALL,到底该怎么用

2.1 UNION 与 UNION ALL:功能、去重逻辑与代价

如果你已经理解了 JOIN 这种“左右拼接”的思路,那 UNION 就更好理解了:JOIN 是横向扩展列,UNION 是纵向扩展行。它把两条或多条 SELECT 语句的结果上下堆叠在一起,形成一张更大的结果集。

UNION和UNION ALL的区别只有一个:UNION会对合并后的结果做去重,UNION ALL直接全部堆叠,不管有没有重复。

很多人只记得“UNION 去重,UNION ALL 不去重”,却不清楚去重的代价。UNION的去重实现会涉及排序或哈希,数据量大时开销相当明显。所以业务里如果你的两个查询结果在逻辑上本来就不会重复,或者重复对业务没有影响,果断用UNION ALL。

一个典型场景是年度数据合并。假设业务按年份拆了客户表,现在要把两年的客户合在一起看:

SELECT customer_name, mobile FROM customer_2022 UNION ALL SELECT customer_name, mobile FROM customer_2023;

同一个客户如果两年都存在,用UNION ALL会出两条记录;用UNION会合并成一条。这个决策要交给业务需求,而不是凭个人喜好选。

2.2 联合查询的列数、排序与 LIMIT 细节

UNION的硬性要求是每个 SELECT 的列数必须一致,并且对应位置的字段类型要兼容。类型不一致时 MySQL 可能报错,也可能做隐式转换,但隐式转换容易带来意想不到的结果,所以写的时候最好手动统一字段类型。

默认情况下,结果集的字段名以第一条 SELECT 的字段名为准。比如第一条写的是customer_name,第二条写的是name,最终显示在表头的仍然是customer_name。如果你希望统一命名,可以给第二条的字段起别名。

还有一个特别容易踩的坑:排序和分页。如果只是简单地在 UNION 的最后加一个ORDER BY,那是对整个合并结果排序,这没问题。但如果你希望“每个子查询内部先取前三名,再合并”,直接在子查询后面写LIMIT是不生效的,因为 MySQL 会认为这个ORDER BY和LIMIT只属于当前分支,但合并后会重新排序。

正确做法是给每个子查询套一层括号:

(SELECT id, score FROM spring_rank ORDER BY score DESC LIMIT 10) UNION ALL (SELECT id, score FROM autumn_rank ORDER BY score DESC LIMIT 10);

这样每个分支先独立取前 10 条,再纵向合并。如果还想对合并后的结果整体排序,那就在最后再加一个ORDER BY。

字段顺序也要对齐。比如第一条 SELECT 是(id, score),第二条写成(score, id),结果会乱套。MySQL 不检查字段名是否一致,只检查数量和类型,所以这种错不会报错,但查出来的数据语义完全错位,属于“难查难改”的隐蔽问题。

2.3 什么时候才值得用 UNION

UNION 不是万能胶,能用一条 SQL 解决的查询,就不要硬拆成多条再合并。但在下面几类场景里,UNION 是合理甚至唯一的选择:

  • 多张结构相同的历史表需要合并查询,比如按年份拆的表,或者按月份拆的流水表。
  • 报表里要把不同口径的数据拼在一张结果里展示,比如“本周榜单”和“本月榜单”合并给前端。
  • 单条 SQL 很难同时满足多个复杂条件时,拆成多个简单查询再合并,逻辑更清晰,也更容易维护。

我自己在实际项目里用得最多的是第二种,榜单、报表、统计聚合,UNION ALL 几乎是标配。它让代码的可读性提高了不少,排查问题的时候一眼就能看出每个分支负责什么口径。

3. 分页查询:LIMIT 背后的偏移量逻辑与性能陷阱

3.1 LIMIT 语法与分页公式推导

LIMIT 的语法有两种写法:

-- 跳过 20 行,取 10 行 SELECT * FROM products ORDER BY product_id LIMIT 20, 10; -- 等价写法 SELECT * FROM products ORDER BY product_id LIMIT 10 OFFSET 20;

前一个参数是偏移量(OFFSET),后一个参数是返回的最大行数。很多初学者第一次写分页会把两个参数搞反,写成了“取前 20 条再跳过 10 条”,结果数据完全不对。

分页公式其实就是一个小学应用题。假设每页展示pageSize条数据,当前是第pageNum页,那么:

offset = (pageNum - 1) * pageSize

第一页 offset 为 0,第二页 offset 为 pageSize,第三页就是 2 倍的 pageSize。比如每页 10 条,第 5 页的数据就是从第 41 条开始取 10 条,也就是LIMIT 40, 10。

最后别忘了,分页查询最好带上ORDER BY。如果没有排序,MySQL 返回行的顺序是不固定的,很可能出现翻页时数据重复或者漏掉的情况。排序字段建议用一个唯一字段,或者多个字段组合,避免相同排序值时顺序不稳定。

3.2 深分页为什么会变慢

LIMIT 看起来只是“取一段数据”,但 MySQL 的实现方式决定了它有一个致命弱点:如果偏移量是 100000,MySQL 需要先把前 100000 行都扫描出来,再丢弃,最后才返回后面的 10 行。相当于你要跳过 10 万行,数据库就得老老实实在那块数据里走一遍。

如果表本身不大,或者查询用上了覆盖索引,这个问题还不太明显。但一旦数据量到了几十万上百万,而且 SELECT 的字段很多,需要回表查原始数据,深分页的耗时就会成倍增加。我见过一个真实案例,后台列表翻到第 2000 页的时候,单次查询达到了十几秒,直接把管理界面卡死。

所以对于 OLTP 系统,不建议把传统 LIMIT 分页用在很深的页码上。这里说的“深”,没有绝对标准,但超过几千页的一般都得警惕。

3.3 分页优化的两种常用方案

方案一:延迟关联,也叫覆盖索引优化。思路是先走索引找出当前页的主键 ID,再用主键回表取完整数据,避免从排序阶段就直接回表。

SELECT p.id, p.title, p.price FROM products p JOIN ( SELECT id FROM products ORDER BY sold_count DESC LIMIT 100000, 20 ) t ON p.id = t.id;

内层子查询只查id字段,可以在覆盖索引上完成扫描,不需要访问原始数据行。外层再通过主键精确取数据,回表的行数被压缩到极低。这个方案在传统翻页场景里效果很明显。

方案二:Seek Method,也就是基于游标的分页,适合“上一页/下一页”的列表场景。不需要记录 offset,而是记住上一页最后一条记录的排序字段值,下一页用 WHERE 条件直接定位。

-- 上一页最后一条的 sold_count = 9800,id = 105 SELECT id, title, sold_count FROM products WHERE sold_count < 9800 OR (sold_count = 9800 AND id < 105) ORDER BY sold_count DESC, id DESC LIMIT 20;

这种写法跳过了“前 N 行扫描”,不管翻到第几页,性能都稳定在一个很小的区间。但前提是排序字段必须稳定且唯一,建议在排序字段后面再加主键id作为二级排序。缺点是不能随意跳页,只适合“上一页/下一页”的交互方式。

4. 综合实战:一个贴近业务的 DQL 组合案例

4.1 业务需求与数据模型

前面都是零散的知识点,现在把它们串起来做一个实际案例。假设一个内容平台要做“榜单中心”,业务方提了三个需求:

  • 每个分类下按热度取前 3 条内容
  • 本周榜单和本月榜单合并展示,重复内容允许出现
  • 全站热榜列表分页展示,每页 20 条

简化后的数据表结构如下:

category 表:id, name content 表:id, category_id, title, heat_score, publish_time content_week_rank 表:content_id, score content_month_rank 表:content_id, score

4.2 分步 SQL 实现

第一个需求,每个分类取热度前 3。这类“取每组前 N 条”的问题,在 MySQL 8.0 里可以用窗口函数一行解决:

SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(PARTITION BY category_id ORDER BY heat_score DESC) AS rn FROM content ) t WHERE rn <= 3;

但如果你的 MySQL 是 5.7 或更早版本,就得上相关子查询。核心思路是:当前内容的同分类里,热度比它高的内容少于 3 个,说明它就是前 3。

SELECT c.id, c.title, c.heat_score FROM content c WHERE ( SELECT COUNT(*) FROM content c2 WHERE c2.category_id = c.category_id AND c2.heat_score > c.heat_score ) < 3 ORDER BY c.category_id, c.heat_score DESC;

这个写法的好处是兼容老版本,坏处是分类数据量大时性能会吃亏,建议先确认线上 MySQL 版本再决定方案。

第二个需求,周榜和月榜合并。两张榜单的表结构一致,而且业务方明确说“重复内容允许出现”,直接UNION ALL:

SELECT content_id, score FROM content_week_rank UNION ALL SELECT content_id, score FROM content_month_rank;

如果这里误用了UNION,两个榜单都出现的爆款内容就会被去重合并,统计口径就错了。所以联合查询用UNION还是UNION ALL,一定要回到业务语义上去判断。

第三个需求,全站热榜分页。前几页用传统 LIMIT 没问题,但要考虑翻到很深的场景。这个业务是“上一页/下一页”交互,非常适合 Seek Method。假设接口收到上一页最后一条记录的heat_score = 9000、id = 1024,下一页就这样写:

SELECT id, title, heat_score FROM content WHERE heat_score < 9000 OR (heat_score = 9000 AND id < 1024) ORDER BY heat_score DESC, id DESC LIMIT 20;

这样查询不受偏移量影响,翻页越深优势越明显。

4.3 完整查询与优化备注

把三个需求串起来看,你会发现它们各自用了 DQL 里的不同利器:第一个需求用了窗口函数或者相关子查询,第二个需求用了 UNION ALL,第三个需求用了永不过时的 LIMIT 和基于游标的分页思想。

这个案例里还有几个可以继续深挖的优化点。第一,content表按category_id建索引,能让 GROUP BY 和窗口函数的分区操作走索引;第二,heat_score上加索引可以加速排序;第三,月榜和周榜数据量如果巨大,可以考虑按榜单周期归档,而不是长期堆在同一张表里。

5. 高频报错与排查思路速查表

5.1 常见报错对照表

我把这些年收集到的 DQL 高频问题整理成了一张速查表,遇到类似情况可以对照排查:

报错或现象原因分析处理办法
Every derived table must have its own aliasFROM 后面的子查询没起别名给派生表加别名,例如FROM (SELECT ...) t
Subquery returns more than 1 row标量子查询返回了多行确认子查询条件能唯一锁定一行,或改用IN
NOT IN 查询返回空结果子查询结果包含 NULL改用NOT EXISTS,或在内层过滤掉 NULL
分页数据重复或缺失ORDER BY 字段不唯一加主键等唯一字段做二级排序
UNION 结果集列数不匹配报错每个 SELECT 列数不一致补齐字段,或使用 NULL 占位
UNION 结果字段顺序错乱各子查询字段顺序不一致统一字段顺序,必要时用别名标明字段含义
深分页速度越来越慢OFFSET 过大导致扫描大量无效行改用延迟关联或 Seek Method

5.2 性能排查经验

遇到 DQL 性能问题,第一件事不是改 SQL,而是先看执行计划。在 SQL 前面加EXPLAIN,重点看三列:type是ALL还是index,key有没有走索引,rows的估算值是多少。如果rows显示几十万甚至上百万,说明这条查询要大幅优化。

排查子查询问题时,可以先单独执行内层 SQL,确认它返回的数据量。如果内层结果集本身就很大,外层再匹配,整体性能不会太好。这时候思考能不能改写为 JOIN,让优化器自己决定驱动表。

排查 UNION 性能问题时,先确认是否真的需要去重。不需要去重却用了UNION,等于白白浪费一次排序或哈希的时间。还有一点,UNION 两边如果数据量过分悬殊,可以评估是不是业务模型的问题,比如某张表的数据是否能提前过滤掉一部分。

最后想说,DQL 的这四个主题——子查询、联合查询、分页、优化,单独拿出来都不算难,难的是在实际业务里做出正确选择。我个人在项目里最常用的经验是:子查询能不用就不用,除非它让代码可读性提升明显;UNION 优先写 UNION ALL;分页能上一页下一页就别做深跳转。这三条看着简单,但真能帮你省下不少深夜排查问题的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询