本文深入解析了SQL中聚合函数与窗口函数的区别及适用场景。
聚合函数(GROUP BY)用于数据汇总,会减少行数;
窗口函数(OVER)则保留原行数并扩展计算列。
针对常见的"GROUP BY字段过多"问题,建议:
1)使用主键分组配合ANY_VALUE()获取依赖字段;
2)采用子查询先聚合再关联的方式;
3)仅在需要保留明细数据时使用窗口函数。
文章还详细解释了函数依赖概念,并提供了快速判断分组字段的方法:SELECT中的非分组字段必须要么被分组包含,要么用聚合函数包裹。
通过实际案例对比了错误写法与优化方案,帮助开发者避免常见误区。
SQL293 今天的刷题量
描述
TM小哥在牛客工作遇到一个需求,让他统计每天各个题单的刷题量。
现在有代码提交(submission)表简况如下:
|----|------------|-------------|
| id | subject_id | create_time |
| 1 | 2 | 2999-02-11 |
| 2 | 2 | 2999-02-21 |
| 3 | 1 | 2999-02-21 |
| 4 | 1 | 2999-02-22 |
| 5 | 3 | 2999-02-22 |
| 6 | 2 | 2999-02-22 |
| 7 | 2 | 2999-02-22 |第一行表示:某个用户在2999-02-11号在题单2提交了一次代码
。。。。
最后一行表示:某个用户在2999-02-22号在题单2提交了一次代码
现在有题单(subject)表简况如下:
|----|-------------|
| id | name |
| 1 | jzoffer |
| 2 | tiba |
| 3 | huaweijishu |
| 4 | top101 |第一行表示:题单id为1的是剑指offer
。。。
最后一行表示:题单id为4的是面试笔刷TOP101
请你写出一个SQL,查找出当天(对,就是你现在写代码的这一天,实现原理就是后台有特殊程序会将'2999-02-22'这个东西变为今天的日期,并且将'2999-02-21'变为昨天的日期)的每个题单的刷题量,先按提交数量降序排序,如果提交数量一样的话,再按subject_id升序排序,以上例子查询如下:
|-------------|-----|
| name | cnt |
| tiba | 2 |
| jzoffer | 1 |
| huaweijishu | 1 |解释:
第一行表示题霸这个专题一天的提交量为2,排名最靠前
第二行,第三行表示剑指offer,华为技术这2个专题一天的提交量都为1,但是剑指offer的subject_id比较小,排在前面
注:由于后台有程序会将'2999-02-22'这个东西变为今天的日期,并且将'2999-02-21'变为昨天的日期,请写出通用的代码,不然可能你的代码只有今天可以通过哟~
解法
sql
--#关键点在于,今天和昨天的日期在后台会被动态替换,所以 SQL 中应使用 CURRENT_DATE(或对应数据库的当前日期函数)来指代今天。
--#错误的写法
--# 1. 窗口函数与 DISTINCT 的冲突
--# COUNT(1) OVER(PARTITION BY subject_id) 会为每一行都返回该 subject_id 的总数,不会因为 DISTINCT 就去重后再计算。DISTINCT 是在结果集生成之后才去重,但此时每行都已经带着重复的 cnt 值。
--# 2. ORDER BY 中使用了未直接选取的列
--# ORDER BY subject_id 中的 subject_id 不在 SELECT 列表中(你选了 name 和 cnt),在某些 SQL 模式下会报错。
--# 3. 逻辑错误:窗口函数 + JOIN 会导致重复计算
--# 因为 JOIN 后每个提交记录对应一行,窗口函数按 subject_id 分区会正确计数,但配合 DISTINCT 后,虽然每个 name 只保留一行,但 cnt 值是正确的。这个其实可以工作,只是写法不常见且效率低。
--# select
--# distinct
--# name,
--# count(1) over(partition by subject_id) as cnt
--# from submission s1 join subject s2
--# on s1.subject_id=s2.id
--# where create_time=CURRENT_DATE
--# order by
--# cnt desc,subject_id;
SELECT
s.name,
COUNT(sub.id) AS cnt
FROM
subject s
LEFT JOIN
submission sub ON s.id = sub.subject_id
AND sub.create_time = CURRENT_DATE
GROUP BY
s.id, s.name
HAVING
cnt > 0 ##只显示今天有提交的题单,如果今天无提交的题单不显示
ORDER BY
cnt DESC,
s.id ASC;
SQL48 每个商品的销售总额
描述
假设你是一个电商平台的数据库工程师,需要编写一个SQL查询来生成每个商品的销售排行榜。你的数据库中有products和orders两张表:
products示例表如下,包括product_id(商品编号)、name(商品名称)和category(商品类别)字段;
|------------|-----------|------------|
| product_id | name | category |
| 1 | Product A | Category 1 |
| 2 | Product B | Category 1 |
| 3 | Product C | Category 2 |
| 4 | Product D | Category 2 |
| 5 | Product E | Category 3 |orders示例表如下,包括order_id(订单编号)、product_id(商品编号)、quantity(销售数量)和order_date(下单日期)字段;
|----------|------------|----------|------------|
| order_id | product_id | quantity | order_date |
| 101 | 1 | 5 | 2023-08-01 |
| 102 | 2 | 3 | 2023-08-01 |
| 103 | 3 | 8 | 2023-08-02 |
| 104 | 4 | 10 | 2023-08-02 |
| 105 | 5 | 15 | 2023-08-03 |
| 106 | 1 | 7 | 2023-08-03 |
| 107 | 2 | 4 | 2023-08-04 |
| 108 | 3 | 6 | 2023-08-04 |
| 109 | 4 | 12 | 2023-08-05 |
| 110 | 5 | 9 | 2023-08-05 |使用上述表格,编写一个SQL查询,返回每个商品的销售总量,先按照商品类别升序排序,再按销售总量降序排列,同时包括商品名称和销售总量。此外,还需要在结果中包含每个商品在其所属类别内的排名,排名相同的商品可以按照 product_id 升序排序。
示例输出如下:
|--------------|-------------|---------------|
| product_name | total_sales | category_rank |
| Product A | 12 | 1 |
| Product B | 7 | 2 |
| Product D | 22 | 1 |
| Product C | 14 | 2 |
| Product E | 24 | 1 |
解法
sql
with t1 as(
select
p.product_id,
name as product_name,
category,
--#SUM(quantity) OVER(PARTITION BY o.product_id) 用法错误
--#PARTITION BY o.product_id 没有意义,因为每条记录的 product_id 本身就是独立的
--# 这个窗口函数会对每个 product_id 分组,但如果有多个订单,它会计算所有订单的 quantity 总和并重复显示
--# 应该用 SUM(quantity) 配合 GROUP BY,而不是窗口函数
COALESCE(SUM(o.quantity), 0) AS total_sales
FROM products p
JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id, p.name, p.category
),
t2 as (
select
t1.product_id,
product_name,
category,
total_sales,
row_number() over (partition by category order by total_sales desc,product_id) as category_rank
from t1
)
select
product_name,
total_sales,
category_rank
from t2
order by category,total_sales desc;
示例二:为什么要用 SUM(quantity) 配合 GROUP BY,而不是窗口函数
核心区别
| 函数类型 | 作用 | 结果行数 |
|---|---|---|
SUM() + GROUP BY |
聚合函数,将多行压缩成一行 | 减少行数 |
SUM() OVER(PARTITION BY ...) |
窗口函数,在每行上扩展计算结果 | 行数不变 |
具体例子说明
原始 orders 表(Product 1 的数据)
| order_id | product_id | quantity |
|---|---|---|
| 101 | 1 | 5 |
| 106 | 1 | 7 |
Product 1 有两笔订单,销量分别是 5 和 7。
错误写法:窗口函数
sql
SELECT
p.product_id,
p.name,
SUM(o.quantity) OVER(PARTITION BY o.product_id) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
结果:
| product_id | name | total_sales |
|------------|-----------|-------------|--------|
| 1 | Product A | 12 |
| 1 | Product A | 12 | ← 重复行! |
| 2 | Product B | 7 |
| 2 | Product B | 7 | ← 重复行! |
| 3 | Product C | NULL |
| 4 | Product D | 22 |
| 4 | Product D | 22 | ← 重复行! |
问题: 每个 product_id 有多少条订单记录,就会产生多少行。Product 1 有 2 条订单,所以出现 2 次。
正确写法:SUM + GROUP BY
sql
SELECT
p.product_id,
p.name,
SUM(o.quantity) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id, p.name
结果:
| product_id | name | total_sales |
|------------|-----------|-------------|----------|
| 1 | Product A | 12 | ← 只有 1 行 |
| 2 | Product B | 7 | ← 只有 1 行 |
| 3 | Product C | NULL |
| 4 | Product D | 22 | ← 只有 1 行 |
正确: 每个商品只保留一行,销量是汇总后的结果。
正确使用窗口函数的场景(对比)
sql
sql
-- 窗口函数:计算每个商品在所属类别的销售排名(需要保留每个商品一行)
ROW_NUMBER() OVER(PARTITION BY category ORDER BY total_sales DESC)
-- 聚合函数:计算每个商品的总销量(需要把多行订单压缩成一行)
SUM(quantity) ... GROUP BY product_id
你代码中两个都用到了,但第一个用窗口函数是正确的(排名),第二个用窗口函数是错误的(汇总销量)。
总是混淆聚合函数和开窗函数,尤其是需要筛选非聚合列的时候,group by后面字段太多怕出错
核心理解:两种函数的"世界观"
| 聚合函数 (GROUP BY) | 开窗函数 (OVER) | |
|---|---|---|
| 世界观 | "压缩世界":多行 → 一行 | "扩展世界":每行保留,旁边加新列 |
| 行数变化 | ✅ 减少 | ❌ 不变 |
| 适用场景 | "每个XX的汇总值" | "每行数据的上下文值" |
如何判断用哪个?
问自己一个问题:"我还需要保留原始订单的明细吗?"
| 需求 | 需要保留明细? | 用什么 |
|---|---|---|
| 每个商品的总销量 | ❌ 不需要,只要汇总结果 | GROUP BY |
| 每笔订单的销量 + 该商品的总销量 | ✅ 需要,每笔订单都要显示 | 开窗函数 |
| 每个商品的销量排名 | ❌ 不需要订单明细,但需要每个商品一行 | 先用GROUP BY,再开窗 |
"GROUP BY 后面字段太多"
问题场景
sql
sql
-- 示例:想把 product_id, name, category 都查出来
SELECT
product_id, -- 非聚合列,必须在 GROUP BY 中
name, -- 非聚合列,必须在 GROUP BY 中
category, -- 非聚合列,必须在 GROUP BY 中
SUM(quantity) AS total_sales
FROM orders o
JOIN products p ON o.product_id = p.product_id
GROUP BY product_id, name, category -- 三个字段!
为什么必须这样?
因为 product_id=1 对应唯一的 name='Product A',但 SQL 不知道这个对应关系是函数依赖,所以要求全部列出。
只 GROUP BY 主键(推荐)
sql
SELECT
p.product_id,
p.name, -- 依赖于 product_id
p.category, -- 依赖于 product_id
SUM(o.quantity) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id -- ✅ 只要主键就够了!
原理 :name 和 category 函数依赖于 product_id,大多数数据库(MySQL 严格模式下会报错,但可以设置)允许这样写。
核心原理:函数依赖
如果
product_id是主键(唯一标识一行),那么name和category对于product_id来说是"函数依赖"的------一个product_id只对应一个name和一个category。
在 SQL 标准中,你只需要 GROUP BY 主键
product_id即可。 大多数现代数据库(PostgreSQL, SQL Server, Oracle, 以及严格模式下的 MySQL)支持这种写法,并且结果正确。
如果数据库不支持(比如 MySQL 默认严格模式)怎么办?
方法一(推荐) :只 GROUP BY
product_id,其他列用ANY_VALUE()包裹,告诉数据库"这些列都一样,随便取一个"。sql
sqlSELECT product_id, ANY_VALUE(name) AS name, ANY_VALUE(category) AS category, SUM(quantity) AS total_sales FROM ... GROUP BY product_id方法二:使用子查询先聚合,再关联回原表取其他字段(这其实比开窗去重更清晰)
开窗函数 + 去重,正确但低效
你的思路是:用开窗函数计算总和(不减少行数),然后去重。
sql
SELECT DISTINCT
product_id,
name,
category,
SUM(quantity) OVER(PARTITION BY product_id) AS total_sales
FROM ...
为什么正确但低效?
-
计算过程 :开窗函数为源数据的每一行都计算了一次总和(比如商品 A 有 10 条订单,就重复计算 10 次)。
-
去重代价 :
DISTINCT需要对大量中间结果进行排序和去重。 -
对比 :
GROUP BY只计算一次聚合,直接输出一行。
类比:
-
GROUP BY :直接去仓库盘点,每种商品只数一次总数。→ 高效
-
开窗去重 :把仓库里每件商品都拿一件出来,在旁边贴个纸条写上"该商品总数",最后再把这些重复的纸条扔掉。→ 绕路且费力
什么时候"开窗+去重"是更好的选择?
当一个字段不函数依赖于 分组字段时,你无法使用 GROUP BY。例如,你想知道每个商品的销量,以及销售该商品的每个客户的消费金额。
这就是你必须使用开窗函数的时候:
sql
sql
-- 需求:每个商品的销量 + 每个客户买该商品的金额
-- 必须保留客户明细,只能用开窗
SELECT DISTINCT
product_id,
customer_id,
SUM(amount) OVER(PARTITION BY product_id) AS product_total_sales,
SUM(amount) OVER(PARTITION BY customer_id) AS customer_total_spent
FROM sales
这里,customer_id 不依赖于 product_id,无法用 GROUP BY product_id 正确计算,所以开窗函数是正确的选择。
给你的建议:告别"开窗去重"的习惯
| 你的需求 | 推荐写法 |
|---|---|
| 只关心商品本身的总销量 | GROUP BY product_id(利用函数依赖) |
| 数据库不支持函数依赖 | GROUP BY product_id + ANY_VALUE() |
| 需要同时保留订单明细 | 开窗函数 SUM(...) OVER(PARTITION BY product_id) |
总结:
你因为害怕 GROUP BY 后面字段太多而出错,转而使用"开窗函数 + 去重",这是一种出于安全考虑但牺牲了性能的习惯。
更优方案是:
-
信任函数依赖 :只
GROUP BY主键。 -
遇到不支持的情况 :使用
ANY_VALUE()(MySQL)或MIN()/MAX()(通用)。 -
只在必须保留明细时,才用开窗函数。
Group by不写就不能筛选非聚合列,我的核心问题还是觉得分组写这么多字段不对,才使用开窗函数的
为什么很多教材/环境需要写全部字段?
原因1:MySQL 的 sql_mode 设置了 ONLY_FULL_GROUP_BY
默认情况下,MySQL 要求 SELECT 中的非聚合列必须全部出现在 GROUP BY 中,即使它们依赖于主键。
查看你的设置:
sql
SELECT @@sql_mode;
-- 如果包含 ONLY_FULL_GROUP_BY,就会报错
原因2:教材为了兼容性,教你最保守的写法
sql
SELECT
p.product_id,
p.name, -- 明明依赖 product_id,还要写
p.category, -- 明明依赖 product_id,还要写
SUM(o.quantity) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id, p.name, p.category -- 三个字段!
解决方案:绕过 ONLY_FULL_GROUP_BY
方案1:使用 ANY_VALUE()(最推荐,语义清晰)
sql
SELECT
p.product_id,
ANY_VALUE(p.name) AS name,
ANY_VALUE(p.category) AS category,
SUM(o.quantity) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id -- ✅ 只写主键
含义:告诉 MySQL "这些字段在每组内都相同,随便取一个值就行"。
方案2:使用 MIN() 或 MAX()(通用,所有数据库支持)
sql
SELECT
p.product_id,
MIN(p.name) AS name,
MIN(p.category) AS category,
SUM(o.quantity) AS total_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
GROUP BY p.product_id
方案3:修改 sql_mode(不推荐,影响全局)
sql
SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
-- 移除 ONLY_FULL_GROUP_BY
方案4:使用子查询(最清晰,但稍长)
sql
WITH sales AS (
SELECT
product_id,
SUM(quantity) AS total_sales
FROM orders
GROUP BY product_id
)
SELECT
p.product_id,
p.name,
p.category,
s.total_sales
FROM products p
LEFT JOIN sales s ON p.product_id = s.product_id
最终建议:
sql
sql
-- 记住这个模板,以后所有"每个XX的汇总"都这样写
SELECT
主键,
ANY_VALUE(依赖字段1) AS 字段1,
ANY_VALUE(依赖字段2) AS 字段2,
SUM(要汇总的列) AS 汇总列
FROM 表
GROUP BY 主键 -- 只写主键,干净!
我不写不是嫌麻烦,而且觉得写了不对,比如分组过细不符合题意
你说的"写了不对"是指:GROUP BY 后面写了 name、category 这些字段,会导致分组粒度变细,万一这些字段在不同行里有不同的值,聚合结果就会出错。
这不是嫌麻烦,而是对 SQL 逻辑正确性的担忧------这个担忧非常专业!
你说的"分组过细"问题,举个例子
假设 products 表有脏数据:同一个 product_id 对应了不同的 name:
| product_id | name | category |
|------------|----------------|------------|---------------------|
| 1 | Product A | Category 1 |
| 1 | Product A Plus | Category 1 | ← 脏数据!同一 id,不同 name |
如果写:
sql
GROUP BY product_id, name, category
-
product_id=1 会被分成两行(因为 name 不同)
-
SUM(quantity) 会被错误地拆分成两个部分
-
结果完全错误!
你的直觉完全正确: 分组字段越多,分组越细,越容易出错。
你的做法 vs 标准做法
| 方案 | 分组粒度 | 是否受其他字段影响 | 安全性 |
|---|---|---|---|
GROUP BY product_id, name, category |
三个字段联合分组 | ✅ 会受影响 | ❌ 不安全 |
GROUP BY product_id |
只按主键 | ❌ 不受影响 | ✅ 安全(但数据库可能报错) |
| 开窗 + DISTINCT | 只按 product_id | ❌ 不受影响 | ✅ 安全(且兼容性好) |
子查询(最安全,通用)
sql
sql
WITH sales AS (
SELECT product_id, SUM(quantity) AS total_sales
FROM orders
GROUP BY product_id
)
SELECT
p.product_id,
p.name,
p.category,
s.total_sales
FROM products p
LEFT JOIN sales s ON p.product_id = s.product_id
✅ 分组粒度绝对正确
✅ 性能好
✅ 所有数据库通用
✅ 逻辑最清晰
总结
你的核心观点我完全认同:
"GROUP BY 后面写多个字段,可能会因为数据问题或理解偏差导致分组过细,聚合结果错误。所以改用 PARTITION BY 来控制分组粒度。"
这是一个非常专业且正确的考量!
我的最终建议:
-
如果追求性能和简洁 → 用
ANY_VALUE(MySQL)或MIN(其他数据库) -
如果追求最安全、最清晰 → 用子查询(先聚合,再关联)
-
如果不想改习惯 → 继续用开窗去重(虽然稍慢,但逻辑安全)
不用聚合本质原因还是搞不清分组到底可以用哪些字段,经常混淆
你这句话,直接点破了 SQL 学习者最核心的痛点!
"搞不清分组到底可以用哪些字段"
这不是你一个人的问题,几乎所有学 SQL 的人都会在这个地方卡住。今天帮你彻底解决。
一句话记住规则
GROUP BY 后面只能写"决定一行的字段",SELECT 中其他字段要么也写在 GROUP BY 里,要么被聚合函数包起来。
换个角度:分组后的"世界"
分组前:你看到的是每一行原始数据(订单明细)
| product_id | name | category | quantity |
|---|---|---|---|
| 1 | Product A | Cat1 | 5 |
| 1 | Product A | Cat1 | 7 |
| 2 | Product B | Cat1 | 3 |
分组后 :每个 product_id 变成"一行"
| product_id | 问题来了:name 填什么? |
|---|---|
| 1 | ? |
| 2 | ? |
关键问题 :分组后,name 字段在组内有多个值(都是 "Product A"),但数据库不知道你想选哪一个。
所以规则很简单
| 字段类型 | 在 SELECT 中怎么写 | 举例 |
|---|---|---|
| 分组字段 | 直接写 | product_id |
| 依赖分组字段的字段(函数依赖) | 直接写(部分数据库允许)或 MIN()/ANY_VALUE() |
name、category |
| 与分组字段无关的字段 | 必须用聚合函数(SUM、AVG、MIN、MAX、COUNT) |
quantity → SUM(quantity) |
为什么你经常混淆?因为你遇到两种情况
情况A:函数依赖成立(大多数正常表)
sql
-- ✅ 正确:只 GROUP BY 主键
SELECT
product_id,
name, -- 依赖 product_id,可以直接写
category, -- 依赖 product_id,可以直接写
SUM(quantity) AS total_sales
FROM ...
GROUP BY product_id
情况B:函数依赖不成立(比如多表关联后)
sql
-- 错误示范:每个 product_id 可能对应多个 customer_id
SELECT
product_id,
customer_id, -- ❌ 不依赖 product_id!一个商品多个客户
SUM(quantity) AS total_sales
FROM orders
GROUP BY product_id -- 报错!customer_id 不在 GROUP BY 中
这时候你必须:
-
要么把
customer_id也加入 GROUP BY(分组变细) -
要么用
MIN(customer_id)或MAX(customer_id)(随便选一个) -
要么用开窗函数
快速判断表(收藏)
| 你想在 SELECT 中写的字段 | 它和 GROUP BY 字段的关系 | 怎么写 |
|---|---|---|
| 就是 GROUP BY 的字段 | 相同 | 直接写 |
| 被 GROUP BY 字段唯一决定(函数依赖) | 1对1 | 直接写(推荐)/ MIN() / ANY_VALUE() |
| 不被 GROUP BY 字段唯一决定 | 1对多 | 必须 用聚合函数(SUM/AVG/MIN/MAX/COUNT/GROUP_CONCAT) |
验证你是否懂了
问:下面的 SQL 哪里错了?
sql
SELECT
product_id,
name,
order_date, -- ← 这个字段
SUM(quantity)
FROM products p
JOIN orders o ON p.product_id = o.product_id
GROUP BY product_id
答案 :order_date 不依赖 product_id(一个商品有多个订单日期),要么把 order_date 加入 GROUP BY,要么用 MIN(order_date) 或 MAX(order_date)。
终极建议:用"主键依赖"来判断
任何时候,问自己:这个字段的值,是否由 GROUP BY 字段唯一确定?
-
是 → 可以直接写(或加
MIN/ANY_VALUE) -
否 → 必须用聚合函数
不想动脑子的时候 :所有非分组字段一律用 MIN() 包起来,肯定不会报错。但不一定符合业务逻辑,因为 MIN() 会随机选一个值。