滚动7天UV的核心难点是无法直接用窗口函数对COUNT(DISTINCT)聚合,因多数数据库不支持或性能极差,必须通过日期序列关联+分组去重实现,且需严格控制时间窗口为当日及前6天共7天。

什么是滚动7天独立访客数(UV)的核心难点
滚动7天 UV 不是简单按 DATE_SUB(CURDATE(), INTERVAL 7 DAY) 截断后 GROUP BY,而是对每一天,都要统计它往前推7天(含当天)这个滑动窗口内的去重用户数。窗口函数本身不直接支持对 COUNT(DISTINCT ...) 做窗口聚合——MySQL 8.0+ 和 PostgreSQL 支持 COUNT(DISTINCT ...) 作为窗口函数,但 SQLite、旧版 MySQL 不支持;即使支持,性能也极差,因为底层需为每个窗口维护完整用户集合。
所以真实可行的路只有一条:用 ROW_NUMBER() 或 RANK() 配合自连接 / LATERAL / 递归 CTE 模拟滑动窗口,再在外层去重计数。
MySQL 8.0+ 的实用解法(推荐)
MySQL 8.0+ 支持 WINDOW 子句 + LATERAL(8.0.14+),但最稳的方式仍是“生成日期序列 + 关联原始日志”。假设你有带 visit_date 和 user_id 的访问表 visits:
- 先确保
visit_date是DATE类型(不是DATETIME),否则窗口边界易出错 - 建一个辅助日期表(或用 CTE 生成连续日期),覆盖你所需分析的时间范围
- 对每个目标日期
d,LEFT JOIN 所有满足visit_date BETWEEN DATE_SUB(d, INTERVAL 6 DAY) AND d的记录 - 外层按
d分组,用COUNT(DISTINCT user_id)
WITH date_range AS ( SELECT DISTINCT visit_date AS d FROM visits WHERE visit_date >= '2024-01-01' ), windowed AS ( SELECT dr.d, v.user_id FROM date_range dr LEFT JOIN visits v ON v.visit_date BETWEEN DATE_SUB(dr.d, INTERVAL 6 DAY) AND dr.d ) SELECT d, COUNT(DISTINCT user_id) AS uv_7d FROM windowed GROUP BY d ORDER BY d;
注意:这里用 INTERVAL 6 DAY 是因为“7天内”包含当天,即 [d−6, d] 共7个自然日。别写成 INTERVAL 7 DAY,否则会多算一天。
PostgreSQL 中更简洁的 LATERAL 写法
PostgreSQL 支持 LATERAL 子查询,可避免全量自连接,内存更友好:
SELECT d.visit_date AS d,
COUNT(DISTINCT v.user_id) AS uv_7d
FROM (SELECT DISTINCT visit_date FROM visits) AS d
LEFT JOIN LATERAL (
SELECT user_id
FROM visits v2
WHERE v2.visit_date BETWEEN d.visit_date - INTERVAL '6 days' AND d.visit_date
) AS v ON true
GROUP BY d.visit_date
ORDER BY d.visit_date;关键点:
-
INTERVAL '6 days'同样是为凑足7天(含当天) -
LATERAL让子查询能引用外层d.visit_date,避免笛卡尔积 - 若数据量大,务必在
visits(visit_date, user_id)上建联合索引
常见翻车点和绕过方案
遇到 ERROR 3578 (HY000): Window function 'COUNT' with DISTINCT is not allowed?那是你用的是 MySQL 5.7 或 MariaDB —— 它们不支持 COUNT(DISTINCT ...) 窗口化。此时必须放弃窗口函数,改用上面的日期序列 + JOIN 方案。
- 如果用户量极大(千万级
user_id),COUNT(DISTINCT)在 JOIN 后可能卡死:改用HLL(HyperLogLog)近似去重,如 MySQL 的HYPERLOGLOG_INIT/HYPERLOGLOG_MERGE(需启用插件),或 PostgreSQL 的hll扩展 - 时间字段含时分秒?先用
DATE(visit_time)统一转为日期,否则窗口会按秒切分,爆炸式膨胀 - 结果里某天 UV 为 NULL?说明那天没访问,LEFT JOIN 后
user_id全为 NULL,COUNT(DISTINCT NULL)返回 0,但某些旧版 MySQL 可能返回 NULL —— 加COALESCE(COUNT(DISTINCT user_id), 0)更安全
实际跑起来,最耗资源的永远不是窗口逻辑本身,而是那个“对每一天都拉一遍7天数据”的 IO 和内存开销。如果你的访问表每天千万行,又想算90天滚动 UV,就得提前物化中间结果,或者用 ClickHouse 这类列存引擎来扛。

















