ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DQL查询语言核心解析:SQL真正执行顺序与JOIN分组优化实战

DQL查询语言核心解析:SQL真正执行顺序与JOIN分组优化实战 从接到“2DQL 查询语言”这个标题开始我就知道这大概率不是一篇面向纯小白的入门教程而是一份写给正在上手 SQL、或者在实际业务里被查询优化折磨过的人看的实战笔记。DQLData Query Language数据查询语言听起来是个很学院派的名字但说白了它就是 SQL 里的 SELECT 语句——你从数据库里“拿数据”的所有操作全归它管。无论是做报表、跑数、写接口还是排查线上数据问题你写的第一行代码基本都是 SELECT。这篇文章我打算不绕弯子直接从 DQL 的骨架讲起把 SELECT 的书写顺序、执行顺序、JOIN 的底层逻辑、GROUP BY 的分组陷阱、子查询与窗口函数的适用场景全部拆开揉碎最后附上我这些年踩过的坑和排查思路。适合正在学 SQL 的初学者系统性建立认知也适合写过几年 SQL 但全靠试错、没系统梳理过的同学查漏补缺。1. 先搭骨架DQL 的完整结构长什么样很多人学 SQL 会陷入一种误区东看一个函数、西看一个关键字最后 SELECT 写得飞起却连一条查询语句的完整组成部分都说不清楚。我建议你先在脑子里刻一张图——一条完整、健壮的 DQL 语句通常由六个核心子句按固定顺序组成SELECT 字段列表 FROM 数据来源 WHERE 行级过滤条件 GROUP BY 分组字段 HAVING 组级过滤条件 ORDER BY 排序字段 LIMIT 分页限制(仅在 MySQL/PostgreSQL 等方言中)你没看错这七个部分就是 DQL 的全部骨架。但有个反直觉的点必须第一时间强调书写顺序和真正的执行顺序根本不是一回事。数据库引擎在执行这条语句时实际顺序是这样的FROM - WHERE - GROUP BY - HAVING - SELECT - DISTINCT - ORDER BY - LIMITFROM 最先执行LIMIT 最后执行。这个顺序差异太重要了因为它直接决定了你能在哪个阶段引用什么字段。比如 WHERE 里不能使用 SELECT 中的别名因为 SELECT 在 WHERE 之后才执行而 ORDER BY 却可以使用别名因为排序发生在 SELECT 之后。很多新手在这里报错报错信息都看不懂就是没搞懂执行顺序。为了让你直观理解这个执行顺序我拿一个真实的业务表来当例子。假设我们有一张订单表ordersCREATE TABLE orders ( order_id INT, user_id INT, product_id INT, amount DECIMAL(10,2), status TINYINT, -- 1待支付 2已支付 3已取消 created_at DATETIME );现在我们要统计“已支付订单中每个用户的总消费金额只显示总金额大于 1000 的用户按金额从高到低排序取前 10 条”。这条需求一个 DQL 就写完了SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE status 2 GROUP BY user_id HAVING SUM(amount) 1000 ORDER BY total_amount DESC LIMIT 10;这条语句看着简单但它是 DQL 的“标准范式”承载了所有核心子句的协同方式。我在面试别人时喜欢先让对方解释这条语句的执行过程能把每一步说的清楚明白的DQL 的基本功就算过关了。1.1 为什么执行顺序是 FROM 先走很多初学者不理解为什么不是 SELECT 先执行我选字段难道不应该先知道选什么吗这个问题的答案藏在数据库的底层逻辑里。SQL 是一种描述性语言你告诉数据库“我要什么”而不告诉它“怎么做”。数据库引擎拿到一条 SQL 后第一步是解析语法第二步是生成执行计划。在优化器的视角里SELECT 的字段列表只是一个“投影”动作而真正的数据源头是 FROM 指定的表或表连接产生的结果集。所以优化器会先确定“数据从哪里来”——先找表、再通过 WHERE 过滤掉不需要的行、然后分组聚合、最后才去投影你要的列。如果 SELECT 先执行那 WHERE 过滤时还没有“列”的概念整个逻辑就断裂了。我常用一个类比来解释你去市场买菜先跟摊主说“我要这筐土豆里最大的 10 个”但你必须先走到那筐土豆面前FROM然后在筐里挑出长得好的WHERE装袋前再按大小分堆GROUP BY挑出够大的HAVING最后装袋带走SELECT。虽然你嘴上先说的是“我要土豆”但身体行动顺序必须是先走到摊位跟前。2. WHERE 的过滤艺术别小看行级筛选WHERE 是 DQL 中最常用的子句但恰恰因为太常用很多人对它缺乏敬畏。我整理了几个高频踩坑点全是实战中撞出来的。2.1 WHERE 中的 NULL 陷阱直接上结论在 SQL 中NULL 和任何值比较结果都是 NULL不会是 TRUE也不会是 FALSE。这意味着你在 WHERE 中写status NULL永远查不到任何行——因为它被当作 NULL 而不是 FALSE行不会被选中但也不会报错就像凭空消失了一样。有个真实案例某次线上报表数据对不上排查了半天发现是同事写了WHERE end_time ! NULL他本意是筛选出”结束时间不为空“的记录结果因为 NULL 参与比较永远为 NULL所有行都被过滤掉了报表数据直接少了一大截。正确写法应该是WHERE end_time IS NOT NULL。这个错误为什么常见因为在很多编程语言里! NULL是有意义的但在 SQL 的三值逻辑TRUE、FALSE、UNKNOWN里NULL 就是那个 UNKNOWN。我建议你在写 WHERE 条件时养成一个习惯凡是可能为空的列它的过滤条件必须下意识地想想——我是不是该用 IS NULL / IS NOT NULL2.2 IN、LIKE、BETWEEN 的边界认知这三个关键字是 WHERE 里的常客但各自有坑。IN 列表WHERE status IN (1,2,3)本质上是一连串 OR 的简写。但如果你在 IN 里放了 NULL比如WHERE status IN (1,2,NULL)结果不会匹配任何 NULL 行因为“等于 NULL”永远返回 UNKNOWN。这个行为跟 NULL是同一个底层逻辑别混。LIKE 模糊匹配LIKE 张%表示以“张”开头。这里有个性能隐患如果列上有索引且你的模式写成了LIKE %张或LIKE %张%索引基本失效除非你的数据库支持倒排索引或特殊优化。原因是 B 树索引的最左前缀特性——你想要匹配开头引擎才能在索引里跳着找一旦模式以通配符开头它就只能全表扫了。我在实际调优中见过太多LIKE导致的全表扫描尤其在日志表或用户表上动辄几百万行直接拖垮查询。BETWEEN 含边界WHERE amount BETWEEN 100 AND 200是闭区间等价于amount 100 AND amount 200。这个语义我强调一下是因为有些数据库方言比如某些报表工具的 SQL 引擎可能把它实现为开区间跨数据库迁移时容易踩坑。3. JOIN 的世界表连接的底层逻辑DQL 里最难理解、也最容错出错的部分就是多表连接。我曾收到过一条私信对方把业务里的 SQL 发过来让我帮忙优化我一看六张表全部用 LEFT JOIN 串在一起再加一堆子查询共 300 多行。这种 SQL 不是不能写但连 JOIN 的语义都没吃透写出来的大概率是性能黑洞。3.1 INNER JOIN 与 LEFT JOIN 的本质区别用最直白的话说INNER JOIN取两表的交集。只有两边都匹配上的行才会出现在结果集里。LEFT JOIN以左表为基准左表每一行必然保留右表能匹配上就带上右表的字段匹配不上就把右表字段填成 NULL。听起来简单但真正的坑在 WHERE 条件放哪。看下面这个反例-- 错误示范想查每个用户的订单但 RIGHT 表的过滤条件写进了 WHERE SELECT u.user_id, o.order_id FROM users u LEFT JOIN orders o ON u.user_id o.user_id WHERE o.status 2;表面上看这个查询假设是先 LEFT JOIN 再过滤 status2 的订单。但实际执行时WHERE 是在 JOIN 之后执行的所以它会把所有右表不匹配产生的 NULL 行全部过滤掉LEFT JOIN 的结果变成了 INNER JOIN 的效果——那些“没有已支付订单的用户”直接从结果里消失了。如果我的本意确实是“只要有已支付订单的用户都查出来没有订单的用户也保留”正确的写法是把这个条件放进 ON 里SELECT u.user_id, o.order_id FROM users u LEFT JOIN orders o ON u.user_id o.user_id AND o.status 2;一句话总结ON 决定 JOIN 时右表带哪些行WHERE 决定最终结果显示哪些行。写 LEFT JOIN 时如果右表的过滤条件放进了 WHERELEFT 的语义就被悄悄改掉了。这条规则我建议你贴显示器上。3.2 驱动表与执行计划的关系还有一个经验层面的问题多表 JOIN 时谁先执行谁后执行MySQL 这类数据库在生成执行计划时会根据表的数据量、索引情况、条件选择性来选驱动表即最先访问的表。你写的 FROM 表顺序不一定是实际执行的顺序。这就是为什么我们经常需要EXPLAIN关键字来看执行计划。但对初学 DQL 的人来说我的建议是先不要过度优化驱动表而是先保证 JOIN 的字段一定走索引。JOIN 列上如果没有索引数据量大时绝对是一场灾难。比如 users 表先查出一批用户几百行然后 JOIN orders 表如果 orders.user_id 没有索引那每行用户都要全表扫一遍 orders——几百次全表扫描查询必慢。反过来说如果 orders.user_id 有索引数据库就能通过索引快速定位用户的订单查询时间可能从秒级降到毫秒级。4. 聚合与分组GROUP BY 的正确打开方式GROUP BY 是 DQL 里“算数”的部分也是最容易产生“看起来对实际错”的环节。4.1 分组后的 SELECT 字段约束GROUP BY 背后有一个硬性规则SELECT 出的非聚合列必须出现在 GROUP BY 中。比如-- 错误写法 SELECT user_id, product_id, SUM(amount) FROM orders GROUP BY user_id;这条语句在 MySQL 默认配置下会报错因为product_id既不在 GROUP BY 里也不是聚合函数。但在某些数据库或低版本的宽松模式下不报错返回一个随机的 product_id。这种数据是垃圾因为一行里可能有十个 product_id它随便选了一个给你。正确写法是要么把 product_id 加进 GROUP BYGROUP BY user_id, product_id这变成了“每个用户每个商品的消费金额”要么明确用聚合函数取一个值比如MAX(product_id)或者GROUP_CONCAT(product_id)——前提是你真的理解这个结果的含义。4.2 HAVING 与 WHERE 的职责划分WHERE 在分组前过滤行HAVING 在分组后过滤组。这个区别是 DQL 的核心进阶点。看一个实际场景统计“每个商品类目的销售笔数且原始订单金额大于 200 元的记录才参与统计最后只保留销售笔数大于 50 的类目”。SELECT category_id, COUNT(*) AS cnt, SUM(amount) AS total_amt FROM orders WHERE amount 200 GROUP BY category_id HAVING COUNT(*) 50;WHERE 先把金额小于 200 的订单行剔除剩下的行才进入 GROUP BY 分组HAVING 再对分组后的统计结果做过滤。如果你把amount 200写进 HAVING逻辑完全不同——它是在”分组后“对组做过滤且 amount 必须出现在聚合里或 GROUP BY 里才能引用语义非常别扭。还有一点HAVING 里不能直接用 SELECT 别名来引用聚合结果在某些数据库里可以比如 PostgreSQL但有些不行比如严格模式下的 MySQL 旧版本。我个人的习惯是HAVING 里老老实实写完整的聚合表达式不依赖别名这样跨数据库通用也更容易排查。4.3 聚合函数遇到 NULL 时的行为SUM、AVG、MAX、MIN 这些聚合函数会自动忽略 NULL 值但有个特例COUNT(*)是统计行数包括 NULL 行COUNT(column)是统计该列非 NULL 的行数。很多 bug 就出在这里。比如你要统计“用户完成支付的数量”如果你写COUNT(status)而 status 列有些行是 NULL那 NULL 的行不会被计入。但如果你其实想统计”所有订单记录数“就得用COUNT(*)。这两个混用了结果对不上排查的时候不仔细看 SQL永远看不出问题在哪。我还在实际业务里遇到过SUM(amount)返回 NULL 的场景——某客户整组数据金额全是 NULLSUM 结果不是 0 而是 NULL。这也会影响下游报表展示如果报表系统不处理 NULL页面上可能就显示一个空值。解决方法是写COALESCE(SUM(amount), 0)把 NULL 转成 0。5. 子查询与窗口函数DQL 的高级武器当你的查询需求从“简单过滤”变成“分组内排序”“环比计算”“找 top N”时光靠 GROUP BY 就不够用了。这时候有两个高级工具子查询和窗口函数。5.1 子查询的分类与适用场景子查询按位置分三类WHERE 子查询、FROM 子查询派生表、SELECT 子查询标量子查询。WHERE 子查询最常见的用法是配合IN、EXISTS、ANY、ALL-- 查出下过订单的用户 SELECT user_id, name FROM users WHERE user_id IN (SELECT DISTINCT user_id FROM orders);这里有个性能经验当子查询返回的数据量很大时IN的性能可能不如EXISTS。原因在于IN会先把子查询结果全部物化具体做法视优化器而定而EXISTS是逐行判断找到一条就返回逻辑上更“短路”。但这也不是绝对的现代数据库优化器已经足够聪明很多时候两者执行计划相同。我的建议是先按语义写对再通过 EXPLAIN 看执行计划不要凭感觉优化。FROM 子查询派生表特别适合处理“先聚合、再关联”的场景。比如先统计每个用户的消费总额再把这个结果跟用户表关联找出消费总额大于 5000 的用户信息SELECT u.user_id, u.name, t.total_amount FROM users u INNER JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE status 2 GROUP BY user_id ) t ON u.user_id t.user_id WHERE t.total_amount 5000;这种写法比“先 GROUP BY 再 JOIN 再 HAVING”干净得多可读性也更好。逻辑上等价但子查询先缩小了数据集JOIN 代价可能更低。5.2 窗口函数让每一行都拥有“上下文”窗口函数是 DQL 的进阶分水岭。它和 GROUP BY 最大的区别是GROUP BY 会把多行压成一行窗口函数不改变行数而是在每一行旁边额外计算一个值。举一个最常见的场景查每个用户最近一笔订单的时间。用普通的查询你可能想破头但有了窗口函数ROW_NUMBER()直接SELECT user_id, order_id, created_at FROM ( SELECT user_id, order_id, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders WHERE status 2 ) t WHERE t.rn 1;这个 SQL 的执行思路很清楚先给每个用户的订单按时间从新到旧编号ROW_NUMBER()然后在外面包一层只取编号为 1 的行就得到了“每个用户的最新订单”。窗口函数常见的有三类排序类ROW_NUMBER()、RANK()、DENSE_RANK()。面试常问的区别RANK 有并列时会跳号比如 1,1,3DENSE_RANK 不跳号1,1,2。聚合类SUM() OVER (PARTITION BY ...)、AVG() OVER (...) 等。可以把“累计值”直接算在每一行上比如求“截至当前日期的累计销售额”SELECT created_at, amount, SUM(amount) OVER (ORDER BY created_at) AS running_total FROM orders WHERE status 2;这个 running_total 就是从第一行到当前行的累计金额做增长趋势分析时特别常用。取值类LAG()、LEAD()。取当前行前一行或后一行的值适合做“环比上一个月”的对比计算比自关联 JOIN 写起来高效且可读。窗口函数的语法是函数() OVER (PARTITION BY 分组字段 ORDER BY 排序字段)。PARTITION BY 相当于“窗口内的分组”不写的话整个结果集就是一个窗口。这个工具学会之后你会发现以前用子查询和复杂 JOIN 才能实现的需求现在三五行就搞定了而且性能往往更好。6. 排序与分页细节决定体验ORDER BY 和 LIMIT 看起来是 DQL 里最没技术含量的两个子句但真用起来有两个点值得单独说一下。6.1 NULL 的排序位置默认情况下升序ASC时 NULL 排在最后降序DESC时 NULL 排在最前MySQL 的行为。这个设计让很多人懵圈为什么我按价格降序排最前面的不是最贵的商品而是一堆 NULL如果你想让 NULL 放在指定位置可以显式处理。比如把 NULL 金额的排最后SELECT amount FROM orders ORDER BY (amount IS NULL) ASC, amount DESC;amount IS NULL这个表达式在 MySQL 里行是 NULL 时为 1非 NULL 时为 0所以先按这个表达式升序排非 NULL 行在前再按金额降序。这是我处理排序时常用的一个小技巧。6.2 深分页的性能问题分页查询是我们写接口时绕不开的。随着页码加深LIMIT 100000, 20的性能会急剧下降。因为数据库要先扫描并丢弃前面的 100000 行才能拿出你要的 20 行。我优化过一个线上翻页接口用户翻到第 5000 页的时候查询耗时从 50ms 涨到了 3 秒——用户体验直线下降。解决思路有三个基于游标分页不翻页码而是记住上一页最后一条记录的 ID或时间戳下一页的条件写成WHERE id 上一页最大id ORDER BY id LIMIT 20。这种方案在深度翻页时性能基本恒定是推荐做法。延迟关联SELECT ... FROM orders INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 20) tmp USING (id)。先在覆盖索引上完成排序和偏移再回表取完整行能有效减少无效 IO。业务限制直接限制最大翻页深度比如只能翻到 100 页。对很多后台系统来说这个方案最实际——用户真需要那么深的分页吗很少。7. 实战排坑DQL 开发中我遇过的典型问题这一节我把自己这些年排查过的 DQL 问题整理成一张速查表每一条都是真实踩过的坑供你写 SQL 时对照自查。症状根本原因解决方案查询结果莫名为空WHERE 中用了 NULL改为IS NULL/IS NOT NULLLEFT JOIN 没返回左表所有行右表过滤条件写进了 WHERE移到 ON 后面GROUP BY 报错“non-aggregated column”SELECT 列不在 GROUP BY 中加进 GROUP BY 或改用聚合函数聚合结果出现 NULLSUM/AVG 等遇到全 NULL 分组用COALESCE(expr, 0)兜底数据量一大查询变慢JOIN 列无索引或 LIKE 前置通配符加索引或改写查询条件深分页接口超时LIMIT 大偏移量导致扫大量行改游标分页或延迟关联结果顺序不稳定缺少明确的 ORDER BY业务上需要稳定顺序必须加 ORDER BY表别名与字段引用模糊多表 JOIN 未使用别名所有字段统一加表别名前缀再额外补充一条经验写完一条 DQL先跑一遍 EXPLAIN 再上生产环境。EXPLAIN 能告诉你这个查询走的是全表扫描typeALL还是索引搜索typeref/index range预估扫描行数是多少有没有使用临时表或 filesort。这几项是判断查询是否健康的核心指标。我在团队里立过一个规矩凡是新写的复杂查询必须附带 EXPLAIN 截图否则不 review。执行计划是 DQL 的一面镜子你平时写 SELECT 的功力到底几分拉出来一看便知。最后分享一个个人的实操小习惯我在写任何一条 DQL 之前都会先在草稿纸上用一句话描述”我要从哪些表中、过滤掉什么、按什么分组、算什么、最终要哪些列“。这是一个很朴素的”从分析到组装“的过程。看似多了一步实则在脑袋里完成了一次逻辑校验能拦住大部分因为语义理解偏差导致的 SQL 返工。做数据开发这些年我越发觉得DQL 真正的门槛不在语法而在你能不能把模糊的业务问题翻译成一台机器能精确执行的查询逻辑。这个翻译能力靠的不只是记住语法还得理解数据在表里怎么组织结构、索引怎么加速、每一条子句在引擎眼里到底是什么。写到这里我没有给你留什么课后作业只希望你下次再打开 SQL 编辑器时把本文提到的执行顺序、NULL 语义、JOIN 过滤条件位置这三个基础锚点先在心里过一遍。就这三点已经能帮你躲开日常开发里至少一半的坑了。
返回列表