带团队这几年,面试里我最喜欢问的一个问题就是: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 function(MySQL)/Misuse of aggregate(SQLite)/ 类似的提示。很多人看到这个报错就背答案——“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 的部门。
这三个条件的位置不能乱换。比如有人会把第二个条件也写进 HAVING:
SELECT 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、COUNT,COUNT(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 WHEN:
SELECT 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:为什么优先用 WHERE
4.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 的那些行白白参与了分组和聚合运算。
正确的写法应该把条件下推到 WHERE:
SELECT 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或ref,rows明显变小,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 里却时灵时不灵
先看一条报错 SQL:
SELECT 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 主流数据库行为对照表
为了让你在不熟悉的数据库里少踩坑,我把几个常见行为整理成了一张表。注意:这是基于我实际使用的经验总结,具体版本可能有细微差异,生产环境务必自己验证。
| 行为 | MySQL | PostgreSQL | SQL Server | Oracle |
|---|---|---|---|---|
| WHERE 中使用聚合函数 | 报错 | 报错 | 报错 | 报错 |
| 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;写法 B:
SELECT 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 的问题,你就再也不用猜了。