在做数据分析或报表开发时,经常需要回答这样的问题:最近7天的平均销售额是多少?从年初到当前月份累计完成了多少业绩?某个用户的连续登录天数有多长?这类问题统称为滑动窗口统计。如果用传统 SQL 的自关联写法,不仅语句冗长难读,数据量一大还会带来严重的性能问题。窗口函数的出现让这类需求变得非常简洁,尤其是配合 ROWS 和 RANGE 帧控制子句,一条查询就能完成过去需要多层子查询才能实现的效果。本文将系统讲解如何用窗口函数实现各类滑动窗口统计,包括语法原理、典型场景和性能优化。

窗口函数基础与帧的概念
窗口函数的核心思想是:在不折叠行数的前提下,对每一行计算一个与其相关的聚合结果。与 GROUP BY 最大的区别在于,GROUP BY 会把多行合并成一行,而窗口函数保留每一行,只是在旁边附加一列计算结果。基本语法结构如下:
SELECT
order_date,
amount,
SUM(amount) OVER (
PARTITION BY customer_id -- 分区:按客户分组
ORDER BY order_date -- 排序:确定行的先后顺序
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- 帧:参与计算的行范围
) AS rolling_sum_7d
FROM orders;这三个组成部分缺一不可:PARTITION BY 决定了在哪个范围内滚动,比如按客户分别统计就靠它;ORDER BY 决定了行的先后顺序,滑动窗口必须依赖一个明确的排序才有意义;而最关键的是帧(Frame)子句,它精确定义了当前行参与计算时要带上哪些相邻的行。
帧子句有两种模式,这是很多初学者容易混淆的地方。ROWS 模式按物理行数计算边界,ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 表示当前行加上前面6行,一共最多7行;而 RANGE 模式按逻辑值计算边界,RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW 表示按日期值往前推6天,即使这6天内有很多行也都算进来。两者的区别在数据有重复值时会非常明显,后面会结合例子详细说明。
典型场景一:移动平均与滑动求和
移动平均是滑动窗口最经典的应用,常用于平滑波动趋势、剔除噪声。假设有一张每日销售表 daily_sales,包含日期和销售额两列,计算7日移动平均可以这样写:
SELECT
sale_date,
amount,
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS avg_7d,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS sum_7d
FROM daily_sales
ORDER BY sale_date;这里需要注意一个细节:前6天的数据不足7行,窗口函数依然会计算,只是参与平均的行数较少,导致结果偏大或偏小。如果希望只在窗口凑满7行时才输出结果,可以先用 COUNT(*) OVER (...) 数出窗口内的行数,再在外层查询中过滤,或者使用 CASE 条件判断。
如果同一天有多条记录,比如一天内有多笔订单,用 ROWS 就会产生偏差,因为窗口里可能装不满完整的天数。这时应该改用 RANGE 模式:
SELECT
order_date,
SUM(amount) OVER (
ORDER BY order_date
RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW
) AS sum_7d
FROM orders;这种写法在 MySQL 8.0 及以上、PostgreSQL、Oracle 中都支持。RANGE 模式按日期值计算边界,无论一天有多少条记录,窗口始终覆盖最近7个自然日的数据,逻辑上更符合业务直觉。总结一句:行数固定用 ROWS,时间跨度固定用 RANGE。
典型场景二:累计统计与增长率计算
累计求和只需要把帧的上界去掉,只保留起点即可。常见的写法有三种:从分区第一行累计到当前行、从当前行往后累计、或者排除当前行只算之前的行。下面是一个月内累计销售额的例子:
SELECT
sale_date,
amount,
SUM(amount) OVER (
PARTITION BY DATE_FORMAT(sale_date, '%Y-%m')
ORDER BY sale_date
) AS month_cum_sum, -- 默认从分区起点到当前行
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS total_cum_sum,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
) AS cum_before_current -- 不含当前行的累计值
FROM daily_sales;当不写帧子句时,带 ORDER BY 的窗口函数默认使用 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是从分区第一行累计到当前行(按逻辑值)。第三个窗口用 1 PRECEDING 作为上界排除了当前行,这个技巧在计算环比时特别有用。
结合自关联替代方案,可以计算每日环比增长率:用累计值减去前一天的累计值得到当日增量,或者更直接地用 LAG 函数取前一行:
SELECT
sale_date,
amount,
LAG(amount, 1) OVER (ORDER BY sale_date) AS prev_amount,
ROUND(
(amount - LAG(amount, 1) OVER (ORDER BY sale_date))
/ LAG(amount, 1) OVER (ORDER BY sale_date) * 100, 2
) AS growth_rate_pct
FROM daily_sales;LAG 和 LEAD 虽然不是聚合型窗口函数,但它们与滑动统计配合得非常紧密。LAG 负责取前面的行,LEAD 负责取后面的行,比如判断连续登录天数、计算用户行为间隔,都可以用它们完成,避免了以前自关联的写法。
性能优化与常见坑
窗口函数虽然好用,但性能并非没有代价。数据库需要对窗口列进行排序,如果 PARTITION BY 和 ORDER BY 涉及的列上有合适的索引,排序开销可以大幅降低。比如按 customer_id 加 order_date 建复合索引,窗口函数就能利用索引避免额外的排序步骤。对于超大规模数据,还可以先按分区键做预处理或分批计算。
其次是帧模式带来的性能差异。ROWS 模式通常比 RANGE 模式快,因为 ROWS 只需要数行数,而 RANGE 需要比较值并处理重复值分组。在不影响业务正确性的前提下优先用 ROWS。另外,一个查询中多个窗口函数如果排序方式相同,尽量写在一个 OVER 子句定义里复用,很多数据库支持 WINDOW 子句来命名和复用窗口定义:
SELECT
sale_date,
amount,
SUM(amount) OVER w AS cum_sum,
AVG(amount) OVER w AS cum_avg,
COUNT(*) OVER w AS cum_cnt
FROM daily_sales
WINDOW w AS (ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);最后提醒几个常见坑:第一,MySQL 8.0 之前不支持窗口函数,老版本需要用变量模拟,升级才是正解;第二,滑动窗口统计对 NULL 值要小心,AVG 会自动忽略 NULL,如果希望 NULL 按 0 参与,需要先用 COALESCE 处理;第三,RANGE 模式在部分数据库(如 SQL Server)中不支持日期间隔写法,需要折算成数值或改用 ROWS。掌握这些细节后,滑动窗口统计的各类需求基本都能用一条清晰高效 SQL 解决。