动态滚动窗口去重指按时间或序号滑动固定长度窗口,在每个窗口内对user_id等字段独立去重计数,核心是ROW_NUMBER()标记首现后过滤,而非COUNT(DISTINCT)窗口函数。

什么是动态滚动窗口去重?
它不是标准 SQL 术语,而是业务中常指:按时间(或序号)滑动一个固定长度窗口,对窗口内 user_id、event_type 等字段去重计数(比如“过去7天内每个用户首次触发行为次数”)。关键在于“滚动”和“去重”不可分——不能先全量去重再窗口,必须在每个窗口内独立去重。
ROW_NUMBER() + 窗口过滤是最稳的实现方式
多数数据库(PostgreSQL、SQL Server、Doris、StarRocks)不支持直接在窗口函数里用 DISTINCT 聚合,所以得绕开。核心思路是:先用 ROW_NUMBER() 标记每个用户在窗口内的首次出现,再对外层结果求和/计数。
- 写法示例(以 PostgreSQL 为例,按
user_id分组,滚动 7 天,统计每天窗口内首次出现的用户数):
SELECT
event_date,
COUNT(*) FILTER (WHERE rn = 1) AS uniq_users_in_7d
FROM (
SELECT
event_date,
user_id,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY event_date
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) AS rn
FROM events
) t
GROUP BY event_date;
-
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW表示包含当天共 7 天的滚动范围(注意:PostgreSQL 支持RANGE配合时间,MySQL 8.0+ 只支持ROWS,需转成排序序号模拟) - MySQL 用户必须用
ROWS+ 自增序号(如ROW_NUMBER() OVER (ORDER BY event_time)),再通过LAG()或自连接算时间差,否则无法真正按时间滚动 - 如果只想要“当前行往前 N 行”的窗口(非时间对齐),直接用
ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW,但此时去重逻辑不变——仍要靠ROW_NUMBER()标记首现
为什么不用 COUNT(DISTINCT ...) 加窗口?
因为 COUNT(DISTINCT ...) 本身不支持作为窗口函数(除 BigQuery、某些新版本 Doris 外)。你写 COUNT(DISTINCT user_id) OVER (...) 在 PostgreSQL、MySQL、SQL Server 中都会报错:window function cannot contain a DISTINCT clause。
- 常见错误现象:
ERROR: window function cannot contain a DISTINCT clause - 有些引擎(如 Spark SQL)允许
COUNT(DISTINCT)窗口,但性能极差——它会为每个窗口重新哈希全部数据,远不如ROW_NUMBER()+ 过滤高效 - 若强行用子查询+关联模拟,代码膨胀且难以维护,尤其在多维度(如同时按
user_id和device_type去重)时极易出错
容易被忽略的边界细节
滚动窗口去重最易翻车的地方不在语法,而在语义对齐:
- 时间窗口边界是否包含端点?PostgreSQL 的
RANGE默认包含两端,但如果你用event_time::date截断,而原始数据有毫秒,可能漏掉同天最后一毫秒的数据 -
PARTITION BY user_id是必须的,但别漏掉其他去重要求字段(比如要的是“每个用户每种事件类型”的首次,就得PARTITION BY user_id, event_type) - 排序字段必须唯一或加二级排序,否则
ROW_NUMBER()结果不确定——建议总加上id或event_time作为第二排序键:ORDER BY event_date, id - 空值处理:如果
user_id可能为 NULL,PARTITION BY会把所有 NULL 归为一组,导致统计失真;提前WHERE user_id IS NOT NULL更安全

















