写数据库查询这回事,市面上教程一抓一大把,但大多要么注水严重,要么拿一个简单例子来回炒。今天这篇不一样,标题说“挤不出一滴水,纯精华”,那咱们就聊点真正有用的:围绕DQL(数据查询语言)这条主线,把我实际用过的、踩过坑的、以及面试和工作中真正高频的查询写法全拆开讲透。无论是你刚学SELECT,还是已经写过一段时间SQL但总觉得差点意思,这篇都能让你查漏补缺,把查询功底打得结实一点。
这篇文章不打算讲什么花活,也不堆砌概念。核心就一个问题:当你面对一张表、或者一堆表的时候,怎么用DQL把数据准确、高效地捞出来。我会按照从简到繁的顺序,把SELECT的每一个核心环节拆开,配合实操场景和排查经验,把DQL的底层逻辑和实战技巧一次性讲清楚。适合所有正在学数据库、准备面试、或者日常工作中要写大量查询的同学。
1. 内容整体设计与思路拆解
1.1 为什么DQL是数据库学习的“腰部力量”
很多人学数据库,一开始就被建表、约束、范式这些DDL/DML内容劝退了。但说实话,等你真正上手干活,你会发现写查询才是日常工作的主旋律。你可以不经常建表、不经常改数据,但你几乎每天都要查数据,哪怕只是看一眼“昨天订单量多少了”。
DQL全称Data Query Language,核心就一个SELECT语句,但就是这个SELECT,能玩出的花样比想象中多得多。我见过不少同学,CRUD写得很溜,一碰到稍微复杂一点的报表统计、多表关联、子查询嵌套就卡壳了。根本原因在于:他们只是“记住了语法”,但没有理解查询的执行逻辑和数据流动过程。
所以这篇内容,与其说是DQL语法教学,不如说是一次查询思维训练。我会把每一类查询拆成“它要解决什么问题 → 语法长什么样 → 底层怎么跑 → 有什么坑”四个维度来讲,这样你以后遇到没见过的查询需求,也能靠这套思维方式推理出答案,而不是靠背。
1.2 DQL的学习路径应该怎么规划
DQL看似只是一条语句,实则内在有一条非常清晰的难度阶梯:
- 单表基础查询:
SELECT字段、FROM表、WHERE过滤、ORDER BY排序、LIMIT分页,这是地基。 - 聚合与分组:
COUNT/SUM/AVG/MAX/MIN配合GROUP BY,这是统计报表的起点,也是很多人第一次感觉到SQL“有点抽象”的地方。 - 多表连接:
INNER JOIN/LEFT JOIN/RIGHT JOIN,这是DQL真正发力的地方,也是JOIN陷阱最多的地方。 - 子查询与集合操作:
IN/EXISTS嵌套查询、UNION合并结果,属于进阶玩法,能解决复杂业务问题。 - 查询优化意识:
EXPLAIN执行计划、索引命中分析、避免回表等。严格来说这算性能优化范畴,但DQL使用者必须具备,否则你写出来的查询在数据量一上来就会“爆炸”。
这篇文章重点放在1到4,同时把第5部分最核心的判断方法揉进去,毕竟查询写得再好,跑不动也没意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SELECT核心语法与执行顺序的深度拆解
2.1 完整SELECT语句的基本结构
一个完整的、带各种子句的SELECT长这样:
sql复制SELECT DISTINCT 字段列表
FROM 表名
JOIN 另一张表 ON 连接条件
WHERE 过滤条件
GROUP BY 分组字段
HAVING 分组后的过滤条件
ORDER BY 排序字段
LIMIT 起始偏移量, 行数;
注意我这里的顺序,就是标准SQL的书写顺序。但很多初学者会有一个巨大的困惑:**SQL到底先执行哪一部分?**记住,书写顺序不等于执行顺序。
数据库引擎在执行时,大致的逻辑顺序是这样的:
text复制1. FROM:确定数据源,先找到要操作的表
2. JOIN ON:按照连接条件,把多张表的数据拼接起来
3. WHERE:对拼接后的原始数据做逐行过滤
4. GROUP BY:将过滤后的数据按字段分组
5. HAVING:对分组后的数据做筛选
6. SELECT:投影,确定最终要返回哪些列,顺便计算别名、聚合表达式等
7. DISTINCT:对结果去重
8. ORDER BY:对最终结果排序
9. LIMIT:截取指定行数
搞懂这个执行顺序至关重要,它能解释你日常写SQL时遇到的90%的报错问题,尤其是“WHERE里不能用别名、HAVING里可以”这种经典问题——因为WHERE执行时,SELECT还没开始算别名呢,引擎根本不知道你写的别名是什么;而HAVING在SELECT之后执行,所以能用别名。
提示:执行顺序是DQL的任督二脉,建议把它贴在电脑旁。后面遇到的每一个疑难问题,回到这个顺序上来思考,答案基本都能浮出水面。
2.2 SELECT字段与DISTINCT去重的细节
SELECT后面跟的字段列表,决定了结果集的列。这里有几个细节容易被忽视。
第一,SELECT *是偷懒神器但也是性能杀手。在工作中,尽量不要在生产环境用SELECT *。原因很简单:第一,你可能用不到那么多列,多查出来的列白白浪费IO和网络带宽;第二,如果表结构后续增加了字段,SELECT *的结果集会悄悄变化,可能导致程序解析出错。正确做法是只查询你需要的字段。
第二,DISTINCT去重是针对整行的去重,不是单个字段。比如:
sql复制SELECT DISTINCT department FROM employees;
这个是把department列去重。但如果写成:
sql复制SELECT DISTINCT department, age FROM employees;
它是把(department, age)这个组合看成一条记录,只有两个字段值都相同才会被去掉。所以如果想只对某个字段去重,就别把其他字段一起查出来,否则大概率去重会失效。
第三,字段别名(AS)不只是为了好看。当字段名太长、或者用了聚合函数(如COUNT(*))、或者多表连接时有同名字段,起别名能让结果集更清晰。但要注意,别名只在ORDER BY和HAVING中可用,在WHERE中不可用,原因前面执行顺序已经讲了,这里不重复。
2.3 聚合函数与NULL值的爱恨情仇
聚合函数是DQL中让数据“由细变粗”的关键工具,常用的有COUNT、SUM、AVG、MAX、MIN。用法很简单,但有几个关于NULL的坑,我必须单独拎出来说。
先看经典问题:COUNT(*)和COUNT(字段)有什么区别?
COUNT(*):统计结果集的总行数,包含NULL值所在的行。COUNT(字段):统计该字段非NULL的行数。
所以如果你想知道一张表里到底有多少条记录,用COUNT(*);如果你想统计某个字段有多少个有效值,用COUNT(字段)。
再看SUM和AVG:它们会自动忽略NULL值。比如AVG(score)的语义是“有成绩的人的平均分”,不是“全员(包括没成绩的人)的平均分”。如果你的业务希望把没成绩的人按0分算,那就需要先处理NULL,比如:
sql复制SELECT AVG(COALESCE(score, 0)) FROM exam_results;
COALESCE函数的作用是返回第一个非NULL的值,这里就是把score里的NULL先转成0再求平均。
最后是MAX和MIN:它们同样忽略NULL。但如果整个字段全是NULL,结果是NULL,不是0。
注意:聚合函数的忽略NULL特性,在统计报表中经常导致数据“看起来对不上”,排查时第一件事就要想到是不是有NULL值在作祟。
2.4 WHERE过滤的进阶写法与LIKE模糊查询
WHERE是DQL里最常用的过滤工具,基础的条件运算符如=、>、<、>=、<=、!=(或<>)大家都熟,这里讲几个进阶但高频的写法。
多条件组合:AND优先级高于OR。所以如果你写:
sql复制SELECT * FROM products WHERE category = 'A' OR category = 'B' AND price < 100;
别天真地以为是“(A或B)且价格小于100”,实际上它是“A 或 (B且价格小于100)”。如果想让A和B平级,必须加括号:
sql复制SELECT * FROM products WHERE (category = 'A' OR category = 'B') AND price < 100;
范围查询:IN和BETWEEN是简化条件的两把好手。IN等价于多重OR的简化写法;BETWEEN包含边界值,即BETWEEN 100 AND 200等价于>= 100 AND <= 200,别记成开区间。
NULL判断:判断一个字段是不是NULL,不能用= NULL,必须用IS NULL或IS NOT NULL。这是几乎每个初学者都会踩的坑,因为NULL在SQL里代表“未知”,它不等于任何值,甚至不等于NULL本身。所以WHERE name = NULL的结果永远是空集。
LIKE模糊查询:%代表任意多个字符(包括0个),_代表单个字符。比如LIKE '张%'能匹配所有以“张”开头的字符串。这里有一个性能相关的建议:尽量避免以通配符开头的模糊查询(如LIKE '%abc'),因为这种写法通常无法利用索引,会触发全表扫描。在数据量大时,LIKE '%abc%'这样的查询很容易拖垮数据库。
2.5 排序与分页:ORDER BY和LIMIT的正确姿势
ORDER BY默认是升序(ASC),降序用DESC。多字段排序时,从左到右依次生效:
sql复制SELECT name, score FROM students ORDER BY score DESC, name ASC;
这个语义是:先按score降序排,如果score相同,再按name升序排。注意顺序别写反了。
LIMIT分页则有两种写法:
sql复制-- 写法一:从第0行开始取10行
LIMIT 10;
-- 写法二:从偏移量20开始取10行(等价于第3页,每页10条)
LIMIT 20, 10;
这里有一个经典的“分页越深越慢”的问题。当偏移量非常大时,比如LIMIT 1000000, 10,数据库需要先扫描并丢弃前10万行,才能取到目标数据,这很浪费。优化方案通常有两种:一是用“上一页最大ID”作为查询条件来替代偏移量,二是用子查询先定位起始位置。
sql复制-- 方案一:记住上一页最后一条的ID
SELECT * FROM orders WHERE id > 上一页最大id ORDER BY id LIMIT 10;
-- 方案二:子查询定位偏移位置(MySQL)
SELECT * FROM orders
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 1000000, 1)
ORDER BY id LIMIT 10;
3. 多表连接JOIN的实战要点与避坑
3.1 INNER JOIN与LEFT JOIN的核心差异
真实业务中,数据几乎很少存在一张表里。订单表存订单主信息,订单明细表存商品列表,用户表存买家信息;你要查“谁买了什么”,就必须把多张表拼起来看,这就是JOIN存在的意义。
INNER JOIN返回的是两张表中都满足连接条件的行。它相当于取交集:匹配不上的数据直接消失。
LEFT JOIN(左外连接)返回的是左表全部行,再加上右表匹配的行;如果右表没有匹配,则右表字段填NULL。
举个具体的例子。假设有订单表orders(左表)和用户表users(右表):
sql复制SELECT orders.order_id, users.user_name
FROM orders
LEFT JOIN users ON orders.user_id = users.user_id;
如果某个订单对应的用户不存在(比如用户注销后被删了),这个订单依然会出现在结果里,只是user_name为NULL。这就是LEFT JOIN的“保左”特性。
很多新手在LEFT JOIN之后用WHERE去过滤右表字段,结果发现左表数据也变少了,比如:
sql复制SELECT orders.order_id, users.user_name
FROM orders
LEFT JOIN users ON orders.user_id = users.user_id
WHERE users.user_id IS NOT NULL;
这其实是为了去掉右表不匹配的NULL行,逻辑上等价于INNER JOIN。如果你真的想保留左表全部数据,过滤条件就不能放在WHERE里针对右表字段;如果确实需要过滤,可以放进ON条件中。
3.2 ON与WHERE的执行时机差异
很多人搞不清楚ON和WHERE的区别,尤其是LEFT JOIN场景。核心记住一句话:ON决定怎么拼接,WHERE决定拼接之后留谁。
对于INNER JOIN来说,ON条件和WHERE条件在结果上几乎等价,因为内连接本身就会丢弃不匹配的行。但对于LEFT JOIN来说,区别就大了。把过滤条件写在ON里,表示“在拼接时只匹配满足条件的行,但不影响左表全保留”;把过滤条件写在WHERE里,表示“拼完之后,整行进行过滤”,左表中被过滤掉的逻辑就不存在了。
举一个典型业务场景:查所有用户的订单数量,但只需要统计2024年的订单。
sql复制-- 正确:每个用户都有记录,但订单只统计2024年的
SELECT u.user_id, COUNT(o.order_id) AS cnt
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_date >= '2024-01-01' AND o.order_date < '2025-01-01'
GROUP BY u.user_id;
-- 可能出错:只保留2024年下过订单的用户,没下过的用户被过滤掉了
SELECT u.user_id, COUNT(o.order_id) AS cnt
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date >= '2024-01-01'
GROUP BY u.user_id;
第二种写法的问题在于:WHERE过滤发生在JOIN结果之后,所有没有2024年订单的用户,因为右表字段全是NULL,无法满足>= '2024-01-01',整行被丢弃了。结果就是“没下单的用户直接消失了”,跟业务预期完全不一致。
3.3 多表连接时的性能隐患与索引利用
JOIN本来就是一个比较昂贵的操作,多张表连接时,磁盘IO和CPU消耗都会显著上升。要想让JOIN跑得快,核心手段就是让连接字段走索引。
比如LEFT JOIN users ON orders.user_id = users.user_id,那users.user_id上最好有主键或唯一索引。MySQL里主键天然是索引,所以要是连接主键,问题不大;但如果是其他字段连接,就得考虑加普通索引。
另外还有一个容易被忽略的点:小表驱动大表。在JOIN中,MySQL的优化器会倾向于用“小表作为驱动表”,先查小表,再根据小表的结果去大表里找数据。但优化器的判断并不总是完美,有时候需要我们通过STRAIGHT_JOIN或调整子查询来引导它。不过对于大多数业务场景,只要连接字段有索引,性能基本都能接受。
实操心得:如果你的多表JOIN查询慢到怀疑人生,第一件事不是改SQL,而是用
EXPLAIN看执行计划。重点看type列是否出现index或ALL,以及key列是否为NULL。如果是ALL(全表扫描),优先考虑加索引,这比任何SQL改写都立竿见影。
4. 分组聚合与子查询的精髓
4.1 GROUP BY分组统计与HAVING过滤
GROUP BY的作用是把结果集按某些字段进行分组,然后配合聚合函数对每个组进行计算。这是统计报表的核心操作。
举个例子,按部门统计员工人数和平均工资:
sql复制SELECT department, COUNT(*) AS emp_cnt, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
它做的事情是:先把所有记录按department值分堆,然后每个堆里分别执行COUNT(*)和AVG(salary),最后每个堆输出一行结果。
GROUP BY使用中最大的陷阱是:SELECT后面只能出现分组字段或聚合函数。比如下面这个写法就是错误的:
sql复制-- 错误示例
SELECT department, employee_name, AVG(salary)
FROM employees
GROUP BY department;
因为一个部门里可能有多个人,employee_name该返回谁?数据库无法判断。MySQL在默认配置下会直接报错(ONLY_FULL_GROUP_BY模式),但即使某些模式下不报错,返回的值也是随机的,完全不可控。
HAVING用于对分组后的结果进行二次过滤。和WHERE的区别在于:WHERE过滤的是原始行,HAVING过滤的是分组。所以如果你想筛选“平均工资大于1万的部门”,用HAVING AVG(salary) > 10000,而不能用WHERE AVG(salary) > 10000——因为执行WHERE时还没分组,聚合函数根本没有意义。
4.2 子查询的三种形态:标量、行、表
子查询就是嵌套在查询里的查询。按返回结果形态,可以分三类:
标量子查询:返回单行单列,一个值。常用于比较条件中。
sql复制SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
上面这个子查询先算出全公司平均工资,再返回高于平均工资的员工。注意,这种写法里子查询必须保证只返回一个值,如果返回多行,MySQL会直接报错。
行子查询:返回单行多列。用得相对少,但偶尔有用。比如同时比较多个字段。
sql复制SELECT * FROM employees
WHERE (department, salary) = (SELECT department, MAX(salary) FROM employees WHERE department = '技术部');
表子查询:返回多行多列,可以当临时表用,通常放在FROM后面。
sql复制SELECT dept_stats.department, dept_stats.avg_salary
FROM (
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
) AS dept_stats
WHERE dept_stats.avg_salary > 8000;
这种“派生表”写法,可以先把数据计算成一个中间结果,再在外层做进一步查询。逻辑上是清晰的,但要注意:派生表在MySQL中通常会具体化(物化),如果数据量很大,要谨慎使用,否则可能造成临时表占用大量内存或磁盘空间。
4.3 EXISTS与IN的选择:性能与语义的平衡
子查询中,IN和EXISTS经常可以互换,但性能表现可能差异巨大。
IN子查询:先执行子查询,产生结果集,然后外层查询再用结果集去匹配。适合子查询结果集比较小的情况。EXISTS子查询:对外层结果的每一行,都执行一次子查询,看是否返回至少一行。适合子查询结果集大、但外层结果集小的情况。
举一个“查询有订单的用户”的例子:
sql复制-- 用IN
SELECT * FROM users WHERE user_id IN (SELECT user_id FROM orders);
-- 用EXISTS
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);
对于EXISTS的子查询,通常把SELECT后面写成1或*都行,反正只关心有没有返回行,不关心具体值。
实操心得:在MySQL较新版本中,优化器已经能自动做半连接优化(semi-join),很多场景下
IN和EXISTS会被改写成同一种执行计划。但作为写SQL的人,我觉得语义上的选择更重要:当“对外表逐行判断”的语义更贴合业务时,用EXISTS;当“先收集一个值集合”更自然时,用IN。先保证语义清晰,再考虑优化器。
4.4 子查询的常见坑:关联与非关联
子查询分为关联子查询和非关联子查询。
非关联子查询不依赖外层查询,就像上面那个算平均工资的例子,子查询可以独立执行。关联子查询则引用了外层查询的字段,比如EXISTS例子里的u.user_id,子查询无法独立执行,因为u是外层的别名。
写关联子查询时,最容易犯的错是列名歧义。当外层表和内层表有同名字段时,一定要用表别名限定,否则数据库可能无法解析字段归属。比如上面那个例子,o.user_id = u.user_id,如果不加u.和o.前缀,数据库会认为是同一张表的同一个字段和自己比较,结果永远为真,查询就变成“查出所有用户”了。
5. 常见问题与排查技巧实录
5.1 查询结果与预期不符的排查思路
这是日常工作中最常遇到的一类问题:SQL没报错,但结果就是不对。我的排查套路基本是固定的,分享出来供参考。
第一步:先跑子部分,再组合。如果是多表关联或子查询,不要一上来就指望一次写对。先把每张表单独查一遍,确认数据长什么样;再按顺序逐步拼接。比如先查订单表,看看user_id有哪些值;再查用户表,看看有哪些用户;最后再JOIN起来,用眼睛检查一下有没有预期外的匹配。
第二步:检查NULL参与运算的风险。很多“莫名其妙”的查询结果,根源都是NULL在某个环节掺了一脚。比如WHERE salary NOT IN (SELECT salary FROM manager_salary),如果子查询的结果集中包含NULL,那么整个NOT IN的匹配结果很可能是空集。因为NOT IN遇到NULL时,既不能算匹配,也不能算不匹配,最终结果不是预期值。解决方案是改成NOT EXISTS,或者先在子查询里排除NULL。
第三步:确认聚合函数的忽略NULL行为。前面提过的COUNT(字段)和AVG会忽略NULL,如果你没意识到这一点,统计出来的数据偏少是必然的。
5.2 DQL性能慢的典型场景与响应策略
数据量一大,慢查询就来了。我实际工作中遇到最多的慢查询场景有三种。
第一种是无索引或索引失效。常见原因包括:对索引字段做了函数运算(如WHERE YEAR(create_time) = 2024),对索引字段做了隐式类型转换(如字符串字段直接跟数字比较),以及前置通配符的LIKE。这些都是老生常谈,但依然高频出现。
第二种是不必要的全表扫描。比如只需要查一个字段,但因为SELECT *把整行都拉出来,增大了IO开销。对策很简单:只查询需要的列。
第三种是深分页或者大偏移量的LIMIT。前面提过,偏移量越大,扫描的行越多。解决方案是“根据ID定位”的方式,而不是直接用LIMIT加偏移量。
注意:排查性能问题,任何“猜测”都不如一条
EXPLAIN命令来得实在。学会看EXPLAIN输出中的type、key、rows三列,基本能定位90%的慢查询原因。
5.3 常见DQL报错速查表
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
| Column 'xxx' cannot be null | 插入数据时给非空字段传了NULL,或者JOIN时右表无匹配且该字段被用于WHERE过滤导致语义错误 | 检查数据来源,确认业务逻辑,必要时用COALESCE兜底 |
| Unknown column 'xxx' in 'where clause' | 在WHERE里用了SELECT中的别名 | 把别名改成原始字段表达式,或者把过滤逻辑挪到HAVING里 |
| Invalid use of group function | 在WHERE中使用了聚合函数 | 将聚合条件移到HAVING |
| Subquery returns more than 1 row | 标量子查询返回了多行 | 在子查询中加LIMIT 1或改用IN/EXISTS |
| Every derived table must have its own alias | FROM后面的派生表没有起别名 | 给每个子查询加一个别名,如AS t1 |
| Expression #1 of SELECT list is not in GROUP BY clause | SELECT字段不在GROUP BY中,且不是聚合函数 | 要么把该字段加进GROUP BY,要么去掉该字段 |
这张表里的前四个报错,我几乎每周都会在群聊里看到新手问。把这些坑提前避开,能省不少调试时间。
5.4 独家心得:如何写出“一眼就懂”的DQL
最后分享一点偏个人习惯的经验。我觉得写DQL不只是写给数据库执行,也是写给三个月后的自己看的。所以我会坚持几个小习惯:
一是所有关键查询都加表别名。尤其多表连接时,orders o、users u、products p,一眼就能看出哪个字段来自哪张表。不加别名,写的时候一时爽,改的时候火葬场。
二是复杂查询分层写。先写子查询或CTE(WITH语法),把中间计算过程一层层搭起来,不要把所有逻辑揉在一个大SQL里。CTE比嵌套子查询可读性高太多,也方便单独调试每一层。
三是WHERE和HAVING的职责明确。能放在WHERE里过滤的,就不要留到HAVING。原因有两个:功能上,WHERE先过滤可以减少分组计算的数据量;可读性上,读SQL的人会默认WHERE是行级过滤,HAVING是组级过滤,混在一起容易误判。
四是在写完查询后自己解释一遍逻辑。拿一张小数据量表,跑一遍,然后对着结果“翻译”成业务语言。如果翻译出来的和你最初的业务需求对不上,说明你写的查询和脑子里的查询不是一个东西。这个方法对查错特别有效。
6. 写在最后:DQL只是起点,不是终点
我个人在实际操作中体会最深的一件事是:DQL写得好不好,不只是语法熟不熟的问题,而是对数据理解深不深的问题。同一个查询需求,你理解数据分布和业务含义的程度,决定了你是用一条简洁的JOIN解决,还是绕一圈子查询还查不对。所以不要只背语法,要多拿真实数据练手,多给自己出业务题目,逼着自己用不同的查询思路去解。
如果你正在学数据库,或者已经在写SQL了,我建议你把这篇文章收藏起来,照着里面的例子逐个跑一遍,再给自己设计几个面试题场景,比如“查每个部门工资最高的员工”“查连续三天有订单的用户”“查下单金额超过平均值的用户”这些经典问题。把这些场景都吃透,你的DQL水平就算真正立住了。
后续这个系列我还会持续更新,下一期大概率会讲索引与执行计划,再往后可能聊事务和锁机制。这些都是DQL的“进阶燃料”,但基础的查询功底打不牢,后面都白搭。所以,先把今天的DQL内容消化好,有任何疑问欢迎在评论区一起讨论,我看到都会回。
