统计同一笔购物车合并付款的交易中,包含 3 个及以上不同店铺商品的订单占总交易笔数的比例
面试真题:购物车合并付款多店铺交易占比统计
题目描述:
在电商场景中,用户可以将多个店铺的商品加入购物车并合并为一笔交易(同一交易编号 trade_id)进行付款。请编写 SparkSQL 统计:包含 3 个及以上不同店铺商品的交易笔数占总交易笔数的比例(结果保留4位小数)。
1. 基础数据准备
订单明细表(user_order_detail)
| trade_id (交易ID) | order_id (子订单ID) | product_id (商品ID) | shop_id (店铺ID) | user_id (用户ID) |
|---|---|---|---|---|
| T101 | O001 | P001 | S1 | U01 |
| T101 | O002 | P002 | S2 | U01 |
| T101 | O003 | P003 | S3 | U01 |
| T102 | O004 | P001 | S1 | U02 |
| T102 | O005 | P004 | S1 | U02 |
| T102 | O006 | P002 | S2 | U02 |
| T103 | O007 | P001 | S1 | U03 |
| T103 | O008 | P002 | S2 | U03 |
| T103 | O009 | P003 | S3 | U03 |
| T103 | O010 | P005 | S4 | U03 |
| T104 | O011 | P001 | S1 | U04 |
数据解析说明:
- 交易
T101:包含店铺 S1, S2, S3(共3个不同店铺),满足条件。 - 交易
T102:包含店铺 S1, S2(共2个不同店铺),不满足条件。 - 交易
T103:包含店铺 S1, S2, S3, S4(共4个不同店铺),满足条件。 - 交易
T104:包含店铺 S1(共1个店铺),不满足条件。 - 总交易笔数:4笔 (
T101,T102,T103,T104)。 - 满足条件的交易笔数:2笔 (
T101,T103)。 - 目标比例:2 / 4 = 0.5000 (50.00%)。
2. SparkSQL 核心解法
sql
WITH trade_shop_summary AS (
-- Step 1: 统计每笔交易对应的独立店铺数量
SELECT
trade_id,
COUNT(DISTINCT shop_id) AS shop_cnt
FROM
user_order_detail
GROUP BY
trade_id
)
-- Step 2: 计算符合条件的交易占比
SELECT
COUNT(CASE WHEN shop_cnt >= 3 THEN 1 END) AS multi_shop_trade_cnt,
COUNT(1) AS total_trade_cnt,
ROUND(
COUNT(CASE WHEN shop_cnt >= 3 THEN 1 END) / COUNT(1),
4
) AS ratio
FROM
trade_shop_summary;
输出结果:
| multi_shop_trade_cnt | total_trade_cnt | ratio |
|---|---|---|
| 2 | 4 | 0.5000 |
3. 面试官视角:SparkSQL 深度分析与优化
在实际面试中,写出上述 SQL 只能拿到基础分。面试官通常会针对 Spark 的底层执行机制和高并发大数据量场景进行追问。以下是应对追问的黄金解析:
核心考点一:COUNT(DISTINCT) 的数据倾斜问题与优化
问题痛点:
在trade_shop_summary子查询中,我们使用了COUNT(DISTINCT shop_id)。在 Spark 中,这会触发 Shuffle。如果某笔交易(例如大促期间的某些企业大单、代购订单)包含了极高数量的商品,会导致该trade_id被分发到单个 Reducer 上进行去重,从而引发数据倾斜(Data Skew)。解决方案(两阶段聚合):
可以通过“先分组去重,再计数”的方式打破瓶颈,避免直接使用COUNT(DISTINCT):sqlWITH temp_distinct AS ( -- 先按 trade_id 和 shop_id 进行去重分组,分散倾斜压力 SELECT trade_id, shop_id FROM user_order_detail GROUP BY trade_id, shop_id ), trade_shop_summary AS ( -- 此时只需 COUNT(1) 即可,无去重压力 SELECT trade_id, COUNT(1) AS shop_cnt FROM temp_distinct GROUP BY trade_id ) SELECT ROUND(SUM(CASE WHEN shop_cnt >= 3 THEN 1 ELSE 0 END) / COUNT(1), 4) AS ratio FROM trade_shop_summary;
核心考点二:Spark 执行计划与 Shuffle 减负
- Map 阶段本地聚合(Map-side Aggregation):
在执行第一步GROUP BY trade_id时,Spark 会默认开启spark.sql.mapOutputs.minSize等相关优化。对于普通的COUNT聚合,Spark 会在 Map 端先进行Aggregate,减少传输到 Executor 的数据量。 - 避免笛卡尔积:
该题切忌使用JOIN自身来判断多店铺。有些候选人会尝试用a.shop_id <> b.shop_id AND b.shop_id <> c.shop_id的自关联方式来凑 3 个店铺,这在海量数据下会产生严重的 Shuffle 甚至内存溢出(OOM)。一律优先使用GROUP BY + CASE WHEN解决分类计数问题。
核心考点三:精度丢失与边界处理
- 除数非空/非零处理:在工业界代码中,必须防范分母为 0 的情况。可以使用
NULLIF(COUNT(1), 0)来规避Division by zero报错。 - 类型转换:Spark 中两个整型相除可能会丢失精度,但 SparkSQL 在进行
/除法运算时会自动隐式转换为Double。为了代码的安全性和可读性,显式使用CAST(xxx AS DOUBLE)也是加分项。