统计每个快递柜每天“所有格口均处于占用状态(无空闲格口)”的累计持续时长(分钟数),找出最易爆柜的前 100 个快递柜
SparkSQL 面试题:快递柜爆柜(满载)累计持续时长统计
1. 题目背景与业务逻辑
在智能快递柜运营中,“爆柜”(即所有格口均被占用,无可用空闲格口)会导致快递员无法投递,严重影响用户体验和运营效率。
本题要求:统计每个快递柜每天“所有格口均处于占用状态”的累计持续时长(分钟数),并找出最易爆柜(即日均爆柜时长最长)的前 100 个快递柜。
2. 示例数据
表 1:快递柜主表 cabinet_info
记录快递柜的基本信息及总格口数。
| 字段名 | 类型 | 说明 |
|---|---|---|
cabinet_id |
String | 快递柜ID |
total_grids |
Integer | 该快递柜的总格口数 |
cabinet_info 示例数据:
| cabinet_id | total_grids |
|---|---|
| CAB001 | 3 |
| CAB002 | 4 |
表 2:格口状态变更流水表 grid_status_log
记录每个格口的占用与释放事件。每次状态变更生成一条记录。
status= 1 表示被占用(投递)status= 0 表示变为空闲(取出)
| 字段名 | 类型 | 说明 |
|---|---|---|
cabinet_id |
String | 快递柜ID |
grid_id |
String | 格口ID |
status |
Integer | 状态:1-占用,0-空闲 |
event_time |
Timestamp | 事件发生时间 |
grid_status_log 示例数据:
(以 CAB001 拥有 3 个格口为例,展示某天状态变化)
| cabinet_id | grid_id | status | event_time |
|---|---|---|---|
| CAB001 | G1 | 1 | 2023-10-25 08:00:00 |
| CAB001 | G2 | 1 | 2023-10-25 08:05:00 |
| CAB001 | G3 | 1 | 2023-10-25 08:10:00 |
| CAB001 | G1 | 0 | 2023-10-25 08:30:00 |
| CAB001 | G1 | 1 | 2023-10-25 09:00:00 |
| CAB001 | G2 | 0 | 2023-10-25 09:15:00 |
3. 核心解题思路与 SparkSQL 分析
这是一道典型且高频的时序状态分析(State Duration)面试题,考察窗口函数、状态重建以及时间段重叠计算。在 SparkSQL 中解决此问题分为四个核心步骤:
步骤一:计算每次事件发生后的“当前占用格口数”
利用窗口函数,对每个柜子按时间升序,使用累计求和(Cumulative Sum)计算出每次事件发生后,当前处于占用状态的格口总数。
- 计算公式:
current_occupied = SUM(status_change)。 - 注意:如果是状态变更为
1(占用),则增量为+1;若变更为0(释放),则增量为-1。
步骤二:构建状态持续区间(生成生命周期)
我们需要计算“当前状态”持续了多久。利用 LEAD 窗口函数获取下一次状态变更的时间作为当前状态的结束时间。
- 若下一次变更跨天或没有下一次变更,则结束时间截止到当天深夜
23:59:59。
步骤三:筛选出“爆柜”区间并计算时长
当 当前占用格口数 = 总格口数 时,代表进入“爆柜”状态。
- 爆柜持续时长 =
下一次变更时间 - 当前变更时间。
步骤四:按柜子和日期聚合,并计算日均时长排序
按 cabinet_id 和 date 分组求和,再按 cabinet_id 求平均值,降序排列取前 100。
4. SparkSQL 满分标准答案
sql
WITH pv_status_change AS (
-- 1. 标准化状态变更值:1表示+1,0表示-1
SELECT
cabinet_id,
event_time,
CAST(TO_DATE(event_time) AS STRING) AS op_date,
CASE WHEN status = 1 THEN 1 ELSE -1 END AS change_val
FROM grid_status_log
),
pv_occupied_count AS (
-- 2. 计算每次事件后的累计占用数
SELECT
log.cabinet_id,
log.op_date,
log.event_time,
SUM(log.change_val) OVER (
PARTITION BY log.cabinet_id
ORDER BY log.event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS current_occupied,
info.total_grids
FROM pv_status_change log
JOIN cabinet_info info ON log.cabinet_id = info.cabinet_id
),
pv_state_duration AS (
-- 3. 使用 LEAD 函数获取当前状态的结束时间,构建时间区间
SELECT
cabinet_id,
op_date,
current_occupied,
total_grids,
event_time AS start_time,
-- 如果没有下一条记录,则默认截止到当天最后一秒
COALESCE(
LEAD(event_time) OVER (PARTITION BY cabinet_id, op_date ORDER BY event_time),
CAST(CONCAT(op_date, ' 23:59:59') AS TIMESTAMP)
) AS end_time
FROM pv_occupied_count
),
pv_full_load_minutes AS (
-- 4. 筛选出“全部占满”的区间,并计算持续分钟数
SELECT
cabinet_id,
op_date,
-- 计算差值并转换为分钟数(Spark中TIMESTAMP相减得到秒数,需除以60)
SUM(CAST(unix_timestamp(end_time) - unix_timestamp(start_time) AS DOUBLE) / 60.0) AS daily_full_duration_minutes
FROM pv_state_duration
WHERE current_occupied = total_grids
GROUP BY cabinet_id, op_date
),
pv_avg_full_load AS (
-- 5. 计算每个快递柜的日均爆柜时长
SELECT
cabinet_id,
AVG(daily_full_duration_minutes) AS avg_daily_full_duration_minutes,
COUNT(DISTINCT op_date) AS active_days
FROM pv_full_load_minutes
GROUP BY cabinet_id
)
-- 6. 输出最易爆柜的前 100 个快递柜
SELECT
cabinet_id,
ROUND(avg_daily_full_duration_minutes, 2) AS avg_daily_full_duration_minutes,
active_days
FROM pv_avg_full_load
ORDER BY avg_daily_full_duration_minutes DESC
LIMIT 100;
5. 针对面试官的 Spark 优化与深度回答建议
如果在面试中写出上述 SQL,可以主动提及以下 Spark 优化点,以极大提升面试评价:
数据倾斜应对(Data Skew):
- 痛点:快递柜数量多,但某些“网红小区”的快递柜使用极度频繁,导致这些
cabinet_id的流水数据远多于其他柜子。在PARTITION BY cabinet_id时会发生严重的数据倾斜。 - 解决方案:如果出现倾斜,可以引入“随机前缀”将大快递柜拆分,或者在执行 Window 函数前,确保 Spark 的
spark.sql.shuffle.partitions设置合理,并开启 Adaptive Query Execution (AQE) 自动合并和拆分倾斜分区。
- 痛点:快递柜数量多,但某些“网红小区”的快递柜使用极度频繁,导致这些
Jumping Window / 跨天边界处理的边界问题:
- 面试官可能会问:“如果一个柜子从昨天晚上 23:00 一直满载到今天早上 02:00,你的 SQL 怎么处理?”
- 回答提示:本 SQL 采用
PARTITION BY cabinet_id, op_date进行了按天截断(利用23:59:59封顶)。如果需要精确的跨天连续计算,应当去掉op_date分区,在全局时间轴上使用LEAD,然后再通过 UDF 或 Spark 算子将跨天的区间拆分到两个自然日中。
Join 优化(MapJoin / Broadcast Hash Join):
- 快递柜基础信息表
cabinet_info通常非常小(几十万行以内),而流水表grid_status_log非常大(数亿行)。 - 优化方案:在对两表进行 JOIN 时,应使用
BROADCAST提示强制将cabinet_info广播到各个 Executor,避免大表 Shuffle:sqlJOIN /*+ BROADCAST(info) */ cabinet_info info ON log.cabinet_id = info.cabinet_id
- 快递柜基础信息表