分析在“双十一”或“618”期间,各快递柜从“出现空闲格口”到“彻底满载”的平均消耗时长,输出高危爆柜网点名单
SparkSQL经典面试题:大促期间快递柜爆柜分析
1. 题目背景与业务需求
在“双十一”或“618”等电商大促期间,快递量呈爆发式增长。小区的快递柜频繁出现“爆柜”(即满载无空闲格口)的情况。为了优化快递员投递路线、合理调整快递柜收费策略或规划新柜网点,物流团队需要分析各快递柜从“出现空闲格口”到“彻底满载”的平均消耗时长,并输出高危爆柜网点名单(定义为:平均满载时长小于等于2小时的网点)。
- “出现空闲格口”定义:快递柜状态由“全满(0个空闲)”变为“有空闲格口(>0个空闲)”,或者当天初始状态即有空闲。
- “彻底满载”定义:快递柜空闲格口数变为 0。
- 计算口径:一个完整的“充盈周期”是指从出现空闲格口开始,到首次彻底填满结束。需要计算每个快递柜所有已完成周期的平均持续时间(分钟)。未完成的周期(即目前仍有空闲,尚未填满的)不纳入计算。
2. 面试题模拟数据
快递柜状态变更日志表 (locker_status_log)
| locker_id (快递柜ID) | district (所属网点/区域) | record_time (记录时间) | free_slots (当前空闲格口数) |
|---|---|---|---|
| L001 | 北京朝阳望京件区 | 2023-11-11 08:00:00 | 0 |
| L001 | 北京朝阳望京件区 | 2023-11-11 08:15:00 | 5 |
| L001 | 北京朝阳望京件区 | 2023-11-11 08:45:00 | 2 |
| L001 | 北京朝阳望京件区 | 2023-11-11 09:15:00 | 0 |
| L001 | 北京朝阳望京件区 | 2023-11-11 10:00:00 | 3 |
| L001 | 北京朝阳望京件区 | 2023-11-11 10:30:00 | 0 |
| L002 | 北京海淀中关村区 | 2023-11-11 08:00:00 | 0 |
| L002 | 北京海淀中关村区 | 2023-11-11 09:00:00 | 10 |
| L002 | 北京海淀中关村区 | 2023-11-11 12:00:00 | 5 |
| L002 | 北京海淀中关村区 | 2023-11-11 15:00:00 | 0 |
| L003 | 上海浦东陆家嘴区 | 2023-11-11 08:00:00 | 8 |
| L003 | 上海浦东陆家嘴区 | 2023-11-11 09:30:00 | 4 |
| L003 | 上海浦东陆家嘴区 | 2023-11-11 11:00:00 | 0 |
| L003 | 上海浦东陆家嘴区 | 2023-11-11 13:00:00 | 6 |
| L003 | 上海浦东陆家嘴区 | 2023-11-11 14:00:00 | 2 |
3. 期望输出结果
| locker_id | district | avg_fill_duration_minutes (平均满载时长/分) | completed_cycles (已完成周期数) | risk_level (危险等级) |
|---|---|---|---|---|
| L001 | 北京朝阳望京件区 | 45.00 | 2 | 极高隐患(1小时内爆柜) |
| L003 | 上海浦东陆家嘴区 | 180.00 | 1 | 正常 |
| L002 | 北京海淀中关村区 | 360.00 | 1 | 正常 |
4. SparkSQL 核心解题代码
sql
WITH status_with_lag AS (
-- Step 1: 使用LAG函数获取上一次的空闲格口数,用于识别状态跃迁点
SELECT
locker_id,
district,
record_time,
free_slots,
LAG(free_slots) OVER (PARTITION BY locker_id ORDER BY record_time) AS prev_free_slots
FROM locker_status_log
),
flagged_status AS (
-- Step 2: 标记新周期的起点。当上一次是满载(0)或无记录(NULL),且当前有空闲(>0)时,视为新周期起点
SELECT
locker_id,
district,
record_time,
free_slots,
SUM(
CASE
WHEN (prev_free_slots = 0 OR prev_free_slots IS NULL) AND free_slots > 0 THEN 1
ELSE 0
END
) OVER (PARTITION BY locker_id ORDER BY record_time) AS cycle_id
FROM status_with_lag
),
completed_cycles AS (
-- Step 3: 按 cycle_id 分组,计算每个周期的开始时间与结束时间。
-- 过滤掉未完成的周期(即该组中没有 free_slots = 0 的记录)
SELECT
locker_id,
district,
cycle_id,
MIN(CASE WHEN free_slots > 0 THEN record_time END) AS start_time,
MIN(CASE WHEN free_slots = 0 THEN record_time END) AS end_time
FROM flagged_status
WHERE cycle_id > 0
GROUP BY locker_id, district, cycle_id
HAVING SUM(CASE WHEN free_slots = 0 THEN 1 ELSE 0 END) > 0
),
cycle_durations AS (
-- Step 4: 计算每个周期的耗时(单位:分钟)
SELECT
locker_id,
district,
cycle_id,
(unix_timestamp(end_time) - unix_timestamp(start_time)) / 60 AS duration_minutes
FROM completed_cycles
)
-- Step 5: 聚合计算平均耗时,并根据业务规则划分高危爆柜网点
SELECT
locker_id,
district,
ROUND(AVG(duration_minutes), 2) AS avg_fill_duration_minutes,
COUNT(1) AS completed_cycles,
CASE
WHEN AVG(duration_minutes) <= 60 THEN '极高隐患(1小时内爆柜)'
WHEN AVG(duration_minutes) <= 120 THEN '高隐患(2小时内爆柜)'
ELSE '正常'
END AS risk_level
FROM cycle_durations
GROUP BY locker_id, district
ORDER BY avg_fill_duration_minutes ASC;
5. 面试官视角的深度剖析与 SparkSQL 优化考点
这道题是典型的状态机模型/会话分段(Sessionization)问题,在实际大厂面试中(如美团、京东、极兔等物流与电商大厂)属于高频且区分度极高的中高级SQL题。
核心考点解析
动态边界处理(Session ID 生成)
- 难点:时间序列数据是连续的,必须将连续的行切分为独立的“生命周期”。
- 破局点:利用
LAG函数寻找状态突变点(0 >0),再配合累加窗口函数 (SUM(...) OVER (...)) 动态生成周期 ID (cycle_id)。这种“信号标记+累加”的技巧是解决各种 Session 划分(如用户流失、设备开机关机、异常检测)的通用银弹。
生命周期的边界条件与脏数据过滤
- 未完成周期的过滤:例如
L003在 13:00 后变为空闲,但到日志截止时仍有 2 个空闲,没有触发“彻底满载”。在 Step 3 中,通过HAVING SUM(CASE WHEN free_slots = 0 THEN 1 ELSE 0 END) > 0优雅地过滤掉了不完整周期,避免了算出来的平均时间被拉长。 - 首行 NULL 值的处理:第一行记录没有
prev_free_slots,通过prev_free_slots IS NULL兼容了初始化状态。
- 未完成周期的过滤:例如
SparkSQL 性能优化与大厂加分项
在面对海量数据(如全国每天数百万个快递柜、数亿条状态变更记录)时,上述 SQL 可能会遭遇性能瓶颈。在面试中,如果能主动提出以下优化点,将是极大的加分项:
1. 避免数据倾斜(Data Skew)
- 痛点:
PARTITION BY locker_id会将单个快递柜的所有历史记录发送到同一个 Reduce 任务中。如果某些核心热点区域的快递柜(如写字楼、大型小区)状态变更极度频繁,会导致严重的单点瓶颈(Data Skew)。 - 解决方案:
- 加盐(Salting)局部聚合:如果只需计算区域大网点指标,可以引入随机数或按
(locker_id, hash(record_time) % 10)进行第一阶段分组,做局部会话合并,再进行全局聚合。 - 在两阶段聚合中,第一阶段先在更细的时间粒度(如按天)内进行局部聚合,削减单key的数据量。
- 加盐(Salting)局部聚合:如果只需计算区域大网点指标,可以引入随机数或按
2. 状态压缩(State Compression)—— 过滤无用跃迁
- 在进行窗口计算前,很多中间状态是多余的。例如:
free_slots从5 -> 4 -> 3 -> 2 -> 0。 - 其实我们只关心起点(>0)和终点(0)。
- 优化策略:在第一步
status_with_lag之前,可以使用简单过滤或前置轻量聚合,剔除连续的、无状态变化的中间冗余行。例如,如果连续多行free_slots都是大于0的,且没有发生 0 的跃迁,可以利用 Spark 算子进行清洗,减少参与 Window 排序的数据量。
3. 避免全表扫描与 Shuffle 优化
- 在实际数仓建设中,此表应按天/按小时进行分区(如
dt='20231111')。在FROM子句中务必带上分区过滤,限制 Spark 读取的数据量。 - 合理设置
spark.sql.shuffle.partitions,确保每个 Task 处理的数据量在 100MB-200MB 之间,避免因默认 200 分区过大或过小导致的 GC 溢出或小文件过多问题。