资讯详情

资讯详情

SQL GROUP BY 分组聚合实战:从基本语法到性能优化与常见坑

老实说干了这么多年数据处理SQL里让我觉得“一朝学会受用终身”的功能GROUP BY绝对排前三。它解决的那个核心问题特别朴素一堆杂乱明细数据怎么按某个维度聚成几组再算出每组的统计值。你要做日报、销量榜、客户分层、留存分析背后全是这三个关键词“分组、聚合、统计”。这篇文章我想把GROUP BY一次讲透。不光是语法还包括它背后的执行逻辑、和HAVING的分工、多字段分组的本质、性能优化以及我实际踩过的坑。适合刚接触 SQL 的新手也适合那些写了几个月GROUP BY但总在“为什么报错”“为什么这么慢”上卡壳的开发者。既然要彻底搞懂我们就从一组最普通的订单数据开始。1. GROUP BY到底做了什么从需求到分组逻辑1.1 一条SQL背后的分组过程很多人理解GROUP BY只是机械地认为“把相同字段的合并”但真正触发分组动作的其实是SELECT里的聚合函数。我举个例子一张订单表ordersorder_iduser_idcityamount11001北京20021002上海15031001北京30041003广州8051002上海35061001上海120如果执行SELECT city, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY city;我猜你已经知道结果北京有2单、金额500上海有3单、金额620广州有1单、金额80。但数据库不是“数相同城市然后加总”这么简单。它的执行逻辑大致是扫描全表按city字段计算哈希值相同哈希值的行被分到同一个桶里。每个桶代表一个分组分组键是唯一的city值。对每个桶执行聚合函数COUNT(*)桶内行数SUM(amount)桶内所有 amount 累加。输出每个桶的分组键和聚合结果。所以你可以这么理解GROUP BY city产生的是“桶”聚合函数才是桶里的“计算器”。没有聚合函数的GROUP BY其实等价于DISTINCT但这种用法很罕见我后面会讲到。1.2 分组统计中的“组键”与“度量”写GROUP BY前推荐你先问自己两个问题按什么分组组键算什么度量聚合值组键是分组的维度比如时间、地区、类别、用户度量是你关心的数字比如订单量、销售额、平均值。组键决定“分成几组”度量决定“每组输出什么”。我记得自己刚入职时经常把需求写反。比如“统计3月份每个城市的订单量和平均客单价”组键是城市度量是订单量和平均客单价。但如果需求是“统计每个用户在3月份的订单量”组键就变成用户城市不再作为分组维度只能作为详情展示或者忽略。这里有个容易出错的点SELECT 中出现的非聚合列必须出现在 GROUP BY 中。这个我们在第5章详细展开。现在你只需要记住一个朴素的判断凡是你要“逐组一行”输出的列基本都是组键凡是你要“每组算一个值”的列都是度量出现在聚合函数里。2. 核心语法与聚合函数的黄金搭档2.1 基本写法从单字段开始基础语法是SELECT 分组键, 聚合函数(度量列) FROM 表名 WHERE 条件 GROUP BY 分组键 ORDER BY 排序键;注意执行顺序FROM-WHERE-GROUP BY-聚合函数计算-HAVING-SELECT-ORDER BY。也就是说WHERE是在分组前过滤行而ORDER BY是在分组结果产生后再排序。来个最简单场景统计每个商品类目的销量。SELECT category, SUM(sales_qty) AS total_qty FROM product_sales GROUP BY category ORDER BY total_qty DESC;这里SUM(sales_qty)是每组的合计销量category是组键。如果你只写SELECT category, SUM(sales_qty) FROM product_sales而不加GROUP BY数据库会直接报错除非你关掉了ONLY_FULL_GROUP_BY因为当出现聚合函数时没分组就无法决定category取哪一行。单字段分组是最简单的但实际业务里你经常会遇到“多维度钻取”这时就要用到多字段分组。2.2 多个字段分组GROUP BY a, b不是拼接而是嵌套多字段分组可能是当年让我最“顿悟”的语法SELECT city, category, SUM(amount) AS total_amount FROM orders GROUP BY city, category;这个结果不是“把 city 和 category 拼起来成一个组”而是“先按 city 分组再在 city 内部按 category 分组”语义上是两层维度输出每一对(city, category)的唯一组合作为一行。我举一组数据辅助理解citycategoryamount北京食品100北京食品200北京电子300上海食品150上海电子250上海电子60执行GROUP BY city, category后citycategorytotal_amount北京食品300北京电子300上海食品150上海电子310注意北京食品两行合并了上海电子两行也合并了。如果你想要“城市小计”或“品类小计”这样的不同粒度汇总多字段分组是做不到的那需要ROLLUP或GROUPING SETS这个属于进阶功能不在本篇文章重点但由此可以理解GROUP BY a, b生成的是 a×b 的组合粒度而不是“总合计”。很多报表工具比如 Excel 透视表本质也是这种多字段分组不过在 SQL 里你要明确知道SELECT 里出现的所有非聚合列都必须在GROUP BY中例如SELECT city, category, SUM(amount) FROM orders GROUP BY city, category;如果我再多选一个order_date而不加进GROUP BY在大多数数据库默认配置下会直接报错因为这个列的值在一个分组内不唯一数据库不知道该选哪天的日期。这里也回应一个热搜词“group by 多个字段”的常见疑问多字段分组不是对每个字段单独分组再合并而是生成多个字段的组合分组。这是两个完全不同的东西。2.3 聚合函数使用细节COUNT、SUM、AVG、MAX/MIN、GROUP_CONCAT分组之后计算度量就靠聚合函数。我见过很多种误用这里挑重点说COUNT(*)和COUNT(column)不一样。COUNT(*)统计分组内的总行数哪怕某些列是 NULL 也算COUNT(column)只统计该列非 NULL 的行数。所以如果你统计“有多少用户填了手机号”用COUNT(mobile)统计“这个组有多少条订单记录”用COUNT(*)或COUNT(order_id)效果一样。SUM(column)会自动忽略 NULL如果所有值都是 NULL结果是 NULL 而不是 0。你最好用COALESCE(SUM(column), 0)来处理否则报表里会出现空值让人摸不着头脑。AVG(column)也是忽略 NULL 的它会用非 NULL 的行数做分母。比如一组里有 4 行amount 分别是 100、200、NULL、300AVG(amount)的结果是 (100200300)/3 200而不是 600/4 150。这个坑很容易在计算客单价时踩到如果不想要这种“忽略 NULL”的逻辑可以先用COALESCE(amount, 0)把 NULL 转成 0再求平均。MAX、MIN对数字、日期、字符串都有效。字符串比较是按字典序比如你要找每组最早的订单日期用MIN(order_date)非常方便省去了子查询。GROUP_CONCATMySQL或STRING_AGGPostgreSQL/SQL Server 2017可以把分组内的多个值拼成一个字符串。比如查询每个用户最近三个购买渠道虽然可能需要配合窗口函数但先拼出来观察也很有用。这个函数在处理一对多关系的简化展示上很香。聚合函数可以叠加使用吗比如SUM(COUNT(*))这种嵌套聚合在很多数据库里不允许或者表达的是另一层意思。常规业务里COUNT(DISTINCT column)更常用比如统计每个城市的去重用户数SELECT city, COUNT(DISTINCT user_id) AS uv FROM orders GROUP BY city;注意COUNT(DISTINCT)在数据量大时会比较慢因为数据库需要额外去重。你可以用近似去重函数如 HLL来优化但那是另一个话题了。3. 分组后的筛选HAVING和WHERE的边界3.1 WHERE在分组前过滤HAVING在分组后过滤WHERE和HAVING都做筛选但作用时点完全不同。WHERE在分组之前执行针对的是原始行。它不能使用聚合函数比如“筛掉金额小于100的订单”可以写WHERE amount 100但“筛掉总金额小于1000的城市”就不能写WHERE SUM(amount) 1000因为此时分组还没做SUM还没算出来。HAVING在分组之后执行针对的是每组聚合后的结果。它可以使用聚合函数比如SELECT city, SUM(amount) AS total_amount FROM orders GROUP BY city HAVING SUM(amount) 1000;这个执行顺序导致两者有性能差异如果你能用WHERE提前过滤掉大量行就尽量不要只靠在HAVING里过滤。举个例子要查“2024年每个城市销售额超过10000的城市”正确且高效的做法是SELECT city, SUM(amount) AS total_amount FROM orders WHERE order_year 2024 GROUP BY city HAVING SUM(amount) 10000;WHERE order_year 2024在分组前就把其他年份的数据删掉了分组桶的数量和扫描的行数都大大减少HAVING SUM(amount) 10000则在分组后过滤小城市。如果你把年份条件放进HAVINGSELECT city, SUM(amount) AS total_amount FROM orders GROUP BY city HAVING MAX(order_year) 2024 AND SUM(amount) 10000;虽然结果可能差不多但所有年份的数据都被拉进分组浪费大量 IO 和内存而且逻辑上也会出错——如果某城市2023年销售额高但2024年没有订单用HAVING MAX(order_year)2024会误筛。所以在明确只需要某段数据时WHERE永远是第一选择。3.2 案例找出复购率高的客户我们串一个稍微综合点的例子找出“下单次数大于3次且总消费金额大于1000”的客户。先看错误写法SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE COUNT(*) 3 AND SUM(amount) 1000 GROUP BY user_id;这个 SQL 在多数数据库里直接报错因为WHERE不能使用聚合函数。正确写法SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING COUNT(*) 3 AND SUM(amount) 1000;顺便说HAVING后面既可以使用聚合函数也可以使用GROUP BY中的字段别名吗这个因数据库而异。MySQL 支持用别名比如HAVING order_cnt 3但 SQL Server、PostgreSQL 一般不推荐或直接不允许。为了可移植性和可读性建议HAVING里写完整的聚合表达式或原始字段不要依赖 SELECT 的别名。还有一点值得强调HAVING条件里如果要用到不参与分组的普通列必须放在聚合函数里否则它没法确定取哪一行。比如“筛出城市名以‘州’结尾的分组”你可以写HAVING city LIKE %州因为city是分组键每个分组只有一个值没问题。但如果“筛出该分组内最新一条订单的金额大于100”就必须写成HAVING MAX(CASE WHEN order_date MAX(order_date) THEN amount END) 100写法会非常绕实际不如先排序再分组或者用窗口函数。这个以后单独写。4. 原理与性能优化别让GROUP BY拖垮数据库4.1 分组底层在做什么哈希聚合与排序聚合GROUP BY慢不慢取决于数据库用了哪种聚合算法。第一个是哈希聚合Hash Aggregation。数据库扫描数据时以分组键为 key在内存中维护一个哈希表。每读一行就算出分组键的哈希值定位到对应桶然后原地累加度量值。这种算法只需要一次遍历非常适合“分组键基数高、内存足够”的场景。MySQL 8.0 的内存临时表、PostgreSQL 的 HashAggregate、SQL Server 的 Hash Match 都是这个思路。第二个是排序聚合Sort Aggregation。数据库先把数据按分组键排序相同的键自然相邻然后顺序扫描连续的相同键算一组当键变化时输出上一组的聚合结果。这种算法需要排序开销更大但用的内存更少且能够支持流式处理适合数据量超大、内存吃紧或者分组键本身需要有序输出的场景。怎么判断你的 SQL 走了哪种你可以在执行计划里看。MySQL 用EXPLAIN FORMATTRUE或Performance SchemaPostgreSQL 用EXPLAIN (ANALYZE, BUFFERS)。如果看到Using temporary; Using filesort说明走了排序聚合内存临时表可能已经不够用了。看到Using index配合 group by说明可以利用索引避免排序这是最理想的。哈希聚合稍不注意会爆内存。数据库可能在内存不够时把临时结果落盘这时候性能断崖式下跌。所以控制分组键的基数很重要如果分组出的组数超过几十万甚至上百万这个查询大概率会慢。思考一下业务是否真的需要这么细的粒度或者是否可以换更粗的维度先汇总。4.2 索引策略与避免分组慢的七个习惯很多 DBA 喜欢说“GROUP BY 用不上索引就会慢”实际要看索引结构。对于 BTree 索引如果GROUP BY的字段正好是索引的最左前缀排序聚合可以直接用索引顺序扫描避免额外 filesort。比如表有联合索引(city, order_date)那么GROUP BY city, order_date可以完全复用索引。但如果只GROUP BY order_date就无法利用最左前缀数据库不得不做额外的排序或哈希。给你七个实战建议让WHERE尽量精确减少进入分组的数据量别把所有历史数据都拖出来分组。只 SELECT 你需要的分组键和聚合结果不要SELECT *。一旦把所有列都拿出来内存临时表的压力会几何级上升。能在WHERE过滤掉的绝不放HAVING这是性能铁律。如果GROUP BY的字段基数很高但输出只有少量组考虑是否可以用条件聚合替代。比如按天分组转成按月然后程序再处理数据库压力小很多。数据量特别大时可以把分组结果先落到临时表加索引后再二次聚合避免一次查询做太重的事。使用EXPLAIN监控是否出现Using temporary出现就要谨慎重点排查排序字段是否和索引顺序匹配。如果业务允许可以提前做预聚合物化视图、汇总表。日报表数据按天聚合后只有几千行查询毫秒级直接每次从明细表GROUP BY数据量过千万时再好也会有几秒延迟。我自己遇到过最刻骨铭心的例子一张 2 亿行的用户行为日志表按user_id分组统计次数。没加任何条件直接把全表扫了一遍跑了 4 分钟还没出结果临时文件几十 GB。后来改成先按天分区过滤再让分组落在分区裁剪内秒出。分组统计不怕数据多怕的是不必要的数据多。5. 常见错误与排查笔记5.1 ONLY_FULL_GROUP_BY 引发的“列不存在”这是新手最容易撞的墙。在 MySQL 5.7 默认开启了ONLY_FULL_GROUP_BY意味着SELECT中出现的非聚合列必须与GROUP BY列一致。错误示例SELECT user_id, order_date, SUM(amount) FROM orders GROUP BY user_id;你本意可能是每个用户的总金额顺便带一个日期。但order_date没有出现在GROUP BY并且它也不是聚合函数参数。同一组内可能有多个order_date数据库不知道选哪个所以直接报错。很多老程序员习惯了 5.6 的宽松模式会偷偷关闭这个约束。但我不建议。这个约束能帮你发现真正的逻辑歧义。如果你确实想取组内某个日期就明确用聚合函数比如MAX(order_date)这样语义清晰。如果你把user_id, order_date都加进GROUP BY那已经不是“每个用户一行”而是“每个用户和日期的组合一行”完全变了一种统计口径。所以每次报错先停下来想我到底要哪个粒度5.2 多字段分组时把分组键选错多字段分组写起来简单出问题时也最隐蔽。我见过这样的需求“按城市统计销售额并且要展示订单ID列表”于是有人写SELECT city, order_id, SUM(amount) FROM orders GROUP BY city, order_id;这个结果确实是按city order_id分组因为每条订单的order_id基本唯一所以每组只有一条明细SUM(amount)等于这条订单的金额。这根本算不上按城市统计所有城市下的order_id碎片化地把数据拆开了。正确的做法有两种如果只要城市维度的汇总就GROUP BY cityorder_id不在列表里出现如果非要把订单ID拼出来用GROUP_CONCAT(order_id)SELECT city, GROUP_CONCAT(order_id) AS order_ids, SUM(amount) AS total_amount FROM orders GROUP BY city;这个错误的核心是分组键的个数决定了输出粒度。你要展示“城市”级别的指标GROUP BY就只能有city你要展示“城市品类”级别GROUP BY才能写两个字段。多加一个低基数或唯一字段粒度就细到接近明细聚合自然没有意义。5.3 聚合函数里的NULL和去重陷阱我之前提过COUNT(col)忽略 NULL这里再放个例子。有一张客户回访表visit_user_id可能在退货记录上是空的SELECT region, COUNT(visit_user_id) AS visited_cnt, COUNT(*) AS row_cnt FROM customer_visit GROUP BY region;结果visited_cnt可能比row_cnt小很多。如果你把这个看成“回访用户数”就会对不上。所以看清数据定义很重要。另一个陷阱是SUM遇到了非数字类型。很多数据库在隐式转换时报错或警告比如字符串类型的金额列存了“100元”SUM会直接炸掉。设计表时把金额做成DECIMAL是正道但如果拿到脏数据你得先用CAST清洗。COUNT(DISTINCT col)在数据量上来后极其慢。我见过一条 SQL按渠道统计去重用户DISTINCT user_id基数几千万。查询跑了半小时。后面改成先用临时表去重再分组结果快不少。所以在高基数场景下要接受有损近似比如HLL_PRECISE、APPROX_COUNT_DISTINCT或者分层聚合。去重和分组经常同时出现但要注意去重是在分组内做的不是先全表去重再分组。所以COUNT(DISTINCT user_id)的结果是“每个分组内的去重用户数”和“全表去重用户数再分组”是两码事后者很多时候没意义。6. 实战从订单表到运营报表的一次完整分组统计6.1 表结构和需求的拆解我给你一个实际场景假设有一张电商订单表order_detailCREATE TABLE order_detail ( id BIGINT PRIMARY KEY, order_no VARCHAR(32), user_id BIGINT, city VARCHAR(64), channel VARCHAR(16), category VARCHAR(32), amount DECIMAL(10,2), order_date DATE, pay_status TINYINT );现在运营想要一张“2024年12月各省份、各渠道的销售汇总”指标包括订单数支付订单数销售总额平均客单价购买用户数需求拆解如下过滤范围order_date在 2024-12-01 到 2024-12-31且pay_status 1已支付。分组维度province这里我们用 city 代表地域维度也可以扩展省份、channel。度量指标订单数COUNT(order_no)销售总额SUM(amount)平均客单价AVG(amount)购买用户数COUNT(DISTINCT user_id)。这个需求不能直接一个 SQL 满足所有指标因为COUNT(DISTINCT user_id)和组内的其他明细聚合可以在同一个分组查询里没问题但分母要小心。如果“客单价”定义为“总销售额 / 支付订单数”那么AVG(amount)其实是“每笔订单平均金额”而客单价更准确的写法是SUM(amount) / COUNT(order_no)。这两个在数值上等价吗AVG(amount)就是金额总和除以非 NULL 行数如果每笔订单都有金额两者等价。但如果存在退款或负金额需要按业务口径重算。我建议报表里显式写成SUM(amount) / COUNT(*)这样审查的人一眼就能明白口径。6.2 从简单分组到多维度报表的SQL演进第一版 SQL先不想太多直接一把梭SELECT city, channel, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, SUM(amount) / COUNT(*) AS avg_order_amount, COUNT(DISTINCT user_id) AS buyer_cnt FROM order_detail WHERE order_date 2024-12-01 AND order_date 2025-01-01 AND pay_status 1 GROUP BY city, channel ORDER BY total_amount DESC;这个 SQL 在数据量小的时候完全没问题。但是COUNT(DISTINCT user_id)在city channel粒度下如果用户基数大会明显拖慢整个查询。这时我通常会分两步。第一步先做一个临时的用户维度汇总CREATE TEMPORARY TABLE tmp_buyer_count AS SELECT city, channel, COUNT(DISTINCT user_id) AS buyer_cnt FROM order_detail WHERE order_date 2024-12-01 AND order_date 2025-01-01 AND pay_status 1 GROUP BY city, channel;第二步再算订单维度的聚合SELECT city, channel, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, SUM(amount) / COUNT(*) AS avg_order_amount, t.buyer_cnt FROM order_detail o JOIN tmp_buyer_count t USING (city, channel) WHERE order_date 2024-12-01 AND order_date 2025-01-01 AND pay_status 1 GROUP BY city, channel, t.buyer_cnt ORDER BY total_amount DESC;这里面有个细节为什么GROUP BY里带了t.buyer_cnt因为t.buyer_cnt不是order_detail的列也不是聚合函数如果不在GROUP BY中出现数据库会报“列必须出现在 GROUP BY 中”。从业务逻辑上t.buyer_cnt本身就是(city, channel)分组维度上的值加入GROUP BY不会改变分组粒度所以是安全的。两种方式哪种更好取决于你的数据量。如果order_detail只有几百万行单条 SQL 没问题。如果上亿行COUNT(DISTINCT user_id)的复杂度会让查询特别漫长分步临时表可以在第一步去重后大幅缩小数据规模第二步再关联时更快。这里没有银弹EXPLAIN看了再调。6.3 性能验证和结果校验最后别急着上线先做个结果校验。拿一条城市数据手算一遍假设有一组明细citychanneluser_idamount北京AppA100北京AppA200北京AppB150北京WebC300北京WebC50按city北京, channelApp分组order_cnt2total_amount350avg_order_amount175buyer_cnt2A、B 两个用户。如果你做报表看到“订单数 2、购买人数 2、客单价 175”就对了。再检查一个容易忽略的点amount为 NULL 怎么办。如果某条已支付订单的amount是 NULLSUM(amount)会忽略它COUNT(*)却会计数那算出的avg_order_amount会被严重拉低。遇到这种数据你得先明确业务规则比如“NULL 金额按 0 处理”那就要写SUM(COALESCE(amount, 0))。我在自己项目里常用一个自查方法把 GROUP BY 的结果和明细用SUM(明细数) 汇总数验证。比如按city分组的order_cnt之和应该等于WHERE条件下的明细行数total_amount之和应该等于全量明细金额之和。对不上说明聚合里某个维度或过滤条件写错了趁早拆解。尾声一点个人体验我写过很多GROUP BY也看过几个实习生把同一个表拍了十几次。坦白讲GROUP BY真正值钱的地方不在语法而在于你能不能把业务问题翻译成“组键 聚合函数 筛选时机”。多字段分组是对业务纬度的拆解HAVING是对组结果的门禁性能优化则是对数据集物理特性的尊重。下次再遇到“统计 XX 的 YY”先问自己XX 是组键YY 是度量过滤要放在哪个阶段。答案自然就有了。如果非要给一个练习建议拿一份真实的订单表或日志表试着写五条不同分组维度的报表 SQL每条都EXPLAIN一遍再手算一下结果。坚持两周你踩过的那些“为什么慢”“为什么错”的坑都会变成你简历上最有底气的谈资。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →