计算每个用户首次下单实付金额,与第二次下单实付金额的差值,并统计全平台用户的平均差值
面试真题:用户首次与第二次下单金额差值及平台平均差值统计
1. 模拟数据集
订单基础表:user_orders
| order_id (订单ID) | user_id (用户ID) | order_time (下单时间) | pay_amount (实付金额) |
|---|---|---|---|
| o001 | u01 | 2023-10-01 10:00:00 | 100.00 |
| o002 | u01 | 2023-10-02 11:00:00 | 80.00 |
| o003 | u01 | 2023-10-03 12:00:00 | 50.00 |
| o004 | u02 | 2023-10-01 09:00:00 | 150.00 |
| o005 | u02 | 2023-10-01 15:00:00 | 200.00 |
| o006 | u03 | 2023-10-02 08:00:00 | 300.00 |
| o007 | u04 | 2023-10-01 12:00:00 | 50.00 |
| o008 | u04 | 2023-10-03 14:00:00 | 50.00 |
2. 核心考点与解题思路
- 确定订单顺序(行变列/序列定位):需要识别出每个用户的“首次下单”和“第二次下单”。这需要使用窗口函数
ROW_NUMBER()按照用户分组,时间升序排列。 - 提取特定顺位的数据:通过条件聚合(
CASE WHEN+MAX)或者行转列的方法,将每个用户的第一笔和第二笔订单金额提取到同一行。 - 过滤不合规数据:仅有1次下单记录的用户(如
u03)无法计算差值,需要在计算差值时予以过滤(即第二笔订单金额为NULL的用户不参与后续计算)。 - 计算全局指标:要求“统计全平台用户的平均差值”,在输出明细的同时计算全局平均值,最优雅的方式是使用 无
PARTITION BY的窗口函数AVG(diff) OVER(),避免再次写子查询进行JOIN。
3. SparkSQL 最佳实践代码
sql
WITH ranked_orders AS (
-- Step 1: 为每个用户的订单按时间进行排序,并只保留前2次下单记录以减少数据Shuffle量
SELECT
user_id,
pay_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time ASC) as rn
FROM user_orders
),
user_pivoted AS (
-- Step 2: 使用行转列(条件聚合),获取每个用户首次和第二次下单的金额
SELECT
user_id,
MAX(CASE WHEN rn = 1 THEN pay_amount END) as first_pay_amount,
MAX(CASE WHEN rn = 2 THEN pay_amount END) as second_pay_amount
FROM ranked_orders
WHERE rn <= 2
GROUP BY user_id
),
user_diff_calc AS (
-- Step 3: 计算差值,并过滤掉没有第二次下单的用户
SELECT
user_id,
first_pay_amount,
second_pay_amount,
(first_pay_amount - second_pay_amount) as diff
FROM user_pivoted
WHERE second_pay_amount IS NOT NULL
)
-- Step 4: 输出每个用户的差值明细,并通过窗口函数计算全平台的平均差值
SELECT
user_id,
first_pay_amount,
second_pay_amount,
diff,
ROUND(AVG(diff) OVER (), 2) as platform_avg_diff
FROM user_diff_calc;
4. 预期输出结果
| user_id | first_pay_amount | second_pay_amount | diff | platform_avg_diff |
|---|---|---|---|---|
| u01 | 100.00 | 80.00 | 20.00 | -10.00 |
| u02 | 150.00 | 200.00 | -50.00 | -10.00 |
| u04 | 50.00 | 50.00 | 0.00 | -10.00 |
(注:u03 因只有1笔订单被过滤;平台平均差值计算公式为:(20 + (-50) + 0) / 3 = -10.00)
5. SparkSQL 面试加分项与深度分析
在面试中,仅仅写出 SQL 是不够的。如果能主动向面试官解释以下优化点和底层原理,通过率会极大提升:
① 避免使用 Self-Join(自连接)
初学者常使用 JOIN 把首次下单表和第二次下单表关联起来:
sql
-- 不推荐做法
SELECT ... FROM (SELECT ... WHERE rn=1) a JOIN (SELECT ... WHERE rn=2) b ON a.user_id = b.user_id
- 弊端:在 Spark 中,
JOIN会引入昂贵的 Shuffle (Shuffle Hash Join / Sort Merge Join),导致网络传输大增,且容易因为数据倾斜(某些大用户有极多订单)导致 OOM。 - 优化方案:本解法使用
MAX(CASE WHEN rn = 1 ...)条件聚合。这种方式只需要一次GROUP BY聚合,Spark 底层仅发生一次基于user_id的 Hash 聚合 Shuffle,性能远优于JOIN。
② 提前过滤数据(Filter Pushdown 思想)
在第一层 CTE (ranked_orders) 往第二层 (user_pivoted) 转化时,增加了 WHERE rn <= 2 条件。
- 原理:如果一个用户有 1000 笔订单,我们其实只关心前 2 笔。提前过滤掉
rn > 2的数据,可以极大地减少聚合阶段内存和磁盘 I/O 的压力。
③ 巧妙使用开窗聚合 AVG() OVER ()
在最终输出时,题目要求同时展示“每个用户”和“全平台平均”。
- 普通做法:写一个子查询
SELECT AVG(diff) FROM ...,然后再CROSS JOIN回原表。 - Spark 优化做法:使用
AVG(diff) OVER ()。在没有PARTITION BY的情况下,Spark 会将所有数据汇总到一个 Window 算子中计算出平均值,并广播/追加到每一行,代码极其优雅且执行效率高。
④ 数据倾斜预防(Data Skew)
如果面试官追问:“如果某些大 V 用户的订单量极大,导致 ROW_NUMBER() 阶段发生数据倾斜怎么处理?”
- 回答:
- 在
ROW_NUMBER之前,如果业务允许,先通过WHERE过滤掉不必要的历史订单。 - 如果仅仅是为了计算前两次,可以在 Map 端先做一次局部 TopN(配合物理执行计划中的
Limit压减),但由于 Spark SQL 窗口函数不支持直接在 Map 端局部聚合,最有效的方式是提高spark.sql.shuffle.partitions的并行度,或对倾斜的user_id加盐(Salting)打散后再聚合。
- 在