DQL详解:从SELECT基础到SQL查询性能优化实战

📅 发布时间:2026/10/19 14:37:11
DQL详解:从SELECT基础到SQL查询性能优化实战
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、DROPDML是数据操作语言负责增删改例如INSERT、UPDATE、DELETEDCL是数据控制语言负责权限和事务控制例如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_amountSQL标准要求必须写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()统计的是表记录数包括NULLCOUNT(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结果行数暴增关联字段有重复值或缺少去重约束检查连接字段唯一性确认业务粒度必要时先聚合子查询再JOINORDER 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_NUMBERRANK和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的知识总结能帮你少踩几次类似的坑。