漏斗转化分析的 SQL?(funnel 步骤间的留存)
SQL for funnel analysis? (retention between funnel steps)
考察要点
这道题面试官想看你是否懂:
- 业务逻辑的 SQL 建模能力:如何将“漏斗转化”这一多步骤、有顺序的业务场景,转化为精确的 SQL 查询逻辑。
- 复杂查询的构建能力:熟练运用子查询(Subquery)、公用表表达式(
CTE)、条件聚合(Conditional Aggregation)等技术来分步解决问题。 - SQL 性能优化意识:是否能考虑到大数据量下的查询效率,并给出索引设计、查询改写等优化方案。
核心概念
漏斗分析是衡量用户在关键业务流程中每一步转化与流失情况的核心数据分析模型。
-
漏斗模型 (Funnel Model) 它描述了用户从进入产品到完成最终目标(如购买、注册)所经历的、逐步递减的连续步骤。
mermaid1graph TD2 A[步骤1: 启动App<br/>10000用户] --> B[步骤2: 浏览商品<br/>5000用户];3 B --> C[步骤3: 加入购物车<br/>1500用户];4 C --> D[步骤4: 成功下单<br/>800用户];核心在于 顺序性 和 流失性。一个严格的漏斗要求用户必须按顺序完成步骤,例如,必须先“浏览商品”才能“加入购物车”。
-
事件表 (Event Table) 用户行为通常记录在一张事件流水表中。
- DDL 示例:
sql1CREATE TABLE user_events (2 user_id BIGINT,3 event_name VARCHAR(50),4 event_time DATETIME,5 -- 为了高性能查询,索引至关重要6 INDEX idx_user_event_time (user_id, event_name, event_time)7);
- 数据示例:
user_id event_name event_time 101 app_launch 2023-10-26 10:00:00 102 app_launch 2023-10-26 10:01:00 101 view_product 2023-10-26 10:02:00 103 app_launch 2023-10-26 10:03:00 102 view_product 2023-10-26 10:04:00 101 add_to_cart 2023-10-26 10:05:00 102 create_order 2023-10-26 10:06:00 101 create_order 2023-10-26 10:07:00
- DDL 示例:
-
条件聚合 (Conditional Aggregation) 在一次
GROUP BY中使用CASE WHEN语句对不同条件的数据进行聚合,是实现数据透视(Pivot)和高效计算的关键。sql1-- 计算每个用户完成各个步骤的首次时间2SELECT3 user_id,4 MIN(CASE WHEN event_name = 'app_launch' THEN event_time END) AS launch_time,5 MIN(CASE WHEN event_name = 'view_product' THEN event_time END) AS view_time6FROM user_events7GROUP BY user_id;这个查询会将行式数据转换为列式,为后续判断步骤顺序打下基础。
多种解法
假设我们的漏斗步骤为:app_launch -> view_product -> add_to_cart -> create_order。我们需要计算一个 严格顺序漏斗,即用户必须按顺序完成步骤才被计数。
解法 1:GROUP BY + 条件聚合(推荐,性能和扩展性好)
该方法通过两层聚合实现:第一层按用户聚合,将用户的行为序列“拍平”到一行;第二层对所有用户进行计数,计算漏斗。
- 版本:✅ 所有主流数据库版本(MySQL 5.7+, PostgreSQL, Oracle 等)
- SQL 代码:
sql1WITH user_step_times AS (2 -- 步骤1: 计算每个用户完成每个事件的首次时间3 SELECT4 user_id,5 MIN(CASE WHEN event_name = 'app_launch' THEN event_time END) AS t1_launch,6 MIN(CASE WHEN event_name = 'view_product' THEN event_time END) AS t2_view,7 MIN(CASE WHEN event_name = 'add_to_cart' THEN event_time END) AS t3_cart,8 MIN(CASE WHEN event_name = 'create_order' THEN event_time END) AS t4_order9 FROM user_events10 WHERE event_name IN ('app_launch', 'view_product', 'add_to_cart', 'create_order')11 GROUP BY user_id12),13funnel_counts AS (14 -- 步骤2: 基于步骤时间顺序,计算每个漏斗层级的用户数15 SELECT16 SUM(CASE WHEN t1_launch IS NOT NULL THEN 1 ELSE 0 END) AS step1_users,17 SUM(CASE WHEN t1_launch IS NOT NULL AND t2_view IS NOT NULL AND t2_view > t1_launch THEN 1 ELSE 0 END) AS step2_users,18 SUM(CASE WHEN t1_launch IS NOT NULL AND t2_view IS NOT NULL AND t3_cart IS NOT NULL AND t2_view > t1_launch AND t3_cart > t2_view THEN 1 ELSE 0 END) AS step3_users,19 SUM(CASE WHEN t1_launch IS NOT NULL AND t2_view IS NOT NULL AND t3_cart IS NOT NULL AND t4_order IS NOT NULL AND t2_view > t1_launch AND t3_cart > t2_view AND t4_order > t3_cart THEN 1 ELSE 0 END) AS step4_users20 FROM user_step_times21),22funnel_report AS (23 -- 步骤3: 将列式结果转换为行式,并计算转化率 (MySQL 8.0+ / PostgreSQL)24 SELECT 'app_launch' AS step_name, step1_users AS user_count FROM funnel_counts25 UNION ALL26 SELECT 'view_product' AS step_name, step2_users AS user_count FROM funnel_counts27 UNION ALL28 SELECT 'add_to_cart' AS step_name, step3_users AS user_count FROM funnel_counts29 UNION ALL30 SELECT 'create_order' AS step_name, step4_users AS user_count FROM funnel_counts31)32-- 步骤4: 使用窗口函数计算上一步至今的转化率33SELECT34 step_name,35 user_count,36 -- LAG(user_count, 1, user_count) OVER() 表示取上一个值,如果不存在(即第一行),则取当前值37 ROUND(user_count * 100.0 / LAG(user_count, 1, user_count) OVER(ORDER BY (CASE step_name WHEN 'app_launch' THEN 1 WHEN 'view_product' THEN 2 WHEN 'add_to_cart' THEN 3 ELSE 4 END)), 2) AS conversion_rate38FROM funnel_report39ORDER BY (CASE step_name WHEN 'app_launch' THEN 1 WHEN 'view_product' THEN 2 WHEN 'add_to_cart' THEN 3 ELSE 4 END);
- 时间/空间复杂度:第一步
GROUP BY是主要开销,若使用哈希聚合为 ,若排序则为 ,其中 N 是user_events表中符合条件的行数。后续步骤处理的数据量很小。 - 索引依赖:复合索引
(user_id, event_name, event_time)极大地提升性能。user_id在前,可以利用索引完成GROUP BY;event_name在后,用于WHERE条件过滤。
解法 2:LEFT JOIN 链式连接(直观但性能较差)
该方法模拟漏斗的每一步,通过 LEFT JOIN 将用户的后续行为关联起来。每增加一个漏斗步骤,就增加一次 LEFT JOIN。
- 版本:✅ 所有 SQL 版本
- SQL 代码:
sql1WITH step1 AS (2 SELECT user_id, MIN(event_time) AS step_time FROM user_events WHERE event_name = 'app_launch' GROUP BY user_id3),4step2 AS (5 SELECT user_id, MIN(event_time) AS step_time FROM user_events WHERE event_name = 'view_product' GROUP BY user_id6),7step3 AS (8 SELECT user_id, MIN(event_time) AS step_time FROM user_events WHERE event_name = 'add_to_cart' GROUP BY user_id9),10step4 AS (11 SELECT user_id, MIN(event_time) AS step_time FROM user_events WHERE event_name = 'create_order' GROUP BY user_id12)13SELECT14 'app_launch' AS step_name,15 COUNT(DISTINCT s1.user_id) AS user_count16FROM step1 s117UNION ALL18SELECT19 'view_product' AS step_name,20 COUNT(DISTINCT s2.user_id) AS user_count21FROM step1 s122JOIN step2 s2 ON s1.user_id = s2.user_id AND s2.step_time > s1.step_time23UNION ALL24SELECT25 'add_to_cart' AS step_name,26 COUNT(DISTINCT s3.user_id) AS user_count27FROM step1 s128JOIN step2 s2 ON s1.user_id = s2.user_id AND s2.step_time > s1.step_time29JOIN step3 s3 ON s2.user_id = s3.user_id AND s3.step_time > s2.step_time30UNION ALL31SELECT32 'create_order' AS step_name,33 COUNT(DISTINCT s4.user_id) AS user_count34FROM step1 s135JOIN step2 s2 ON s1.user_id = s2.user_id AND s2.step_time > s1.step_time36JOIN step3 s3 ON s2.user_id = s3.user_id AND s3.step_time > s2.step_time37JOIN step4 s4 ON s3.user_id = s4.user_id AND s4.step_time > s3.step_time;38-- 注:此方法计算转化率需要再包一层CTE和窗口函数,代码更冗长。
- 时间/空间复杂度:,其中 M 是每一步的用户数。随着漏斗层级加深,
JOIN的次数和复杂度急剧增加,性能会远差于解法 1。 - 索引依赖:每个子查询都需要
(event_name, user_id, event_time)索引,JOIN操作需要user_id上的索引。
何时使用:
- 解法 1:是生产环境中的首选。它只扫描一次主表,扩展性强(增加漏斗步骤只需在
CASE WHEN中加列),性能更优。 - 解法 2:适用于漏斗层级很少(2-3步)且需要快速编写的场景。对于面试,可以作为一种思路提出,但要明确指出其性能局限性。
常见低分回答
- 忽略用户唯一性:使用
COUNT(*)或COUNT(user_id)而不是COUNT(DISTINCT user_id)。一个用户可能多次触发同一事件,漏斗统计的是独立用户数。 - 忽略漏斗的顺序性:只独立计算每个事件的用户数,没有
event_time的先后判断。sql1-- 错误示例:这只是计算了每个事件的独立用户数,不是漏斗2SELECT event_name, COUNT(DISTINCT user_id)3FROM user_events4GROUP BY event_name; - 使用
LEFT JOIN但未正确处理NULL:在LEFT JOIN的链条中,如果某一步用户未完成,后续JOIN的user_id会是NULL。计数时需要COUNT(DISTINCT s_last.user_id),而不是COUNT(DISTINCT s1.user_id)。 - 多次扫描全表:使用多个
UNION ALL连接的独立查询,每个查询都扫描一遍全表,效率极低。sql1-- 错误示例:效率低下2SELECT 'app_launch', COUNT(DISTINCT user_id) FROM user_events WHERE event_name = 'app_launch'3UNION ALL4SELECT 'view_product', COUNT(DISTINCT user_id) FROM user_events WHERE event_name = 'view_product'5... - 在
WHERE子句中使用函数:例如WHERE DATE(event_time) = '2023-10-26',这会导致event_time列上的索引失效。应改为WHERE event_time >= '2023-10-26 00:00:00' AND event_time < '2023-10-27 00:00:00'。
工程实践与优化
- 索引是生命线:对于
user_events这种量级的表,最优索引是(user_id, event_name, event_time)。user_id在前,因为核心是按用户聚合;event_name在后,用于快速过滤事件类型;event_time最后,用于排序和比较。 - 数据分区:当事件表达到数十亿甚至千亿级别时,必须按时间(如
event_time)进行分区(Partitioning)。查询时带上时间范围,可以实现分区裁剪(Partition Pruning),只扫描相关的分区,性能提升几个数量级。 - 预聚合/物化视图:对于高频访问的漏斗报表,在原始事件表上实时计算是不可接受的。工程上会采用 T+1 的方式,每天凌晨通过离线任务(如 Spark/Hive SQL)计算好前一天的漏斗数据,存入结果表(Summary Table)。前端直接查询结果表,实现秒级响应。
- 执行计划分析:使用
EXPLAIN分析解法 1 的查询计划。- 第一步
CTE(user_step_times):理想的Extra字段应包含Using index for group-by,表示 MySQL 可以直接利用索引的有序性来完成GROUP BY,避免了额外的排序或临时表,这是最高效的GROUP BY方式。type应该是range或ref。 - 第二步
CTE(funnel_counts):这一步是在内存中对一个很小的结果集(只有一行)进行计算,开销极小。
- 第一步
- 近似计算:在某些对精度要求不高的场景(如内部看板),
COUNT(DISTINCT ...)的精确计算非常耗时。可以考虑使用近似去重算法,如 HyperLogLog。一些数据库(如 ClickHouse, Presto)内置了approx_distinct()函数,性能比精确去重高出很多。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,达到百亿级别,这条 SQL 性能怎样?怎么优化?
- 回答要点 1 (瓶颈分析):直接在百亿级原始表上运行此 SQL 会非常慢,甚至无法在有效时间内跑出结果。主要瓶颈在于第一步
GROUP BY user_id,它需要扫描大量数据并进行聚合。COUNT(DISTINCT)操作本身在分布式系统(如 Hive/Spark)中也会触发耗时的Shuffle。 - 回答要点 2 (架构级优化):必须放弃实时查询,转向预计算架构。
- 数据分层:引入数据仓库概念,将原始事件表(ODS 层)通过每日的 ETL 任务,加工成用户行为宽表(DWD 层)。这个宽表可以预先处理好每个用户每天的首次行为时间戳。
- 聚合层:基于用户行为宽表,再次计算生成漏斗聚合表(DWS/ADS 层),存储最终的漏斗步骤和用户数。
- 查询服务:线上报表直接查询这个最终的聚合表。整个链路从小时级或天级更新,保证了查询性能。
- 回答要点 1 (瓶颈分析):直接在百亿级原始表上运行此 SQL 会非常慢,甚至无法在有效时间内跑出结果。主要瓶颈在于第一步
-
追问:如何计算用户完成漏斗各步骤之间的平均/中位数耗时?
- 回答要点:在解法 1 的
user_step_timesCTE基础上进行扩展。这个CTE已经有了每个用户完成各步骤的时间戳t1_launch,t2_view等。 - SQL 改造:
sql1-- 在 user_step_times CTE 之后2SELECT3 'step1_to_step2' AS duration_name,4 AVG(TIMESTAMPDIFF(SECOND, t1_launch, t2_view)) AS avg_duration_seconds5FROM user_step_times6WHERE t1_launch IS NOT NULL AND t2_view IS NOT NULL AND t2_view > t1_launch7UNION ALL8SELECT9 'step2_to_step3' AS duration_name,10 AVG(TIMESTAMPDIFF(SECOND, t2_view, t3_cart)) AS avg_duration_seconds11FROM user_step_times12WHERE t2_view IS NOT NULL AND t3_cart IS NOT NULL AND t3_cart > t2_view13-- ... 以此类推
- 对于中位数,MySQL 8.0+ 支持
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ...)窗口函数,可以用来计算中位数耗时。
- 回答要点:在解法 1 的
-
追问:如果漏斗的定义是“7天内完成”,如何修改查询?
- 回答要点:需要在判断步骤顺序的同时,增加时间差的限制。
- SQL 改造:在解法 1 的
funnel_countsCTE中增加时间判断逻辑。sql1-- 在 funnel_counts CTE 中2SUM(CASE3 WHEN t1_launch IS NOT NULL4 AND t2_view IS NOT NULL5 AND t2_view > t1_launch6 -- 增加时间差判断7 AND TIMESTAMPDIFF(DAY, t1_launch, t2_view) <= 78 THEN 1 ELSE 0 END) AS step2_users,9SUM(CASE10 WHEN ... -- 前序步骤满足11 AND t3_cart > t2_view12 -- 整体时间窗口从第一步开始算13 AND TIMESTAMPDIFF(DAY, t1_launch, t3_cart) <= 714 THEN 1 ELSE 0 END) AS step3_users15-- ... 以此类推 - 需要和面试官明确“7天内完成”的定义:是指任意相邻两步之间不超过7天,还是指从第一步到最后一步总耗时不超过7天。上面的例子演示了两种不同的判断方式,需要根据具体业务需求来定。