☰
SQL核心DQL深入解析:从SELECT执行逻辑到窗口函数实战
2026/10/3 3:48:14 网站建设 项目流程

哪有开发人员不用写查询的?说白了,咱们平时嘴上说的“写SQL”,大部分时间写的都是 DQL——Data Query Language,数据查询语言。在一个系列教程里排在第二讲,也正说明它是地基中的地基:前面学的建表、插数据,都是为了这一讲做准备的。这一篇我会把 DQL 掰开揉碎讲清楚:从一条 SELECT 语句的执行逻辑,到单表过滤、多表连接、分组聚合,再到子查询和窗口函数,最后附上我这些年积累的排查经验和踩坑记录。不管你是刚接触数据库的新手,还是写了好几年 SQL 但总感觉差点意思的“老手”,这篇都值得你花点时间过一遍。

1. DQL 在 SQL 家族里的位置与整体设计思路

很多人刚学 SQL 的时候,容易被 DDL、DML、DQL、DCL 这一堆缩写绕晕。你先记住一句话:DQL 是 SQL 里最接近业务需求的部分,因为它做的事只有一个——查数据。建库建表是 DDL,增删改是 DML,权限控制是 DCL,而你把表建好、数据灌进去之后,所有“给我看看某某数据”的需求,最后全部落到 DQL 上。

1.1 SQL 语言分类与 DQL 的定位

SQL 按功能大致分四类,这里用一张表给你理清楚:

分类全称代表关键字作用
DDLData Definition LanguageCREATE、ALTER、DROP定义和管理数据库对象
DMLData Manipulation LanguageINSERT、UPDATE、DELETE操作表中数据
DQLData Query LanguageSELECT查询和检索数据
DCLData Control LanguageGRANT、REVOKE权限与访问控制

注意点:DQL 在 SQL 标准里其实可以算 DML 的一部分,但实际工作中大家习惯把 SELECT 单独拎出来叫 DQL,因为查询和“写”操作在逻辑、性能、使用频率上完全不是一个量级的。一个典型的业务系统,读请求和写请求的比例随便就是 10:1 甚至更高,所以 DQL 优化得好不好,直接决定了系统快不快。

1.2 SELECT 的书写顺序与逻辑执行顺序

搞懂 DQL,第一件事不是背语法,而是理解执行顺序。SQL 有个特点:你写 SELECT 的时候,字段在最前面,但数据库执行的时候,最先看的是 FROM。这个区别太关键了,几乎所有 SQL 报错和“怎么结果不对”的疑问,源头都在这里。

逻辑执行顺序大概是这样的:

  1. FROM:确定数据来源,加载表
  2. WHERE:对源数据逐行过滤
  3. GROUP BY:按条件分组
  4. HAVING:对分组后的结果过滤
  5. SELECT:计算并选择输出列
  6. ORDER BY:排序
  7. LIMIT:截取行数

举个例子,你写SELECT emp_name AS name FROM emp WHERE emp_name = '张三',这没问题。但你如果在 WHERE 里用别名,比如WHERE name = '张三',就会直接报错。为啥?因为 WHERE 执行的时候,SELECT 还没跑到,别名根本不存在。理解了这条顺序线,后面所有的分组、排序、别名的坑你都能自己推出来。

提示:判断一条 SQL 为什么报错,先拿这个执行顺序去套,十有八九能找出问题。

2. 单表查询基础:SELECT 语法与 WHERE 条件过滤

单表查询是 DQL 的起点,也是日常最常用的。别觉得简单,越基础的东西越容易出细节问题。这一节我把字段选择、去重、条件过滤的细节全部过一遍。

2.1 基础字段选择、别名与 DISTINCT 去重

最基础的 SELECT 语法长这样:

SELECT emp_id, emp_name AS name, dept_id FROM emp;

几个细节值得说:

  • 别名用AS,可以省略,但建议写上,可读性好。别名分不带引号和带引号两种,不带引号的别名会自动转为大写(Oracle 里尤其明显),带双引号则区分大小写,实际开发中保持统一就好。
  • SELECT DISTINCT是对整行去重,不是单独对某一列去重。很多人写SELECT DISTINCT dept_id FROM emp以为只是把 dept_id 去重,实际上它是“按你查出来的所有列的组合”去重。如果你查两个字段,那两字段组合完全一样的行才会被合并。
  • 还有一个很少人注意的点:SELECT DISTINCT和ORDER BY一起用的时候,排序字段必须出现在 SELECT 列表里,否则部分数据库会直接报错。这个限制跟“去重后排序没有意义”有关,理解就好,别硬记。

2.2 WHERE 条件过滤与常见运算符细节

WHERE 后面可以跟的条件,核心就是比较运算、逻辑运算和模糊匹配。我用一个员工表的真实查询场景来演示:

-- 查询工资大于 8000 且部门为 101 的员工 SELECT emp_name, salary, dept_id FROM emp WHERE salary > 8000 AND dept_id = 101;

细说几个高频坑:

NULL 的判断必须用 IS NULL 或 IS NOT NULL。这是新手最容易犯的错。WHERE salary = NULL永远是查不到数据的,因为在 SQL 里 NULL 表示“未知”,未知和任何值比较,结果都是未知,不是 TRUE。哪怕 NULL = NULL,结果也不是 TRUE。这一点想不通的话,你换一个角度理解:NULL 不是一个“值”,而是一个“状态”,状态没法用等号比较。

LIKE 模糊匹配要留意通配符。%表示任意多个字符,_表示任意一个字符。查询emp_name LIKE '张%'能匹配“张三”,也能匹配“张三丰”。但如果你想查名字里带下划线的用户,比如LIKE 'a_b',那它会匹配“aab”“acb”,不一定是带下划线的字面值,这时候需要转义字符,MySQL 里可以写LIKE 'a\_b' ESCAPE '\\'。

IN 列表别写太长。IN 后面跟的子查询可能临时表很大,业务上如果列表超过几百个,性能就会明显下降,能改 JOIN 就改 JOIN,后面多表部分会讲。

2.3 实操示例:从员工表查询满足条件的数据

我们组合一下,看看一个带条件、带排序、带截断的完整单表查询长什么样。假设需求是:找出部门 101 和 102 中,工资在 6000 到 10000 之间,名字包含“张”的员工,按工资从高到低排,只要前 5 条。

SELECT emp_id, emp_name, salary, dept_id FROM emp WHERE dept_id IN (101, 102) AND salary BETWEEN 6000 AND 10000 AND emp_name LIKE '张%' ORDER BY salary DESC LIMIT 5;

BETWEEN 是闭区间,包含两端。ORDER BY 后面默认 ASC,DESC 是降序。LIMIT 5 在 MySQL 和 PostgreSQL 里都支持,SQL Server 要用TOP 5,Oracle 老版本要用ROWNUM,语法差异属于另一个话题,但你要知道有这么回事。

3. 多表查询与 JOIN 连接实战

单表查询解决不了所有问题,真实业务里数据是分散在不同表里的,用户表存用户信息、订单表存订单、商品表存商品,你查“每个订单的用户名和商品名”就得跨三张表。这就是多表查询,也是 DQL 里最考验功力的部分。

3.1 JOIN 类型对比与适用场景

JOIN 就是“连接”,把两张表按某个条件“拼”成一张大表。我用最直白的大白话给你描述四种连接,看完你就记住:

  • INNER JOIN(内连接):只要两边都匹配得上的记录。两表取交集。
  • LEFT JOIN(左连接):左表全要,右表匹配得上的就带过来,匹配不上就补 NULL。左表是老大。
  • RIGHT JOIN(右连接):右表全要,左表匹配不上补 NULL。现实中用得少,因为调换表顺序就能用 LEFT JOIN 替代。
  • FULL JOIN(全连接):两边全要,谁也补 NULL。MySQL 原生不支持,需要 UNION 模拟。
JOIN 类型结果特点常见场景
INNER JOIN只保留匹配成功的行查订单时同时需要订单表和用户表都有的记录
LEFT JOIN左表全部保留,右表匹配不上补 NULL查所有用户及其订单,没下过单的用户也要显示
RIGHT JOIN右表全部保留,左表匹配不上补 NULL一般用 LEFT JOIN 反向替代
FULL JOIN两表全部保留,缺失部分补 NULL少见,做数据对比或对账时用

这里说下我的体会:日常工作里 INNER JOIN 和 LEFT JOIN 占到了九成以上,你先把这两个吃透,RIGHT 和 FULL 遇到再看文档也来得及。

3.2 ON 与 WHERE 的本质区别:一个天壤之别的细节

这个点值得单独拿出来讲,因为太容易踩坑了。INNER JOIN 的时候,把过滤条件写在 ON 里和 WHERE 里结果一样;但 LEFT JOIN 的时候,差别巨大。

-- 写法 A:条件放在 WHERE SELECT u.user_id, u.user_name, o.order_id FROM user u LEFT JOIN order o ON u.user_id = o.user_id WHERE o.amount > 100; -- 写法 B:条件放在 ON SELECT u.user_id, u.user_name, o.order_id FROM user u LEFT JOIN order o ON u.user_id = o.user_id AND o.amount > 100;

写法 A 的逻辑是:先把两个表按用户 ID 连接起来,然后过滤出金额大于 100 的订单。结果就是,那些没下过单的用户和下了小金额订单的用户,全被过滤掉了。

写法 B 的逻辑是:连接的时候只关联金额大于 100 的订单,右表没匹配上的就补 NULL。结果就是,所有用户都还在,没下过单的显示 NULL。

一句话总结:LEFT JOIN 时,想让左表全保留,过滤右表的条件就放 ON;want 对连接后的结果做整体过滤,就放 WHERE。这个顺序问题不理解透,你查出来的数据一旦少了几行,完全意识不到哪里错了。

3.3 自连接与多表 JOIN 的实操:一个上下级关系的查询

自连接就是一张表和自己连接。比如员工表里有 manager_id 指向另一个员工的 emp_id,你想查出每个员工的姓名和上级姓名,就得让 emp 表自己跟自己连:

SELECT e1.emp_name AS 员工姓名, e2.emp_name AS 上级姓名 FROM emp e1 LEFT JOIN emp e2 ON e1.manager_id = e2.emp_id;

这里给 emp 起了两个别名 e1 和 e2,相当于把同一张表当成两张独立表来用。注意用 LEFT JOIN 而不是 INNER JOIN,因为老板可能没有上级,INNER JOIN 会把老板本人过滤掉,LEFT JOIN 则保留他并让上级姓名显示为 NULL。

三张以上表的连接也是一样的套路,表 A JOIN 表 B JOIN 表 C,你只需要搞清每步连接的关系。但连接表一多,性能问题就来了,后面专门说。

4. 分组聚合与排序分页:从明细数据到统计结果

很多业务需求不是查明细,而是查统计结果:“每个部门多少人”“每个分类的销售额”“每周的订单数”。这些需求全靠分组聚合这套组合拳。

4.1 聚合函数与 GROUP BY 的使用规则

聚合函数就是那五个老熟人:COUNT、SUM、AVG、MAX、MIN。它们把多行数据“压缩”成一行结果。

GROUP BY 的作用是把数据按某列分组,然后对每个组做聚合。写 GROUP BY 有几个铁律:

  • SELECT 后面出现的非聚合列,必须出现在 GROUP BY 里。比如你GROUP BY dept_id,SELECT 里就只能写 dept_id 和聚合函数,不能写 emp_name,因为每个组里有多个人,数据库不知道该显示哪个。
  • 分组之前想过滤数据,用 WHERE;分组之后想过滤组,用 HAVING。这就是 HAVING 存在的意义:它专门用来对聚合结果做条件判断。

看个经典例子:

-- 查询平均工资大于 8000 的部门 SELECT dept_id, AVG(salary) AS avg_salary FROM emp WHERE status = 'active' -- 分组前过滤,只统计在职员工 GROUP BY dept_id HAVING AVG(salary) > 8000; -- 分组后过滤,只保留平均工资高的部门

新手最容易把 WHERE 和 HAVING 搞混,其实你记住执行顺序就行:WHERE 先跑,GROUP BY 再跑,HAVING 最后跑。WHERE 不能写聚合函数(比如WHERE AVG(salary) > 8000直接报错),HAVING 也别用来做普通字段过滤,能用 WHERE 的尽量用 WHERE,性能更好。

4.2 ORDER BY 排序与 LIMIT 分页的细节

排序排在逻辑执行顺序的倒数第二步,所以它可以对 SELECT 里的别名排序,比如ORDER BY avg_salary DESC是合法的。

排序有几个容易被忽视的细节:

  • 多字段排序:ORDER BY dept_id ASC, salary DESC表示先按部门升序,部门相同的再按工资降序。注意顺序是“从左到右”,不是谁写在前面谁优先级高这么简单,是排完第一关键字再排第二关键字。
  • NULL 的排序位置:MySQL 里 NULL 默认排在最前面(ASC 时),Oracle 里 NULL 默认排在最后面。你要是想强制控制,Oracle 可以写ORDER BY salary NULLS LAST,MySQL 得用ISNULL(salary)技巧去处理。
  • 分页参数越界:LIMIT 0, 20是第一页,LIMIT 20, 20是第二页。但页码越来越大时,MySQL 的LIMIT 100000, 20性能会急剧下降,因为数据库还是要扫到十万行才知道从哪开始。优化思路是用上一页的最大 ID 做条件,也就是“游标分页”,这个后面优化部分还会提。

4.3 综合实操:按部门统计员工的工资情况

我们把前面这些东西串起来,写一个实际的统计需求:统计每个部门的员工人数、工资总额、平均工资、最高工资,按平均工资降序排,只保留人数大于等于 2 的部门。

SELECT dept_id, COUNT(*) AS emp_count, SUM(salary) AS total_salary, AVG(salary) AS avg_salary, MAX(salary) AS max_salary FROM emp WHERE status = 'active' GROUP BY dept_id HAVING COUNT(*) >= 2 ORDER BY avg_salary DESC;

这里有个特别容易错的点:HAVING 里写COUNT(*) >= 2,结果是对的;但如果你在 SELECT 里给了COUNT(*) AS emp_count,想在 HAVING 里写HAVING emp_count >= 2,在 MySQL 里可能能跑(它有些版本对别名支持很宽松),但在标准 SQL 里是错的,因为 HAVING 比 SELECT 先执行,别名不可见。保险的写法是 HAVING 里直接用聚合函数,别偷懒用别名。

5. 子查询与窗口函数:应付复杂查询的进阶工具

单表、多表、分组都讲完了,但实际业务里总有更刁钻的需求:查“工资高于部门平均值的人”“每个部门工资排名前五的员工”“环比增长率”之类。这些需求靠子查询和窗口函数才能优雅解决。

5.1 子查询的常见形态与适用位置

子查询就是嵌套在查询里的查询,可以出现在好几个位置:

WHERE 里的子查询,最典型:

-- 查询工资高于公司平均工资的员工 SELECT emp_name, salary FROM emp WHERE salary > ( SELECT AVG(salary) FROM emp );

FROM 里的子查询(派生表),把子查询结果当成一张临时表来用:

-- 查询各部门平均工资高于全公司平均工资的部门 SELECT dept_id, avg_salary FROM ( SELECT dept_id, AVG(salary) AS avg_salary FROM emp GROUP BY dept_id ) t WHERE avg_salary > ( SELECT AVG(salary) FROM emp );

注意 FROM 里的子查询必须给别名,这里的t就是别名,不写会报错。

SELECT 后面的标量子查询,返回单个值:

SELECT emp_name, salary, ( SELECT AVG(salary) FROM emp ) AS company_avg_salary FROM emp;

子查询不是越多越好,能用 JOIN 解决的优先 JOIN。有些数据库对子查询的优化做得一般,多层嵌套的子查询性能往往不如等价 JOIN。但相关子查询(子查询里引用了外层表的列)有时候无法避免,后面会看到。

5.2 窗口函数入门:ROW_NUMBER、RANK 与聚合开窗

窗口函数是 DQL 里的高阶玩法,特点是“不分组压缩行数,还能看到分组后的统计信息”。它的典型结构是:

聚合函数/排名函数 OVER (PARTITION BY 列 ORDER BY 列)
  • PARTITION BY相当于只给窗口函数的分组,不影响 SELECT 输出行数
  • ORDER BY是窗口内部的排序规则

最常用的排名函数是ROW_NUMBER()、RANK()、DENSE_RANK():

函数行为举例(成绩 90, 90, 80)
ROW_NUMBER()无并列,连续编号1, 2, 3
RANK()有并列,但跳号1, 1, 3
DENSE_RANK()有并列,不跳号1, 1, 2

还有一个神奇的用法是聚合函数配合 OVER,可以在不压缩明细行的同时显示分组汇总。比如:

SELECT emp_name, dept_id, salary, AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg_salary FROM emp;

这个查询输出每一行员工,同时每一行后面带着所属部门的平均工资。如果用 GROUP BY 做,输出就只剩部门级别的几行了。这就是窗口函数“鱼和熊掌兼得”的能力。

5.3 经典案例:取每个部门工资最高的员工

这是一个面试高频题,也是日常特别常见的需求。最传统的写法是子查询关联:

SELECT e1.emp_name, e1.dept_id, e1.salary FROM emp e1 WHERE e1.salary = ( SELECT MAX(salary) FROM emp e2 WHERE e2.dept_id = e1.dept_id );

这种写法有个缺点:如果同一个部门有两个人工资并列最高,会把两个人都查出来。这在某些场景算优点,有些场景不算。如果你只要“每个部门工资最高的那一个人”,用窗口函数是最清晰的:

SELECT emp_name, dept_id, salary FROM ( SELECT emp_name, dept_id, salary, ROW_NUMBER() OVER ( PARTITION BY dept_id ORDER BY salary DESC ) AS rn FROM emp ) t WHERE t.rn = 1;

先开窗给每个部门按工资降序编号,然后外层过滤编号为 1 的记录。这套组合拳在“分组 Top N”需求里几乎无敌。再提醒一次,FROM 后面的子查询一定要起别名,例子里的t就是干这个的。

6. 常见问题与排查技巧实录

这一节是我最想写的内容。SQL 报错和结果不对,很多时候不是因为语法不会,而是踩了一些“没有写在文档里”的坑。我按高频程度整理了一张速查表,然后展开讲几条。

6.1 高频报错与结果异常速查表

问题现象根本原因解决办法
查询结果中 NULL 变成空字符串显示应用层或客户端配置导致显示转换检查驱动配置,必要时用 COALESCE 处理
WHERE 条件写了却查不出数据字段是 VARCHAR,你传了数字,触发了隐式转换保持类型一致,或显式 CAST
LEFT JOIN 后行数变多了右表有多条匹配记录,导致左表行被重复先确认右表是否存在重复关联字段,必要时子查询去重
GROUP BY 报错“不是聚合函数”SELECT 里写了非聚合列且没在 GROUP BY 里要么加进 GROUP BY,要么删掉该列
查询结果和业务预期对不上没注意 NULL 参与运算,SUM/AVG 自动跳过 NULL用 COALESCE 明确 NULL 的处理策略
分页翻到后面越来越慢LIMIT 偏移量过大改用游标分页,或用子查询先取 ID

6.2 隐式转换与字符集问题

隐式转换是那种“不出错但你总觉得莫名其妙”的问题。比如表里的 phone 字段是 VARCHAR,你用WHERE phone = 13800138000去查,数字会被转成字符串再比较,索引可能就用不上了,全表扫描。更糟的是字段里如果有非数字字符,部分数据库会直接报错。

字符集问题更隐蔽,查询条件里传中文查不出结果,很可能是表字段的字符集和连接字符串的字符集不一致。特别是历史老库,有的表是 utf8 有的是 gbk,JOIN 的时候也可能因为排序规则不一致报错。遇到中文乱码或者中文查不出,第一反应不是业务逻辑错了,而是检查字符集。

6.3 查询性能排查的三板斧

写好的 DQL 不仅要对,还要快。我的排查套路固定三板斧:

第一,看执行计划。MySQL 里EXPLAIN SELECT ...,看 type 字段:如果是 ALL(全表扫描),说明索引没生效;如果是 ref 或 range,说明索引被用上了。这条能解决大部分慢查询。

第二,杜绝SELECT *。不是装,是真的有很多问题:它会让数据库把不必要的数据都传输到应用层,而且如果哪天表结构加了字段,查询结果集也跟着变,可能搞坏接口的字段映射。写查询时明确列出你需要的列,这条习惯要从第一天养成。

第三,留意 WHERE 写的条件是否影响索引。对字段做函数运算(WHERE YEAR(create_time) = 2024)、对字段做类型转换(WHERE varchar_col = 123)这些写法都会让索引失效,改成范围条件(WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01')才是正解。

6.4 一条慢查询的问题定位实录

有一回我收到线上告警,某个接口的平均响应时间从 100ms 涨到了 3 秒。查日志发现慢 SQL 长这样:

SELECT o.order_id, u.user_name, p.product_name FROM order o LEFT JOIN user u ON o.user_id = u.user_id LEFT JOIN product p ON o.product_id = p.product_id WHERE o.order_time >= '2024-06-01' AND o.order_time < '2024-07-01' ORDER BY o.order_time DESC LIMIT 20;

这个查询本身很常规。用 EXPLAIN 一看,order 表扫描行数 20 万,但 order_time 明明建了索引,type 却是 ALL。再仔细看,order 表的 order_time 是 VARCHAR 类型,和字符串常量比较按理该用索引,但表里存的时间格式有脏数据,导致优化器认为走全表更快——这就是典型的统计信息失真加数据质量问题叠加。

后来把 order_time 转换为 DATETIME 类型,重刷数据后,同样的 SQL 扫描行数降到几千,接口直接回到 100ms 以内。这个故事想说的是:SQL 慢不一定是 SQL 写法问题,表结构设计和数据质量同样重要。遇到慢查询穷追不舍,刨到根上才能彻底解决。

7. 个人操作体会:DQL 学习路径上的几个关键认知

写了这么多,最后说点实话。我从刚入行时只会SELECT * FROM xxx,到后来能比较熟练地写复杂报表查询,中间有几次认知上的突破,值得分享。

第一个突破是理解执行顺序。以前写 SQL 是硬背语法,背了就忘。后来把逻辑执行顺序刻在脑子里,遇到“为什么这里不能用别名”“为什么 WHERE 里不能写聚合”这种问题,自己推理就能得到答案,学习效率瞬间翻倍。

第二个突破是接受“用 JOIN 之前先想清楚查询意图”。写多表查询很容易犯一个错:不管三七二十一先 JOIN,结果表行数翻倍,数据还不对。现在我写任何多表查询前,都会先在脑子里过一遍:我要查的结果是“主表全保留”还是“只取交集”?主查哪张表?这决定了用 LEFT JOIN 还是 INNER JOIN。这个思考过程比写 SQL 本身更重要。

第三个突破是学会看官方文档而不是瞎搜。DQL 语法每个数据库都略有差异,网上内容质量参差不齐,碰到不确定的写法,先翻官方文档确认当前数据库版本的语法。特别是窗口函数、JSON 查询这些进阶功能,版本差异极大,靠猜很容易翻车。

最后再分享一个小技巧:调试复杂 SQL 时,先去掉所有条件跑一遍,确认数据量,然后逐条加回 WHERE 条件,观察每次结果集的变化。这么做十秒就能定位到是哪一步过滤改变了数据范围,比盯着整条 SQL 苦苦冥想高效得多。这个办法我用到现在,每次查数据对不上预期,都是这么解决的。

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

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

立即咨询