ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQL核心DQL深入解析:从SELECT执行逻辑到窗口函数实战

SQL核心DQL深入解析:从SELECT执行逻辑到窗口函数实战 哪有开发人员不用写查询的说白了咱们平时嘴上说的“写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 按功能大致分四类这里用一张表给你理清楚分类全称代表关键字作用DDLData Definition LanguageCREATE、ALTER、DROP定义和管理数据库对象DMLData Manipulation LanguageINSERT、UPDATE、DELETE操作表中数据DQLData Query LanguageSELECT查询和检索数据DCLData Control LanguageGRANT、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 后面默认 ASCDESC 是降序。LIMIT 5 在 MySQL 和 PostgreSQL 里都支持SQL Server 要用TOP 5Oracle 老版本要用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 时想让左表全保留过滤右表的条件就放 ONwant 对连接后的结果做整体过滤就放 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_idSELECT 里就只能写 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 LASTMySQL 得用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, 80ROW_NUMBER()无并列连续编号1, 2, 3RANK()有并列但跳号1, 1, 3DENSE_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你传了数字触发了隐式转换保持类型一致或显式 CASTLEFT JOIN 后行数变多了右表有多条匹配记录导致左表行被重复先确认右表是否存在重复关联字段必要时子查询去重GROUP BY 报错“不是聚合函数”SELECT 里写了非聚合列且没在 GROUP BY 里要么加进 GROUP BY要么删掉该列查询结果和业务预期对不上没注意 NULL 参与运算SUM/AVG 自动跳过 NULL用 COALESCE 明确 NULL 的处理策略分页翻到后面越来越慢LIMIT 偏移量过大改用游标分页或用子查询先取 ID6.2 隐式转换与字符集问题隐式转换是那种“不出错但你总觉得莫名其妙”的问题。比如表里的 phone 字段是 VARCHAR你用WHERE phone 13800138000去查数字会被转成字符串再比较索引可能就用不上了全表扫描。更糟的是字段里如果有非数字字符部分数据库会直接报错。字符集问题更隐蔽查询条件里传中文查不出结果很可能是表字段的字符集和连接字符串的字符集不一致。特别是历史老库有的表是 utf8 有的是 gbkJOIN 的时候也可能因为排序规则不一致报错。遇到中文乱码或者中文查不出第一反应不是业务逻辑错了而是检查字符集。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 苦苦冥想高效得多。这个办法我用到现在每次查数据对不上预期都是这么解决的。
返回列表