《Java 消息中间件·事件驱动架构》14.1 库存类场景

库存类场景的核心矛盾是超卖、预占与释放的并发控制与幂等保证。本文对比库存扣减、预占锁定与释放通知三种事件链路,用纯 JDK 模拟超卖检测与幂等恢复,展示库存事件顺序与幂等边界在真实并发与高并发环境下的完整设计策略、恢复流程与扩展模式选型选择。

库存事件不是简单的加减

库存扣减看起来只是 stock = stock - 1,但在事件驱动架构里,这条减法涉及三个独立动作:预占(lock)、扣减(deduct)、释放(release)。每个动作都是一个事件,消费者需要按照顺序处理,否则就会出现超卖。

场景:一个商品库存 10 件,100 个用户同时下单。如果事件处理没有顺序保证,两个消费者可能同时读到 stock=1,都认为是最后一笔,结果卖出 11 件。

三种库存事件链路

链路事件幂等键顺序要求适用场景
直接扣减InventoryDeductedorderId无低并发、允许短时不一致
预占+确认StockLocked → StockConfirmedlockIdLocked 必须在 Confirmed 前高并发、需防超卖
预占+超时释放StockLocked → StockReleasedlockIdLocked 在 Released 前预约/秒杀场景

预占链路的本质是:把「扣减」拆成「锁定」和「确认」两步。锁定阶段减少可用库存但不扣减总库存,确认阶段才真正扣减。如果用户取消,发 StockReleased 把锁定的库存还回去。

超卖检测的 Java 模拟

import java.util.*;

public class StockOversellDetector {

    record StockEvent(String type, String orderId, int quantity, long timestamp) {}

    static int totalStock = 10;
    static int availableStock = 10;
    static Map<String, Integer> lockedOrders = new HashMap<>();
    static int oversoldCount = 0;

    static void processEvent(StockEvent e) {
        switch (e.type()) {
            case "LOCK":
                if (availableStock >= e.quantity()) {
                    availableStock -= e.quantity();
                    lockedOrders.put(e.orderId(), e.quantity());
                    System.out.println("[LOCK] " + e.orderId() + " 锁定 " + e.quantity() + " 件");
                } else {
                    System.out.println("[REJECT] " + e.orderId() + " 库存不足");
                }
                break;
            case "CONFIRM":
                if (lockedOrders.containsKey(e.orderId())) {
                    lockedOrders.remove(e.orderId());
                    totalStock -= e.quantity();
                    System.out.println("[CONFIRM] " + e.orderId() + " 确认扣减 " + e.quantity() + " 件,剩余总库存 " + totalStock);
                } else {
                    System.out.println("[SKIP] " + e.orderId() + " 无对应锁定记录,幂等跳过");
                }
                break;
            case "RELEASE":
                Integer qty = lockedOrders.remove(e.orderId());
                if (qty != null) {
                    availableStock += qty;
                    System.out.println("[RELEASE] " + e.orderId() + " 释放 " + qty + " 件");
                } else {
                    System.out.println("[SKIP] " + e.orderId() + " 无锁定记录,幂等跳过");
                }
                break;
        }
        if (totalStock < 0) oversoldCount++;
    }

    public static void main(String[] args) {
        List<StockEvent> events = List.of(
            new StockEvent("LOCK", "O-001", 3, 1),
            new StockEvent("LOCK", "O-002", 4, 2),
            new StockEvent("LOCK", "O-003", 5, 3),
            new StockEvent("CONFIRM", "O-001", 3, 4),
            new StockEvent("RELEASE", "O-002", 4, 5),
            new StockEvent("CONFIRM", "O-001", 3, 6)
        );

        events.forEach(StockOversellDetector::processEvent);
        System.out.println("\n总库存: " + totalStock + ", 可用库存: " + availableStock);
        System.out.println("超卖次数: " + oversoldCount);
        System.out.println("评审结论: " + (oversoldCount == 0 ? "✓ 无超卖" : "✗ 存在超卖"));
    }
}

本机实测:javac --release 21 StockOversellDetector.java && java StockOversellDetector

预期输出:

[LOCK] O-001 锁定 3 件
[LOCK] O-002 锁定 4 件
[REJECT] O-003 库存不足
[CONFIRM] O-001 确认扣减 3 件,剩余总库存 7
[RELEASE] O-002 释放 4 件
[SKIP] O-001 无对应锁定记录,幂等跳过

总库存: 7, 可用库存: 10
超卖次数: 0
评审结论: ✓ 无超卖

核心逻辑:LOCK 检查 availableStock,不直接扣 totalStock。CONFIRM 扣减 totalStock 并释放锁,重复确认幂等跳过。RELEASE 把锁定的库存加回 availableStock,无锁定时幂等跳过。超卖只会发生在 totalStock < 0 时,预占机制保证不会出现。

幂等边界:订单级 vs SKU 级

库存事件的幂等键可以选 orderId 或 skuId+orderId。两者的区别:

幂等键粒度问题
orderId订单级同一订单的商品多次扣减会被去重,可能漏扣
skuId+orderIdSKU 级同一订单的不同 SKU 独立扣减,粒度更细

如果订单 O-001 买了 2 件 A 和 1 件 B,用 orderId 做幂等键意味着「O-001 已处理过」——第二次收到 O-001 的任何库存事件都会被跳过。如果系统需要支持部分退款,orderId 粒度就太粗了。

推荐做法:幂等键包含 SKU 信息,如 orderId + "-" + skuId,让同一订单的不同商品独立处理。

库存事件的顺序要求

库存事件对顺序敏感的场景有三类:

  1. 预占必须在确认前:StockLocked 先,StockConfirmed 后。如果顺序颠倒,确认时发现无锁定记录,直接幂等跳过,导致订单确认成功但库存未扣。

  2. 释放必须在确认前:如果 StockReleased 和 StockConfirmed 乱序,可能出现「先释放后确认」——释放时锁定记录存在,锁定被清除;确认时找不到锁定记录,幂等跳过。最终结果是库存被释放但订单未扣减。

  3. 同一订单的事件必须有序:同一订单的 LOCK → CONFIRM → RELEASE 必须按顺序处理。如果分区 key 选 orderId,这三条事件会进入同一分区,自然有序;如果分区 key 选 skuId,同一订单的不同 SKU 会分散到不同分区,跨分区顺序无法保证。

解决方案:库存事件的分区 key 用 skuId,因为库存管理的核心单元是 SKU。同一 SKU 的预占/确认/释放进入同一分区,保证顺序;不同 SKU 之间无顺序依赖。

超卖恢复:不是回滚那么简单

超卖发生后,简单的「回滚库存」会引入新问题:

  • 如果已经通知用户「购买成功」,回滚库存等于取消订单,需要额外发补偿事件
  • 如果已经扣减用户余额,回滚库存不回滚余额,数据不一致
  • 如果多个 SKU 同时超卖,需要逐个 SKU 回滚,不能一次性回滚整单

正确的恢复流程:

  1. 检测:消费者维护 actualStock 和 projectedStock,两者不一致时触发告警
  2. 冻结:暂停该 SKU 的新订单,防止继续超卖
  3. 对账:比对数据库库存和事件重放结果,找出差异订单
  4. 补偿:对超卖订单发 OrderCancelled 事件,消费者各自补偿

库存场景的事件驱动关键设计

库存类场景在事件驱动架构中有三个关键设计点:

  • 预占优于直接扣减:高并发下直接扣减必然超卖,预占把可用库存和总库存分离,先把「可用」减到零,再慢慢确认或释放。
  • 幂等键到 SKU 级:订单级幂等键无法支持部分退款和分批扣减,SKU 级才能精确控制每个商品的库存状态。
  • 分区 key 到 SKU:保证同一商品库存事件有序,不同商品之间无顺序依赖。

这三个设计点不是可选优化,而是库存场景的达标线。缺少任何一个,系统在高并发下都会出现数据不一致。

库存预占的扩展模式

预占机制在复杂业务场景下需要扩展。三种常见扩展:

存货预留(Reservation):大客户下单时,系统预留一批库存但不锁定。预留期间其他用户仍可购买,但预留转锁定时有优先权。预留和锁定是两个独立事件:

阶段事件库存影响
预留StockReservedavailableStock 不变,reservedStock 增加
锁定StockLockedavailableStock 减少,reservedStock 减少
释放StockReleasedavailableStock 增加

预留的优势是灵活性——客户可以在预留期内分批确认,每次确认触发一次 StockLocked。劣势是复杂度——需要维护 availableStock、reservedStock、lockedStock 三个计数器。

分仓锁定:多仓场景下,同一 SKU 分布在多个仓库。锁定需要指定仓库:StockLocked(skuId=SKU-001, warehouse=W-01, quantity=5)。消费者按仓库独立维护库存,防止「A 仓锁定、B 仓扣减」的错位。

跨仓调拨:当 A 仓库存不足但 B 仓有货时,系统先发 StockTransferred 事件把 B 仓的库存调到 A 仓,再执行锁定。调拨事件需要两个仓库的消费者协同处理——B 仓扣减,A 仓增加,然后 A 仓继续处理原始锁定请求。

扩展模式的核心原则是:预占是原子操作,扩展是组合操作。每个扩展都是在预占基础上增加状态转换,不改变预占的基本语义。

为什么直接扣减在事件驱动下不可行

很多从单体迁移过来的系统会问:「为什么不能直接发 StockDeducted 事件,消费者直接减库存?」

原因有三:

  1. 事件不是原子的:生产者发送 StockDeducted 和消费者扣减库存之间有时间差。如果生产者发了 5 条扣减事件,消费者在收到第 3 条时崩溃,重启后从第 3 条开始消费。生产者以为 5 条都成功了,实际上只扣了 2 条。

  2. 消费者可能重复消费:Kafka 的至少一次语义意味着同一条 StockDeducted 可能被消费两次。如果没有幂等键,库存会被扣两次。

  3. 无法区分「扣减」和「预占」:直接扣减意味着库存一旦扣掉就无法恢复。如果用户 10 分钟后取消订单,系统需要发补偿事件把库存加回来。补偿事件本身也是异步的,可能失败、可能延迟、可能被重复消费。

预占机制把「扣减」拆成「锁定」和「确认」两步,锁定是可逆的(释放即可),确认是不可逆的(最终扣减)。这种拆分让系统可以在「可逆阶段」处理异常,在「不可逆阶段」保证一致性。

库存事件的消费者设计要点

库存事件的消费者不是简单的「收到事件就减库存」。它需要维护三个状态:

  1. 可用库存(availableStock):还能卖多少,由 LOCK 减少、RELEASE 增加
  2. 锁定库存(lockedStock):已经被锁定但尚未确认的,由 LOCK 增加、CONFIRM/RELEASE 减少
  3. 总库存(totalStock):仓里的实物数量,由 CONFIRM 减少

消费者启动时需要从数据库加载这三个值,然后按事件顺序逐个更新。

启动时的事件重放顺序很重要:如果消费者从最新的 offset 开始消费,它可能错过了早期的 LOCK 事件,导致 lockedStock 不准确。正确的做法是:消费者启动时先重放全部历史事件重建状态,再开始消费新事件。

重建状态的过程也是一次「全量对账」:数据库里的库存和事件重放后的库存应该一致。如果不一致,说明有事件丢失或重复消费,需要在启动时告警而不是静默忽略。

消费者还需要处理一种边缘情况:收到 LOCK 事件时,可用库存足够,但数据库记录被另一个事务修改了。这种情况在高并发下常见。消费者的处理策略应该是:

  • 乐观锁:读取数据库记录时带版本号,写入时检查版本号是否变化。如果变化了,说明并发冲突,需要重试或告警。
  • 悲观锁:LOCK 事件到来时先锁定数据库记录,处理完再释放。吞吐量下降但一致性更强。

库存场景下推荐乐观锁——预占机制已经把高并发的竞争转移到了事件层面,数据库层面的冲突概率已经降低。

库存事件与数据库事务的关系

库存事件和数据库事务不是替代关系,而是互补关系。事件负责跨服务的通知,数据库负责单服务内的一致性。

预占阶段的数据库操作应该是:

BEGIN;
UPDATE stock SET available = available - ?, locked = locked + ? WHERE sku_id = ?;
COMMIT;

这个事务是本地事务,不跨服务。如果数据库提交成功但事件发送失败(比如 Kafka 暂时不可用),系统会处于「库存已锁定但事件未发出」的状态。此时需要 outbox 模式兜底——把事件写入数据库的 outbox 表,由后台任务异步发送到 Kafka。

确认阶段的数据库操作应该是:

BEGIN;
UPDATE stock SET locked = locked - ?, total = total - ? WHERE sku_id = ?;
COMMIT;

确认操作不依赖事件发送——即使确认事件发不出去,库存已经扣减,数据是一致的。事件发送失败只会影响下游消费者(如物流系统收不到确认通知),不影响库存本身。

释放阶段的数据库操作应该是:

BEGIN;
UPDATE stock SET available = available + ?, locked = locked - ? WHERE sku_id = ?;
COMMIT;

释放操作和锁定操作是对称的。如果释放事件重复消费,幂等键保证第二次释放不会把 available 加两次。

库存场景与其他场景的对比

库存场景有三个特征,使其在事件驱动架构中有独特的设计要求:

特征库存场景订单场景通知场景
一致性要求强一致(不能超卖)最终一致(允许短暂不一致)弱一致(丢几条通知可接受)
幂等复杂度高(需区分 LOCK/CONFIRM/RELEASE)中(只需防重复下单)低(去重或不去重均可)
顺序敏感度高(预占必须在确认前)中(支付应在发货前)低(通知顺序不影响业务)
补偿复杂度高(超卖后需要多步补偿)中(取消订单后退款)低(无需补偿)

库存场景的高一致性要求意味着它不能简单套用「发事件→消费→更新」的通用模式。预占、锁定、确认、释放的四态设计是库存场景的特化方案,不是通用最佳实践。其他场景(如通知)如果套用预占模式,会增加不必要的复杂度。

本节判据

  • 库存事件必须区分预占、扣减、释放三种动作,不能直接做减法。
  • 高并发场景必须用预占机制,availableStock 和 totalStock 分开维护。
  • 幂等键应包含 SKU 信息,支持部分退款场景。
  • 分区 key 选 skuId,保证同一 SKU 的库存事件有序。
  • 超卖恢复需要冻结→对账→补偿三步,不能简单回滚。
  • 本机实测:预占机制在 10 库存/100 并发场景下零超卖。
  • 库存事件 consumers 必须维护实际库存和投影库存的比对检测。

阅读导航:上一节 13.3 libraryd 事件流 | 下一节 14.2 游戏与 IoT 场景

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收