最稳妥解法是count(distinct user_id)配合group by分组字段,需注意语法正确性、null处理、数据清洗、索引优化(复合索引顺序为group_col,user_id)、多层聚合实现活跃天数筛选及join前去重避免虚高。

直接用 COUNT(DISTINCT user_id) GROUP BY 分组字段
这是最常用也最稳妥的解法,只要分组维度明确(比如 channel、region、product_type),就直接在 GROUP BY 后套 COUNT(DISTINCT user_id)。它天然支持多数据库(MySQL 5.7+、PostgreSQL、BigQuery、Snowflake),语义清晰:每组内有多少个不同用户。
常见错误是写成 COUNT(user_id) 或 COUNT DISTINCT user_id(语法错误)。前者会把同一用户多次登录重复计数;后者根本无法执行,报错如 syntax error at or near "DISTINCT"。
-
COUNT(DISTINCT user_id)自动忽略NULL值,但如果业务上需统计“未知用户”,得额外加COUNT(*) - COUNT(user_id) - 字段含空格或匿名 ID(如
' anonymous_123')会导致去重不准,建议前置清洗:COUNT(DISTINCT TRIM(user_id)) - WHERE 条件必须写在
GROUP BY前,比如查近7天:WHERE event_time >= CURRENT_DATE - INTERVAL '7 days',否则先全量分组再过滤,浪费资源
大表慢?优先建复合索引 (group_col, user_id)
COUNT(DISTINCT) 在千万级数据上变慢,通常不是函数本身的问题,而是缺少合适索引导致全表扫描 + 内存哈希去重。此时加索引比换算法更立竿见影。
索引顺序很关键:必须是分组字段在前、去重字段在后,例如 CREATE INDEX idx_channel_uid ON events (channel, user_id)。如果反过来建 (user_id, channel),GROUP BY channel 就用不上。
- MySQL 8.0+ 对
DATE(event_time)等函数支持函数索引,但日常场景普通 B-tree 覆盖索引已够用 - PostgreSQL 用户可考虑
APPROX_COUNT_DISTINCT(user_id),误差约 1%~3%,速度提升 5x,适合实时看板 - ClickHouse 推荐用
uniq(user_id),底层是 HyperLogLog,亿级去重毫秒级
要按活跃天数达标筛选?必须嵌套 GROUP BY + HAVING
如果目标不是“每个分组多少人”,而是“每个分组里活跃满7天的用户有多少”,就不能只靠外层 COUNT(DISTINCT)。因为活跃天数本身是聚合结果,WHERE 执行在聚合前,无法引用 COUNT(DISTINCT login_date)。
正确路径是两层聚合:内层按 group_col, user_id 分组算每人活跃天数,用 HAVING COUNT(DISTINCT login_date) >= 7 过滤;外层再对保留的用户按 group_col 计数。
- 内层
GROUP BY group_col, user_id是硬性要求,漏掉group_col就等于没分组 - 时间窗口必须提前控制,比如只统计最近30天:
WHERE login_date >= CURRENT_DATE - INTERVAL '30 days',放在内层子查询里 - SQLite 不支持子查询中直接用
HAVING,得改用 CTE 或临时表
JOIN 后 COUNT(DISTINCT) 虚高?先去重再关联
为补全用户属性(如性别、城市)而 LEFT JOIN 维表,常导致活跃数翻倍甚至更高——维表一条用户记录对应多个地址或标签,产生笛卡尔积膨胀。
根本解决法不是调 GROUP BY 顺序,而是把去重逻辑锁死在事实表层面:要么在 JOIN 前用子查询先 SELECT DISTINCT user_id, group_col FROM facts,要么确保维表 JOIN 条件唯一(如只连 main_address = 1 的行)。
- 若维表无唯一约束,可用
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY update_time DESC)取最新一条 - 千万别在 JOIN 后直接
COUNT(DISTINCT user_id),尤其当维表有历史快照时,虚高几乎必然 - ClickHouse 等引擎支持
JOIN前自动 dedup,但 MySQL/PostgreSQL 必须手动控制
实际执行时,最容易被跳过的是时间窗口与分组维度的绑定关系——比如算渠道留存,首日必须是“该渠道下用户的首次行为”,而不是全量用户的首次行为。这个细节不显眼,但一旦出错,整个分组统计就失去业务意义。











