应统一用 date_trunc('day', event_time)(PostgreSQL/Trino)或 toStartOfDay(event_time)(ClickHouse)对齐时间粒度,避免时区与隐式转换问题;新增与活跃用户需用独立子查询分别计算,不可混用同一GROUP BY;多维归因须先确定用户首次行为的维度值再聚合;大数据量下推荐预计算或近似去重。

用 GROUP BY date_trunc('day', event_time) 对齐时间粒度
直接用 DATE(event_time) 在 PostgreSQL 里没问题,但在 ClickHouse 或某些旧版 MySQL 中可能丢失时区语义或触发隐式转换。更稳妥的做法是统一用 date_trunc('day', event_time)(PostgreSQL/Trino)或 toStartOfDay(event_time)(ClickHouse)。MySQL 8.0+ 可用 DATE(event_time),但要注意字段是否带时区——如果 event_time 是 TIMESTAMP 类型且服务器时区不一致,不同实例统计结果可能错位一天。
关键不是函数名本身,而是确保所有维度的时间切片逻辑严格一致。比如不能一个地方用 DATE(created_at) 算新增,另一个地方用 DATE(login_time) 算活跃,却没对齐到同一日历日(UTC 还是本地?)。
区分「新增用户」和「活跃用户」必须用不同子查询或条件聚合
新手常犯的错误是试图在同一个 GROUP BY 下用 COUNT(DISTINCT user_id) 同时表达两个含义,结果发现数字对不上——因为新增和活跃的定义域不同:新增只看当天首次出现的 user_id,活跃则看当天任意一次行为的 user_id。
推荐写法是用两个独立的 CTE 或子查询,再 JOIN 对齐日期:
WITH daily_new AS (
SELECT date_trunc('day', first_seen) AS day, COUNT(DISTINCT user_id) AS new_users
FROM (
SELECT user_id, MIN(event_time) AS first_seen
FROM events
GROUP BY user_id
) t
GROUP BY date_trunc('day', first_seen)
),
daily_active AS (
SELECT date_trunc('day', event_time) AS day, COUNT(DISTINCT user_id) AS active_users
FROM events
GROUP BY date_trunc('day', event_time)
)
SELECT n.day, n.new_users, a.active_users
FROM daily_new n
JOIN daily_active a ON n.day = a.day;
如果数据量大,MIN(event_time) OVER (PARTITION BY user_id) 窗口函数替代子查询可能更快,但要注意窗口结果不能直接用于外层 GROUP BY 时间截断,仍需嵌套一层。
加设备类型、渠道等维度时,新增用户数会受「首次归因」逻辑影响
一旦加入 device_type 或 utm_source,问题就变了:一个用户可能第一天用 iOS 从微信进来,第二天用 Android 从直接访问进来。那么他在「iOS + 微信」维度是新增,在「Android + 直接访问」维度也是新增——但这不是重复计数,而是归因维度拆分后的合理表现。
此时不能简单地在最外层加 GROUP BY day, device_type,否则 new_users 会变成“当天该设备类型的首次行为用户”,而非“该用户生命周期中第一次用这个设备类型”。正确做法是先算出每个用户的首次设备/渠道(用 MIN(event_time) 配合 ARG_MIN(device_type, event_time) 或类似函数),再按维度聚合。
PostgreSQL 示例:
SELECT
date_trunc('day', first_event) AS day,
first_device,
COUNT(*) AS new_users
FROM (
SELECT
user_id,
MIN(event_time) AS first_event,
(ARRAY_AGG(device_type ORDER BY event_time))[1] AS first_device
FROM events
GROUP BY user_id
) t
GROUP BY date_trunc('day', first_event), first_device;
性能陷阱:COUNT(DISTINCT) 在亿级表上容易 OOM 或超时
当 events 表每天过亿行,且要算 30 天的 COUNT(DISTINCT user_id),直接跑会爆内存或卡死。ClickHouse 建议用 uniqCombined(user_id) 替代 count(distinct);PostgreSQL 可考虑物化每日的 user_id 集合(如用 string_agg(DISTINCT user_id::text, ',') 不现实,改用 HLL 插件的 hll_add());Trino 推荐开启 approx_distinct 并接受 2.3% 误差。
更实际的解法是预计算:每天凌晨跑一个任务,把当日 active_users 和 new_users 写入汇总表,主查询只查这张轻量表。新增逻辑尤其适合预计算——只要维护一张 user_first_event 表(user_id 主键 + first_event_time + first_device 等),后续所有多维新增统计都基于它,完全避开全表扫描。
真正难的不是写 SQL,而是确认「新增」到底按什么锚点算:是注册时间?第一次埋点上报?还是第一次完成关键行为?这个定义一旦和业务对不齐,后面所有维度叠加都是空中楼阁。

















