1. DQL是什么:先把它在SQL体系里摆正位置
DQL,全称Data Query Language,数据查询语言,它是SQL里面存在感最高、也最考验基本功的部分。很多程序员第一次写SQL,写的就是SELECT,而SELECT正是DQL的核心动作。简单讲,DQL负责的事情就一件:按照你设定的条件,从数据库表里把数据挑出来,再按照需要的方式加工、排序、汇总后返回给你。业务上的报表、后台列表、接口取数、数据分析师的看板,最底层几乎都是DQL。
这篇文章适合不想再看零散百科词条、希望一次把DQL常见知识点串起来的人。不管你是刚入行的后端新人,还是写了很多年SQL但偶尔被慢查询卡住的分析师,都能在这里找到能直接上手的思路。我会按"基础认知、单表查询、多表连接、聚合统计、性能优化"这条线往下走,尽量把每个关键点背后的为什么讲清楚。
1.1 先分清DQL和DDL、DML、DCL的区别
很多初学者会把整个SQL都当成"查询语言",实际上SQL按功能可以拆成四块。DDL是数据定义语言,负责建表、改表结构,例如CREATE、ALTER、DROP;DML是数据操作语言,负责增删改,例如INSERT、UPDATE、DELETE;DCL是数据控制语言,负责权限和事务控制,例如GRANT、COMMIT。DQL则只负责取数,核心就是SELECT。四者里面,DQL最特别:它不改变任何数据,只做读取和加工,所以它也最需要逻辑感。
我见过不少同学在联调接口时,把UPDATE误当成查询来定位问题,查了半天发现是DML语句写错,这就是没把分类意识建立起来。DQL和DML虽然都是高频操作,但DQL只读不改,这是它能在数据库里被各种缓存机制优化、被只读副本承接的前提。业务上可以给DQL单独走只读账号,就是为了防止手滑写错把线上数据改了。
1.2 为什么单独啃DQL非常值得
这一两年我面试别人,几乎不问冷门函数,只拿一张三五十行的业务表加两个连接查询问题,就能看出SQL水平。原因在于:DQL的语法体系远比DDL、DML庞大复杂,而且它的性能问题直接影响业务响应速度。你会写CREATE TABLE和INSERT INTO只是入门,能把一条DQL写出高效执行计划,才算是真正吃透了SQL。
另外DQL是分析型需求的必经之路。业务部门随口说一句"我要看上周每个商品的销售情况",落到数据层就是一套带分组、聚合、排序的DQL。熟练的人五分钟出结构,不熟练的人先拼条件再调格式,半小时起步。这中间的差距,不是操作速度,而是对查询思路是否清晰。正因为这么常见,所以值得专门花时间系统梳理。
2. SELECT单表查询的完整拆解与执行顺序
SELECT看起来简单,实际是SQL里"最混淆"的部分。很多人能写出能跑的查询,但不知道它按什么顺序跑,结果一旦涉及条件过滤和分组统计,就各种不对。这一节我会把一条查询的各个子句拆开,再讲清楚数据库内部真正的执行顺序。
2.1 SELECT语句的子句组成和各自职责
一条完整单表查询通常由六个子句组成,分别是SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY,最后还会带LIMIT。SELECT决定返回哪些列,FROM决定从哪张表读数据,WHERE负责在分组前过滤行,GROUP BY把数据按某一列或某几列分组,HAVING在分组后过滤组,ORDER BY做排序,LIMIT控制返回数量。每个子句各管一段,职责不重叠。
我举一个具体例子。假设有一张销售明细表sales,记录每个订单的日期、商品分类、销售额和销售人员,你用下面这条查询就可以拿到"2024年5月,总销售额超过10万元的商品分类,按销售额从高到低排列,只取前5个分类"。
SELECT category, SUM(amount) AS total_amount FROM sales WHERE sale_date >= '2024-05-01' AND sale_date < '2024-06-01' GROUP BY category HAVING SUM(amount) > 100000 ORDER BY total_amount DESC LIMIT 5;这段代码很日常,但里面有两个隐藏点。一是HAVING语句里不能直接用SELECT中的别名total_amount,SQL标准要求必须写SUM(amount),虽然某些数据库放宽了限制,但为了可移植性建议别依赖。二是WHERE先过滤行,再分组,再用HAVING过滤分组结果。如果条件能写在WHERE里,就不要挪到HAVING里去,因为提前过滤可以减少分组时需要处理的数据量。
2.2 逻辑执行顺序:复杂查询正确性的命门
很多人背过六子句的执行顺序,但只是背,不知道用途。真正要记住的是:FROM先确定数据来源,WHERE先过滤,GROUP BY做分组,HAVING过滤组,SELECT算列,ORDER BY排序,LIMIT截断。为什么这个顺序重要?因为它决定你在哪个环节能用什么表达式。
比如WHERE子句里不能使用SELECT中定义的别名,原因就是WHERE执行在SELECT之前,别名还没生成。ORDER BY可以使用别名,因为排序发生在SELECT之后。我经常看到有人写WHERE total_amount > 10000,而total_amount明明是SELECT里的别名,结果报错后就开始怀疑数据库坏了,其实只是顺序没搞清。
理解了执行顺序,优化思路也通了。例如某条查询在WHERE条件里用了很强过滤性的字段,数据库会先缩小数据范围再排序,速度自然快;反过来,如果你把过滤条件都留在HAVING里,数据库得先分组统计完再筛掉一批组,相当于白算了大量无用数据。实际调优时,遇到HAVING里过滤很多行的情况,第一反应应该是:"哪些条件其实可以下推到WHERE?"这一步往往比加索引更有效。
3. 多表连接与子查询的实操要点
单表查询只是热身,日常需求里绝大多数是两张及以上的表关联。到了这一步,不少人开始乱:一会用INNER JOIN,一会用LEFT JOIN,条件放在ON还是WHERE也不确定,查出来的数据多一行少一行都不知道为什么。这一节我会把连接查询的典型坑位逐个讲清楚。
3.1 连接查询:INNER JOIN、LEFT JOIN怎么选
多表连接的直观理解就是把两张表按照某个关联键拼在一起。INNER JOIN只保留两边都满足连接条件的记录,LEFT JOIN则把左侧表的记录全部留下,右侧没有匹配的字段填NULL。这个基础概念大家都会,真正的坑在选择连接条件时过滤写在哪里。在LEFT JOIN中,把右侧表的过滤条件写在WHERE里,会在连接完成后把右表NULL的行过滤掉,导致LEFT JOIN的效果退化成INNER JOIN。
比如你要查"所有用户及其订单金额",但只想看订单金额大于100元的记录,如果写成WHERE o.amount > 100,没下过单的用户就会被过滤掉,这通常不是"所有用户"想要的结果。正确做法是把右侧表的过滤条件放在ON里,左表的过滤条件放WHERE。这个细节很细小,线上数据一多,结果差几百行都很正常。
连接时还要警惕笛卡尔积。如果关联键选错,或者某张表有重复关联数据,结果行数会膨胀。我在处理一对多关联升级成多对多时踩过坑:用户表和订单表关联,用户在会员信息里有多个状态版本,直接关联后一张订单被重复计算了好几遍,聚合金额翻倍。这种问题的排查思路不是看SQL,而是先用SELECT COUNT(*)分别看两边数据量,再手动核对几条关联结果的唯一性。简单说,多表连接的调试策略是:一步一步来,先小数据量验证关联关系,再放大范围聚合。
3.2 子查询:什么时候用、什么时候尽量避免
子查询是嵌套在另一个查询中的查询,常见场景有:WHERE中的IN、EXISTS,以及FROM中的派生表。标量子查询可以用来对比一个值,例如查询比某商品平均销售额高的商品,写法是在WHERE里嵌套一个SELECT AVG。WHERE IN子查询简单直观,但如果子查询结果集很大,性能往往不如JOIN。
一个经常被忽略的致命坑是NOT IN加NULL。当IN子查询结果中包含NULL值时,NOT IN的行为会变成"整体条件不成立",因为SQL三值逻辑里NULL既不是真也不是假。也就是说,SELECT * FROM a WHERE id NOT IN (SELECT id FROM b)时,如果b表id列有NULL,本来该返回的a表记录一条都不会返回。我建议:只要不确定子查询结果里有没有NULL,就用NOT EXISTS替代,稳妥得多。碰到这种问题,很多开发查半天,最后发现是数据有空值,这就是三值逻辑没吃透。
FROM子查询在复杂统计中很有用。例如先按商品分组统计出每个商品的销售情况,再从这些统计结果里筛出超过整体平均值的数据,用派生表写起来结构非常清晰。不过需要注意,派生表每一行都要能明确定义,别在子查询里塞一堆业务逻辑,不然老了难维护。我的习惯是:子查询能少用就少用,能用JOIN表达就用JOIN,但遇到"取每组前N条"这类场景,窗口函数比子查询更合适,这个放到最后一节讲。
3.3 空值和重复数据:两个最容易翻车的点
处理NULL值时,等值判断永远不生效。数据库里NULL表示"未知",所以WHERE name = NULL查不到任何记录,必须写成IS NULL。我发现很多线上事故就是在这类条件上出的问题,尤其是在写更新和删除前,没用IS NULL核对清楚目标范围。另一个是空字符串和NULL的区别,前者是一个长度为0的值,后者是什么都没有,统计时千万别混为一谈。
重复数据处理方面,DISTINCT可以用来消除结果中完全相同的行,但它不等于按业务键去重。比如统计去重后的用户数,写COUNT(DISTINCT user_id)是合理的,但如果在多列上使用DISTINCT,它表示的是"这几列的组合不重复",并不保证某一列唯一。实际业务中经常有人用DISTINCT掩盖JOIN产生的重复行,结果数据看起来对了,但根本原因还是关联关系有问题。去重之前,先确认重复的来源,不要无脑加DISTINCT。
4. 聚合分组与统计函数的使用细节
DQL的力量不仅在于取数,更在于把原始数据加工成指标。聚合函数和分组是这个加工过程的两大支柱,不过很多人只停留在"会写SUM、COUNT"的层面。这一节我会把几个容易踩坑的函数语义讲透,顺便聊聊GROUP BY和HAVING的正确打开方式。
4.1 聚合函数:COUNT、SUM、AVG的几个关键区别
COUNT有几种写法,COUNT()、COUNT(1)、COUNT(column)结果含义不同。COUNT()统计的是表记录数,包括NULL;COUNT(1)和COUNT()在实际执行计划上基本等价,也是统计行数;COUNT(column)只统计该列非NULL的行数。如果某一列存在大量NULL,用COUNT(column)统计出来的数量会比COUNT()少,这不是Bug,而是语义差异。
SUM函数忽略NULL值,AVG也忽略NULL,这会导致一个常见误解:有人用AVG(score)算平均分,但某学生缺考记录是NULL不是0,最后算出来的平均分比其他方式的统计偏高。如果想缺考按0分算,需要先用COALESCE把NULL转成0再聚合。从经验上看,聚合函数里最容易写错的就是"聚合列和普通列混用"。在没有GROUP BY的情况下SELECT部门名称和COUNT(*),绝大部分数据库会报错;一些宽松模式的老版本可能不报错,但返回结果随机,属于隐性Bug。我的建议是:不要依赖数据库的宽松模式,写聚合查询时记住一条铁律,SELECT后面的非聚合列必须出现在GROUP BY中,否则就是逻辑错误。
4.2 GROUP BY分组和HAVING过滤的配合
分组的核心是"按什么维度聚合"。GROUP BY可以接多个列,例如"按日期和城市"统计,就是把两个字段的每一种组合作为一组。分组的粒度决定了聚合结果的维度,如果业务希望看到"每天每个城市的销售额",GROUP BY date, city就是正确粒度。每次写分组前先问自己:我要的输出粒度是什么?这是避免多算或漏算的好方法。
HAVING和WHERE的配合前面提到过。WHERE在分组前过滤行,HAVING在分组后过滤组,两者实现语义完全不同。统计"每个分类销售额超过10万"时,WHERE负责先剔除掉无效日期或异常金额,HAVING负责最终筛选分类。但有些条件在两个位置都能写,要注意性能和结果差异。例如HAVING COUNT(*) > 100,必须等分组统计完才能判断,这个条件只能放HAVING;而WHERE sale_date > '2024-01-01'可以在分组前大幅减少数据量,放HAVING也可以得到同样的分组结果,但性能会差很多。所以一条经验是:能用WHERE过滤的,永远优先WHERE。
4.3 去重统计DISTINCT的常见误用
DISTINCT看上去是"去重神器",实际是性能杀手。在数据量大的列上执行COUNT(DISTINCT column),需要对全表数据排序或构建哈希结构,代价不小。如果只是想要一个大概的业务量级,在很多数据库上可以改用估算函数,例如某些引擎的APPROX_COUNT_DISTINCT,精度略降但速度提升明显。
另外,DISTINCT与GROUP BY有一定重叠。比如SELECT DISTINCT city FROM users和SELECT city FROM users GROUP BY city结果一样,但语义上DISTINCT重在去重展示,GROUP BY重在做聚合基础。在需要同时去重和保留明细时,窗口函数ROW_NUMBER()配合PARTITION BY会比DISTINCT加自连接更清晰。实际项目中我见到过用DISTINCT去处理JOIN造成的重复行,最后查询慢得离谱,根源还是连接条件产生了大量重复匹配,这种问题应该回头修JOIN,而不是用DISTINCT硬扛。
5. DQL性能优化与慢查询排查实录
查询写得对不对,决定结果正确性;查询写得好不好,决定线上能不能扛住。数据量一上来,DQL的差劲写法会立刻暴露,接口超时、数据库CPU飙升都跟着来。这一节我把慢查询排查常用的思路整理出来,希望能帮你少走弯路。
5.1 先学会看执行计划
遇到慢查询,第一件事不是猜,而是用EXPLAIN看执行计划。执行计划会告诉你这个SQL会走索引还是全表扫描,估算扫描多少行,连接顺序如何。常用关注点有几个:type列如果是ALL,代表全表扫描;如果是ref或const,说明走上了索引;rows列可以预估扫描行数,这个数越大,查询通常越慢。还有一个Extra列,里面若出现Using temporary或Using filesort,往往意味着分组排序没法利用索引,需要额外内存或磁盘操作。
看执行计划的能力比背优化口诀更重要,因为表结构和数据分布不同,同样一条查询在不同环境下的执行计划完全不同。我建议把EXPLAIN看成DQL的"体检报告",每次写完重要查询,都花一分钟确认没有全表扫描。如果一条查询在测试环境秒回,线上却要几秒,先别急着调SQL,很可能线上数据量大,执行计划已经变了。这时重新EXPLAIN一次,往往一眼就能看到问题所在。
5.2 索引优化写法与常见失效场景
索引是DQL提速的核心,但索引不是建了就能用。最基础的是最左前缀原则:在联合索引(a, b, c)上,查询条件里只有b、没有a时,这个联合索引很难被有效利用。然后是索引列上做函数运算或隐式类型转换,例如WHERE DATE(create_time) = '2024-01-01'会让create_time的索引失效,正确写法是改成范围查询WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。还有LIKE前导通配符,WHERE name LIKE '%张三'无法使用普通索引,但'张三%'可以。
另外OR条件也可能导致索引失效,尤其是OR两边有一边没有索引时。实际调优时,先看执行计划确认是否真的没用索引,再考虑改写为UNION或调整条件分支。SELECT *的另一个隐藏问题是,如果表宽且字段多,查询需要回表取出所有列;如果只SELECT需要的列,查询可能只要走覆盖索引,效率天差地别。我的习惯是:非必要不SELECT *,线上查询一律显式列出需要的列。
5.3 慢查询疑难杂症速查表
整理了排查中最高频的几个问题,方便直接对号入座。
| 问题现象 | 常见原因 | 处理思路 |
|---|---|---|
| 查询走了全表扫描 | 索引缺失或条件写法导致索引失效 | EXPLAIN确认type,按最左前缀和避免函数运算原则改写 |
| 分页很深很慢,如LIMIT 100000, 20 | 大偏移量需要扫描大量行 | 改成基于索引游标的分页,或用延迟关联先取主键再回表 |
| JOIN结果行数暴增 | 关联字段有重复值或缺少去重约束 | 检查连接字段唯一性,确认业务粒度,必要时先聚合子查询再JOIN |
| ORDER BY排序超慢 | 排序字段没走索引或涉及大字段 | 让排序使用索引,或减少参与排序列的数据量 |
| 同样的查询时快时慢 | 缓存失效、统计信息滞后或系统负载变化 | 刷新统计信息,看执行计划是否变化,评估是否合理 |
| 去重统计极慢 | COUNT(DISTINCT)计算代价高 | 业务允许时用估算函数或定期物化结果 |
这张表里提到的"延迟关联分页"我重点说说。普通分页LIMIT 100000, 20,数据库要先把前10万行都找出来再丢弃,性能越来越差。改写思路是先只查主键,LIMIT放到主键上,再通过主键关联回明细。这种写法在很多数据库上能让深分页从秒级降到毫秒级,我一直推荐把它作为分页优化的首选方案。
还有一个容易忽略的优化方向是查询语句本身的简洁性。很多人写DQL喜欢一长串嵌套,能查出结果就行,完全不考虑可维护性。我的观点是:花半小时把一条复杂的查询拆成两到三个清晰的中间步骤,看着多了一步,实际上能帮助执行优化器选择更好的路径,也省下了后人读代码理解语义的时间。DQL是给数据库的指令,更是给人看的逻辑表达。
6. 进阶技巧:窗口函数和公共表表达式让DQL更优雅
前面讲的都是传统写法,足够应对大部分业务。但有两个进阶特性值得专门提一下,因为它们能解决很多"能用但很别扭"的查询场景,也能让SQL可读性上一个台阶。
6.1 窗口函数:分组内排序和排名的实践经验
窗口函数在处理"每组内排名"、"取每组前N条"这类需求时特别顺。比如"按每个商品分类,找出销售额最高的商品",如果用传统子查询或者JOIN去写,代码会绕得厉害,而用ROW_NUMBER()加PARTITION BY就非常清晰。
SELECT category, product, amount, rank_no FROM ( SELECT category, product, amount, ROW_NUMBER() OVER (PARTITION BY category ORDER BY amount DESC) AS rank_no FROM sales ) t WHERE rank_no = 1;这里PARTITION BY相当于给每个商品分类划了一个独立的小窗口,窗口内部按销售额倒序编号,最后只取每个窗口的第一名。与传统自连接相比,窗口函数避免了重复扫描和笛卡尔积,数据量大时性能更稳定。除了ROW_NUMBER,RANK和DENSE_RANK也常见,区别在于并列名次的有无空位,例如两个并列第二,RANK会让下一个名次变成第四,DENSE_RANK则还是第三。业务报表中排名连续时用DENSE_RANK,允许跳号时用RANK。
使用窗口函数时要注意,它是在FROM、WHERE、GROUP BY、HAVING处理完之后才执行的,所以不能直接在WHERE里引用窗口别名。如果要对排名结果过滤,必须像上面的例子一样包一层子查询。这个逻辑与2.2节讲的执行顺序是一脉相承的,理解了执行顺序,窗口函数的使用范围瞬间就清晰了。
6.2 公共表表达式CTE:把嵌套查询写出层次感
公共表表达式,也就是WITH子句,可以把一段复杂的查询结果先定义成一个临时视图,后面再引用它。它最大的价值不是性能,而是可读性。一个三层嵌套的子查询,很多人在还原括号对应关系时都会头皮发麻,如果用CTE拆开,每一步是一个简单查询,问题就清楚很多。
WITH monthly_sales AS ( SELECT category, DATE_FORMAT(sale_date, '%Y-%m') AS month, SUM(amount) AS total FROM sales WHERE sale_date >= '2024-01-01' GROUP BY category, DATE_FORMAT(sale_date, '%Y-%m') ) SELECT category, AVG(total) AS avg_monthly_sales FROM monthly_sales GROUP BY category;这段查询统计每个分类的月平均销售额。先把每月销售额算出来放进monthly_sales,再从这里面按分类聚合。如果不用CTE,这个逻辑就得靠两段子查询拼出来,阅读成本高,修改时也容易误伤。CTE在排查问题时的优势更明显,我可以先单独执行WITH内的SELECT验证月度汇总对不对,再整体跑,分步调试效率很高。
另外CTE还支持递归,可以用来处理树形结构,例如组织架构、商品分类层级等场景。递归CTE写起来有点绕,但胜在不用写存储过程,一条SQL就能输出层级结果。考虑到篇幅,这里不过多展开,但如果你经常处理层级类需求,递归CTE值得专门查文档练一遍。反正我的经验是:任何一段嵌套超过两层的SQL,先试着用CTE重写一遍,代码质量和后续维护成本都会明显改善。
最后分享一个我自己的小习惯:写完每条稍复杂的DQL,先跑一次EXPLAIN,再跑一个带COUNT(*)的版本确认数据行数,最后才把结果交出去。这三个动作加起来不到两分钟,但能在SQL上线前拦住绝大多数性能隐患和逻辑错误。实战里我吃过亏,一条看着没问题的查询,上了线上遇到大数据量原形毕露,值班电话响个不停,那经历实在不好受。希望这篇关于DQL的知识总结,能帮你少踩几次类似的坑。