很多人在学数据库的时候,一开始就被"SQL 四大分类"劝退过:DDL、DML、DCL,还有一个 DQL。前几个还比较好理解——建表、增删改、权限控制,都属于"操作型"的东西。唯独这个 DQL,也就是数据查询语言,几乎是所有 SQL 学习曲线里最陡的一段:光是 SELECT 一个关键字,就能衍生出过滤、连接、分组、排序、分页、子查询、聚合统计一整套玩法。
我自己带过不少新人,也接盘过不少烂尾项目,一个很直观的感受是:很多人写 CRUD 里的增删改没问题,一到"给我出一份某某报表""把某张表按条件查出来再和另一张表关联"就开始卡壳。其实这不是智商问题,而是学习方法的问题——DQL 不是靠背语法就能会的,它是一种思维模式,是要把"面向集合"的思维方式建立起来才能玩转的东西。
这篇内容就是围绕数据查询语言 DQL 做一次完整拆解,从它在 SQL 体系里的定位,到 SELECT 的完整执行逻辑,再到一个能直接抄作业的订单业务实战,最后附上我这些年踩过的坑和排查技巧。不管你是刚学到第七课的新手,还是已经写了半年 SQL 但总觉得哪里没通透的开发者,这篇应该都能让你对查询这件事有个更系统的认识。
1. DQL 的定位与查询思维:为什么单独把它拎出来讲
1.1 SQL 四大语言分类里,DQL 到底管什么
SQL(结构化查询语言)严格来说不是一个"语言",它是一族语言的总称,按功能可以拆成四块。DDL 管结构,比如 CREATE、ALTER、DROP,说白了就是"盖房子";DML 管数据操作,INSERT、UPDATE、DELETE,就是"往房子里搬家具、换家具、扔家具";DCL 管权限,GRANT、REVOKE,是"谁有钥匙能进门";而 DQL,也就是 Data Query Language,核心只有一个——SELECT,它是"我在这个房子里找东西"。
注意一个细节:很多人会把 DML 和 DQL 混在一起说,甚至有的教材把 SELECT 归到 DML 里。但从 ANSI 标准到各大数据库的实现来看,DQL 是独立的一块。原因很简单:SELECT 不改变任何数据状态,它只做读取和计算。这个"不改变"在数据库的事务隔离、锁机制、性能优化上都有关键影响。比如 MySQL 里默认的 InnoDB 引擎,普通的 SELECT 走的是快照读,不加锁,而 UPDATE 会加行锁——这就是为什么你要把"查询"单独拎出来理解。它的行为模式和 DML 有本质区别,思维模型也完全不一样。
1.2 为什么查询是日常开发里的"重头戏"
我统计过自己经手的项目,一个典型业务系统的 SQL 语句里,SELECT 占了七成以上。原因很直白:增删改是低频操作,用户不会天天改自己的资料,但查询是高频操作——用户一打开 App 就在查列表、查详情、查订单状态。而且查询的复杂度增长是指数级的:单表查询谁都会,一旦涉及到多表关联、条件组合、分组聚合,SQL 的写法瞬间从"填空题"变成"应用题"。
另一个原因是从业者绕不开的:面试几乎必考。不管你是面后端、面数据分析、面运维,DQL 都是压轴题的区域。面试官问你"LEFT JOIN 和 INNER JOIN 的区别""HAVING 和 WHERE 的执行顺序""为什么这个查询慢",问的全都是 DQL 的范畴。所以把 DQL 单独拎出来系统学一遍,收益不仅仅是"能写查询",而是能把整个 SQL 的执行机制串起来。
1.3 查询思维的核心:从"怎么做"到"要什么"
这是我特别想强调的一点。如果你之前写过 Python、Java、C 这类命令式语言,写 SQL 的时候最容易犯的毛病就是"想太多"。命令式语言的思维是"一步一步告诉机器怎么做":先遍历这个数组,再判断那个条件,然后累加结果。但 SQL 是声明式语言,你要做的是"描述你最终想要的结果长什么样",至于怎么查、用不用索引、先过滤还是先连接,那是数据库优化器去决定的事。
举个生活化的例子。命令式思维是:你走进一家餐厅的后厨,自己拿刀切菜、开火、炒菜,每一步都是你控制的。声明式思维是:你坐在餐桌前,告诉服务员"来一份辣子鸡,不要放太多盐,要微辣",然后等着上菜就行。至于后厨是先腌鸡肉还是先备辣椒,你不用管,你只需要把你的需求描述准确。
这个思维转变是学好 DQL 的第一步。你写 SELECT 的时候,大脑里应该想的是"我要什么字段、从哪几张表来、满足什么条件、按什么维度汇总",而不是"数据库应该先做这个再做那个"。
2. SELECT 的核心语法拆解:七个子句的执行顺序与使用要点
2.1 SELECT 的完整骨架和执行顺序
一个完整的 DQL 查询长这样:
SELECT 字段列表 FROM 表名 WHERE 过滤条件 GROUP BY 分组字段 HAVING 分组后过滤 ORDER BY 排序字段 LIMIT 分页限制;很多初学者会按书写顺序去理解,觉得先 SELECT 再 FROM 再 WHERE,数据库也这么干。但真实执行顺序完全不是这样。数据库拿到你的 SQL 之后,实际是这么跑的:
- FROM:确定数据源,先找到要查的表
- WHERE:对每一行原始数据做过滤,把不满足条件的行直接扔掉
- GROUP BY:把过滤后的数据按指定字段分组
- HAVING:对分组后的结果做过滤,不符合条件的分组被丢弃
- SELECT:确定要输出的列,计算表达式、别名在这一步才生效
- ORDER BY:对最终结果排序
- LIMIT:截取指定数量的行
这个顺序不是枯燥的理论,它能直接解释很多"玄学问题"。比如:为什么 WHERE 里不能用 SELECT 里面定义的别名?因为 WHERE 在第 2 步执行,而 SELECT 在第 5 步执行,别名还没生成,你当然用不了。为什么 HAVING 能用聚合函数,而 WHERE 不能用?因为聚合发生在 GROUP BY 阶段,WHERE 执行时压根还没有分组结果,自然没法用 COUNT、SUM 这些聚合函数。
2.2 WHERE 过滤的艺术:别小看这个最简单的环节
WHERE 是 DQL 里最常用、也最容易被写出隐患的子句。我把它拆成几个常见场景来说。
比较运算与逻辑组合:最基本的=、>、<、>=、<=、!=大家都会用,但组合条件时要特别注意逻辑优先级。AND 的优先级高于 OR,所以WHERE a = 1 OR a = 2 AND b = 3的实际含义是a = 1 OR (a = 2 AND b = 3),而不是你想当然的三个条件并列。我见过太多新人在这个细节上翻车,正确习惯是多加括号,哪怕括号有时候是多余的——清晰比简洁重要。
范围与集合匹配:BETWEEN a AND b是闭区间,包含两端边界,这一点和很多编程语言里习惯用的"左闭右开"不一样,容易搞混。IN (1, 2, 3)本质上是多个 OR 的语法糖,写起来清爽,但要注意:当IN后面跟子查询时,如果子查询的结果集很大,性能可能比 JOIN 更差,这个后面会细说。
模糊匹配:LIKE配合%和_两个通配符。%代表任意长度的任意字符,_代表一个任意字符。LIKE 'abc%'能用到索引,但LIKE '%abc'和LIKE '%abc%'因为前导通配符的存在,会放弃索引走全表扫描。这个在数据量大的时候是致命的。
2.3 JOIN 连接查询:从一张表到多张表的关键一跃
实际业务里几乎没有只查一张表就能搞定的事。用户表和订单表要关联,订单表和商品表要关联,商品表和分类表还要关联。JOIN 就是把多张表的数据按某种规则拼接起来的技术。
四种 JOIN 的关系我常用集合论来类比:INNER JOIN 是取交集,LEFT JOIN 是左表全保留加右表匹配部分,RIGHT JOIN 是右表全保留加左表匹配部分,FULL OUTER JOIN 是并集,不过 MySQL 不直接支持 FULL OUTER JOIN,需要用 LEFT JOIN 和 RIGHT JOIN 加 UNION 来模拟。
项目里用得最多的是 INNER JOIN 和 LEFT JOIN。选哪个不是看心情,而是看你的业务需求:如果"没有匹配数据就不需要展示",用 INNER JOIN;如果"即使右表没匹配上,左表的数据也必须全部显示",用 LEFT JOIN。比如查"所有用户及其订单"和查"有订单的用户",这两个需求就是 LEFT JOIN 和 INNER JOIN 的典型区别案例。
这里有个绕不开的坑——ON 和 WHERE 的区别。INNER JOIN 时,ON 和 WHERE 的效果一样,但 LEFT JOIN 时区别巨大。在 ON 后面加右表的条件,只是决定"匹配不上的右表行是否置为 NULL";把同样的条件放到 WHERE 里,结果会直接变成 INNER JOIN 的效果,左表中不符合条件的行会被删掉。实际开发里因为这个写出错的案例太多了,最稳妥的判断标准是:对右表的过滤条件,如果希望保留左表全量,就放在 ON 里;如果过滤的是最终结果,就放在 WHERE 里。
JOIN 还有一个常被忽视的性能要点——驱动表。多表连接时,优化器会选择一个表作为驱动表,拿它的每一行去另一张表里找匹配。经验法则是:用小表驱动大表,也就是说,如果 A 表 100 行、B 表 10000 行,让 A 表当驱动表会更高效。不过现代优化器很多时候会自动调整连接顺序,但你自己写 SQL 时尽量遵循这个原则总没错。
2.4 分组聚合:GROUP BY、HAVING 与聚合函数的正确姿势
分组聚合是把"明细数据"变成"汇总数据"的核心手段,也是 DQL 里最容易写出"看似对、实际错"的部分。
GROUP BY 的语义要抓住一句话:分组之后,每一组只输出一行。这意味着 SELECT 后面的字段是有严格限制的——要么出现在 GROUP BY 里,要么被聚合函数包裹。比如SELECT user_id, COUNT(*) FROM orders GROUP BY user_id是合法的,user_id 在分组字段里,COUNT(*) 是聚合函数。但SELECT user_id, order_no FROM orders GROUP BY user_id在很多数据库里直接报错,因为 order_no 既没被分组、又没被聚合,结果集里根本放不下它。
聚合函数常用的有五个:COUNT、SUM、AVG、MAX、MIN。有几个细微差别要记牢:COUNT(*) 是统计行数,包括 NULL 值的行;COUNT(列名) 只统计该列非 NULL 的行;SUM 和 AVG 会自动忽略 NULL,但如果列里全是 NULL,SUM 返回 NULL 而不是 0,这个在做报表时要注意用 IFNULL 或 COALESCE 兜底。
HAVING 和 WHERE 的分工是:WHERE 在分组前过滤原始行,HAVING 在分组后过滤结果组。如果你要把"下单时间在 2024 年之前的订单"排除掉,用 WHERE 写在分组前;如果你要筛掉"订单数少于 10 的用户",用 HAVING 写在分组后。千万别混——把 HAVING 能做的事硬塞给 WHERE,直接报错,因为 WHERE 阶段聚合函数还没生成。
2.5 ORDER BY 与 LIMIT:排序分页里的性能陷阱
ORDER BY 默认升序(ASC),要降序就写 DESC。多字段排序时从左到右依次生效:ORDER BY a DESC, b ASC先按 a 降序,a 相同时再按 b 升序。这一块本身不难,难的是和 LIMIT 配合做分页时产生的性能问题。
LIMIT 有两个参数:LIMIT offset, row_count,或者LIMIT row_count OFFSET offset。比如每页 20 条、查第 3 页,就是LIMIT 40, 20——跳过前 40 条,取接下来的 20 条。问题在于,数据库为了实现这个"跳过 40 条",需要先把前面 40 条查出来然后再扔掉。当页数很深时,比如LIMIT 100000, 20,数据库要先读 100020 行再扔掉 100000 行,效率极低。深分页是 DQL 性能问题的重灾区,后面实战部分我会给优化方案。
3. 实战演练:一个订单业务系统的完整 DQL 查询流程
3.1 建表与初始化数据
纸上谈兵讲再多,不如亲手跑一遍。我设计一个非常典型的订单业务场景,三张表:用户表 users、商品表 products、订单表 orders。结构尽量精简,但足够覆盖 DQL 的常用场景。
-- 用户表 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, city VARCHAR(50) ); -- 商品表 CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, category VARCHAR(50) ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, order_time DATETIME NOT NULL, INDEX idx_user_id (user_id), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (product_id) REFERENCES products(id) );插入一些测试数据,不用太多,能让查询结果看得清楚就行:
INSERT INTO users (name, age, city) VALUES ('张三', 28, '北京'), ('李四', 34, '上海'), ('王五', 22, '广州'), ('赵六', 29, '深圳'), ('孙七', 41, '北京'); INSERT INTO products (name, price, category) VALUES ('手机', 5999.00, '数码'), ('耳机', 899.00, '数码'), ('卫衣', 299.00, '服装'), ('运动鞋', 799.00, '服装'), ('背包', 459.00, '箱包'); INSERT INTO orders (user_id, product_id, quantity, order_time) VALUES (1, 1, 2, '2024-01-05 10:30:00'), (1, 2, 1, '2024-01-12 15:00:00'), (2, 3, 3, '2024-01-20 09:15:00'), (3, 1, 1, '2024-02-02 14:20:00'), (3, 4, 2, '2024-02-08 11:45:00'), (4, 5, 1, '2024-02-15 16:00:00'), (5, 2, 2, '2024-02-18 13:30:00'), (2, 5, 2, '2024-03-01 10:00:00'), (3, 3, 1, '2024-03-10 18:30:00');3.2 从单表查询到多表联查:循序渐进
首先做一个最简单的单表查询,比如"查询所有来自北京的用户,按年龄从小到大排":
SELECT id, name, age, city FROM users WHERE city = '北京' ORDER BY age ASC;这个例子把 WHERE 过滤、ORDER BY 排序都串起来了,结果会返回两条数据:张三(28 岁)在前,孙七(41 岁)在后。
接下来上多表 JOIN。需求是:"查询每笔订单的详细信息,包括下单人姓名、商品名称、数量和下单时间"。这个查询涉及三张表,orders 和 users 关联拿到用户名,orders 和 products 关联拿到商品名:
SELECT u.name AS 用户姓名, p.name AS 商品名称, o.quantity AS 购买数量, o.order_time AS 下单时间 FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id ORDER BY o.order_time DESC;这里用了表别名 o、u、p,让 SQL 短一些,读起来也清晰。注意AS定义中文别名,在部分数据库客户端里可能显示乱码,生产环境建议用英文字段别名,这里只是为了演示直观。
再来看一个 LEFT JOIN 的典型场景:"查询所有用户及其订单数量,包括没下过单的用户"。如果写成 INNER JOIN,没下过单的用户会被过滤掉,但我们要求"所有用户":
SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name ORDER BY order_count DESC;这里注意 COUNT(o.id) 而不是 COUNT()。如果是没下过单的用户,LEFT JOIN 会把右表字段置为 NULL,COUNT() 会把这一行也算进去,结果变成 1,那就错了。COUNT(o.id) 只统计非 NULL 的订单 ID,没下单的用户自然就是 0。这个细节是我在实际项目里被坑过之后才彻底记住的。
3.3 分组统计:从明细到报表的进阶
查询类需求做到后面,绝大多数都是报表类——按月统计、按分类统计、按用户排名。这些全是 GROUP BY 的战场。
第一个需求:"统计每个月的订单总金额"。订单明细表里只有单价和数量,没有总金额字段,所以要在查询里算出来:p.price * o.quantity。按月份分组用DATE_FORMAT(o.order_time, '%Y-%m')格式化:
SELECT DATE_FORMAT(o.order_time, '%Y-%m') AS order_month, SUM(p.price * o.quantity) AS total_amount FROM orders o INNER JOIN products p ON o.product_id = p.id GROUP BY order_month ORDER BY order_month ASC;执行结果应该是 2024-01 为一组、2024-02 为一组、2024-03 为一组,每组一个合计金额。这个查询做到了"多表 JOIN + 表达式计算 + 分组聚合 + 排序"的融合,基本覆盖了日常报表的核心玩法。
第二个需求:"筛选出下单次数大于等于 2 的用户"。注意,这里先分组统计每个用户的订单数,然后再筛掉订单数不足 2 的组——这就是 HAVING 的典型场景:
SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name HAVING COUNT(o.id) >= 2 ORDER BY order_count DESC;从结果可以看到,李四、张三、王五这三个用户都下了 2 单以上。这个 SQL 里 WHERE 没派上用场(因为不需要在分组前过滤),但你可以对比一下:如果需求是"只看 2024 年 2 月之后的订单",那就要在 JOIN 之前或 WHERE 阶段先把订单过滤掉,减少分组的数据量,再走 HAVING 做分组后筛选。
3.4 分页查询与深分页优化
分页需求在列表页里极其常见:"按下单时间倒序,每页 3 条,取第 2 页":
SELECT o.id, u.name, p.name AS product_name, o.quantity, o.order_time FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id ORDER BY o.order_time DESC LIMIT 3 OFFSET 3;结果会返回第 4~6 笔订单的信息。这种写法在数据量几千条的时候毫无压力,但一旦订单表到了几百万行,翻到第 100000 页的时候,OFFSET 会让数据库白白扫描十万行再扔掉——这就是前面提到的深分页问题。
优化的常见手段之一是"延迟关联":先通过覆盖索引快速定位到需要的主键 ID 范围,再回表查完整数据。
SELECT o.id, u.name, p.name AS product_name, o.quantity, o.order_time FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id INNER JOIN ( SELECT id FROM orders ORDER BY order_time DESC LIMIT 3 OFFSET 99999 ) tmp ON o.id = tmp.id ORDER BY o.order_time DESC;子查询里只查了主键 ID,走的是索引覆盖,不会回表读完整行,速度会快很多。数据量上了百万之后,这种写法带来的性能提升非常明显。
4. 常见问题与排查技巧实录
4.1 经典问题速查表
我把这些年实际开发里被问得最多的 DQL 问题整理成一张速查表,方便遇到底层原理说不清时直接对照。
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| WHERE 里用 SELECT 别名报错 | WHERE 执行在前,SELECT 别名生成在后 | 直接写原始字段或表达式,或用 HAVING 代替 |
| COUNT 结果比实际行数少 | 用的是 COUNT(可空列),NULL 值被忽略 | 统计行数用 COUNT(*),统计非空值才用 COUNT(列) |
| LEFT JOIN 结果少了左表行 | 左表的过滤条件写在 WHERE 里 | 移到 ON 后面,或者改用子查询先过滤 |
| 查询很慢但字段都建了索引 | WHERE 里对索引列做了函数计算或隐式转换 | 去掉函数包装,改写条件保持索引列原始形态 |
| 分页翻到后面越来越慢 | LIMIT 大偏移量导致扫描大量无效行 | 延迟关联、游标分页,或基于自增 ID 定位 |
| 日期条件查不到当天数据 | DATE_FORMAT 或字符串比较格式不一致 | 统一用 DATE 类型比较,或用>= '2024-01-01' AND < '2024-01-02' |
| GROUP BY 后 SELECT 报错 | SELECT 列既不在 GROUP BY 里也不是聚合字段 | 把该列加入 GROUP BY,或用聚合函数包裹 |
4.2 NULL 值的三大陷阱
NULL 是 DQL 里最反直觉的存在,我把它单独拎出来讲。第一条:NULL 不等于 0,也不等于空字符串,它是一个"未知"状态。任何和 NULL 做比较运算,结果都不是 TRUE/FALSE,而是 NULL。所以WHERE age = NULL永远查不到数据,必须写成WHERE age IS NULL。这个坑我见过无数人踩。
第二条:聚合函数对 NULL 的处理不同。COUNT(*) 数行数,COUNT(列) 不数 NULL 行;SUM 忽略 NULL 但结果可能为 NULL;AVG 计算时忽略 NULL。做金额统计时,如果明细表里某些行的金额字段是 NULL,SUM 出来的结果可能让你意外。
第三条:NULL 值在 ORDER BY 里的表现形式特殊。MySQL 里默认 NULL 排在最前面(ASC 时),Oracle 里 NULL 排在最后。如果需求有明确要求,记得用ORDER BY 列 IS NULL或COALESCE手动控制。
4.3 性能排查思路:先 EXPLAIN,再优化
遇到 DQL 性能问题,我的第一个动作永远是跑EXPLAIN SELECT ...而不是瞎猜。EXPLAIN 会告诉你怎么访问表、用没用索引、处理了多少行,这里重点看三列:type从好到差依次是 system、const、eq_ref、ref、range、index、ALL;key表示实际用到的索引,为 NULL 就是没走索引;rows表示预估扫描行数,越小越好。
如果看到 type 是 ALL 或者 rows 特别大,优先检查几条:WHERE 条件里的列有没有索引;有没有在索引列上做运算导致索引失效;JOIN 的关联字段类型是否一致——字段类型不一致是隐式转换的重灾区,只要一张表是 INT、另一张是 VARCHAR,索引很可能就用不上。
4.4 几条实操心得
最后分享几个在我自己代码规范里一直坚持的习惯。一是能不写 SELECT * 就不写,字符串和文本字段的大字段会放大 IO 开销,哪怕你只需要两列,也把列名写出来。二是写完查询先跑 EXPLAIN,尤其是涉及 JOIN 和 GROUP BY 的复杂查询,执行计划扫一眼能避免很多上线后才爆发的问题。三是给表起别名并规范大小写,多人协作时,SQL 的可读性直接决定排障效率。
我个人对 DQL 最大的体会是:它不像编程语言那样需要记忆海量的 API,但特别考验"精确描述需求"的能力。同一个查询需求,有人写出来 5 秒跑完,有人写出来 5 分钟跑不完,差别往往不在语法,而在对执行机制的理解深度。所以在实战里遇到慢查询,别急着骂数据库,先回头看看自己的 SELECT 是不是真的把需求描述清楚了——这是我在踩过无数次坑之后最想分享的一点。