直接用ROW_NUMBER()按入库时间排序不足以实现FIFO库存核算,因其仅排序不跟踪批次剩余量;必须通过自连接+累计窗口模拟消耗链路,逐批匹配并计算实际扣减量,才能处理部分消耗场景。

为什么直接用 ROW_NUMBER() 按入库时间排序还不够
先进先出(FIFO)库存核算不是简单地给每条入库记录排个序,而是要按「可用库存量」动态消耗最早入库的批次。常见误区是写 ROW_NUMBER() OVER (ORDER BY in_date) 然后累加,但这无法处理「某批次只消耗一部分」的情况——比如入库 100 件,只出库 30 件,剩余 70 件要参与下一次出库匹配。
核心难点在于:出库单需逐批穿透入库批次,直到出库数量被完全覆盖;而 SQL Server 窗口函数本身不支持跨行状态累积(即“上一批剩多少,影响下一批怎么扣”),必须靠递归 CTE 或自连接模拟消耗链路。
用自连接 + 累计窗口实现可落地的 FIFO 匹配
适用于中小数据量(单商品日出入库 ≤ 1 万行),逻辑清晰、无需递归,且兼容 SQL Server 2012+。
- 先将入库(
in_records)和出库(out_records)按时间合并打标,统一用event_time排序 - 对每个出库记录,用
CROSS APPLY找出所有「尚未被完全消耗的入库批次」,并按in_date升序排列 - 在子查询中用
SUM() OVER (ORDER BY in_date ROWS UNBOUNDED PRECEDING)计算该出库点之前的累计入库量,再结合已消耗量反推本批次实际可扣数量
关键代码片段(简化版):
SELECT
o.out_id,
i.in_id,
i.in_date,
i.qty AS in_qty,
CASE
WHEN o.qty_left <= i.qty - ISNULL(i.consumed, 0)
THEN o.qty_left
ELSE i.qty - ISNULL(i.consumed, 0)
END AS matched_qty
FROM out_records o
CROSS APPLY (
SELECT TOP 1
in_id, in_date, qty, consumed
FROM in_records i2
WHERE i2.in_date <= o.event_time
AND i2.qty > ISNULL(i2.consumed, 0)
ORDER BY in_date
) i注意:consumed 字段需预先通过另一个窗口计算(如用 SUM(out_qty) OVER (PARTITION BY item_id ORDER BY event_time) 累计历史出库),否则无法知道某批次还剩多少。
LAG() 和 LEAD() 在 FIFO 中基本没用
这两个函数只能取相邻行值,无法解决「当前出库要匹配第 3 批入库」这类跳跃式依赖。有人试图用 LAG(consumed, 1) 减去前一批剩余量,结果发现当某批入库被多次部分消耗时,LAG 完全无法跟踪状态流转。
真正需要的是「从当前出库出发,向前扫描所有未耗尽入库批次」,这本质上是半聚合半遍历操作,LAG/LEAD 的固定偏移语义与业务逻辑错位。
性能卡点和绕过方式
自连接方案在百万级出入库记录上会明显变慢,因为每次 CROSS APPLY 都可能扫全量入库表。实际部署时必须加约束:
- 务必在
in_records(item_id, in_date)和out_records(item_id, event_time)上建复合索引 - 限制单次核算范围:不要全量跑,而是按
item_id分批处理,配合WHERE item_id = @target_item - 避免在视图里封装 FIFO 逻辑——SQL Server 优化器很难正确估算中间结果集大小,容易选错执行计划
如果数据持续增长且实时性要求高,最终还是要导出到内存计算(如 C# + SortedSet)或改用支持递归深度控制的引擎(如 Spark SQL)。纯 T-SQL 做 FIFO,本质是在用关系代数模拟状态机,边界感必须非常清楚。

















