尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SQL中HAVING与WHERE的区别:从执行顺序到性能优化彻底搞懂

SQL中HAVING与WHERE的区别:从执行顺序到性能优化彻底搞懂 带团队这几年面试里我最喜欢问的一个问题就是HAVING 和 WHERE 到底有什么区别说实话能一次答对的候选人真的不多。最常见的回答是“WHERE 过滤行HAVING 过滤组”这句话本身没毛病但接着问一句“那为什么 WHERE 里不能用聚合函数”很多人就开始支支吾吾了。更普遍的情况是写 SQL 全靠试错法写了不报错就当成写对了至于执行计划里的慢扫描、临时表、文件排序一概不看。这篇文章我就把这个老生常谈但又总讲不透的话题彻底拆开。不绕弯子直接从执行顺序、报错原因、性能差异、数据库兼容性这些角度一层层剥开最后用几道实战题把思路串起来。不管你是刚入门的学生还是天天被慢 SQL 折磨的开发者看完应该都能形成一套自己的判断逻辑以后写 HAVING 和 WHERE 不用再拍脑袋。1. 面试中最常见的答法为什么都不算对1.1 先说结论WHERE 管“行”HAVING 管“组”很多教程喜欢用一句话概括WHERE 过滤行HAVING 过滤组。这句话确实没错但它只回答了“是什么”没回答“为什么”。真正要理解这两个关键字需要把 SQL 的执行顺序刻在脑子里。我用最朴素的语言解释一遍。WHERE 的过滤对象是 FROM/JOIN 阶段从表里读出来的每一行原始数据。此时数据还没有任何分组动作每一行都是独立的个体你能访问的只有这一行自己的列值。所以 WHERE 后面可以写salary 5000、department_id 10、name LIKE 张%这种针对单个字段的判断。HAVING 的过滤对象是 GROUP BY 分组完成之后形成的每一个“组”。一个组里可能包含几十上百行数据但 HAVING 只能看到这个组的整体信息比如组内行数、组内某个字段的总和、平均值、最大值等。所以 HAVING 后面通常跟着聚合函数比如AVG(salary) 8000、COUNT(*) 100。判断规则其实就一条条件里出现聚合函数COUNT、SUM、AVG、MAX、MIN就必须用 HAVING条件里只有普通列就优先写 WHERE两个条件同时存在各写各的位置WHERE 先过滤行HAVING 再过滤组。1.2 从报错开始理解WHERE 为什么不能放聚合函数初学者最常见的报错大概是这种SELECT department_id, COUNT(*) FROM employees WHERE COUNT(*) 5 GROUP BY department_id;数据库会直接甩一个错误Invalid use of group functionMySQL/Misuse of aggregateSQLite/ 类似的提示。很多人看到这个报错就背答案——“WHERE 里不能写聚合函数”但不知道为什么。原因其实很简单执行到 WHERE 的时候COUNT(*) 根本还没被计算。数据库正在一行一行地扫描employees表每一行只有自己的字段值压根不知道“这个部门目前有多少行”。你让它在还没有数完人数之前就拿“人数”当筛选条件它当然做不到。打个比方你不可能在点完名之前就知道这个班今天来了多少人更不可能用“班级人数超过 50”这个条件来决定要不要把一个学生拦在教室门外。逻辑顺序本身就是矛盾的。2. 执行顺序是理解这两个关键字的钥匙2.1 SQL 的逻辑处理顺序表很多人写 SQL 只看结果对不对不关心数据库是怎么一步步算出来的。但 HAVING 和 WHERE 的区别本质上就是执行顺序的区别。一条最普通的查询逻辑上的执行顺序是这样的FROM / JOIN / ON确定数据源把需要的表关联起来WHERE对关联后的每一行做过滤剔除不满足条件的行GROUP BY按指定列对过滤后的行进行分组HAVING对分组后的组做过滤剔除不满足条件的组SELECT计算要返回的列包括聚合函数的结果ORDER BY对最终结果排序LIMIT / OFFSET分页截取这张顺序表的含义非常丰富。你现在再看“WHERE 为什么不能用聚合函数”“WHERE 为什么不能用 SELECT 里的别名”“ORDER BY 为什么可以用别名”这些问题全部都能自洽地解释出来。WHERE 在 GROUP BY 之前执行所以当 WHERE 执行时数据还没分组聚合函数自然无处安放SELECT 在 HAVING 之后执行所以 SELECT 里刚算出来的别名在 WHERE 阶段还不存在ORDER BY 在 SELECT 之后执行所以 ORDER BY 能使用别名。2.2 一个生活化类比先筛简历再看团队成绩如果觉得执行顺序太抽象我再用一个场景类比。假设公司要评选“年度优秀团队”流程分成两步。第一步是筛简历先把实习期未转正的员工、已经离职的员工从名单里拿掉这些人不参与团队评选。这一步就好比 WHERE它针对的是“个体”把不符合条件的行提前剔除。第二步是把剩下的员工按部门分组算出每个部门的平均绩效、总业绩然后只看“团队”这个整体平均绩效低于 80 的部门直接淘汰。这一步就好比 HAVING它针对的是“组”是在分组完成之后才进行的判断。注意这两个动作的顺序不能颠倒。你不可能在还没有给员工分组之前就用“部门平均绩效”去过滤某个员工因为这时的“部门平均绩效”根本不存在。写 SQL 也是同样的道理。3. 实战对照同一份需求两种写法的差别3.1 经典组合WHERE GROUP BY HAVING 一起出现理解完理论下面看一个真实场景。假设有一张员工表employees(id, name, department_id, salary)需求是统计每个部门的平均薪资但有两个前提条件——只统计薪资大于 5000 的员工只返回平均薪资大于 8000 的部门。正确的 SQL 长这样SELECT department_id, AVG(salary) AS avg_sal FROM employees WHERE salary 5000 GROUP BY department_id HAVING AVG(salary) 8000;执行过程我拆开讲。第一步WHERE 把薪资低于等于 5000 的行过滤掉。这一步的意义是这些低薪员工根本不参与分组和平均计算。如果忘了写这个 WHERE那部门平均薪资会被低薪员工拉低统计结果就完全不对了。第二步GROUP BY 把过滤后的员工按部门分组。第三步HAVING 对每个部门算出平均薪资然后只保留 avg_sal 大于 8000 的部门。这三个条件的位置不能乱换。比如有人会把第二个条件也写进 HAVINGSELECT department_id, AVG(salary) AS avg_sal FROM employees GROUP BY department_id HAVING AVG(salary) 8000 AND salary 5000;这种写法在 MySQL 的某些宽松模式下不报错但语义已经变了。HAVING 里的salary 5000到底指组里哪一行的 salary数据库只能取一个“组内的随机值”来判断结果完全不可控。在标准 SQL 中这种写法直接报错。所以记住普通列的过滤条件老老实实放 WHERE。3.2 不常见的 HAVING 用法不带 GROUP BY 时它代表什么还有一种情况容易让人懵SQL 里没有 GROUP BY却出现了 HAVING。比如SELECT COUNT(*) AS cnt FROM orders WHERE status PAID HAVING COUNT(*) 1000;这合法吗合法。这里的逻辑是当没有 GROUP BY 时所有过滤后的行被看作一个“大组”HAVING 对这个唯一的组做判断。上面的 SQL 语义就是“已支付订单数是否大于 1000”。实际工作中这种写法不算多因为同样的判断用EXISTS或子查询可能更直白。但理解它有助于建立“组”的概念。你也可以把 GROUP BY 理解成一个隐式的“全表分组”——不写 GROUP BY就是把所有行放进同一个组SELECT 里的聚合函数是对全表计算的。3.3 统计去重数量COUNT(DISTINCT) 必须放到 HAVING 的场景聚合函数不止 SUM、AVG、COUNTCOUNT(DISTINCT 列)也是聚合计算的一种。举个例子订单表orders(order_id, customer_id, product_id)想找出“购买过至少 3 种不同商品”的客户SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(DISTINCT product_id) 3;这里的COUNT(DISTINCT product_id) 3是对每个客户的订单分组后统计去重商品数再过滤只能在 HAVING 里实现。同样想找“取消订单超过 5 次”的客户可以配合 CASE WHENSELECT customer_id FROM orders GROUP BY customer_id HAVING SUM(CASE WHEN order_status CANCELLED THEN 1 ELSE 0 END) 5;这类“先分组再按统计结果筛选”的需求是 HAVING 的主场。判断标准很简单你的筛选条件是不是对一组数据计算后的结果是就用 HAVING。4. 性能与慢 SQL为什么优先用 WHERE4.1 反例把本该在 WHERE 的普通条件塞进 HAVING我见过不少同事写 SQL喜欢把所有过滤条件都堆在 HAVING 里理由是“反正 HAVING 也能过滤”。从结果上看某些情况下确实能过滤对但从性能上看代价可能很大。看一个反例SELECT department_id, COUNT(*) FROM employees GROUP BY department_id HAVING department_id 10;这段 SQL 的语义是“统计除部门 10 外每个部门的员工数”。但department_id 10明明是普通列条件放在 HAVING 里意味着数据库先把所有员工都分组、计数然后再把部门 10 的组扔掉。也就是说部门 10 的那些行白白参与了分组和聚合运算。正确的写法应该把条件下推到 WHERESELECT department_id, COUNT(*) FROM employees WHERE department_id 10 GROUP BY department_id;这样数据库可以用索引直接跳过部门 10 的行进入分组的行数大幅减少IO 和 CPU 消耗都会下降。当然现代优化器有时候会把 HAVING 里的简单条件自动下推到 WHERE 阶段所以你未必能看到明显的性能变化。但写 SQL 不能指望优化器帮你兜底。条件本身就是行级条件就该放在行级过滤的阶段语义清晰也利于同事阅读。4.2 用 EXPLAIN 核对慢 SQL 的排查思路如果你接手了一条慢 SQL怀疑是 HAVING 用错了位置我的排查步骤一般是这样。先看这条 SQL 的 HAVING 后面有没有普通列条件比如HAVING city 北京、HAVING status 1。如果有立刻搬到 WHERE 试试。再看 EXPLAIN 输出。以一条慢查询为例SELECT city, COUNT(*) FROM user_activity GROUP BY city HAVING city 北京;EXPLAIN 结果常见的情况是type为 ALL全表扫描rows非常大说明大量行进入了分组Extra里出现Using temporary; Using filesort说明分组过程产生了临时表和文件排序。改造后SELECT city, COUNT(*) FROM user_activity WHERE city 北京 GROUP BY city;如果city有索引EXPLAIN 里的type可能变成range或refrows明显变小Extra里的Using temporary大概率会消失。排查思路可以总结成一句话凡是能用 WHERE 提前过滤掉的行绝对不要让它在 GROUP BY 阶段多待一毫秒。HAVING 是分组后的最后一道闸门能少放东西进去就少放。4.3 索引组合优化让进入分组的数据越少越好聊到性能索引是绕不开的话题。HAVING 里的聚合条件比如AVG(salary) 8000没法走索引因为它是对计算结果做比较。但你可以通过优化 WHERE 和 GROUP BY 来减少进入聚合的数据量让聚合本身的压力变小。假设常见查询长这样按部门统计平均薪资但只统计薪资大于 5000 的员工且部门 id 有过滤条件。SELECT department_id, AVG(salary) FROM employees WHERE department_id 10 AND salary 5000 GROUP BY department_id;这种情况下建一个(department_id, salary)的联合索引比较合适。WHERE 里department_id 10可以精确定位到部门salary 5000在索引里继续过滤GROUP BY 的 department_id 也在索引前缀里排序/分组成本会低很多。需要提醒的是索引不是越多越好。加索引之前先看慢查询日志确认哪个查询是真的频繁且慢再针对性地建索引。分组字段基数很小比如性别只有男女时索引的帮助也非常有限。5. 别名、窗口函数和各种“数据库差异”的坑5.1 为什么 WHERE 里不能用别名HAVING 里却时灵时不灵先看一条报错 SQLSELECT salary * 12 AS annual_salary FROM employees WHERE annual_salary 100000;数据库会报“字段不存在”之类的错。原因在前面已经说过WHERE 在 SELECT 之前执行annual_salary是 SELECT 阶段才生成的别名WHERE 阶段根本不知道它。正确写法是SELECT salary * 12 AS annual_salary FROM employees WHERE salary * 12 100000;或者用子查询包一层SELECT * FROM ( SELECT salary * 12 AS annual_salary FROM employees ) t WHERE annual_salary 100000;那 HAVING 呢在 MySQL 里你可以这样写SELECT department_id, AVG(salary) AS avg_sal FROM employees GROUP BY department_id HAVING avg_sal 8000;MySQL 允许 HAVING 引用 SELECT 里的别名这是它对标准 SQL 的一个扩展。但在 SQL Server、Oracle、PostgreSQL 里这个写法不一定能跑通即使能跑通也不建议依赖这个行为。我的建议是跨数据库写 SQL 时HAVING 里直接写完整的聚合表达式别贪图省事写别名。写成HAVING AVG(salary) 8000在任何数据库里都是安全的。5.2 窗口函数和 WHERE 的冲突一个执行顺序问题窗口函数是另一个高频踩坑点。很多人写“分组内排名取第一”的需求时会下意识写成SELECT * FROM employees WHERE ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) 1;这条 SQL 一定报错。原因和聚合函数一样窗口函数是在 SELECT 阶段计算的WHERE 阶段还没有这个值。正确做法是子查询或 CTE 包一层WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees ) SELECT * FROM ranked WHERE rn 1;你会发现ROW_NUMBER()和COUNT(*)这类聚合函数虽然用途不同但它们的共同点都是“对一组数据做计算”计算结果只能用于 HAVING 或外层查询不能用在 WHERE 阶段直接过滤。5.3 主流数据库行为对照表为了让你在不熟悉的数据库里少踩坑我把几个常见行为整理成了一张表。注意这是基于我实际使用的经验总结具体版本可能有细微差异生产环境务必自己验证。行为MySQLPostgreSQLSQL ServerOracleWHERE 中使用聚合函数报错报错报错报错WHERE 中使用 SELECT 列别名报错报错报错报错HAVING 中使用 SELECT 列别名允许扩展不支持不支持不支持HAVING 使用非分组普通列受 ONLY_FULL_GROUP_BY 控制报错报错报错ORDER BY 中使用 SELECT 列别名允许允许允许允许这里特别提醒一下 ONLY_FULL_GROUP_BY。MySQL 5.7.5 之后默认开启了这个模式开启后SELECT 和 HAVING 里出现“不在 GROUP BY 中、也不是聚合函数的列”会直接报错。很多老项目从 5.6 升级到 5.7 后莫名报错多半就是这个原因。6. 用 4 道实战题巩固从入门到绕坑6.1 平均分大于 90 且至少参加 3 门考试的学生假设表结构CREATE TABLE student_score ( student_id INT, course_id INT, score DECIMAL(5, 2) );需求找出平均分大于 90、且至少参加了 3 门不同考试的学生。SELECT student_id FROM student_score GROUP BY student_id HAVING AVG(score) 90 AND COUNT(DISTINCT course_id) 3;两个条件都是聚合后的结果必须放 HAVING。这里有个细节COUNT(DISTINCT course_id)和COUNT(course_id)含义不同。如果同一门课考了多次COUNT(course_id)会把多次考试都算进去而需求里说的是“3 门不同考试”所以必须加 DISTINCT。6.2 2024 年累计下单金额超过 1 万的客户表结构CREATE TABLE orders ( order_id INT, customer_id INT, order_amount DECIMAL(10, 2), order_date DATE );需求统计 2024 年累计下单金额超过 10000 的客户按累计金额倒序。SELECT customer_id, SUM(order_amount) AS total_amount FROM orders WHERE order_date BETWEEN 2024-01-01 AND 2024-12-31 GROUP BY customer_id HAVING SUM(order_amount) 10000 ORDER BY total_amount DESC;注意顺序WHERE 先锁定 2024 年的订单避免把其他年份的数据拉进分组GROUP BY 按客户分组HAVING 筛出累计金额超标的客户ORDER BY 最后用别名排序。如果一上来就用 HAVING 过滤订单日期那整个逻辑就乱了。6.3 没有任何订单的客户WHERE IS NULL vs HAVING COUNT 0表结构CREATE TABLE customers ( customer_id INT PRIMARY KEY, customer_name VARCHAR(50) ); CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT );需求找出没有任何订单的客户。写法 A推荐SELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id WHERE o.order_id IS NULL;写法 BSELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id GROUP BY c.customer_id, c.customer_name HAVING COUNT(o.order_id) 0;这里有一个隐藏的坑COUNT(o.order_id)只会统计非 NULL 值。没有订单的客户LEFT JOIN 后o.order_id是 NULL所以计数为 0。但如果写成COUNT(*)结果永远是 1因为 LEFT JOIN 已经产生了一行COUNT(*)把这行也算进去了。这个细节写错的人非常多。从性能上看写法 A 通常更好因为它不需要全量分组。写法 B 也有存在的意义——当过滤条件还需要包含其他聚合逻辑时HAVING 就不可避免了。6.4 连续 3 天有销售记录的门店HAVING 窗口函数最后来一道稍微进阶的题。表结构CREATE TABLE store_sales ( store_id INT, sale_date DATE, amount DECIMAL(10, 2) );需求找出至少连续 3 天都有销售记录的门店。这个问题的经典解法是“日期减行号”技巧在 MySQL 8.0 里可以这样写WITH daily AS ( SELECT DISTINCT store_id, sale_date FROM store_sales ), seq AS ( SELECT store_id, sale_date, DATE_SUB(sale_date, INTERVAL ROW_NUMBER() OVER ( PARTITION BY store_id ORDER BY sale_date ) DAY) AS grp FROM daily ) SELECT store_id FROM seq GROUP BY store_id, grp HAVING COUNT(*) 3;逻辑不复杂先把每天有销售的门店去重再用窗口函数按门店给日期排序编号然后用“日期减编号”得到一个分组标记。日期连续的记录减出来的结果一定是同一个日期日期一旦中断分组标记就变了。最后用GROUP BY store_id, grp加上HAVING COUNT(*) 3就能筛出连续 3 天有记录的门店。这道题同时用到了窗口函数、CTE、GROUP BY 和 HAVING非常能检验你对“分组后过滤”这个概念的理解程度。7. 我的最终速查习惯讲了这么多最后分享我实际写 SQL 时心里默念的几条规则。第一条件里有聚合函数位置就在 HAVING条件里只有普通列位置就在 WHERE。第二能放在 WHERE 里的条件绝不放 HAVING。第三HAVING 里不要依赖 SELECT 别名跨数据库时直接写完整表达式。第四写完 SQL 用 EXPLAIN 扫一眼重点看 type、rows、Extra 三列有 Using temporary 就多想想能不能把条件提前。我带团队时只让大家记住一句话先筛行再分组先分组再筛组。WHERE 是对原始行的第一道过滤HAVING 是对分组结果的最后一道把关。顺序对了语义就对了语义对了性能多半也不会差到哪去。如果你以前是靠“报错就换 HAVING”来写 SQL 的建议找个时间把本文里的练习题亲手敲一遍尤其是 6.3 和 6.4。把这几道题吃透以后遇到 WHERE 和 HAVING 的问题你就再也不用猜了。
返回列表