漏斗统计不能只用 count(distinct user_id) + group by,因为各环节用户池不一致,无法保证后一步是前一步的子集;正确做法是先对 (user_id, event_type) 去重,再用 case when + sum 在同一用户粒度上判别各环节达成情况。

漏斗统计为什么不能只用 COUNT(DISTINCT user_id) + GROUP BY
因为那样算出来的各环节人数,分母根本不是同一个用户池。比如 view 有 1000 人,add_to_cart 有 800 人,但这 800 人里可能只有 600 人来自那 1000 个 view 用户——你根本不知道谁是“从上一步走下来的”。漏斗的核心约束是:后一步的分子,必须是前一步用户的子集。而 GROUP BY event_type 是把每类事件独立统计,完全破坏了用户行为路径的连贯性。
正确写法:先去重用户池,再用 CASE WHEN + SUM 多条件判别
关键不是分组,是在同一行中对每个用户判断其是否达成各环节。操作分三步:
- 用子查询或 CTE 对
(user_id, event_type)去重(防止单用户多次触发同事件被重复计数) - 在外层用多个
CASE WHEN event_type = 'X' THEN 1 ELSE 0 END判定该用户是否满足某环节 - 用
SUM统计,确保所有列都基于同一份去重后的用户集合
示例(PostgreSQL / MySQL 8.0+):
SELECT
SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS uv_view,
SUM(CASE WHEN event_type = 'add_to_cart' THEN 1 ELSE 0 END) AS uv_cart,
SUM(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS uv_purchase,
ROUND(100.0 * uv_cart / NULLIF(uv_view, 0), 2) AS rate_view_to_cart
FROM (
SELECT DISTINCT user_id, event_type
FROM events
WHERE user_id IS NOT NULL
AND event_type IN ('view', 'add_to_cart', 'purchase')
) t;
ELSE 0 必须显式写出;NULLIF(uv_view, 0) 防除零报错;原始数据中若 event_type 含空格(如 'add_to_cart '),CASE WHEN 会完全不匹配,务必提前 TRIM()。
需要时间顺序时,CASE WHEN 不够用,得靠窗口函数
如果漏斗要求「加购必须发生在浏览之后 30 分钟内」,静态判别就失效了。此时必须按用户建时间序列:
- 用
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time)标序,排除重复/乱序干扰 - 用
LAG(event_type, 1) OVER (PARTITION BY user_id ORDER BY event_time)拿上一步事件类型 - 用
LAG(event_time, 1)算时间差,加WHERE过滤有效链路(如event_type = 'add_to_cart' AND prev_type = 'view' AND event_time - prev_time ) - Hive 不支持
DISTINCT ON,得用ROW_NUMBER() = 1取首行代替去重逻辑
注意:LAG() 对每个用户首行返回 NULL,无需额外 IS NOT NULL 过滤,但要在外层加 WHERE user_id IS NOT NULL,否则空值会被聚成一组虚增用户。
多维度下漏斗容易崩,CTE + 条件聚合比多层 LEFT JOIN 更稳
想按渠道、设备、日期等维度拆解漏斗?硬写 N 层 LEFT JOIN 极易因笛卡尔积导致 UV 膨胀(一个用户在某步有多个事件,就会和另一步的所有事件交叉)。更可靠的做法是:
- 用 CTE 先打标:每个用户维度组合下,是否完成各环节(
MAX(CASE WHEN ...)) - 再对外层按维度
GROUP BY,用SUM汇总各环节人数 - 避免在
JOIN条件中漏掉ON user_id AND channel = channel这类维度对齐逻辑
真正难的从来不是写对一行 SQL,而是保证所有环节的用户集合严格对齐——哪怕只有一处没去重、没限定时间窗、没处理空值,转化率数字就失去业务意义。











