活跃指标是最常见也最容易出现口径分歧的报表之一。DAU、WAU、MAU 表面上只是按天、按周、按月做去重计数,但实际写 SQL 之前必须先确定三件事:活跃事件怎么定义,去重维度用用户还是设备,时间字段按哪个时区处理。多数业务系统以用户登录或启动事件作为活跃信号,统计时使用 COUNT(DISTINCT user_id) 得到去重用户数。下面先看一张最简化的用户登录明细表 user_login_log,它至少包含 user_id 和 login_time 两个字段。

日活的 SQL 并不复杂,关键是日期函数要把时间戳截断到天。MySQL 中可以使用 DATE(login_time),PostgreSQL 可以使用 login_time::date,Hive/Spark SQL 同样支持 to_date(login_time)。按日分组后对用户去重,就能得到每天的活跃用户数。示例如下:
SELECT
DATE(login_time) AS active_date,
COUNT(DISTINCT user_id) AS dau
FROM user_login_log
WHERE login_time BETWEEN '2024-06-01 00:00:00' AND '2024-06-30 23:59:59'
GROUP BY DATE(login_time)
ORDER BY active_date;
这段 SQL 的执行计划通常会先按日期分组,再对 user_id 去重。如果表里每天有上千万行登录日志,COUNT(DISTINCT user_id) 会消耗较多内存,后面会聊到替代方案。日活计算本身不难,真正容易出错的是把日活直接相加当作周活或月活,这样一定会重复计算同一用户,必须重新基于明细去重。
周活与月活的两种口径
周活 WAU 至少有两种业务口径。第一种是自然周,也就是把周一至周日作为一个统计周期,通常周报表用这种口径。第二种是滚动 7 天窗口,每天计算从当天往前推 7 天的活跃用户数,适合观测趋势。两种口径没有绝对优劣,产品看数习惯决定选择。月活 MAU 也分自然月和滚动 30 天。自然月的 SQL 可以利用 MySQL 的 DATE_FORMAT 生成周标识或月标识,也可以使用 PostgreSQL 的 DATE_TRUNC。
-- MySQL 自然周
SELECT
YEARWEEK(login_time, 1) AS week_id,
COUNT(DISTINCT user_id) AS wau
FROM user_login_log
GROUP BY YEARWEEK(login_time, 1)
ORDER BY week_id;
-- PostgreSQL 自然月
SELECT
DATE_TRUNC('month', login_time) AS month_start,
COUNT(DISTINCT user_id) AS mau
FROM user_login_log
GROUP BY DATE_TRUNC('month', login_time)
ORDER BY month_start;
上面示例中 YEARWEEK(login_time, 1) 的第二个参数 1 表示以周一作为一周的开始,这在很多中国业务里更常用。自然周实现简单,但周初产生的周活值通常低于周末,因为统计周期内数据还没有完整。滚动窗口可以避免这种波动,写法也直观:对每个目标日期,取包含该日在内的近 7 天数据去重。
SELECT
d.active_date,
COUNT(DISTINCT l.user_id) AS rolling_7d_active
FROM date_dim d
JOIN user_login_log l
ON l.login_time BETWEEN d.active_date - INTERVAL 6 DAY
AND d.active_date + INTERVAL 1 DAY - INTERVAL 1 SECOND
WHERE d.active_date BETWEEN '2024-06-01' AND '2024-06-30'
GROUP BY d.active_date
ORDER BY d.active_date;
这段 SQL 假设有一个 date_dim 日期维度表,负责生成连续日期。实际业务里可以直接用登录表的日期作为驱动表,但如果某天没有登录行为,结果集会缺失当天,不利于趋势图展示。滚动 7 天的计算量比自然周大很多,因为一个用户可能被扫描 7 次以上,所以在明细量极大的场景下,通常会预聚合每日活跃用户表,再基于每日活跃表计算滚动窗口。
月活的滚动 30 天逻辑完全类似,只需把 6 天换成 29 天,间隔区间换成 30 天。值得注意的是,滚动窗口在跨月时尤其有价值,比如 6 月 1 日的滚动 30 天活跃包含 5 月的数据,而自然月只统计 6 月 1 日到 6 月 30 日。拿到自然月或滚动月数据后,还可以计算黏性指标 DAU/MAU,用于衡量用户留存健康度。
多数据源合并与用户维度关联
实际生产环境里,活跃事件通常不是一张登录表能覆盖的。启动日志在客户端表,支付日志在交易表,访问日志在 Web 埋点表,三张表都记录了用户行为。要计算统一活跃,需要先把它们合并成一个用户活跃事件集合,再一次性去重。SQL 里最稳妥的做法是 UNION ALL 合并明细,外层再按天做 COUNT(DISTINCT user_id)。注意不要使用 UNION,因为它会提前去重,既降低性能又不会改变最终结果。
SELECT active_date, COUNT(DISTINCT user_id) AS total_active
FROM (
SELECT user_id, DATE(login_time) AS active_date
FROM app_launch_log
WHERE login_time BETWEEN '2024-06-01' AND '2024-06-07'
UNION ALL
SELECT user_id, DATE(order_time) AS active_date
FROM order_main
WHERE order_time BETWEEN '2024-06-01' AND '2024-06-07'
UNION ALL
SELECT user_id, DATE(visit_time) AS active_date
FROM web_visit_log
WHERE visit_time BETWEEN '2024-06-01' AND '2024-06-07'
) t
GROUP BY active_date;
这个 SQL 先把三个来源的用户 ID 和活跃日期拉平,再按天去重。如果某用户同一天既启动 App 又下单,在 UNION ALL 中会出现两行,最终 COUNT(DISTINCT user_id) 只计一次,符合活跃用户定义。合并后的明细如果量级很大,可以先把各表按天和用户预聚合,再合并预聚合结果,能显著减少中间数据量。
活跃统计还经常需要关联用户维度表,例如只看新用户、付费用户或者某个渠道用户。此时可以在去重前先 JOIN 用户表过滤,避免对所有用户聚合。示例如下:
SELECT
DATE(a.login_time) AS active_date,
COUNT(DISTINCT a.user_id) AS new_user_active
FROM app_launch_log a
JOIN user_info u ON a.user_id = u.user_id
WHERE u.register_date BETWEEN '2024-06-01' AND '2024-06-30'
AND a.login_time BETWEEN '2024-06-01' AND '2024-06-30'
GROUP BY DATE(a.login_time)
ORDER BY active_date;
这段代码统计每日新注册用户中的活跃人数。用户维度表 user_info 可能保存了首登渠道、注册时间、用户类型等属性,关联后可以灵活拆分活跃指标。需要注意的是,如果 user_info 是缓慢变化维度,同一用户在历史上可能有多条记录,关联前要先按用户取最新一条,否则会出现一对多放大日志行数,导致活跃数虚高。
性能优化与容易踩的统计偏差
COUNT(DISTINCT user_id) 在 MySQL 中通常需要临时表或排序,数据量大时执行会明显变慢。在 Hive、Spark 等分布式引擎中,精确去重会触发数据倾斜,因为所有相同用户 ID 都要被发送到同一个任务里。优化方向主要有两种:一是改成两阶段聚合,先在子查询中按天和用户去重,再统计;二是在对精度不敏感的场景下使用近似去重函数。
-- 两阶段聚合
SELECT active_date, COUNT(*) AS dau
FROM (
SELECT DATE(login_time) AS active_date, user_id
FROM user_login_log
WHERE login_time BETWEEN '2024-06-01' AND '2024-06-30'
GROUP BY DATE(login_time), user_id
) t
GROUP BY active_date;
-- PostgreSQL 近似去重
SELECT
DATE(login_time) AS active_date,
COUNT(DISTINCT user_id) AS exact_dau,
APPROX_COUNT_DISTINCT(user_id) AS approx_dau
FROM user_login_log
GROUP BY DATE(login_time);
两阶段聚合的思路是先缩小数据量,去掉同一用户同一天的重复记录,再统计每天有多少用户。近似函数 APPROX_COUNT_DISTINCT 在 PostgreSQL、SQL Server 和部分云数仓中可用,Hive/Spark 中可以使用 approx_count_distinct,ClickHouse 则用 uniq 或 uniqExact。这些近似函数基于 HyperLogLog 等概率结构,误差通常在 1% 到 2% 以内,适合大屏看板和不要求精确对账的场景。
时区问题也经常造成日活和实际业务报表对不上。如果登录时间在数据库里存的是 UTC,直接按 DATE(login_time) 统计,中国业务会从早上 8 点切日,而不是凌晨零点。正确做法是先将时间转换到业务时区,例如 MySQL 使用 CONVERT_TZ(login_time, '+00:00', '+08:00'),PostgreSQL 使用 login_time AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Shanghai'。转换后再截断日期,才能得到符合用户习惯的活跃日。
另一个容易忽略的偏差是机器人流量和测试账号。活跃数如果包含爬虫、压测脚本或内部员工,很可能出现异常峰值。SQL 层可以通过关联黑名单表过滤,或者在埋点阶段打上流量类型标记。最后,周活和月活不是日活的简单相加,也不是日活平均值,必须回到用户明细按周期重新去重。已经存在每日活跃表的情况下,可以基于每日活跃表计算周活月活,但同样要使用 COUNT(DISTINCT user_id),不能对日活求和。
掌握这些基础口径和优化方法后,可以根据自己的数据库类型选择对应日期函数和去重函数,把活跃指标做成固定报表或 BI 看板。关键点从来没有变过:先明确活跃事件和去重维度,再确定自然周期还是滚动窗口,最后用最合适的 SQL 方案控制性能和精度。

