从 GROUP BY 到窗口函数

零基础渐进教程 — 每一步都有完整数据结果,不跳过任何中间过程

🎯 为什么需要窗口函数?

想象你在看班级成绩单——你想知道每个人的分数(原表数据),同时想知道: 每个人的分数在全班排第几名?每个人的分数和全班平均分差多少?

GROUP BY 做不到这件事——GROUP BY 会把全班 30 人"压缩"成 1 行(只有平均分), 你再也看不到每个人的分数了。
窗口函数能做到——它能在每个人的行旁边,"贴上"全班排名、全班平均分等信息。 原来的 30 行还是 30 行,但每一行多了一列"排名"或"平均分"。

这就是窗口函数的核心价值:既保留明细数据,又能看到聚合信息。

示例数据(全篇通用)

employees 员工表

CREATE TABLE employees ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, dept VARCHAR(30), salary DECIMAL(10,2), hire_date DATE );
全部数据 (7 rows)
idnamedeptsalaryhire_date
1张三技术部150002022-03-15
2李四技术部180002021-07-01
3王五市场部120002023-01-10
4赵六市场部120002023-06-20
5张三财务部110002020-11-05
6孙七技术部180002022-09-18
7周八NULL90002024-02-01
记住这7条数据,后面所有查询都基于它。注意:技术部3人、市场部2人、财务部1人、周八没有部门

第 0 步:最普通的查询(没有分组)

0. 普通查询
1. 聚合函数
2. GROUP BY
3. HAVING
4. 窗口函数

问题 全公司薪资最高的3个人是谁?

SELECT name, dept, salary FROM employees ORDER BY salary DESC LIMIT 3;
执行结果 (3 rows)
namedeptsalary
李四技术部18000
孙七技术部18000
张三技术部15000

问题 薪资大于12000的有哪些人?

SELECT name, dept, salary FROM employees WHERE salary > 12000;
执行结果 (3 rows)
namedeptsalary
张三技术部15000
李四技术部18000
孙七技术部18000
👆 到目前为止,每行就是一条记录,没有任何"合并"或"统计"。

第 1 步:聚合函数(对整张表做统计)

0. 普通查询
1. 聚合函数
2. GROUP BY
3. HAVING
4. 窗口函数

问题 全公司有多少人?平均薪资多少?

SELECT COUNT(*) AS 总人数, ROUND(AVG(salary),2) AS 平均薪资, SUM(salary) AS 薪资总和, MAX(salary) AS 最高薪资, MIN(salary) AS 最低薪资 FROM employees;
执行结果 (1 row — 7条数据被压缩成了1条)
总人数平均薪资薪资总和最高薪资最低薪资
713571.4395000.0018000.009000.00

🧠 理解要点

聚合函数把一整列的数据压缩成一个数字。7个人的薪资 → 1个平均值、1个总和、1个最大值。
代价是:细节没了——你不知道是谁的薪资最高,只知道最高是18000。

问题 COUNT(*) vs COUNT(dept) 有什么区别?

SELECT COUNT(*) AS count_star, COUNT(dept) AS count_dept FROM employees;
执行结果 (1 row)
count_starcount_dept
76
COUNT(*) 数所有行 = 7。COUNT(dept) 只数 dept 不为 NULL 的行 = 6(周八的 dept 是 NULL,被跳过)。

第 2 步:GROUP BY(按组分别统计)

0. 普通查询
1. 聚合函数
2. GROUP BY
3. HAVING
4. 窗口函数

🧠 GROUP BY 到底干了什么?

想象你面前有一堆员工卡片,GROUP BY dept 的意思就是:
按 dept 把卡片分成几堆,然后对每一堆分别做统计。

关键理解:分组之后,每一组变成了一行。原来组里的每个人被"压扁"了,只留下统计结果。
不能在 SELECT 里直接写 name,因为一个组里有好几个人,数据库不知道你想显示谁的 name。

问题 每个部门各有多少人?

SELECT dept, COUNT(*) AS cnt FROM employees GROUP BY dept;
执行结果 (4 rows — 注意 NULL 也算一组)
deptcnt
技术部3
市场部2
财务部1
NULL1
👆 7条数据被分成了4堆,每堆压缩成1行。原来组里每个人的 name、salary、hire_date 都消失了,只剩统计数字。

思考 如果我这样写,会发生什么?

-- ❌ 错误写法! SELECT dept, name, COUNT(*) AS cnt FROM employees GROUP BY dept;
报错! 技术部有3个人(张三、李四、孙七),数据库不知道 name 该显示谁的。规则:SELECT 里出现的非聚合列,必须出现在 GROUP BY 里

问题 每个部门的平均薪资和总薪资?

SELECT dept, COUNT(*) AS 人数, ROUND(AVG(salary),2) AS 平均薪资, SUM(salary) AS 总薪资 FROM employees GROUP BY dept;
执行结果 (4 rows)
dept人数平均薪资总薪资
技术部317000.0051000.00
市场部212000.0024000.00
财务部111000.0011000.00
NULL19000.009000.00

问题 按部门+薪资分组(多字段 GROUP BY)

GROUP BY 后面可以加多个字段,意思是"这两个字段的组合相同才算同一组"。

SELECT dept, salary, COUNT(*) AS cnt FROM employees GROUP BY dept, salary;
执行结果 (5 rows — 相同部门+相同薪资的合并了)
deptsalarycnt
技术部180002
技术部150001
市场部120002
财务部110001
NULL90001
👆 技术部的李四和孙七都是18000,合并成了1行 cnt=2。市场部王五和赵六都是12000,也合并了。

关键理解 GROUP BY 的本质

分组前 — 7行原始数据

namedeptsalary
张三技术部15000
李四技术部18000
孙七技术部18000
王五市场部12000
赵六市场部12000
张三财务部11000
周八NULL9000

GROUP BY dept 后 — 4行汇总

deptCOUNTAVG(salary)
技术部317000
市场部212000
财务部111000
NULL19000
⚠️ name 列消失了!因为一个组有多个 name,不知道该显示哪个。这就是 GROUP BY 的代价。

第 3 步:HAVING(过滤分组后的结果)

0. 普通查询
1. 聚合函数
2. GROUP BY
3. HAVING
4. 窗口函数

🧠 WHERE vs HAVING

WHERE:分组前过滤,一行一行检查("薪资>10000的人")。
HAVING:分组后过滤,一组一组检查("人数>=2的部门")。

执行顺序:WHERE → GROUP BY → HAVING
先过滤行,再分组,再过滤组。

问题 哪些部门有2人及以上?

SELECT dept, COUNT(*) AS cnt FROM employees GROUP BY dept HAVING COUNT(*) >= 2;
执行结果 (2 rows — 财务部和 NULL 组被过滤掉了)
deptcnt
技术部3
市场部2

问题 2人及以上且平均薪资>13000的部门?

SELECT dept, COUNT(*) AS cnt, ROUND(AVG(salary),2) AS avg_sal FROM employees GROUP BY dept HAVING COUNT(*) >= 2 AND AVG(salary) > 13000;
执行结果 (1 row — 市场部平均12000,也被过滤了)
deptcntavg_sal
技术部317000.00
👆 市场部虽然2人,但平均薪资12000不满足>13000,也被过滤了。只剩技术部。

第 4 步:窗口函数(分组统计但不压扁)

0. 普通查询
1. 聚合函数
2. GROUP BY
3. HAVING
4. 窗口函数

🧠 窗口函数 = GROUP BY 的统计 + 保留每一行

GROUP BY 的问题是:统计完,人没了。你知道技术部平均17000,但不知道每个人具体多少。

窗口函数的做法是:分组的逻辑还在,但不压扁。每个人旁边贴一列统计数字。

类比:GROUP BY 像是"把每堆卡片用皮筋捆起来,只看捆上的标签"。
窗口函数像是"每张卡片上都贴一张便签纸,写着这堆的统计信息,但卡片本身还在"。

核心对比 同一个问题,两种写法

问题:每个部门的平均薪资是多少?

❶ GROUP BY — 结果只有4行

SELECT dept, ROUND(AVG(salary),2) AS avg_sal FROM employees GROUP BY dept;
deptavg_sal
技术部17000
市场部12000
财务部11000
NULL9000
name 列没了!7行→4行

❷ 窗口函数 — 结果还是7行,多了统计列

SELECT name, dept, salary, ROUND(AVG(salary) OVER (PARTITION BY dept),2) AS avg_sal FROM employees;
namedeptsalaryavg_sal
张三技术部1500017000
李四技术部1800017000
孙七技术部1800017000
王五市场部1200012000
赵六市场部1200012000
张三财务部1100011000
周八NULL90009000
✅ 人还在!每人旁边多了部门平均
关键区别:GROUP BY 把7行压成4行,窗口函数还是7行,只是每行多了个统计值。avg_sal 那一列的值,跟左边 GROUP BY 的结果完全一样,只是被"贴"到了每个人旁边。

语法拆解 OVER 和 PARTITION BY

AVG(salary) OVER (PARTITION BY dept)
部分作用相当于
AVG(salary)对 salary 求平均跟 GROUP BY 里的 AVG 一样
OVER告诉数据库:这是窗口函数,不是普通聚合窗口函数的标志
PARTITION BY dept按 dept 分组相当于 GROUP BY dept
你可以把 OVER (PARTITION BY dept) 理解为 "按 dept 分组,但不压扁"

问题 全公司薪资排名(三种排名函数对比)

ORDER BY 是窗口函数里另一个关键部分——它决定窗口内怎么排。

SELECT name, dept, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn, RANK() OVER (ORDER BY salary DESC) AS r, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr FROM employees;
执行结果 (7 rows — 重点看并列18000的三种排名)
namedeptsalaryrn (ROW_NUMBER)r (RANK)dr (DENSE_RANK)
李四技术部18000111
孙七技术部18000211
张三技术部15000332
王五市场部12000443
赵六市场部12000543
张三财务部11000664
周八NULL9000775

ROW_NUMBER = 1,2,3,4...

绝不并列。18000的两人,一个排1一个排2(谁1谁2不确定)

RANK = 1,1,3,4...

并列但跳号。18000两人都是第1,下一个直接跳到第3

DENSE_RANK = 1,1,2,3...

并列不跳号。18000两人都是第1,下一个是第2(最常用)

实际用得最多的是 ROW_NUMBER(去重)和 DENSE_RANK(排名)

组合使用 PARTITION BY + ORDER BY

PARTITION BY 决定"按什么分组",ORDER BY 决定"组内怎么排"。两个一起用就能做"每组内排名"。

SELECT name, dept, salary, ROW_NUMBER() OVER ( PARTITION BY dept -- 按部门分组 ORDER BY salary DESC -- 组内按薪资降序 ) AS dept_rank FROM employees WHERE dept IS NOT NULL;
执行结果 (6 rows — 每个部门内部分别排名)
namedeptsalarydept_rank
李四技术部180001
孙七技术部180002
张三技术部150003
王五市场部120001
赵六市场部120002
张三财务部110001
👆 技术部内部排 1,2,3;市场部内部排 1,2;财务部只有1人所以就是1。每个部门的排名是独立计算的。

问题 每个部门只留薪资最高的那个人(去重)

-- 第1步:给每个部门内的人编号 SELECT *, ROW_NUMBER() OVER ( PARTITION BY dept ORDER BY salary DESC, id ASC -- 薪资相同则id小的优先 ) AS rn FROM employees WHERE dept IS NOT NULL;
中间结果 — 先看 rn 列 (6 rows)
idnamedeptsalaryhire_datern
2李四技术部180002021-07-011
6孙七技术部180002022-09-182
1张三技术部150002022-03-153
3王五市场部120002023-01-101
4赵六市场部120002023-06-202
5张三财务部110002020-11-051
-- 第2步:只取 rn = 1 的行 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY dept ORDER BY salary DESC, id ASC ) AS rn FROM employees WHERE dept IS NOT NULL ) t WHERE rn = 1;
最终结果 (3 rows — 每个部门只留1人)
idnamedeptsalaryhire_datern
2李四技术部180002021-07-011
3王五市场部120002023-01-101
5张三财务部110002020-11-051
去重套路:① PARTITION BY 按去重字段分组 → ② ORDER BY 决定保留哪条 → ③ 外层 WHERE rn=1 只取每组第一条。这个模式记住就行,非常常用。

问题 每个人的薪资,和所在部门平均相比差多少?

这个问题 GROUP BY 做不到!因为你需要同时看到"个人薪资"和"部门平均"。

SELECT name, dept, salary, ROUND(AVG(salary) OVER (PARTITION BY dept),2) AS dept_avg, ROUND(salary - AVG(salary) OVER (PARTITION BY dept),2) AS diff FROM employees WHERE dept IS NOT NULL;
执行结果 (6 rows — diff 为正是高于平均,为负是低于平均)
namedeptsalarydept_avgdiff
张三技术部1500017000-2000
李四技术部1800017000+1000
孙七技术部1800017000+1000
王五市场部12000120000
赵六市场部12000120000
张三财务部11000110000
👆 这就是窗口函数的威力:个人数据和分组统计同时出现在一行里。张三(技术部15000)比部门平均低2000,一眼就看出来了。

问题 按入职日期排序,累计发了多少薪资?

SUM + ORDER BY = 累计求和(每一行的结果是"从第一行到当前行的总和")

SELECT name, hire_date, salary, SUM(salary) OVER (ORDER BY hire_date) AS running_total FROM employees WHERE dept IS NOT NULL;
执行结果 (6 rows — running_total 是从上到下逐行累加)
namehire_datesalaryrunning_total计算过程
张三2020-11-05110001100011000
李四2021-07-01180002900011000+18000
张三2022-03-15150004400029000+15000
孙七2022-09-18180006200044000+18000
王五2023-01-10120007400062000+12000
赵六2023-06-20120008600074000+12000

问题 每个人和上一个入职的人比,薪资变化了多少?

SELECT name, hire_date, salary, LAG(salary, 1, 0) OVER (ORDER BY hire_date) AS prev_sal, salary - LAG(salary, 1, 0) OVER (ORDER BY hire_date) AS diff FROM employees WHERE dept IS NOT NULL;
执行结果 (6 rows)
namehire_datesalaryprev_saldiff
张三2020-11-05110000+11000
李四2021-07-011800011000+7000
张三2022-03-151500018000-3000
孙七2022-09-181800015000+3000
王五2023-01-101200018000-6000
赵六2023-06-2012000120000
👆 LAG(salary, 1, 0) = 取上一行的 salary,如果没上一行就返回0。LEAD 是取下一行,用法一样。

问题 按 name + dept 两个字段联合去重,每组只保留1条

PARTITION BY 后面加多个字段,就是"这几个字段的组合相同才算同一组"。

-- 先加一条重复数据 INSERT INTO employees (name, dept, salary, hire_date) VALUES ('李四', '技术部', 18000, '2021-07-02'); -- 现在有8条数据,李四(技术部)有两条 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY name, dept -- 两个字段联合分区 ORDER BY id ASC -- id小的优先保留 ) AS rn FROM employees ) t WHERE rn = 1;
执行结果 (7 rows — 重复的李四只保留了id=2的那条)
idnamedeptsalaryhire_datern
1张三技术部150002022-03-151
2李四技术部180002021-07-011
3王五市场部120002023-01-101
4赵六市场部120002023-06-201
5张三财务部110002020-11-051
6孙七技术部180002022-09-181
7周八NULL90002024-02-011
👆 张三出现了两次(技术部+财务部),但因为 PARTITION BY name, dept,(张三,技术部)和(张三,财务部)是两个不同的分区,所以都保留了。只有(李四,技术部)这个分区有两条,去重后只留了1条。

总结:什么时候用什么

你想干什么为什么
看每个部门的统计数据 GROUP BY 你不需要每个人的细节,只要汇总
过滤掉某些组(如人数<2的部门) GROUP BY + HAVING HAVING 专门过滤分组后的结果
每个人的薪资旁边显示部门平均 窗口函数 需要个人数据和统计同时出现
排名 / 取每组前N名 窗口函数 GROUP BY 做不了排名
去重但保留完整行 窗口函数 DISTINCT 只能看去重列,ROW_NUMBER 能保留全部字段
累计求和 / 移动平均 窗口函数 SUM + ORDER BY = 累计,ROWS BETWEEN = 移动窗口
取前后行的值做对比 窗口函数 LAG / LEAD 专门干这个

一句话记忆

GROUP BY = 压扁(每组→1行)   vs   窗口函数 = 贴便签(每行旁边加统计值)