ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL窗口函数实战避坑指南:排名/同比/滚动计算全解析

SQL窗口函数实战避坑指南:排名/同比/滚动计算全解析

简介:这是一份专为数据库从业者打造的《SQL窗口函数速查表》PDF文档,面向数据库管理员、数据分析师、开发工程师及SQL进阶学习者,聚焦解决复杂数据分析场景中窗口函数选型难、语法易错、应用场景模糊等实际问题。文档系统梳理了排名函数(ROW_NUMBER/RANK/DENSE_RANK)、偏移函数(LEAD/LAG)与聚合类窗口函数(SUM/AVG/COUNT等)三类核心能力,详解语法结构、PARTITION BY与ORDER BY组合逻辑、帧范围定义等关键细节,并配以可直接复用的典型示例代码,助力用户快速定位函数、理解行为差异、规避常见陷阱。资源为单文件PDF格式,共1个841KB文档,内容按功能分类编排,支持高效检索与碎片化学习。目前已有255人下载学习,适合日常查询参考、面试突击复习或教学辅助使用。

1. SQL窗口函数速查表:不是语法手册,而是你写不出正确排名/同比/滚动均值时的「后悔药」

上周帮某高校实验室调一个学生行为分析查询,需求很朴素:按班级分组,给每个学生的考试成绩排个名,再算出他比上一名高多少分、比班级平均分高多少——结果写了三版SQL,前两版要么排名跳号错乱,要么同比字段全 NULL,第三版加了子查询嵌套四层,执行耗时从 0.2s 涨到 8.6s。最后翻出这张《SQL窗口函数速查表》PDF,对照着「RANK() vs DENSE_RANK() 的空值穿透逻辑」和「LAG() 的 frame_clause 默认边界陷阱」两条,15 分钟重写,查询压回 0.3s,结果零误差。这不是一张印满函数名的花哨海报,而是一线 DBA 和分析师在真实 OLAP 场景里反复踩坑后,把「为什么这里必须加 ORDER BY」「为什么 PARTITION BY 后不能漏掉 OVER」「为什么 LEAD(1) 在无序数据上等于随机抽签」这些血泪经验,压缩进一页 A4 纸的实战结晶。它不教你怎么安装数据库,也不讲 ANSI SQL 标准演进史;它只解决一件事:当你面对「连续 N 日销量」「每组 Top3 用户」「同比环比自动对齐」这类需求时,能立刻定位该用哪个函数、怎么写 OVER 子句、哪些参数组合会静默失效。适合正在写报表 SQL 却卡在「排名不连续」「移动平均算不准」「前后行取值错位」的数据库管理员、数据分析师、后端开发工程师——尤其适合那些被业务方催着改第 7 版「看起来对但跑出来是错的」SQL 的人。

2. 窗口函数不是魔法:先搞懂这三类函数的本质差异,再动手写

窗口函数之所以常被误用,根本原因在于很多人把它当成「带 GROUP BY 的聚合函数」来理解。但窗口函数真正的核心,是「保留原始行粒度 + 在逻辑窗口内做计算」。这一条原则,直接决定了三类函数的适用边界、参数敏感度和错误表现形式。下面拆开看,每类都配一个真实翻车场景和对应速查表里的关键提示点。

2.1 排名函数:ROW_NUMBER()、RANK()、DENSE_RANK() 的「并列处理哲学」

这三者表面都是排序编号,但底层逻辑完全不同。ROW_NUMBER() 是纯机械计数器,不管值是否重复,强行塞进 1,2,3…;RANK() 则承认并列,但用「跳号」体现公平性(比如两个并列第一,下一个就是第三);DENSE_RANK() 同样承认并列,却坚持「紧凑编号」(两个第一,下一个就是第二)。速查表里特别强调:RANK() 和 DENSE_RANK() 对 NULL 的处理默认是「最大值优先」,即 NULL 被视为最大值排在末尾——这点在清洗脏数据时极易翻车。

举个典型例子:某电商订单表orders中有user_id,order_date,amount,需按用户分组,对订单金额降序排名。若某用户有两条订单金额均为 NULL:

SELECT user_id, order_date, amount, RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rank_desc FROM orders;

提示:ORDER BY amount DESC下,NULL 默认排最前还是最后?不同数据库行为不一致!PostgreSQL 默认 NULLS LAST,MySQL 8.0+ 默认 NULLS FIRST。速查表第 12 页明确标注:显式声明NULLS LAST或NULLS FIRST是跨库兼容的唯一保险做法,否则同一段 SQL 在测试环境(PostgreSQL)跑通,上线到生产(MySQL)就排名错乱。

2.2 行序号函数:LAG()/LEAD() 的「时间轴陷阱」与「frame_clause 黑匣子」

LAG() 和 LEAD() 常被当作「取上一行/下一行」的快捷键,但这是巨大误解。它们真正操作的是「当前行在窗口内的逻辑位置」,而非物理存储顺序。速查表用加粗红字标出:没有显式ORDER BY的 LAG(),等同于对无序集合做随机采样。更隐蔽的坑在frame_clause:默认ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW只影响聚合类窗口函数,对 LAG()/LEAD() 无效——它们只认ORDER BY定义的序列,不认 frame 边界。

验证这个逻辑,用模拟销售流水表sales_log(含date,region,revenue):

-- 错误示范:没加 ORDER BY,结果不可预测 SELECT date, region, revenue, LAG(revenue) OVER (PARTITION BY region) AS prev_revenue -- ❌ 危险! FROM sales_log; -- 正确写法:强制按日期排序,确保「上一日」语义 SELECT date, region, revenue, LAG(revenue) OVER ( PARTITION BY region ORDER BY date ASC ) AS prev_revenue -- ✅ 明确时间轴 FROM sales_log;

注意:ORDER BY date ASC后,LAG(revenue, 1) 才稳定返回「本区域中,日期紧邻当前行之前的那条记录的 revenue」。如果date有重复(如一天多笔),速查表第 18 页建议:追加二级排序字段,例如ORDER BY date ASC, id ASC,消除不确定性。这是某公司 BI 团队线上事故的根因——促销日单日订单暴增,date重复导致 LAG 取到同日另一笔订单,同比计算全盘失真。

2.3 聚合类窗口函数:SUM()/AVG()/COUNT() 的「窗口范围生死线」

聚合函数在窗口中使用时,行为与普通 GROUP BY 截然不同:它不压缩行数,而是为每行输出一个「基于其所在窗口的聚合值」。但窗口范围(frame)的定义,直接决定计算结果是否符合业务预期。速查表将frame_clause拆成三档:ROWS(物理行偏移)、RANGE(值域偏移)、GROUPS(分组偏移),并用表格对比各档在ORDER BY含重复值时的行为差异。

以计算「滚动 7 日销售额」为例,表daily_sales含sale_date,amount:

-- 危险写法:用 RANGE,当 sale_date 有重复时,窗口可能吞入 10 天数据 SELECT sale_date, amount, SUM(amount) OVER ( ORDER BY sale_date RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW ) AS rolling_7d_sum FROM daily_sales; -- 安全写法:用 ROWS + 显式排序去重,确保严格 7 行 SELECT sale_date, amount, SUM(amount) OVER ( ORDER BY sale_date, id -- id 保证每行唯一序 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS rolling_7d_sum FROM daily_sales;

提示:RANGE BETWEEN INTERVAL '6' DAY PRECEDING依赖sale_date的值连续性。若某日无销售记录(sale_date缺失),该日不会被计入窗口,但「6 天前」的起始点仍按日历计算,导致实际覆盖天数 >7。而ROWS BETWEEN 6 PRECEDING严格取前 6 行 + 当前行,共 7 行,与日期无关。速查表第 23 页用加粗框强调:时间序列类滚动计算,优先选 ROWS + 唯一排序键;RANGE 仅适用于已知日期完整且需按自然日历对齐的场景。

3. 避坑:五条让老手也栽跟头的窗口函数常见问题排查

窗口函数的报错往往不直接,更多是「结果看起来对,但业务逻辑错了」。速查表把高频静默错误归为五类,每条都来自真实生产事故复盘。以下排查路径,我已在三个不同行业的数据平台验证过有效性。

3.1 现象:RANK() 排名出现「1,1,3,3,5」,但业务要求「1,1,2,2,3」

  • 原因:混淆了RANK()与DENSE_RANK()的设计哲学。RANK()在遇到并列时跳过后续编号(两个第一,下一个必是第三),而DENSE_RANK()则保持编号紧凑。
  • 解决:立即检查OVER子句中调用的函数名。若需紧凑排名,将RANK()替换为DENSE_RANK()。速查表第 7 页用双色对比图展示二者在[100,100,90,90,80]序列下的输出差异,一目了然。

3.2 现象:LAG(amount) 返回 NULL,但表中前一行明明有值

  • 原因:LAG()依赖ORDER BY定义的逻辑序列。若ORDER BY字段存在重复值,且未指定二级排序,数据库可能任意选择「前一行」,导致结果不稳定;更常见的是PARTITION BY分组后,首行无前驱,LAG()必然返回 NULL(这是正确行为,非 Bug)。
  • 解决:第一步,确认是否为分组首行——添加ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) AS rn,观察rn=1时LAG()是否为 NULL;第二步,若非首行仍为 NULL,检查ORDER BY字段是否存在大量重复,并追加唯一字段(如主键id)作为二级排序。

3.3 现象:SUM(amount) OVER (PARTITION BY region) 结果与 SELECT SUM(amount) FROM t GROUP BY region 完全一致

  • 原因:误以为窗口聚合会自动「去重」或「过滤」。实际上,SUM() OVER (PARTITION BY region)为每个region组内每一行都返回该组总和,行数不变;而GROUP BY会压缩行数。若两者结果行数相同且值相等,大概率是PARTITION BY字段在表中实际只有单值(如全为 '华东'),导致整个表被划为一个窗口。
  • 解决:执行SELECT COUNT(DISTINCT region) FROM your_table,确认分组字段确实存在多值。若为单值,检查字段名拼写(如region写成reigon导致别名未生效)或数据清洗环节是否误置。

3.4 现象:使用 ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING 计算移动平均,结果首尾两行全为 NULL

  • 原因:ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING定义了一个 3 行窗口(前1+当前+后1)。对于首行,无「前1行」;对于末行,无「后1行」。默认情况下,窗口无法构建,聚合函数返回 NULL。
  • 解决:速查表第 25 页给出两种方案:① 使用ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW改为累积计算(适合趋势分析);② 显式处理边界,用COALESCE(AVG(...), amount)用当前行值填充,或用CASE WHEN ROW_NUMBER()... = 1 THEN ... ELSE ... END分支计算。切勿依赖数据库默认的「自动截断窗口」行为,各厂商实现不一致。

3.5 现象:同一个查询,在 PostgreSQL 上正常,在 MySQL 8.0 上报错Window 'w' not found

  • 原因:MySQL 8.0 对命名窗口(WINDOW w AS (...))的支持较弱,且要求命名窗口定义必须在SELECT子句之前(即SELECT ... WINDOW w AS (...)),而 PostgreSQL 允许在OVER中直接引用或在SELECT后定义。更常见的是 MySQL 不支持RANGE BETWEEN INTERVAL语法。
  • 解决:速查表第 31 页提供跨库兼容清单:① 彻底弃用命名窗口,所有OVER子句写全;② 时间范围一律改用ROWS+ 排序键;③ 在ORDER BY中显式声明NULLS LAST(MySQL 8.0.22+ 支持,旧版需用COALESCE(date, '1970-01-01')临时规避)。上线前必须在目标数据库版本上执行EXPLAIN FORMAT=TREE查看窗口执行计划,确认无隐式转换。

4. 语法结构精解:从OVER (...)拆解到每一个括号里的参数含义

窗口函数的威力全藏在OVER子句里,而OVER又是 SQL 中嵌套层级最深、可选项最多的子句之一。速查表没有罗列所有标准语法,而是聚焦「80% 场景真正需要的参数组合」,用表格+代码块直击要害。下面以SUM(amount) OVER (PARTITION BY region ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)为蓝本,逐层剥开。

4.1PARTITION BY:分组不是目的,是定义「计算上下文」的起点

PARTITION BY将数据划分为独立计算单元,每个单元内单独执行窗口函数。关键认知:它不改变原始行数,只改变每行所见的「局部世界」。例如PARTITION BY region后,上海区域的行看不到北京的数据,但上海内部所有行共享同一个「上海总销售额」。

参数是否必需说明速查表重点提醒
PARTITION BY col1, col2否按多列分组,等价于GROUP BY的多维分组逻辑若省略,整个结果集视为一个大窗口,慎用于大数据量表(内存压力剧增)
PARTITION BY (SELECT ...)否支持子查询,但性能极差,仅限小表速查表第 9 页警告:子查询分区在 MySQL 中可能导致全表扫描,应预先 JOIN 生成分区键

4.2ORDER BY:没有它,LAG()/LEAD()/排名函数就是「玄学」

ORDER BY在窗口函数中承担双重角色:① 为LAG()/LEAD()定义「前/后」的参照系;② 为RANGE类 frame 定义值域边界。速查表用红框标出:ORDER BY是 LAG()/LEAD()/排名函数的绝对必需项,缺则报错或结果不可控;对聚合函数则是可选,但缺则失去时间/序列意义。

-- ✅ 正确:ORDER BY 明确,LAG 语义清晰 SUM(amount) OVER (PARTITION BY region ORDER BY date) -- ❌ 危险:无 ORDER BY,SUM 算的是整个 region 的固定总和,无滚动意义 SUM(amount) OVER (PARTITION BY region) -- ❌ 报错:LAG 必须有 ORDER BY LAG(amount) OVER (PARTITION BY region) -- PostgreSQL: error; MySQL: NULL or random

注意:ORDER BY字段类型必须支持比较运算。对字符串字段,注意排序规则(collation)影响大小写敏感性;对时间字段,确认时区一致性。速查表第 15 页案例:某跨国系统created_at存为TIMESTAMP WITHOUT TIME ZONE,不同服务器时区设置导致ORDER BY created_at结果漂移,LAG 取值错乱。解决方案:统一转为TIMESTAMP WITH TIME ZONE并AT TIME ZONE 'UTC'标准化。

4.3frame_clause:ROWS/RANGE/GROUPS 的生死抉择

frame_clause定义窗口的「活动范围」,是窗口函数最易被忽视也最致命的部分。速查表用三栏对比表(第 22 页)清晰列出:

类型语法示例适用场景致命陷阱
ROWSROWS BETWEEN 2 PRECEDING AND 1 FOLLOWING严格按物理行数计算(如滚动 3 日、前2后1)PRECEDING/FOLLOWING值必须为非负整数,不能是表达式
RANGERANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW按值域范围计算(如「过去7天内所有订单」)RANGE在ORDER BY含重复值时,会将所有重复值纳入同一窗口,导致窗口膨胀
GROUPSGROUPS BETWEEN 1 PRECEDING AND CURRENT GROUP按GROUP BY分组逻辑计算(MySQL 8.0.23+, PostgreSQL 11+)兼容性最差,生产环境慎用,优先用ROWS模拟

4.4DISTINCT与ALL:聚合函数里的「去重开关」

DISTINCT仅对COUNT(),SUM(),AVG()等聚合函数有效,作用于OVER内部的表达式。速查表强调:DISTINCT不能消除PARTITION BY分组内的重复行,它只对聚合输入去重。

-- 示例:统计每个 region 内不同 product_id 的数量 COUNT(DISTINCT product_id) OVER (PARTITION BY region) -- 错误理解:认为这会去掉 region 内重复的 product_id 行 -- 正确理解:对每个 region 窗口,收集所有 product_id,去重后再计数

提示:COUNT(DISTINCT ...)窗口函数在大数据量下性能较差(需哈希去重),速查表第 28 页建议:若只需判断「是否存在」,用BOOL_OR(product_id = 'X') OVER (PARTITION BY region)更高效;若需 TopN 去重统计,改用ARRAY_AGG(DISTINCT product_id) OVER (...)+CARDINALITY()(PostgreSQL)或JSON_LENGTH(JSON_EXTRACT(...))(MySQL)。

5. 实战场景拆解:用速查表三步写出「同比环比」查询

「同比环比」是窗口函数最经典也最容易写错的场景。业务方要的从来不是「技术上能跑」,而是「数字对得上财务系统」。速查表第 35-38 页用某零售企业的真实需求为例,拆解从需求理解到 SQL 落地的完整链路,每一步都标注速查表对应页码和避坑点。

5.1 需求还原:听懂业务语言背后的 SQL 语义

业务描述:「我要看每个门店每月销售额,以及相比上个月的增长率、相比去年同月的增长率」。这背后隐藏三个关键 SQL 语义:

  • 「每月销售额」→ 需GROUP BY store_id, YEAR(month), MONTH(month)聚合;
  • 「相比上个月」→ 需LAG(sum_amount),且ORDER BY必须是「年月」的连续序列(不能只用month,否则 12 月后接 1 月会错乱);
  • 「相比去年同月」→ 需LAG(sum_amount, 12),但前提是数据按「年月」严格升序,且无缺失月份(否则LAG(..., 12)会跳到 11 个月前)。

速查表第 35 页提醒:业务说的「上个月」,在 SQL 中必须映射为DATE_SUB(MAX(date), INTERVAL 1 MONTH)的精确计算,而非简单LAG()。因为LAG()依赖数据存在性,若某月无销售,LAG()会取更早的月份,导致同比错位。

5.2 数据预处理:构造无缺口的「年月维度表」

真实数据总有缺失(如新店开业当月无数据)。速查表第 36 页给出通用方案:用递归 CTE 或日历表 LEFT JOIN,补全所有「门店 × 年月」组合,缺失值填 0。

-- 构造完整年月序列(2023-01 至 2024-12) WITH RECURSIVE month_series AS ( SELECT '2023-01-01'::DATE AS month_start UNION ALL SELECT month_start + INTERVAL '1 MONTH' FROM month_series WHERE month_start < '2024-12-01' ), -- 交叉门店与年月 store_month AS ( SELECT DISTINCT s.store_id, m.month_start FROM stores s CROSS JOIN month_series m ) -- 关联销售数据,缺失补 0 SELECT sm.store_id, sm.month_start, COALESCE(SUM(o.amount), 0) AS monthly_sales FROM store_month sm LEFT JOIN orders o ON sm.store_id = o.store_id AND EXTRACT(YEAR FROM o.order_date) = EXTRACT(YEAR FROM sm.month_start) AND EXTRACT(MONTH FROM o.order_date) = EXTRACT(MONTH FROM sm.month_start) GROUP BY sm.store_id, sm.month_start;

注意:EXTRACT(YEAR FROM ...)比YEAR()函数更跨库兼容;CROSS JOIN生成笛卡尔积后LEFT JOIN,确保每个门店每月都有记录。这是速查表强调的「数据质量先行」原则——窗口函数再强大,也无法在缺失数据上创造逻辑。

5.3 窗口计算:三步写出零误差同比环比

基于补全后的monthly_sales结果,用速查表第 37 页的「三步法」:

Step 1:按门店 + 年月严格排序,生成唯一序列

SELECT store_id, month_start, monthly_sales, -- 为每行生成全局序号,确保 LAG 稳定 ROW_NUMBER() OVER (PARTITION BY store_id ORDER BY month_start) AS rn FROM monthly_sales;

Step 2:用 LAG 计算环比(上月)和同比(12 月前)

SELECT store_id, month_start, monthly_sales, LAG(monthly_sales) OVER ( PARTITION BY store_id ORDER BY month_start ) AS last_month_sales, LAG(monthly_sales, 12) OVER ( PARTITION BY store_id ORDER BY month_start ) AS last_year_same_month_sales FROM ( -- Step 1 的结果 ) t;

Step 3:计算增长率,处理除零和 NULL

SELECT store_id, month_start, monthly_sales, CASE WHEN last_month_sales = 0 THEN NULL -- 避免除零 ELSE ROUND((monthly_sales - last_month_sales) * 100.0 / last_month_sales, 2) END AS mom_growth_pct, CASE WHEN last_year_same_month_sales = 0 THEN NULL ELSE ROUND((monthly_sales - last_year_same_month_sales) * 100.0 / last_year_same_month_sales, 2) END AS yoy_growth_pct FROM ( -- Step 2 的结果 ) t;

速查表第 38 页终极提醒:所有增长率计算必须包裹CASE WHEN ... THEN NULL ELSE ... END,且分母判零逻辑要放在LAG()之后。曾有团队在LAG()前就WHERE last_month_sales > 0,导致过滤掉首月数据,环比全断。从那以后我每次写同比环比,都强制走一遍「补全维度 → 排序打序号 → LAG 取值 → 判零计算」四步,少一步都不提交。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进