村口摆了一场长桌宴:客人按落座顺序排成一长溜,糯米香、米酒甜。你想知道“我们这一桌一共贡献了几道菜”,不必把同村的人全部轰起来重新拼桌——你只管扭头看看同一张桌子前后,心里默默一加就算出来了。SQL 里也有两种做法。GROUP BY 是“重新拼桌”:把相同特征的人凑到一桌,最后只报出每桌的汇总,谁坐在哪、点了什么都不重要了。窗口函数则像这位不挪窝的客人:每一行都留在原处,却能借助 OVER 子句“看向”同区域内前前后后的行,把汇总、排名、差值统统算出来,再贴回到当前行上。这篇文章就带你坐上这张长桌,把窗口函数的语法、排名、聚合、偏移和滑动窗口一次看清——从此写跨行计算,不用再靠一堆自连接绕路。
窗口函数是数据分析师和数据工程师的高频加分项:掌握它,你能把一堆自连接的笨重查询改写成干净的一行 SQL,也让 AI 特征工程里的时序特征构建变得信手拈来。
目录
可视化演示
演示OVER子句窗口框架、PARTITION BY分区排名、ROWS滑动窗口累计求和三大核心场景,点击步骤切换模式。
本节配有交互动效(进入视口后自动加载,约 3KB)。
OVER子句窗口框架 OVER定义窗口范围,不改变行数 当前行高亮,窗口内行参与计算 SUM(val) OVER() = 全局聚合 窗口函数保留原始行,每行附加计算结果
认识窗口函数:一场不换桌的长桌宴
村里摆长桌宴,客人按到达顺序落座。你想统计“我们这一桌一共吃了多少盘菜”,不必让大家换桌拼桌——扭头看看同一桌前后的人,心里一算即可。SQL 里“换桌拼桌”就是 GROUP BY:把相同特征的人聚到一桌,桌上只剩整桌汇总,个人明细全消失。窗口函数则相反:每个人坐在原位,却能“看”到同一张长桌上前后邻座的数据。
窗口函数的全称是“开窗函数”(Window Function),它允许你针对每一行,基于一个由它自己定义的“窗口”(一组相关行)做计算,同时不丢失当前行。它的典型长相是:SUM(amount) OVER (PARTITION BY dept ORDER BY month)。这三个部分各司其职:函数名决定“做什么”(聚合、排名、偏移),OVER 子句决定“窗口开多大”(分区 + 排序),窗口框架决定“窗口往哪扩”。执行时数据库会先按分区排序,再逐行计算,每行都带着完整原始字段输出,这正是它“一行搞定跨行计算”的秘密。
有了这张长桌,每个人既能看到同桌的菜,又不用离开自己的座位。这就是窗口函数的本质:在不动数据行的情况下,让每一行都拥有“左邻右舍”的视野。下次再遇到“每个部门内排名”“每天累计值”这类需求,先想想长桌宴——你不需要重新拼桌。
- 窗口函数名决定计算类型,例如 SUM 求和、RANK 排名、LAG 取前值。
- 参数告诉函数对哪一列进行计算,排名类函数可留空。
- OVER 关键字窗口的“开席口令”,后面括号里描述窗口范围。
- PARTITION BY 列把数据按列分成若干互不干扰的分区,每个分区一张“小桌”。
- ORDER BY 列在分区内排定先后顺序,决定“邻座”是谁。
OVER 子句解析:你的视野有多大
长桌宴上,你的“视野”由三个因素决定:坐在哪一桌(分区)、这桌按什么顺序排(排序)、你能看多远(框架)。OVER 子句就是这套视野规则的说明书。它不改变任何一行数据的位置,只是告诉数据库:帮我把每行的“窗”开好。
PARTITION BY 像用屏风把大堂隔成若干包间——包间之间互相看不见,各自统计;ORDER BY 像把包间里的客人按年龄排座,保证“前一位”和“后一位”是有意义的;窗口框架则决定你回头能看几桌人。三者组合,就构成了每个窗口函数计算时的那一小片“视野”。
几个容易踩的默认值:PARTITION BY 可省略,此时全表就是一个大窗口;没有 ORDER BY 时,聚合结果是整个分区的总量,而不是逐行累计;一旦写 ORDER BY,默认窗口变成“从分区第一行到当前行”,于是 SUM 立刻变成累计求和。理解这三个“默认”,你就掌握了 OVER 子句八成的功力。
OVER 子句就像给每位客人配了一副“定制眼镜”:戴上它,你能看见制定范围内的人,却不必真的挪动位置。记住这个画面,后面三节的排名、聚合、偏移函数,全都建立在这副眼镜之上。
- PARTITION BY 分区把数据按列拆成互不相见的“包间”,包间内独立计算。
- ORDER BY 排序在分区内定先后顺序,决定谁在当前行的前面、后面。
- ROWS BETWEEN 框架显式声明窗口相对当前行的起止边界,例如“前 1 行到当前行”。
- 默认窗口规则有 ORDER BY 时默认从分区首行到当前行;无 ORDER BY 时默认整个分区。
排名函数:颁奖典礼的名次算法
长桌宴进行到颁奖环节:三个人分数分别是 85、90、90。主持人要发名次。ROW_NUMBER 像发票打印机,不论谁先谁后,按座位顺序打出 1、2、3 三张票,绝不重复;RANK 像跳远比赛,两个并列第一之后,第三名实际显示为第 3 名(1、1、3),保留“空位”;DENSE_RANK 则很温柔,并列第一之后直接给第二名(1、1、2),不留空位。NTILE(4) 更像分组员,把客人尽量均匀地分到 4 张桌子,适合做分位数分析。
这些函数使用时都必须配 ORDER BY,排序键决定“谁站在前面”。ROW_NUMBER 还有一个高频用途——去重:按业务键分区、按时间排序,只保留编号为 1 的行,多余的重复数据直接丢进回收站。
RANK 的本质其实很朴素:它就是“有多少行的分数严格高于当前行,再加一”。也正因如此,并列的行会拿到相同的名次,而且名次会跳过后续位置。
颁奖典礼散场后,你只需记住:ROW_NUMBER 发连续票、RANK 留空位、DENSE_RANK 不空位、NTILE 分桌子。选对函数,榜单报表就成功了一半。
- RANK 函数对分区内每行计算名次,括号内不需要参数。
- 条件 score_j > score_i只看严格比当前行分数高的行,并列者互不计入。
- 计数求和每发现一个更高分行就贡献 1,累加出“前面的人数”。
- 加上初始 1第一名从 1 开始,而不是从 0 开始。
聚合窗口函数:不动座位也能报菜名
长桌宴上主持人问:“你们这一桌一共贡献了几道菜?”没人起身,每个人都心算一下,同时报出“本桌总数”。SUM OVER(PARTITION BY) 就是这种“原位报数”——每行都带着整个分区(或窗口)的汇总结果输出,一行明细都不丢。
和 GROUP BY 摆在一起对比最直观:GROUP BY 输出一行代表一桌,像拼桌后的结账单;窗口函数输出行数与输入一致,汇总只是“附身”在每行旁边,像每个人胸前的名牌上多印了一行小字。这个区别是面试官最爱考的点。
配合 ORDER BY 使用,聚合窗口会“活”起来:SUM 变累计和,AVG 变移动平均,COUNT 变累计计数;MIN/MAX 则在每个分区内给出极值。由于默认框架的存在,SUM(x) OVER (PARTITION BY d ORDER BY t) 不需要写任何框架,就是妥妥的按时间累计。
聚合窗口让“报菜名”这件事不用站起来喊,每个人低头一算就有答案。记住:只要看见 SUM/AVG/COUNT 后面跟着 OVER,就意味着汇总结果要“分发”到每一行,而不是合并成一桌。
- 累计和 S_t截止到第 t 行时的总和,每一行都有一个自己的累计值。
- 求和下限 j=1从分区的第一行开始,也就是默认窗口的起点。
- 求和上限 j=t一直加到当前行,窗口终点随行号不断前移。
- v_j参与求和的列值,比如每月的销售额。
偏移访问函数:看前排和后排的客人
长桌宴上,你想知道“坐在我前面那位客人点了什么菜、后面那位来自哪个村”。一扭头就能看见。SQL 里 LAG 就是向前看(按排序的上一行),LEAD 是向后看(下一行)。LAG(sales, 2) 表示“往前数两行取销售额”,非常适合算环比、同比和各类时间差。
FIRST_VALUE 和 LAST_VALUE 则负责看整个分区的首尾行——相当于瞟一眼长桌两端。这四个函数组合起来,能轻松做出“相比上个月增长了多少”“距上一次点击间隔多久”这类特征,是 AI 特征工程里的常客。
使用偏移函数要留意边界:当偏移量超出分区范围时,返回 NULL。比如第一行往前看就没有上一行。实际业务里通常用 COALESCE(LAG(x), 0) 给它一个兜底值,避免下游计算被 NULL 污染。
偏移函数就是“回头看一眼”和“探头看一眼”的 SQL 版。写环比报表时,与其把表和自己 JOIN 一次,不如让 LAG 帮你回头看——既快又清楚。
- LAG 函数取排序后当前行之前第 k 行的值,k 省略时默认为 1。
- v_{t-k}第 t−k 行位置上的列值,即“前面第 k 位客人”的数据。
- 边界处理当 t−k 小于分区第一行时,结果返回 NULL。
滑动窗口范围:一根伸缩的水银柱
量体温时,水银柱爬到哪一截,体温就是多少。滑动窗口就是一根可以伸缩的“水银柱”:它告诉数据库,当前行的窗口起点和终点各在哪。ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 就是把当前行和它前面一行包进来,再往后滑,窗口里的内容就不断“新陈代谢”。
ROWS 与 RANGE 是两种滑法。ROWS 按“物理行数”滑动,说看 3 行就看 3 行,严格数人头;RANGE 按“排序键的值”滑动,只要排序值相同就一起进窗。于是当排序键出现并列时,RANGE 会把并列行一股脑全纳入窗口,ROWS 却只数固定行数,两者结果可能不一样——这是最容易翻车的细节。
移动平均是滑动窗口最经典的应用:比如股票 5 日均线、销售额 7 日平滑,AVG(x) OVER (ORDER BY t ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) 一行就算完。
滑动窗口就像体温计里的水银柱,可长可短、随行滑动。理解 ROWS 与 RANGE 的区别之后,移动平均、累计求和、滚动极值这些报表需求都能信手拈来。
- MA_t第 t 行的移动平均值,每行对应一个。
- 窗口起点 j=t−k+1从当前行往前数 k−1 行开始纳入。
- 窗口终点 j=t到当前行结束,窗口随行号一起前进。
- 除以 k窗口内 k 个值求和后取平均。
性能与实战:让长桌宴不卡壳
千人长桌宴,如果每问一个“邻座是谁”都要重新排一次队,宴席就崩了。自连接就是这种笨办法:把表和自己 JOIN,让每一行和其他行做笛卡尔式比较,复杂度高达 O(n²)。窗口函数相当于先把名单排好序,再从头到尾扫一遍,谁和谁是邻居一目了然,复杂度降为 O(n log n)。
优化三板斧:第一,WHERE 条件一定要写在窗口计算之前,提前“裁剪”掉不参加宴席的客人(分区裁剪);第二,在分区列和排序列上建索引,省掉临时排序;第三,避免窗口套窗口的重复计算,能一次 OVER 算完就别包三层子查询。
实战中最常见的三个场景由此展开:月度销售额累计与环比、部门内员工薪资排名、近 7 日移动平均。三者都只需一条带 OVER 的 SELECT,比自连接方案短一半以上,跑得还更快。
学会窗口函数后,长桌宴不再需要反复“重新排队”。下次写跨行计算时,先想想能不能用 OVER 一次搞定——既能保住明细,又能省下自连接的巨额开销。
- 自连接复杂度 O(n²)每行都要和表中其他行两两比较,等于让所有客人互相握手。
- 窗口函数复杂度 O(n log n)先排序一次,再线性扫描一遍即可完成计算。
- 行数 n 越大差距越明显n 增长时平方级开销飞速膨胀。
AI 应用场景
数据分析师:销售报表里的累计与移动平均
阶段:数据洞察与报表分析
解决的问题:传统做法按月自连接计算累计值和上期值,SQL 又长又慢;窗口函数一行算完“本月累计”与“近 7 日移动平均”,且每日明细完整保留,报表可下钻。
具体案例:某电商公司生成每日经营日报,用 SUM(amount) OVER (PARTITION BY year ORDER BY day) 算年度累计,用 AVG(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) 算 7 日移动平均,再配合 LAG 算环比。
数据工程师:用户行为日志去重与清洗
阶段:数据清洗与预处理
解决的问题:埋点日志常有重复上报,用自连接去重效率低;用 ROW_NUMBER() OVER (PARTITION BY device_id ORDER BY event_time) 给每次事件编号,只保留编号为 1 的行,即可在保留最新记录的同时干掉重复。
具体案例:广告点击日志一天上亿条,数据工程师按“用户 + 事件类型 + 事件时间”分区编号去重,输出干净样本集供下游模型训练。
特征工程:构造时序点击特征
阶段:特征工程
解决的问题:点击率预估模型需要“距上次点击的间隔”“最近 N 次消费均值”等时序特征;用 LAG 和 AVG OVER 直接从行为表算出特征列,避免反复自连接,特征管线更短更快。
具体案例:推荐系统特征流水线用 LAG(timestamp) OVER (PARTITION BY user_id ORDER BY timestamp) 计算相邻两次点击的间隔秒数,喂给排序模型,显著提升行为序列特征的覆盖率。
Python 演示
# -*- coding: utf-8 -*-# 用纯 Python 模拟 SQL 窗口函数: 排名、累计求和、移动平均、偏移取值 def window_rank(rows, order_key, dense=False): # 模拟 RANK() 与 DENSE_RANK() OVER (ORDER BY order_key DESC) indexed = [(i, r[order_key]) for i, r in enumerate(rows)] indexed.sort(key=lambda item: item[1], reverse=True) ranks = [0] * len(rows) rank = 1 dense_rank = 1 for pos, (idx, val) in enumerate(indexed): if pos > 0 and val != indexed[pos - 1][1]: rank = pos + 1 dense_rank += 1 ranks[idx] = dense_rank if dense else rank return ranks def running_sum(rows, key, partition_key=None): # 模拟 SUM(key) OVER (PARTITION BY partition_key ORDER BY 原始顺序) if partition_key is None: groups = [("", rows)] else: temp = {} for row in rows: temp.setdefault(row[partition_key], []).append(row) groups = list(temp.items()) out = [] for _, group in groups: total = 0 for row in group: total += row[key] out.append({**row, "running_sum": total}) return out def moving_average(rows, key, k=3): # 模拟 AVG(key) OVER (ORDER BY 原始顺序 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) out = [] for i in range(len(rows)): window = rows[max(0, i - k + 1): i + 1] avg = sum(row[key] for row in window) / len(window) out.append({**rows[i], "moving_avg": round(avg, 2)}) return out def lag_value(rows, key, gap=1): # 模拟 LAG(key, gap) OVER (ORDER BY 原始顺序) out = [] for i in range(len(rows)): prev = rows[i - gap][key] if i - gap >= 0 else None out.append({**rows[i], "lag_" + str(gap): prev}) return out if __name__ == "__main__": sales = [ {"month": "2024-01", "amount": 120, "team": "A"}, {"month": "2024-02", "amount": 150, "team": "A"}, {"month": "2024-03", "amount": 130, "team": "A"}, {"month": "2024-04", "amount": 170, "team": "B"}, {"month": "2024-05", "amount": 160, "team": "B"}, {"month": "2024-06", "amount": 180, "team": "B"}, ] print("---- 按团队累计求和 ----") for row in running_sum(sales, "amount", "team"): print(row) print("---- 移动平均(3个月) ----") for row in moving_average(sales, "amount", 3): print(row) print("---- LAG 取上月值 ----") for row in lag_value(sales, "amount", 1): print(row) print("---- RANK vs DENSE_RANK ----") scores = [{"name": "Alice", "score": 90}, {"name": "Bob", "score": 85}, {"name": "Cindy", "score": 90}] ranks = window_rank(scores, "score", dense=False) dense = window_rank(scores, "score", dense=True) for i, item in enumerate(scores): print(item["name"], "RANK=", ranks[i], "DENSE=", dense[i])
indexed.sort(key=lambda item: item[1], reverse=True)相当于窗口里的 ORDER BY score DESC,先按分数降序排好队。rank = pos + 1` 与 `dense_rank += 1遇到不同分数时分道扬镳:RANK 取当前排位,DENSE_RANK 只加 1,这正是两者区别的代码级体现。temp.setdefault(row[partition_key], []).append(row)手动实现 PARTITION BY,把同一团队的记录聚到一组。total += row[key]组内逐行累加,就是 ORDER BY 之后的默认累计框架。window = rows[max(0, i - k + 1): i + 1]切片即滑动窗口,等价于 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW。prev = rows[i - gap][key] if i - gap >= 0 else None越界返回 None,等价于 LAG 超出分区范围返回 NULL。
常见误区
误区用 `SUM() OVER()` 之后,输出是不是就只剩一行汇总了?我每次写完都担心数据变少,还得再查一遍行数。
正解窗口函数不会压缩行:它把汇总值“复制”到每一行,输出行数与输入完全一致。真正会压缩行的只有 GROUP BY。就像长桌宴上每个人都能报出全桌总数,但所有客人都还坐在原位,一个都没少。
为何常见:平时用聚合函数都要配 GROUP BY,习惯性地以为“用了聚合就会合并行”,于是把 GROUP BY 的直觉错误地迁移到了窗口函数上。
误区这三个排名函数不都是输出 1、2、3 吗?随便选一个应该都一样吧。
正解遇到并列成绩时三者立刻分道扬镳:90 分并列第一时,ROW_NUMBER 输出 1、2,RANK 输出 1、1、3,DENSE_RANK 输出 1、1、2。ROW_NUMBER 是发票号,RANK 是跳远比赛名次,DENSE_RANK 是温柔版颁奖。
为何常见:平时数据没有并列值时三者结果一模一样,只有碰到边界情况才暴露差异,所以初学阶段很容易忽略。
误区我写了 `SUM(amount) OVER (PARTITION BY dept)`,为什么返回的不是逐月累计,而是每个月都一样的大数?
正解PARTITION BY 只负责分区,不负责排序;没有 ORDER BY 时,SUM 返回的是整个分区的总和,而不是累计值。只有加上 ORDER BY,默认窗口才会变成“分区首行到当前行”,SUM 才具有累计语义。就像不排座位的长桌,你根本分不清谁“前”谁“后”。
为何常见:初学时以为窗口函数只要写出 OVER 就自带排序,忽略了 ORDER BY 这个驱动“累计”方向的关键开关。
误区我在 SELECT 里写了 `SUM(...) OVER (...) AS total` 和 `WHERE total > 100`,为什么报错说列不存在?
正解窗口函数在 WHERE 之后才计算,SELECT 中的别名在 WHERE 阶段还不可见。必须先包一层子查询,在外层再过滤。就像开席前你不能问“哪桌吃得最多再决定谁入座”——得等菜上齐、统计完,才能按结果筛选。
为何常见:SELECT 别名在 ORDER BY 中可用,容易让人误以为 WHERE 也能用,忽略了 SQL 各子句的执行顺序。
误区`ROWS BETWEEN` 和 `RANGE BETWEEN` 不都是限定行数吗?我随便写一个就行。
正解ROWS 按物理行数滑动,说看 3 行就数 3 个人头;RANGE 按排序键的值滑动,遇到相同排序值会把并列行一起纳入窗口。当排序键有大量并列值时,两者窗口大小和计算结果都可能不同。就像“数三个人”和“把分数相同的一批人都叫进来”,规则完全不同。
为何常见:大多数业务数据排序键唯一,两者结果一致,缺少并列场景的实战经验就难以察觉差异。
面试题
Q1. 窗口函数和 GROUP BY 最大的区别是什么?
- A.窗口函数会压缩行数
- B.窗口函数保留每行明细← 正确答案
- C.GROUP BY 不进行聚合
- D.两者功能完全相同
答案:B。窗口函数像长桌宴上每个人都能报出全桌总数而不离席,输出行数与输入一致;GROUP BY 则像把同家人重新拼桌,只剩每桌汇总,明细消失。A、C、D 均与事实相反。
Q2. ROW_NUMBER() 遇到并列的成绩会怎么编号?
- A.两人并列得到相同编号
- B.编号连续且绝不会重复← 正确答案
- C.并列之后编号直接跳跃
- D.并列成绩会被输出为空
答案:B。ROW_NUMBER 像发票打印机,即使成绩相同也按行顺序发 1、2、3,编号连续且唯一。A 是 DENSE_RANK 的错觉,C 是 RANK 的行为,D 完全不存在。
Q3. RANK() 与 DENSE_RANK() 的关键区别是?
- A.两者计算结果永远一样
- B.RANK 并列后名次会跳跃← 正确答案
- C.DENSE_RANK 并列后跳跃
- D.RANK 不允许并列出现
答案:B。两个 90 分并列第一时,RANK 输出 1、1、3(跳空),DENSE_RANK 输出 1、1、2(不跳空)。RANK 像跳远比赛保留坑位,DENSE_RANK 像贴心主持人重新接续。
Q4. OVER 子句内可以包含哪些部分?
- A.PARTITION BY 分区列← 正确答案
- B.ORDER BY 排序列← 正确答案
- C.ROWS 与 RANGE 框架← 正确答案
- D.WHERE 过滤条件
答案:ABC。OVER 里能写分区、排序、框架三件套,它们共同定义窗口视野。WHERE 在窗口计算之前就已生效,属于查询过滤阶段,不能放进 OVER 括号里。
Q5. LAG(sales, 2) 返回的是什么?
- A.当前行之后两行的值
- B.当前行之前两行的值← 正确答案
- C.分区内最大的销售额
- D.窗口内累计的销售额
答案:B。LAG 像回头看前排客人,第二个参数 2 表示往前数两行;回头看后两行的是 LEAD。C 是 MAX 窗口,D 是 SUM 窗口,都与偏移函数无关。
Q6. 想按部门统计总销售额且保留员工明细,应使用哪种写法?
- A.SUM OVER 带部门分区← 正确答案
- B.SUM OVER 带全局排序
- C.GROUP BY 且保留全部明细
- D.窗口函数无法保留明细
答案:A。SUM(amount) OVER (PARTITION BY dept) 按部门分区计算,并把汇总复制到每一行,明细全保留。B 没分区,C 中 GROUP BY 天然会合并行,D 与窗口函数的基本特性相反。
Q7. 关于 ROWS 与 RANGE 的说法,正确的是?
- A.ROWS 按物理行数滑动← 正确答案
- B.RANGE 按排序键的值滑动← 正确答案
- C.有并列值时结果可能不同← 正确答案
- D.两者计算结果永远等价
答案:ABC。ROWS 数人头,RANGE 按排序值把并列行一起纳入窗口。当排序键有大量并列值时,两者窗口边界不同、结果可能不同,D 说法错误。理解这一点是滑动窗口的进阶门槛。
Q8. SELECT 里给窗口函数起别名后,能在 WHERE 中直接用它过滤吗?
- A.可以,直接正常执行
- B.不可以,需包一层子查询← 正确答案
- C.可以,WHERE 自动识别
- D.会用 GROUP BY 代替过滤
答案:B。窗口函数在 WHERE 之后才计算,别名在 WHERE 阶段不可见,必须先子查询再过滤。就像开席前无法统计“哪桌吃得多”,必须等菜上齐、统计完成后才能按结果筛选。
Q9. NTILE(4) 的作用是什么?
- A.把结果分成四个名次
- B.均匀切成四个数据桶← 正确答案
- C.只输出前四行数据
- D.删除后四行数据
答案:B。NTILE(4) 把分区内数据尽量均匀地分成 4 个桶,常用于分位数抽样和分组对比。A 把分桶误解为排名,C、D 更是偏离了分桶的本质。
Q10. 关于窗口函数性能的理解,正确的是?
- A.大表下通常优于自连接写法← 正确答案
- B.仍可能产生全表扫描和排序← 正确答案
- C.合适的索引可缓解排序开销← 正确答案
- D.窗口函数完全不产生排序
答案:ABC。窗口函数把跨行运算收进“一次排序 + 一趟扫描”,比自连接的 O(n²) 嵌套比较高效,但排序成本依然存在。WHERE 裁剪和索引能进一步优化,D 声称零排序成本是不对的。