哪有开发人员不用写查询的?说白了,咱们平时嘴上说的“写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 按功能大致分四类,这里用一张表给你理清楚:
| 分类 | 全称 | 代表关键字 | 作用 |
|---|---|---|---|
| DDL | Data Definition Language | CREATE、ALTER、DROP | 定义和管理数据库对象 |
| DML | Data Manipulation Language | INSERT、UPDATE、DELETE | 操作表中数据 |
| DQL | Data Query Language | SELECT | 查询和检索数据 |
| DCL | Data Control Language | GRANT、REVOKE | 权限与访问控制 |
注意点:DQL 在 SQL 标准里其实可以算 DML 的一部分,但实际工作中大家习惯把 SELECT 单独拎出来叫 DQL,因为查询和“写”操作在逻辑、性能、使用频率上完全不是一个量级的。一个典型的业务系统,读请求和写请求的比例随便就是 10:1 甚至更高,所以 DQL 优化得好不好,直接决定了系统快不快。
1.2 SELECT 的书写顺序与逻辑执行顺序
搞懂 DQL,第一件事不是背语法,而是理解执行顺序。SQL 有个特点:你写 SELECT 的时候,字段在最前面,但数据库执行的时候,最先看的是 FROM。这个区别太关键了,几乎所有 SQL 报错和“怎么结果不对”的疑问,源头都在这里。
逻辑执行顺序大概是这样的:
- FROM:确定数据来源,加载表
- WHERE:对源数据逐行过滤
- GROUP BY:按条件分组
- HAVING:对分组后的结果过滤
- SELECT:计算并选择输出列
- ORDER BY:排序
- 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 苦苦冥想高效得多。这个办法我用到现在,每次查数据对不上预期,都是这么解决的。