找出在 2025 年上半年(1-6月)累计消费金额超过 5000 元,但在下半年(7-12月)消费金额为 0 的“可能流失”的用户
SparkSQL 面试题:找出 2025 年“可能流失”的高价值用户
题目背景
在电商、新零售等行业中,识别高价值流失用户是精准营销的关键。本题要求从用户交易明细中,找出在 2025 年上半年(1-6月)累计消费金额超过 5000 元,但在下半年(7-12月)消费金额为 0(或无消费记录)的“可能流失”用户。
一、 示例数据
1. 用户表(users)
| user_id (用户ID) | user_name (用户名) | register_date (注册日期) |
|---|---|---|
| 101 | 张三 | 2024-05-01 |
| 102 | 李四 | 2024-08-15 |
| 103 | 王五 | 2025-01-10 |
| 104 | 赵六 | 2025-02-20 |
| 105 | 钱七 | 2025-03-01 |
2. 交易订单表(orders)
| order_id (订单ID) | user_id (用户ID) | order_date (订单日期) | order_amount (订单金额) |
|---|---|---|---|
| o001 | 101 | 2025-01-15 | 3000.00 |
| o002 | 101 | 2025-04-10 | 2500.00 |
| o003 | 101 | 2025-08-20 | 500.00 |
| o004 | 102 | 2025-02-18 | 6000.00 |
| o005 | 103 | 2025-03-05 | 1500.00 |
| o006 | 103 | 2025-05-20 | 4000.00 |
| o007 | 104 | 2025-01-10 | 800.00 |
| o008 | 105 | 2025-06-01 | 5500.00 |
| o009 | 105 | 2025-11-12 | 0.00 |
预期输出结果
| user_id (用户ID) | user_name (用户名) | h1_amount (上半年消费) | h2_amount (下半年消费) |
|---|---|---|---|
| 103 | 王五 | 5500.00 | 0.00 |
| 105 | 钱七 | 5500.00 | 0.00 |
注:
- 张三 (101):上半年消费 5500,但下半年消费了 500,不符合“下半年消费为0”的条件。
- 李四 (102):上半年消费 6000,下半年无订单,但未与用户表关联时容易遗漏,正确SQL应包含其个人信息(若要求输出用户名)。
- 王五 (103):上半年消费 1500 + 4000 = 5500 > 5000,下半年无订单,符合条件。
- 赵六 (104):上半年仅消费 800,不满足 > 5000 条件。
- 钱七 (105):上半年消费 5500 > 5000,下半年有一笔 0 元订单(或无订单),符合条件。
二、 SparkSQL 面试题答案
解法一:条件聚合(Conditional Aggregation)—— 推荐解法(最高效)
利用 CASE WHEN 配合 SUM 进行条件聚合,一次扫描表即可完成数据分组与筛选,性能最优。
sql
SELECT
u.user_id,
u.user_name,
t.h1_amount,
COALESCE(t.h2_amount, 0.00) AS h2_amount
FROM users u
JOIN (
SELECT
user_id,
SUM(CASE WHEN order_date >= '2025-01-01' AND order_date <= '2025-06-30' THEN order_amount ELSE 0 END) AS h1_amount,
SUM(CASE WHEN order_date >= '2025-07-01' AND order_date <= '2025-12-31' THEN order_amount ELSE 0 END) AS h2_amount
FROM orders
WHERE order_date BETWEEN '2025-01-01' AND '2025-12-31'
GROUP BY user_id
) t ON u.user_id = t.user_id
WHERE t.h1_amount > 5000
AND (t.h2_amount IS NULL OR t.h2_amount = 0);
解法二:LEFT JOIN 差集法 —— 思路最直观
分别计算出上半年和下半年的消费汇总表,然后通过 LEFT JOIN 进行关联排除。
sql
WITH h1_sales AS (
SELECT user_id, SUM(order_amount) AS h1_amount
FROM orders
WHERE order_date >= '2025-01-01' AND order_date <= '2025-06-30'
GROUP BY user_id
HAVING SUM(order_amount) > 5000
),
h2_sales AS (
SELECT user_id, SUM(order_amount) AS h2_amount
FROM orders
WHERE order_date >= '2025-07-01' AND order_date <= '2025-12-31'
GROUP BY user_id
HAVING SUM(order_amount) > 0
)
SELECT
u.user_id,
u.user_name,
h1.h1_amount,
0.00 AS h2_amount
FROM h1_sales h1
INNER JOIN users u ON h1.user_id = u.user_id
LEFT JOIN h2_sales h2 ON h1.user_id = h2.user_id
WHERE h2.user_id IS NULL;
三、 SparkSQL 深度分析与面试加分项
在面试中,仅仅写出 SQL 是不够的,能够针对 SparkSQL 的执行机制、性能优化进行剖析,才能拿到高薪 Offer。以下是针对本题的深度技术分析:
1. 为什么“解法一(条件聚合)”性能远优于“解法二”?
- 减少 Shuffle 阶段:
- 解法二(LEFT JOIN 差集)需要对
orders表进行两次分组聚合(产生两次 Stage 划分和两次 Shuffle Write/Read),最后还要做一次JOIN关联(再次产生 Shuffle)。 - 解法一(条件聚合)只需要对
orders表进行一次过滤和一次GROUP BY。在 Spark 引擎中,这只会触发一次 Shuffle 过程,大大减少了磁盘 I/O 和网络传输。
- 解法二(LEFT JOIN 差集)需要对
- 扫描次数:解法一通过
WHERE order_date BETWEEN...限定了全年的数据范围,只对orders表做了一次全表/分区扫描;而解法二扫描了两次表。
2. SparkSQL 执行计划与优化(Catalyst Optimizer)
在向面试官解释时,可以提到 SparkSQL 的 Catalyst 优化器是如何处理该查询的:
- 谓词下推(Predicate Pushdown):Spark 会优先将
order_date BETWEEN '2025-01-01' AND '2025-12-31'的过滤条件推送到数据源端。如果是 Parquet 或 ORC 格式,能直接利用列式存储的 Metadata 进行 Row Group 过滤,甚至避免读取无关的文件块。 - 常量折叠与 Projection 剪枝:不需要的列(如非统计维度的其他订单属性)在读取时会被直接忽略,减少内存占用。
3. 数据倾斜(Data Skew)应对策略
如果 orders 表非常庞大(TB级),且某些大促期间或某些头部渠道的用户产生海量订单,GROUP BY user_id 可能会导致数据倾斜(某些 Task 处理的数据量远大于其他 Task)。
- 解决方案(两阶段聚合):
- 先对
user_id加随机前缀进行局部聚合:GROUP BY user_id, floor(rand() * 10) - 去掉前缀进行全局聚合。
- 先对
- 广播连接(Broadcast Hash Join, BHJ):
- 由于
users表(用户维表)通常相对较小,而计算后的流失用户结果集也很小。在最后与users表关联时,Spark 会默认(或通过/*+ BROADCAST(u) */Hint 提示)将维表广播到各个 Executor,从而避免了大表之间的 Shuffle Hash Join,将JOIN转化为本地 Map 端连接。
- 由于
4. 边界漏洞防范(Null 值处理)
面试官非常看重候选人对边界情况的严谨度:
- 0元订单与无订单:下半年“无消费”有两种可能:
- 下半年完全没有订单记录(
LEFT JOIN结果为NULL)。 - 下半年有订单但金额为 0(如积分兑换、退款单,
SUM结果为 0)。
- 下半年完全没有订单记录(
- 防御性代码:在解法一中,使用
(t.h2_amount IS NULL OR t.h2_amount = 0)来同时兼容这两种情况;并使用COALESCE(t.h2_amount, 0.00)保证报表输出的友好性。