三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

SQL GROUP BY 详解:从分类汇总到多维度聚合实战

SQL GROUP BY 详解:从分类汇总到多维度聚合实战

1. 从一次数据统计的“翻车”说起

最近在帮一个刚入行的朋友看代码,他负责一个简单的用户行为日志分析。需求听起来很简单:统计每天每个用户的操作次数。他写了个查询,跑出来的结果却让他傻眼了——数据量对不上,而且出现了很多重复的条目。他把代码发给我一看,问题就出在一个最基础但又最容易被误解的SQL语句上:GROUP BY

这让我想起自己刚接触数据库那会儿,也在这个地方栽过跟头。GROUP BY看起来就是“分组”嘛,把相同的东西放一起不就行了?但实际操作起来,你会发现它远不止“放一起”那么简单。它涉及到数据如何被聚合、哪些字段能出现在查询结果里,以及分组背后的计算逻辑。很多教程讲得太快,或者只给语法,导致新手用起来总是云里雾里,要么报错,要么结果不对。

所以,今天我们就彻底把这个基础但至关重要的知识点掰开揉碎了讲。我会用最直白的语言和大量的例子,带你从零理解GROUP BY。无论你是完全没接触过SQL的小白,还是用过但总觉得心里不踏实的朋友,这篇文章都能让你彻底搞懂它,以后用起来心里有底。

2. GROUP BY 到底是什么?先忘掉“分组”

很多教程一上来就说“GROUP BY 用于分组”,这个说法其实容易让人先入为主。我们换个更准确的角度来理解:GROUP BY 的核心是“分类汇总”

想象一下,你有一筐混合的水果,里面有苹果、香蕉、橘子。老板让你做个库存清单,你需要知道每种水果各有多少个。你会怎么做?你肯定会先把所有苹果挑出来放一堆,数一数;再把所有香蕉挑出来放一堆,数一数;橘子也一样。最后你的清单上写着:苹果XX个,香蕉XX个,橘子XX个。

在这个例子里:

  • 那筐混合水果就是你的数据库表。
  • “按水果种类”这个动作,就是GROUP BY
  • “数一数个数”这个动作,就是聚合函数COUNT(*)
  • 最后那份清单,就是你的查询结果。

所以,GROUP BY的本质是:先按照你指定的一个或多个列,把数据表中的所有行分成不同的“组”或“类”。然后,对每一个组内的所有行,进行某种计算(比如计数、求和、求平均),最终每个组只输出一行汇总结果。

它改变了你观察数据的维度。从不分组时一行代表一条详细记录,变成了分组后一行代表一个类别的汇总信息。

3. 动手前准备:创建我们的实验数据

光说不练假把式,我们直接上手操作。为了演示,我们先创建一个简单的数据表并插入一些数据。你可以把这个SQL在任何MySQL客户端(如MySQL Workbench, Navicat)或命令行里运行。

-- 创建一个名为 `sales` 的销售记录表 CREATE TABLE sales ( id INT PRIMARY KEY AUTO_INCREMENT, -- 记录ID,自增长 sale_date DATE, -- 销售日期 product_name VARCHAR(50), -- 产品名称 salesperson VARCHAR(50), -- 销售员 amount DECIMAL(10, 2) -- 销售金额 ); -- 插入一些示例数据 INSERT INTO sales (sale_date, product_name, salesperson, amount) VALUES ('2023-10-23', '笔记本电脑', '张三', 5500.00), ('2023-10-23', '鼠标', '李四', 88.00), ('2023-10-23', '笔记本电脑', '张三', 5200.00), ('2023-10-24', '键盘', '王五', 200.00), ('2023-10-24', '鼠标', '李四', 88.00), ('2023-10-24', '显示器', '张三', 1500.00), ('2023-10-25', '笔记本电脑', '张三', 5800.00), ('2023-10-25', '键盘', '王五', 200.00), ('2023-10-25', '鼠标', '李四', 95.00);

插入后,表里的数据大概是这样的:

idsale_dateproduct_namesalespersonamount
12023-10-23笔记本电脑张三5500.00
22023-10-23鼠标李四88.00
32023-10-23笔记本电脑张三5200.00
42023-10-24键盘王五200.00
52023-10-24鼠标李四88.00
62023-10-24显示器张三1500.00
72023-10-25笔记本电脑张三5800.00
82023-10-25键盘王五200.00
92023-10-25鼠标李四95.00

有了这份数据,我们就可以开始各种GROUP BY实验了。

4. 单字段分组:最常用的场景拆解

我们先从最简单的开始:只按照一个列进行分组。

4.1 基础计数:每个销售员卖了多少单?

需求:统计每个销售员总共完成了多少笔销售交易。

SELECT salesperson, COUNT(*) AS order_count FROM sales GROUP BY salesperson;

逐句解释:

  1. SELECT salesperson, ...: 我要在结果里看到“销售员”这个信息。
  2. COUNT(*) AS order_count: 我要对数据进行“计数”操作。COUNT(*)会数每一个分组里有多少行数据。AS order_count是给这个计数结果起个别名,让结果列更易读。
  3. FROM sales: 数据来自sales表。
  4. GROUP BY salesperson关键在这里!它告诉数据库:“请按照salesperson列的值,把所有行分成不同的组。所有salesperson是‘张三’的行是一组,‘李四’的行是另一组,‘王五’的又是另一组。”

执行过程脑补:数据库引擎会扫描整个sales表:

  • 遇到“张三”,把他放到“张三组”。
  • 遇到“李四”,放到“李四组”。
  • 又遇到“张三”,还是放到“张三组”。
  • ... 扫描完后,它得到了三个组:组1(张三: 第1,3,6,7行)组2(李四: 第2,5,9行)组3(王五: 第4,8行)。 然后,对每个组执行SELECT指定的操作:取出组名(salesperson),计算组内行数(COUNT(*))。

查询结果:

salespersonorder_count
张三4
李四3
王五2

注意:这里COUNT(*)数的是每个销售员对应的行数,也就是订单数。如果同一个订单有多条记录(比如一个订单多个商品),那逻辑就不一样了,这取决于你的表结构设计。理解COUNT计数的对象非常重要。

4.2 求和与求平均:每个产品的总销售额和平均售价

需求:看看每个产品总共卖了多少钱,以及平均每个卖了多少钱。

SELECT product_name, SUM(amount) AS total_sales, -- 总销售额 AVG(amount) AS avg_price, -- 平均售价 COUNT(*) AS sales_times -- 销售次数 FROM sales GROUP BY product_name;

这里引入了两个新的聚合函数:

  • SUM(amount): 对每个分组内的amount字段进行求和。
  • AVG(amount): 对每个分组内的amount字段求平均值。

执行过程:

  1. product_name分组:得到“笔记本电脑”组(第1,3,7行)、“鼠标”组(第2,5,9行)、“键盘”组(第4,8行)、“显示器”组(第6行)。
  2. 对“笔记本电脑”组:SUM(amount) = 5500 + 5200 + 5800 = 16500AVG(amount) = 16500 / 3 = 5500COUNT(*) = 3
  3. 其他组同理。

查询结果:

product_nametotal_salesavg_pricesales_times
笔记本电脑16500.005500.003
鼠标271.0090.333
键盘400.00200.002
显示器1500.001500.001

实操心得:当你同时使用多个聚合函数时,它们都是独立对同一个分组内的数据进行计算的。SUMAVGCOUNT之间不会互相干扰。另外,AVG在计算时,会自动忽略NULL值。如果amount有为NULL的记录,它不会被计入总和,分母(计数)也会相应减少,这点需要注意。

4.3 最值查找:每天的单笔最高销售额

需求:找出每一天里,金额最大的那一笔销售是多少。

SELECT sale_date, MAX(amount) AS max_amount FROM sales GROUP BY sale_date;

这里使用了MAX聚合函数:

  • MAX(amount): 找出每个分组内amount字段的最大值。

查询结果:

sale_datemax_amount
2023-10-235500.00
2023-10-241500.00
2023-10-255800.00

这个结果告诉我们,在10月23日,单笔销售最高是5500元(对应id为1的记录);10月24日最高是1500元;10月25日最高是5800元。

常见误解纠正:这个查询只能返回最大金额是多少,而不能直接告诉你这笔最大金额的销售对应的产品是什么、销售员是谁。因为SELECT列表里除了分组字段sale_date,就只能放聚合函数MAX(amount)。如果你想找到“每天销售额最高的那笔记录的所有信息”,需要用更高级的方法(如子查询或窗口函数),这不是基础GROUP BY能直接解决的。这是新手最容易困惑的点之一。

5. 多字段分组:组合维度下的精细统计

单字段分组能满足很多需求,但现实情况往往更复杂。比如,老板可能问:“张三每天卖笔记本电脑,总共卖了多少钱?” 这就涉及到“销售员”和“产品”两个维度了。这时候就需要多字段分组。

5.1 二维分组:每个销售员每天的总销售额

需求:细化到人和天,看每日业绩。

SELECT salesperson, sale_date, SUM(amount) AS daily_sales FROM sales GROUP BY salesperson, sale_date ORDER BY salesperson, sale_date; -- ORDER BY 用于排序,让结果更清晰

关键理解GROUP BY salesperson, sale_date这不再是先按人分再按天分,或者反过来。它的意思是:salespersonsale_date的组合值完全相同的行,归为同一组。你可以把它想象成一个二维表格,横轴是销售员,纵轴是日期,每个单元格就是一个独立的分组。

执行过程脑补:数据库会生成类似这样的分组键:(张三, 2023-10-23), (张三, 2023-10-24), (张三, 2023-10-25), (李四, 2023-10-23) ... 只有这两列值都相同的行才会被分到一起。 例如,(张三, 2023-10-23)这个组,包含了第1行和第3行(都是张三在23号卖笔记本电脑的记录)。

查询结果:

salespersonsale_datedaily_sales
张三2023-10-2310700.00
张三2023-10-241500.00
张三2023-10-255800.00
李四2023-10-2388.00
李四2023-10-2488.00
李四2023-10-2595.00
王五2023-10-24200.00
王五2023-10-25200.00

从这个结果,我们可以清晰地看到每个销售员每天的业绩总和。

5.2 三维分组:每天每个产品的销售次数

我们再增加一个维度。需求:统计每一天、每一个产品被销售了多少次(即订单行数)。

SELECT sale_date, product_name, COUNT(*) AS sales_count FROM sales GROUP BY sale_date, product_name ORDER BY sale_date, product_name;

理解GROUP BY sale_date, product_name分组键变成了 (日期, 产品)。例如,(2023-10-23, 笔记本电脑) 是一个组,(2023-10-23, 鼠标) 是另一个组。

查询结果:

sale_dateproduct_namesales_count
2023-10-23笔记本电脑2
2023-10-23鼠标1
2023-10-24键盘1
2023-10-24鼠标1
2023-10-24显示器1
2023-10-25键盘1
2023-10-25笔记本电脑1
2023-10-25鼠标1

这个结果非常细致,它告诉我们:10月23日,笔记本电脑卖了2次,鼠标卖了1次;10月24日,键盘、鼠标、显示器各卖了1次,等等。

核心要点:多字段GROUP BY时,分组的粒度变得更细,结果集的行数通常会更多(因为组合更多)。它用于回答更具体、更交叉的统计问题。选择哪些字段进行分组,完全取决于你的业务问题。

6. GROUP BY 的黄金搭档:HAVING 子句

WHERE子句用于在分组对原始数据进行过滤。但有时候,我们需要对分组的聚合结果进行过滤。比如,“找出总销售额超过10000元的销售员”。这时候WHERE就无能为力了,因为它无法识别SUM(amount)这样的聚合值。这就需要HAVING子句出场。

WHEREvsHAVING的核心区别:

  • WHERE:在分组和聚合计算之前执行,作用于单条原始记录的字段。不能使用聚合函数。
  • HAVING:在分组和聚合计算之后执行,作用于分组后的聚合结果。必须使用聚合函数或分组字段。

6.1 用 HAVING 过滤聚合结果

例1:找出销售订单数超过2笔的销售员。

SELECT salesperson, COUNT(*) AS order_count FROM sales GROUP BY salesperson HAVING COUNT(*) > 2; -- HAVING 过滤分组后的计数结果

执行顺序:FROM sales->GROUP BY salesperson-> 计算COUNT(*)->HAVING COUNT(*) > 2->SELECT输出。 结果只会包含“张三”(4笔)和“李四”(3笔)。“王五”因为只有2笔,被过滤掉了。

例2:找出总销售额超过5000元的产品。

SELECT product_name, SUM(amount) AS total_sales FROM sales GROUP BY product_name HAVING SUM(amount) > 5000;

结果只会显示“笔记本电脑”(16500元)。

例3:找出平均销售额高于100元,且总销售次数大于1的产品。这是一个组合条件过滤。

SELECT product_name, AVG(amount) AS avg_sales, COUNT(*) AS sales_times FROM sales GROUP BY product_name HAVING AVG(amount) > 100 AND COUNT(*) > 1;

这个查询会过滤出那些既卖得贵(均价>100)又卖得多(次数>1)的产品。根据我们的数据,“笔记本电脑”和“键盘”符合条件。

避坑指南:千万不要在WHERE子句里使用聚合函数,比如WHERE SUM(amount) > 1000,这一定会报错。因为数据库在执行WHERE时,根本还没有进行分组和聚合计算,它不知道SUM(amount)是什么。务必牢记:行级过滤用WHERE,组级过滤用HAVING

7. 新手必踩的坑与核心规则详解

理解了基本操作,我们来看看那些让新手头疼的错误和规则。弄懂这些,你才算真正掌握了GROUP BY

7.1 “SELECT列表中的列无效”错误

这是最经典的错误。我们来看一个会报错的例子:

-- 错误示例! SELECT salesperson, product_name, amount FROM sales GROUP BY salesperson;

执行这个语句,MySQL会报一个错误,大意是:product_nameamount字段没有在GROUP BY子句中,也不是聚合函数,因此SELECT列表中不能出现它们。

为什么?回想一下分组的过程:数据库把多条记录(比如所有“张三”的记录)压缩成了一行。那么问题来了:在“张三”这一组里,product_name有可能是“笔记本电脑”,也可能是“显示器”;amount有可能是5500,也可能是1500。当你只GROUP BY salesperson时,数据库无法确定在最终输出的那一行里,product_nameamount应该显示哪个值。它必须得到一个唯一、确定的值。

核心规则(务必记住):在使用了GROUP BY的查询中,SELECT后面只能出现两种类型的列:

  1. 出现在GROUP BY子句中的列。因为它们是分组的依据,在每个组内这个值是唯一确定的。
  2. 使用聚合函数(如SUM,COUNT,AVG,MAX,MIN)包裹的列。因为聚合函数会将组内多个值计算成一个确定的值。

在上面的错误示例中,salesperson符合规则1,可以出现在SELECT中。但product_nameamount既不在GROUP BY里,也没被聚合函数处理,所以数据库拒绝执行。

修正方法:如果你想看每个销售员涉及的产品和金额,你有几种选择:

  • 如果你想知道所有产品:使用GROUP_CONCAT聚合函数,它可以把组内的多个值拼接成一个字符串。
    SELECT salesperson, GROUP_CONCAT(product_name) AS products, -- 拼接产品名 GROUP_CONCAT(amount) AS amounts -- 拼接金额 FROM sales GROUP BY salesperson;
    结果可能像这样:张三 | 笔记本电脑,笔记本电脑,显示器,笔记本电脑 | 5500.00,5200.00,1500.00,5800.00
  • 如果你只想看聚合信息:那就只选择聚合列。
    SELECT salesperson, COUNT(DISTINCT product_name) AS product_types, -- 销售了多少种产品 SUM(amount) AS total_amount FROM sales GROUP BY salesperson;

7.2 关于 COUNT 的细节:COUNT(*) vs COUNT(column) vs COUNT(DISTINCT column)

COUNT函数很常用,但用法不同,结果差异很大。

  • COUNT(*): 统计行数。只要这一行存在,不管它的列是不是NULL,都会被计数。最常用。
  • COUNT(column_name): 统计指定列中非 NULL 值的数量。如果该列某行为NULL,则不计入。
  • COUNT(DISTINCT column_name): 统计指定列中不重复的非 NULL 值的数量。常用于计算唯一值个数。

我们来修改一下数据,假设第9条记录的salesperson字段为NULL

-- 更新第9行,将销售员设为NULL UPDATE sales SET salesperson = NULL WHERE id = 9; -- 对比三种COUNT SELECT COUNT(*) AS total_rows, -- 总行数 COUNT(salesperson) AS not_null_salesperson, -- 非NULL的销售员数 COUNT(DISTINCT salesperson) AS unique_salesperson -- 不重复的销售员数 FROM sales;

查询结果:

total_rowsnot_null_salespersonunique_salesperson
983

解释:总共有9行。salesperson列有8个非NULL值(因为第9行是NULL)。不重复的销售员有3个(张三、李四、王五,NULL不被计入DISTINCT)。

实操心得:在统计“有多少个不同的用户/商品/日期”时,一定要用COUNT(DISTINCT ...)。用COUNT(*)COUNT(column)得到的是记录数,而不是唯一值数量,这会导致数据被严重夸大。

7.3 分组后的排序:ORDER BY 与 GROUP BY 的配合

分组后的结果集默认顺序是不确定的。为了让结果更易读,我们通常会用ORDER BY进行排序。ORDER BY可以基于分组字段,也可以基于聚合函数的结果。

-- 按总销售额从高到低排序 SELECT salesperson, SUM(amount) AS total_sales FROM sales GROUP BY salesperson ORDER BY total_sales DESC; -- 使用别名排序,DESC表示降序 -- 按销售员姓名排序,再按销售日期排序 SELECT salesperson, sale_date, SUM(amount) AS daily_sales FROM sales GROUP BY salesperson, sale_date ORDER BY salesperson ASC, sale_date ASC; -- ASC表示升序,可省略

执行顺序再强调:FROM->WHERE->GROUP BY->HAVING->SELECT->ORDER BYORDER BY是最后一步,所以它可以使用SELECT中定义的别名(如total_sales)。

8. 综合实战:解决一个真实的业务问题

让我们把所有知识点串起来,解决一个稍微复杂点的业务场景。

业务需求:为销售部门生成一份报告,需要列出在最近三天内(2023-10-23至2023-10-25):

  1. 总销售额超过2000元的销售员。
  2. 报告需包含销售员姓名、总销售额、平均单笔销售额、销售单数。
  3. 并按照总销售额从高到低排列。
  4. 同时,如果某个销售员卖过“显示器”这个产品,需要在备注里标出。

这个需求融合了过滤、分组、聚合、排序和条件判断。

SELECT salesperson, SUM(amount) AS total_sales, AVG(amount) AS avg_order_amount, COUNT(*) AS order_count, -- 使用CASE WHEN进行条件判断,生成备注 CASE WHEN MAX(CASE WHEN product_name = '显示器' THEN 1 ELSE 0 END) = 1 THEN '销售过显示器' ELSE '未销售显示器' END AS remark FROM sales -- 首先,用WHERE过滤出指定日期的原始数据 WHERE sale_date BETWEEN '2023-10-23' AND '2023-10-25' -- 然后,按销售员分组 GROUP BY salesperson -- 接着,用HAVING过滤分组后的聚合结果(总销售额>2000) HAVING SUM(amount) > 2000 -- 最后,按总销售额降序排列 ORDER BY total_sales DESC;

逐段解析:

  1. WHERE sale_date BETWEEN ...: 在分组前,先筛选出日期范围内的数据。这是行级过滤。
  2. GROUP BY salesperson: 将过滤后的数据按销售员分组。
  3. 聚合计算: 对每个组计算SUM(amount)AVG(amount)COUNT(*)
  4. CASE WHEN ... THEN ... ELSE ... END: 这是一个条件表达式。我们用它来判断一个销售员是否卖过“显示器”。
    • CASE WHEN product_name = '显示器' THEN 1 ELSE 0 END: 对组内的每一行进行判断,如果是显示器就标记为1,否则为0。这样组内会得到一个由0和1组成的序列。
    • MAX(...) = 1: 取这个序列的最大值。如果组内曾经出现过1(即卖过显示器),那么最大值就是1;如果全是0(没卖过),最大值就是0。
    • 根据最大值是否为1,给remark字段赋予不同的文本值。
    • 为什么用MAX聚合函数?因为CASE WHEN表达式生成了每行的标量值(0或1),我们需要一个聚合函数(这里是MAX)来将这些行级结果汇总成一个组级结果,这样才能符合SELECT的规则。这是一种非常实用的技巧。
  5. HAVING SUM(amount) > 2000: 对分组聚合后的结果进行过滤,只保留总销售额大于2000的组。
  6. ORDER BY total_sales DESC: 对最终结果按总销售额降序排列。

查询结果(基于我们最初的数据,第9行未更新):

salespersontotal_salesavg_order_amountorder_countremark
张三18000.004500.004销售过显示器
李四271.0090.333未销售显示器

王五因为总销售额只有400元,不满足HAVING条件,所以没有出现在结果中。

通过这个综合例子,你应该能体会到,GROUP BY是SQL数据分析的基石。结合WHEREHAVING、聚合函数和各种表达式,你可以应对非常复杂的统计汇总需求。关键在于清晰地理解数据流动的顺序:从原始表过滤,到分组,到组内聚合计算,再到聚合结果过滤,最后排序输出。把这个流程印在脑子里,写起查询来就会有条理得多。

← 返回列表