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

资讯详情

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

SQL HAVING子句详解:分组后数据过滤的正确姿势

SQL HAVING子句详解:分组后数据过滤的正确姿势 有人统计过在一个业务系统里带 GROUP BY 的查询占了全部复杂 SQL 的六成以上而其中约三分之一需要用到分组后的结果过滤。很多新手写到这里就卡住了——WHERE 子句只能在分组前筛数据分组完再想筛怎么办还有的人硬生生把过滤条件写进 SELECT靠起别名加子查询绕一大圈结果把自己绕晕了。这一讲要聊的 HAVING 子句就是为了解决“分组之后怎么筛数据”这件事而生的。如果你是刚开始学 SQL 的初学者或者写过一段时间 SQL 但始终对 GROUP BY 和 HAVING 的配合似懂非懂这篇内容可以帮你把这块拼图彻底补上。看完之后你能明白 HAVING 的定位、和 WHERE 的职责边界、实际业务里怎么用以及最常见的几个报错和性能坑。1. 为什么要单独设计一个 HAVING 子句1.1 WHERE 管不到分组之后的事先从一个最普通的业务场景说起。假设你手上有一张电商订单表里面记录了每一笔订单的金额、客户编号、下单时间。你现在想找出“下单次数超过 5 次的客户”。这个需求一听就是先按客户分组数一下每个组里有多少条订单然后只保留数量大于 5 的组。写成 SQL 很容易想到下面这种写法SELECT customer_id, COUNT(*) FROM orders WHERE COUNT(*) 5 GROUP BY customer_id;但这条语句一执行就会报错报错信息大概是“Invalid use of group function”或者“Cannot use an aggregate function in WHERE clause”。原因很简单WHERE 子句的过滤发生在分组之前它针对的是原始行数据。可是 COUNT() 这个聚合结果必须等分组完成之后才能算出来WHERE 在分组之前执行根本拿不到 COUNT() 的值。这就形成了一个真空地带分组之前的过滤交给 WHERE分组之后的过滤没人管。HAVING 就是专门来填这个空位的。1.2 HAVING 的定位是“组级过滤”说白了WHERE 是行级过滤器HAVING 是组级过滤器。这两者不是替代关系而是各管一段。用生活中的例子来理解你在食堂窗口打饭先要排队FROM排到你之后大师傅问你要什么菜WHERE 过滤掉你不吃的打好饭之后你端着餐盘去结算收银员扫码GROUP BY 把相同菜品归到一起最后收银系统只显示金额超过一定数的就餐记录HAVING 过滤组。食材选择发生在打饭之前金额结算发生在打饭之后两者干预的阶段完全不同。记住这个类比之后你再看下面这句执行顺序口诀就会非常清晰FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMITWHERE 在 GROUP BY 之前运行HAVING 在 GROUP BY 之后运行。一个操作原始数据行一个操作分组后的数据组。2. HAVING 的核心语法与底层执行逻辑2.1 标准语法结构HAVING 的完整语法位置如下SELECT 列1, 聚合函数(列2) FROM 表名 [WHERE 过滤条件] GROUP BY 列1 HAVING 组级过滤条件 ORDER BY 排序字段;注意 HAVING 的位置紧跟在 GROUP BY 之后在 ORDER BY 之前。因为必须先分组才能对组进行过滤过滤完之后剩下的组才能参与排序。举一个可以直接执行的小例子。假设有张学生成绩表score字段是student_id、subject、grade你想找出“平均分在 80 分以上的学生”SELECT student_id, AVG(grade) AS avg_grade FROM score GROUP BY student_id HAVING AVG(grade) 80;这条查询逻辑上分成三段GROUP BY student_id 先把同一个学生的所有成绩汇聚成一组AVG(grade) 计算出每人平均分HAVING 再把平均分大于 80 的组保留下来。2.2 执行顺序决定了哪些条件可以放 HAVING很多初学者不理解执行顺序对写 SQL 的影响。这里展开说一下数据库拿到你的 SQL 之后不是从上往下一行一行读的而是按照固定的逻辑顺序执行。第一步扫描表拿到全部行FROM第二步用 WHERE 把不满足条件的行丢掉第三步按 GROUP BY 的字段把剩余的行分组第四步用 HAVING 对组过滤第五步才轮到 SELECT 计算要输出的列最后才做排序和分页。因为这个顺序SELECT 里定义的别名在 WHERE、GROUP BY、HAVING 这三步都还“不存在”。MySQL 比较特殊允许 HAVING 使用 SELECT 里的别名但标准 SQL 和很多其他数据库不允许。后面我会专门给出一张兼容性对照表。这套顺序也决定了你写一条完整的分组查询时心里要有一个“先筛行 → 再分组 → 再筛组”的流程。哪些条件放 WHERE、哪些条件放 HAVING判断标准就是这个条件能不能在分组之前确定。能确定就放 WHERE不能确定就只能放 HAVING。2.3 WHERE 与 HAVING 的对比总结为了让你一眼看清两者差异我整理了一个对比表对比维度WHEREHAVING执行时机GROUP BY 之前GROUP BY 之后作用对象原始行数据分组后的数据组能否使用聚合函数不能能且通常配合聚合函数使用能否使用普通列条件能直接用表中列名能但前提是该列在 GROUP BY 中或聚合函数中性能影响先过滤再分组分组数据量小通常更快先分组再过滤数据量大时可能较慢没有 GROUP BY 时独立使用过滤行等同于过滤整个结果集但需要聚合上下文所以如果你看到一条 SQL 里同时有 WHERE 和 HAVING正常的理解是WHERE 先把不需要的原始行移除减少分组负担HAVING 再对必要的组做后置筛选。比如统计“2023 年下单次数超过 5 次的客户”时间条件放 WHERE下单次数条件放 HAVING各司其职SELECT customer_id, COUNT(*) FROM orders WHERE order_year 2023 GROUP BY customer_id HAVING COUNT(*) 5;3. HAVING 的实战场景与完整操作过程3.1 场景一电商订单数据分析写 SQL 必须要落到场景里不然学完就忘。我准备了一个比较完整的实操案例全部语句都经过常见数据库的语法验证你可以在自己本地的 MySQL 或 PostgreSQL 里直接跑。先建一张订单明细表CREATE TABLE order_items ( id INT PRIMARY KEY, customer_id INT, product_category VARCHAR(50), amount DECIMAL(10, 2), order_date DATE ); INSERT INTO order_items VALUES (1, 101, 手机数码, 5200.00, 2024-01-05), (2, 102, 家用电器, 3200.00, 2024-01-06), (3, 101, 服饰鞋包, 800.00, 2024-01-08), (4, 103, 手机数码, 6800.00, 2024-01-10), (5, 102, 美妆个护, 1500.00, 2024-01-12), (6, 104, 手机数码, 4500.00, 2024-01-15), (7, 101, 美妆个护, 600.00, 2024-02-01), (8, 103, 家用电器, 2200.00, 2024-02-03), (9, 105, 服饰鞋包, 1200.00, 2024-02-05), (10, 102, 手机数码, 7800.00, 2024-02-08);第一个需求找出累计消费金额超过 5000 的客户。这类需求是最典型的 HAVING 场景因为“累计消费金额”是分组计算完的结果。SQL 写法SELECT customer_id, SUM(amount) AS total_spent FROM order_items GROUP BY customer_id HAVING SUM(amount) 5000 ORDER BY total_spent DESC;执行之后你能看到 101、102、103 这三个客户被筛了出来。这个查询最核心的地方在于SUM(amount)本身是个聚合函数它只能在分组后得到所以过滤条件必须放到 HAVING 里。如果你写成WHERE SUM(amount) 5000数据库立刻报错。第二个需求找出在“手机数码”类目下消费次数不少于 2 次的客户。这个需求里“类目”是原始行的属性直接放到 WHERE 过滤消费次数是分组后的统计值放到 HAVING 过滤SELECT customer_id, COUNT(*) AS purchase_count FROM order_items WHERE product_category 手机数码 GROUP BY customer_id HAVING COUNT(*) 2;我故意用了 2而不是 2因为实际业务里你可能会随时调整阈值用大于等于的写法更通用。这个例子能看出来 WHERE 和 HAVING 的分工先用 WHERE 把非手机数码的订单全部排除分组时只对剩余数据进行汇总再用 HAVING 保留下单次数满足要求的客户。第三个需求统计每个客户在“服饰鞋包”和“美妆个护”两个类目的消费总额只保留消费总额超过 1200 的客户并按金额从高到低排序。SELECT customer_id, SUM(CASE WHEN product_category IN (服饰鞋包, 美妆个护) THEN amount ELSE 0 END) AS category_total FROM order_items WHERE product_category IN (服饰鞋包, 美妆个护) GROUP BY customer_id HAVING category_total 1200 ORDER BY category_total DESC;这个例子稍微进阶一点HAVING 的条件用到了 CASE WHEN 构造的聚合表达式同时 MySQL 方言里允许直接引用 SELECT 中的别名category_total。如果你用的是标准 SQL比如 PostgreSQL 或者 SQL Server可以直接在 HAVING 里重复写SUM(CASE WHEN ... END)这样兼容性更好。3.2 场景二用户行为日志统计第二个场景换成用户行为分析。假设有一张用户登录日志表user_login_log包含user_id、login_date、device_type三个字段。需求找出“至少用 3 种不同设备登录过”的用户同时限定只看 2024 年 3 月的日志。SELECT user_id, COUNT(DISTINCT device_type) AS device_cnt FROM user_login_log WHERE login_date BETWEEN 2024-03-01 AND 2024-03-31 GROUP BY user_id HAVING COUNT(DISTINCT device_type) 3;这里有一个非常容易踩的坑COUNT(DISTINCT device_type)不是简单的计数它统计的是去重后的设备种类数。如果你误写成COUNT(device_type)那么一个用户用 iPhone 登录了 100 次也会被算成 100 条记录筛选结果完全错误。新手经常在这里翻车所以单独拿出来说一下。3.3 场景三没写 GROUP BY 的 HAVING有细心的朋友会问HAVING 能不能不跟 GROUP BY 一起用答案是能但这种用法要格外小心。在标准 SQL 里如果 SELECT 中没有 GROUP BY那么整张表会被当成一个大组HAVING 对这个大组进行过滤。比如你想查出“整个订单表中总金额是否超过某个阈值”SELECT SUM(amount) AS total FROM order_items HAVING SUM(amount) 10000;这条 SQL 最终只会返回一行要么是满足条件的合计值要么是空结果集。看起来有点像聚合查询的历史遗留写法实际工作中并不推荐。因为你完全可以直接写WHERE SUM(amount) 10000的子查询形式语义会更清晰。但了解这个特性有助于你排查问题——有时候你接手别人代码看到一条没有 GROUP BY 却有 HAVING 的 SQL第一反应不是“写错了”而是“作者想对整个结果集做过滤”。至于写法是否符合规范那再说。4. HAVING 使用中常见报错与排查技巧4.1 报错一Unknown column 或者 Invalid reference最常见的报错就是Unknown column avg_grade in having clause。出现这个问题的原因是你在 HAVING 里引用了 SELECT 中定义的别名但你的数据库不认可这种写法。我测试下来不同数据库的处理方式差别不小数据库HAVING 能否引用 SELECT 别名MySQL可以甚至支持引用较前位置的别名PostgreSQL不可以需重复写完整表达式SQL Server不可以需重复写完整表达式Oracle不可以需重复写完整表达式SQLite可以为了最大程度保证移植性我的建议是在 HAVING 中不要用别名把聚合函数或者表达式完整重写一遍。比如上面那个平均分例子写成SELECT student_id, AVG(grade) AS avg_grade FROM score GROUP BY student_id HAVING AVG(grade) 80;虽然看上去重复了但这段 SQL 在任何数据库上都能跑而且执行计划不会因为你多写了一遍聚合函数就变差——优化器会识别出这是同一个计算。把可移植性放在第一位这是我在多个数据库之间来回切换后总结出来的经验。4.2 报错二既想多条件过滤又想简化写法有些业务需求要求同时满足多个 group 级条件比如金额大于 5000 且订单数大于 3。这种场景直接用 AND 连接多个条件即可SELECT customer_id FROM order_items GROUP BY customer_id HAVING SUM(amount) 5000 AND COUNT(*) 3;但这里有个挺常见的逻辑误区有人会写成HAVING SUM(amount) 5000 OR COUNT(*) 3觉得“大于 5000 或者订单数大于 3”也能接受。业务上到底用 AND 还是 OR取决于需求描述。我见过不止一次因为 OR 条件把结果集扩大了好几倍导致报表数据异常的问题。写之前先把自己的过滤逻辑用文字说清楚“既要……又要……”就是 AND“只要……或者……”就是 OR。4.3 报错三HAVING 导致查询非常慢HAVING 慢的原因要先搞清楚它是在分组之后才过滤的意味着所有数据都必须先完成分组计算才能开始过滤。如果底层数据量大GROUP BY 这一步就会消耗大量资源。这时候再叠加 HAVING相当于把所有分组的中间结果都算出来了最后才丢弃一部分组。那么优化思路就非常清晰能在 WHERE 中过滤的绝对不要留到 HAVING。举一个对比特别明显的例子。统计 2024 年累计消费超过 5000 的客户第一种写法SELECT customer_id, SUM(amount) AS total FROM order_items GROUP BY customer_id HAVING SUM(amount) 5000 AND order_date 2024-01-01 AND order_date 2025-01-01;这种写法把日期的条件放在了 HAVING 里数据库需要先所有数据分组、计算每个客户的总金额然后才用日期条件去过滤组。更糟的是order_date 不在 GROUP BY 里在某些数据库的严格模式下这根本不能通过。第二种写法才是正确的SELECT customer_id, SUM(amount) AS total FROM order_items WHERE order_date 2024-01-01 AND order_date 2025-01-01 GROUP BY customer_id HAVING SUM(amount) 5000;两种写法在数据量小的时候看不出差别可一旦表里几百万行第一种写法可能跑几十秒第二种写法毫秒级返回。原因就是 WHERE 提前过滤掉了不属于 2024 年的行参与 GROUP BY 的数据量大幅减少。4.4 报错四HAVING 条件里的 NULL 问题聚合函数遇到 NULL 时的表现特别容易出问题。比如你想找出“平均金额高于 3000 的客户”表里有些订单的金额字段是 NULL。AVG(amount)计算时会自动跳过 NULL这是聚合函数的标准行为。但如果你想统计“有订单记录的客户数”写成COUNT(amount)或者COUNT(*)结果就不一样COUNT(amount)不统计 NULLCOUNT(*)会统计所有行。这个区别放在 HAVING 里也会产生微妙的影响。比如你要求“有 3 笔以上订单的客户”如果你用COUNT(amount)而某些订单的 amount 为 NULL这些订单就会被吞掉结果就可能少掉一个客户。这里建议统一使用COUNT(*)来统计行数除非你有明确需求要排除空值。NULL 还有一个坑是SUM(amount) 5000如果某个客户的所有订单金额都是 NULL那 SUM 的结果是 NULLNULL 和 5000 比较既不是 TRUE 也不是 FALSE而是 UNKNOWN所以这行不会被保留。如果你想把这种情况也纳入判断需要用COALESCE(SUM(amount), 0) 5000来做兜底。5. HAVING 与 SQL 执行计划及性能优化5.1 从 EXPLAIN 看 WHERE 与 HAVING 的代价作为一个经常要处理慢 SQL 的人我拿到一条卡顿的分组查询后第一件事就是看它的执行计划不是靠猜。以 MySQL 为例执行EXPLAIN SELECT ...之后重点看type和rows两个字段。rows表示数据库估计要扫描的行数。如果一条 SQL 的 WHERE 能过滤掉大量行那 GROUP BY 阶段处理的行数就会小很多整体耗时明显下降。如果条件全堆在 HAVING执行计划里分组那一步的rows会非常夸张说明数据库被迫把大量数据先聚合成中间组。我建议你把EXPLAIN当成一个习惯动作。每天写分组查询前想一下这张表有多少行WHERE 能过滤掉多少行如果 WHERE 过滤很弱比如过滤完还剩 90% 的行那 GROUP BY 的代价就很大。这时候考虑是不是该调整设计思路——比如在 where 条件字段上建索引、把大查询拆成小查询或者用窗口函数改写。5.2 索引设计对 HAVING 的间接影响很多人以为 HAVING 的执行性能取决于 HAVING 本身其实真正影响它的是前面的 GROUP BY 和 WHERE。GROUP BY 走索引和走临时表聚合性能差距可以达到一个数量级。还是拿订单表举例。如果查询经常需要按customer_id分组并按order_date过滤那建一个联合索引(order_date, customer_id)通常能让数据库高效地扫描并分组。索引设计要遵循最左前缀原则把等值条件字段放前面范围条件字段放后面。但有一点要提醒索引并不总能覆盖所有场景。如果你的过滤条件在 HAVING 中是一个特别复杂的计算表达式比如HAVING SUM(amount) / COUNT(DISTINCT product_id) 100这种表达式无论如何都走不了索引只能在分组计算后硬过滤。这类需求如果频率很高建议改造表结构增加一个统计字段提前把计算结果维护好查询时直接过滤。5.3 千万级数据量下 HAVING 的改写思路真实业务里几千万行的订单表、用户行为表非常常见。这种量级下一个没有优化过的 HAVING 查询足够把数据库拖垮。一个有效的改写思路是“先瘦身再分组”在子查询里先做 WHERE 过滤和一部分聚合然后用 HAVING 做外层过滤。模拟一个案例在几千万行的订单表里找出“2024 年消费金额前 100 的客户”。我会分成两步来写先算每个客户的金额再用窗口函数取前 100WITH customer_spending AS ( SELECT customer_id, SUM(amount) AS total FROM order_items WHERE order_date 2024-01-01 AND order_date 2025-01-01 GROUP BY customer_id HAVING SUM(amount) 100 ) SELECT customer_id, total FROM customer_spending ORDER BY total DESC LIMIT 100;这个改写跟直接写 HAVING 的区别在于先用 WHERE 把年份范围过滤掉减少了参与分组的行数然后用 HAVING 只保留金额超过 100 的组最后排序取前 100。逻辑上跟原始需求一致但每层都在裁剪数据量。还有一个思路是“预聚合表”。如果这类统计需求每天都要跑完全可以每天凌晨跑一个定时任务把当天数据按客户预聚合到一个统计表里白天查询直接查统计表。这种空间换时间的策略在数据仓库里非常常见。HAVING 的坑永远是它太晚才过滤数据所以尽量不要让它扛大头。6. HAVING 的进阶使用与常见流派6.1 流派之争WHERE 过滤还是 HAVING 过滤在写分组 SQL 的时候条件放在哪里在团队里往往能看出一个开发者的功底。初级水平的常见写法是把所有条件堆在 HAVING 里因为这样看起来“紧凑”一条 SQL 搞定所有事不用动脑子区分。有经验的做法是能用 WHERE 的全部放 WHEREHAVING 只放那些“必须分组后才知道”的条件。一个非常典型的分界线是普通字段的条件比如日期、状态、类目永远放 WHERE聚合函数的结果比如计数、求和、平均才放 HAVING。你可以把这个默认为一条铁律大概率不会错。但有没有特殊情况有。比如你手里有一串已经计算好的指标值你希望整表过滤后只保留满足某指标的组。这种情况下如果指标列正好是分组键之外的计算结果比如某个维度表里的人工打分你可以把这个打分字段加到 GROUP BY 里然后放 HAVING 里过滤。但说实话这种需求用 JOIN 或子查询改写会更清晰。6.2 配合窗口函数时的注意点现在的数据库普遍支持窗口函数很多人开始用窗口函数替代部分 GROUP BY HAVING 的场景。窗口函数不会把多行压缩成一行而是保留原始行的同时附加一个计算列。比如你想找出“每个类目下销售额排名前 3 的客户”用 GROUP BY HAVING 很难表达排名而窗口函数ROW_NUMBER()就很合适SELECT customer_id, product_category, total_amount FROM ( SELECT customer_id, product_category, SUM(amount) AS total_amount, ROW_NUMBER() OVER (PARTITION BY product_category ORDER BY SUM(amount) DESC) AS rn FROM order_items GROUP BY customer_id, product_category ) t WHERE rn 3;这个例子里内层 GROUP BY 负责计算每个客户在每个类目的销售额窗口函数负责在每个类目内部排名外层 WHERE 负责取前三。注意到没有HAVING 在这里没有出场。这不是说 HAVING 不重要而是不同工具有各自的擅长领域。窗口函数擅长排名、跨行计算GROUP BY HAVING 擅长对分组结果做布尔过滤。两者是互补关系。6.3 试试不同数据库的 HAVING 方言差异最后我想聊一个实操时很现实的问题你手头可能是 MySQL也可能是 SQL Server、PostgreSQL、Oracle、SQLite不同数据库对 HAVING 的支持细腻度并不一样。我做了一个简要汇总方便你在跨数据库开发时心里有底特性MySQLPostgreSQLSQL ServerOracleHAVING 引用 SELECT 别名支持不支持不支持不支持HAVING 使用未在 GROUP BY 中的普通列宽松可能只警告严格报错严格报错严格报错没有 GROUP BY 时使用 HAVING支持整表为一组支持支持支持HAVING 中的子查询支持支持支持支持对 NULL 的处理标准行为标准行为标准行为标准行为也就是说同一段带别名的 HAVING 查询在 MySQL 上跑得好好的挪到 SQL Server 可能直接报错。所以我在团队里定的一个规矩是写 SQL 之前先确认线上数据库是什么以目标数据库的方言为准如果代码要在多个数据库之间迁移就按标准 SQL 写拒绝花哨语法。这个经验听着不起眼可我在实际项目中吃过亏。曾经把一段在 MySQL 上调试好的带有 HAVING 别名的 SQL 迁移到 PostgreSQL用户一执行就报错排查了半天才缓过神来——问题不在数据、不在索引就是方言差异惹的祸。7. 从入门到熟练HAVING 的练习路线与心得7.1 新手最容易混淆的三个思维误区第一个误区觉得 HAVING 是“WHERE 的升级版”。实际上两者执行阶段不同升级不升级根本不存在。第二个误区以为 HAVING 只能接聚合函数。实际上它能接普通列条件只是普通列条件通常更适合放 WHERE。第三个误区认为一条 SQL 里必须写 HAVING。这些同学的反面是——如果没有分组需求强行在查询末尾加一行HAVING 11这种行为除了让代码变丑没有意义。这三个误区背后都有一个共同点对 SQL 的执行顺序没有形成条件反射。我的建议是初期每写一条带 GROUP BY 的查询都在心里默念一遍“从 WHERE 开始再到分组最后到 HAVING”连续练上一个月大概率能内化成直觉。7.2 我的练习套路先 WHERE 后 HAVING针对想提升 SQL 水平的读者我提供一个非常朴素的练习套路。找一张大一点的业务表比如订单表、流水表、日志表按下面顺序训练第一步用 WHERE GROUP BY 写出按某个维度汇总的查询不加任何组级过滤。先保证分组和聚合正确。第二步在第一步基础上把写好的聚合结果用一个固定的阈值过滤比如SUM(amount) 100强制自己写在 HAVING 里。第三步额外增加两到三个普通字段的过滤条件强制自己判断该放 WHERE 还是 HAVING。第四步用EXPLAIN观察不同写法下的扫描行数变化感受 WHERE 前移带来的性能差异。这套练习不需要多么复杂的表结构电商订单表就够了。关键在于每次写完都要问自己一句过滤条件放在这里逻辑上正确吗性能上高效吗7.3 最后再分享一个小技巧如果拿不准一条 HAVING 过滤条件里的聚合函数写法对不对最快的验证方式是先在 SELECT 后面把对应的聚合值查出来看一眼确认数据符合预期后再把它挪到 HAVING 中作为条件。比如你想过滤SUM(amount) / COUNT(*) 50先执行一下SELECT SUM(amount), COUNT(*), SUM(amount) / COUNT(*) FROM ... GROUP BY ...看一下这些值分布在什么范围再决定阈值设为多少。用真实数据校准条件比凭空猜阈值要可靠得多。这个习惯帮我避免过很多次“逻辑正确但结果异常”的情况——因为你对业务数据的分布有感知后写 SQL 就不再是照搬语法而是理解数据的过程。回到开头那个问题为什么 HAVING 在 SQL 入门里值得单独拿一讲因为它守住了“分组之后”这道闸门。没有它你只能把所有数据拉到应用层自己数、自己筛性能和代码复杂度都会失控。掌握了 HAVING你的 SQL 才真正跨过了“会写 SELECT”和“能做统计”之间的门槛。
返回列表