根据当前库存量和过去 30 天的日均销量,计算每种商品的预计库存可售天数(DOI),并筛选出可售天数大于 180 天的积压商品列表
面试真题:计算商品库存可售天数(DOI)并筛选积压商品
1. 题目背景
在电商和零售供应链管理中,DOI(Days of Inventory,库存可售天数) 是衡量库存健康度的核心指标。
计算公式为:
如果某种商品的 DOI 超过 180 天,通常被定义为严重积压商品,需要警惕资金占用风险并及时促销清仓。
请根据给定的商品库存表和销售明细表,编写 SparkSQL 查询,找出所有 DOI > 180天 的积压商品,输出商品ID、商品名称、当前库存、过去30天总销量、日均销量及 DOI(保留1位小数)。
2. 基础数据
表 1:商品库存表 (product_inventory)
| product_id (商品ID) | product_name (商品名称) | current_stock (当前库存) |
|---|---|---|
| P001 | 智能运动手表 A1 | 5000 |
| P002 | 无线蓝牙耳机 B2 | 120 |
| P003 | 降噪头戴耳机 C3 | 1500 |
| P004 | 智能家用投影仪 D4 | 800 |
| P005 | 户外便携帐篷 E5 | 50 |
表 2:销售明细表 (sales_records)
注:假设当前计算基准日期为 2023-10-31,过去30天的区间为 2023-10-02 至 2023-10-31。
| product_id (商品ID) | sale_date (销售日期) | quantity (销售数量) |
|---|---|---|
| P001 | 2023-10-05 | 100 |
| P001 | 2023-10-20 | 200 |
| P002 | 2023-10-15 | 150 |
| P002 | 2023-10-28 | 150 |
| P004 | 2023-10-10 | 10 |
| P004 | 2023-10-25 | 20 |
| P005 | 2023-10-01 | 50 |
3. 期望输出结果
| product_id | product_name | current_stock | total_sales_30d | avg_daily_sales | doi |
|---|---|---|---|---|---|
| P001 | 智能运动手表 A1 | 5000 | 300 | 10.0 | 500.0 |
| P003 | 降噪头戴耳机 C3 | 1500 | 0 | 0.0 | 9999.0 |
| P004 | 智能家用投影仪 D4 | 800 | 30 | 1.0 | 800.0 |
4. SparkSQL 解决方案
sql
WITH sales_30d AS (
-- 1. 聚合过去30天内每种商品的销量
SELECT
product_id,
COALESCE(SUM(quantity), 0) AS total_sales_30d
FROM
sales_records
WHERE
sale_date >= DATE_SUB(CAST('2023-10-31' AS DATE), 30)
AND sale_date <= CAST('2023-10-31' AS DATE)
GROUP BY
product_id
),
doi_calculation AS (
-- 2. 关联库存表与销量表,计算日均销量和 DOI
-- 使用 LEFT JOIN 防止过去30天销量为0的商品被过滤掉(这些更是潜在的积压商品)
SELECT
i.product_id,
i.product_name,
i.current_stock,
COALESCE(s.total_sales_30d, 0) AS total_sales_30d,
ROUND(COALESCE(s.total_sales_30d, 0) / 30.0, 4) AS avg_daily_sales,
CASE
-- 处理除数为0的极端边界情况:若30天销量为0,则DOI设为极大值(如9999)代表无限可售
WHEN COALESCE(s.total_sales_30d, 0) = 0 THEN 9999.0
ELSE ROUND(i.current_stock / (COALESCE(s.total_sales_30d, 0) / 30.0), 1)
END AS doi
FROM
product_inventory i
LEFT JOIN
sales_30d s ON i.product_id = s.product_id
)
-- 3. 筛选出 DOI 大于 180 天的积压商品
SELECT
product_id,
product_name,
current_stock,
total_sales_30d,
ROUND(avg_daily_sales, 1) AS avg_daily_sales,
doi
FROM
doi_calculation
WHERE
doi > 180
ORDER BY
doi DESC;
5. 核心考点与 SparkSQL 深度分析
在面试中,这道题看似简单,但其实隐藏了多个高级考点。面试官主要通过此题考察以下几点:
① 边界条件与“除零”异常处理(面试核心加分项)
- 痛点:如果某种商品在过去30天内完全没有销售记录(如
P003),直接进行除法计算current_stock / (total_sales / 30)会导致NULL或Division by zero报错。 - 解决办法:
- 使用
LEFT JOIN保留无销售记录的库存商品。 - 配合
COALESCE(s.total_sales_30d, 0)将NULL转为0。 - 使用
CASE WHEN捕获销量为0的情况,将其 DOI 赋予一个极大值(例如9999)或者特殊标识。这体现了生产级代码的健壮性。
- 使用
② 日期函数的准确运用
- 过去 30 天的范围界定,标准写法是使用
DATE_SUB(current_date(), 30)。 - 在面试或测试中,通常会使用一个固定的基准日期(如
CAST('2023-10-31' AS DATE))来保证结果幂等。
③ 性能优化:Broadcast Hash Join (广播连接)
- 场景分析:商品库存表
product_inventory通常是维度表,数据量相对较小(万级或十万级);而sales_records是流水事实表,数据量极大(千万级或亿级)。 - 优化手段:
- 在 Spark 引擎中,为了避免大范围的 Shuffle,应该主动将小表(库存表)进行广播。
- SQL 写法:可以使用 Hint 强制广播:sql
SELECT /*+ BROADCAST(i) */ ... FROM product_inventory i LEFT JOIN sales_30d s ... - 这将原本默认的
SortMergeJoin转换为BroadcastHashJoin,执行效率能提升数倍。
④ 聚合与过滤的先后顺序
- 必须先过滤过去 30 天的销售明细,再进行 Group By 聚合。不能先全局聚合再去过滤日期,否则会导致全表扫描和无用的 Shuffle 计算。