找出用户输入取件码、柜门打开后但由于某种原因未取走快递(传感器检测到格口重新锁定,且状态为未取出),并在 2 小时内被快递员取出重新投递的记录
SparkSQL 面试题:异常取件与快速重投分析
1. 题目背景与业务场景
在智能快递柜业务中,存在一种特殊的“异常取件”场景:用户输入了正确的取件码,柜门也正常打开,但可能由于用户分神、临时有事离开或误操作,导致用户并未真正拿走快递。快递柜传感器检测到柜门在一定时间后被重新锁定,且系统状态判定为“未取出”。
为了保障快递安全,快递员通常会在收到异常报警后,在短时间内(本题限制为 2 小时内)将该快递取出并重新进行投递。
本题要求通过 SparkSQL 分析快递柜事件日志表和投递记录表,找出所有满足“用户开门未取 -> 2小时内快递员取出重投”的异常记录。
2. 面试数据准备(样例数据)
表 1:快递柜操作事件日志表 (cabinet_event_log)
记录快递柜格口的所有物理事件(开门、锁门、传感器状态)。
| event_id | express_id (快递单号) | event_type (事件类型) | lock_status (锁状态) | cargo_status (货物状态) | event_time (事件时间) |
|---|---|---|---|---|---|
| E001 | EXP1001 | USER_OPEN (用户开门) | OPEN | UNKNOWN | 2023-10-25 10:00:00 |
| E002 | EXP1001 | AUTO_CLOSE (自动关闭) | LOCKED | NOT_TAKEN (未取出) | 2023-10-25 10:02:00 |
| E003 | EXP1002 | USER_OPEN (用户开门) | OPEN | UNKNOWN | 2023-10-25 11:00:00 |
| E004 | EXP1002 | AUTO_CLOSE (自动关闭) | LOCKED | TAKEN (已取出) | 2023-10-25 11:01:00 |
| E005 | EXP1003 | USER_OPEN (用户开门) | OPEN | UNKNOWN | 2023-10-25 14:00:00 |
| E006 | EXP1003 | AUTO_CLOSE (自动关闭) | LOCKED | NOT_TAKEN (未取出) | 2023-10-25 14:03:00 |
表 2:快递投递及处理记录表 (delivery_record)
记录快递员的投递、取出重投等人工操作。
| record_id | express_id (快递单号) | action_type (操作类型) | operator_role (操作员角色) | action_time (操作时间) |
|---|---|---|---|---|
| R001 | EXP1001 | DELIVERY (首次投递) | COURIER (快递员) | 2023-10-25 08:00:00 |
| R002 | EXP1001 | RE_TAKE (异常取出) | COURIER (快递员) | 2023-10-25 11:30:00 |
| R003 | EXP1001 | REDELIVERY (重新投递) | COURIER (快递员) | 2023-10-25 11:35:00 |
| R004 | EXP1003 | RE_TAKE (异常取出) | COURIER (快递员) | 2023-10-25 17:30:00 |
3. 面试题答案 (SparkSQL 实现)
sql
WITH user_abort_events AS (
-- 1. 步骤一:找出“用户开门后未取走、柜门重新锁定”的异常事件
-- 使用窗口函数 LEAD 获取紧邻的下一个锁定事件,并确保货物状态为 'NOT_TAKEN'
SELECT
express_id,
event_time AS open_time,
next_event_time AS lock_time
FROM (
SELECT
express_id,
event_type,
event_time,
LEAD(event_type) OVER (PARTITION BY express_id ORDER BY event_time) AS next_event_type,
LEAD(lock_status) OVER (PARTITION BY express_id ORDER BY event_time) AS next_lock_status,
LEAD(cargo_status) OVER (PARTITION BY express_id ORDER BY event_time) AS next_cargo_status,
LEAD(event_time) OVER (PARTITION BY express_id ORDER BY event_time) AS next_event_time
FROM cabinet_event_log
) t
WHERE t.event_type = 'USER_OPEN'
AND t.next_event_type = 'AUTO_CLOSE'
AND t.next_lock_status = 'LOCKED'
AND t.next_cargo_status = 'NOT_TAKEN'
),
courier_retakes AS (
-- 2. 步骤二:筛选出快递员执行的“异常取出”记录
SELECT
express_id,
action_time AS courier_take_time
FROM delivery_record
WHERE action_type = 'RE_TAKE'
AND operator_role = 'COURIER'
)
-- 3. 步骤三:双表关联,限制快递员取出时间在用户锁定格口后的 2 小时内
SELECT
a.express_id,
a.open_time AS user_open_time,
a.lock_time AS cabinet_relock_time,
c.courier_take_time,
-- 计算快递员响应耗时(分钟)
ROUND((CAST(c.courier_take_time AS LONG) - CAST(a.lock_time AS LONG)) / 60, 2) AS courier_response_minutes
FROM user_abort_events a
JOIN courier_retakes c
ON a.express_id = c.express_id
WHERE c.courier_take_time > a.lock_time
-- 限制在 2 小时(7200秒)内
AND CAST(c.courier_take_time AS LONG) - CAST(a.lock_time AS LONG) <= 7200;
预期输出结果:
| express_id | user_open_time | cabinet_relock_time | courier_take_time | courier_response_minutes |
|---|---|---|---|---|
| EXP1001 | 2023-10-25 10:00:00 | 2023-10-25 10:02:00 | 2023-10-25 11:30:00 | 88.0 |
注:EXP1003 虽然也发生了未取走事件(14:03锁定),但快递员在 17:30 才取出,间隔了 3 小时 27 分钟,超过了 2 小时限制,因此被过滤。
4. SparkSQL 深度分析与面试应对指南
① 考察的核心知识点
- 窗口函数
LEAD的妙用:- 业务要求“开门后重新锁定且未取出”。在日志流中,这是前后紧邻的两条记录。
- 面试官希望看到候选人能够使用
LEAD(col, 1) OVER (PARTITION BY ... ORDER BY ...)将相邻行的状态平铺到同一行,便于进行条件过滤,避免多次自关联(Self-Join)带来的性能开销。
- 时间差值的计算与性能:
- 在 SparkSQL 中,直接将
TIMESTAMP转换为LONG(Unix 时间戳,单位为秒)进行减法运算,是计算时间差最通用、最高效且不易出错的方法,避免了因不同数据库底层DATEDIFF颗粒度不同(有的只支持天)导致的兼容性问题。
- 在 SparkSQL 中,直接将
- 复杂业务逻辑的拆解能力 (CTE 表达式):
- 使用
WITH ... AS ...(Common Table Expressions) 结构。在面试中,清晰的代码结构(如将“用户异常行为”和“快递员响应行为”拆为两个子查询)能极大提升面试官的好感,体现出良好的工程素养。
- 使用
② Spark 优化细节(加分项)
如果在面试中被问及“如何优化这个 SQL 的执行性能?”,可以从以下几个维度回答:
- 避免大表 Join 的 Shuffle (Broadcast Join):
- 在实际生产中,
cabinet_event_log(事件日志表)数据量极大,而符合RE_TAKE且是快递员操作的delivery_record过滤后数据量通常很小。 - 可以向面试官主动提出:如果
courier_retakes过滤后的数据集小于spark.sql.autoBroadcastJoinThreshold(默认 10MB),Spark 会自动采用 Broadcast Hash Join,从而避免了昂贵的 Shuffle 阶段。
- 在实际生产中,
- 分区裁剪与谓词下推:
- 在实际的大数据表中,日志表通常会按
dt(日期) 进行分区。在子查询中应当首先加上分区过滤条件(例如WHERE dt = '20231025'),利用 Spark 的 Partition Pruning 减少读取的数据量。
- 在实际的大数据表中,日志表通常会按
右滑查看面试常问