库存事件不是简单的加减
库存扣减看起来只是 stock = stock - 1,但在事件驱动架构里,这条减法涉及三个独立动作:预占(lock)、扣减(deduct)、释放(release)。每个动作都是一个事件,消费者需要按照顺序处理,否则就会出现超卖。
场景:一个商品库存 10 件,100 个用户同时下单。如果事件处理没有顺序保证,两个消费者可能同时读到 stock=1,都认为是最后一笔,结果卖出 11 件。
三种库存事件链路
| 链路 | 事件 | 幂等键 | 顺序要求 | 适用场景 |
|---|---|---|---|---|
| 直接扣减 | InventoryDeducted | orderId | 无 | 低并发、允许短时不一致 |
| 预占+确认 | StockLocked → StockConfirmed | lockId | Locked 必须在 Confirmed 前 | 高并发、需防超卖 |
| 预占+超时释放 | StockLocked → StockReleased | lockId | Locked 在 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+orderId | SKU 级 | 同一订单的不同 SKU 独立扣减,粒度更细 |
如果订单 O-001 买了 2 件 A 和 1 件 B,用 orderId 做幂等键意味着「O-001 已处理过」——第二次收到 O-001 的任何库存事件都会被跳过。如果系统需要支持部分退款,orderId 粒度就太粗了。
推荐做法:幂等键包含 SKU 信息,如 orderId + "-" + skuId,让同一订单的不同商品独立处理。
库存事件的顺序要求
库存事件对顺序敏感的场景有三类:
预占必须在确认前:
StockLocked先,StockConfirmed后。如果顺序颠倒,确认时发现无锁定记录,直接幂等跳过,导致订单确认成功但库存未扣。释放必须在确认前:如果
StockReleased和StockConfirmed乱序,可能出现「先释放后确认」——释放时锁定记录存在,锁定被清除;确认时找不到锁定记录,幂等跳过。最终结果是库存被释放但订单未扣减。同一订单的事件必须有序:同一订单的
LOCK→CONFIRM→RELEASE必须按顺序处理。如果分区 key 选orderId,这三条事件会进入同一分区,自然有序;如果分区 key 选skuId,同一订单的不同 SKU 会分散到不同分区,跨分区顺序无法保证。
解决方案:库存事件的分区 key 用 skuId,因为库存管理的核心单元是 SKU。同一 SKU 的预占/确认/释放进入同一分区,保证顺序;不同 SKU 之间无顺序依赖。
超卖恢复:不是回滚那么简单
超卖发生后,简单的「回滚库存」会引入新问题:
- 如果已经通知用户「购买成功」,回滚库存等于取消订单,需要额外发补偿事件
- 如果已经扣减用户余额,回滚库存不回滚余额,数据不一致
- 如果多个 SKU 同时超卖,需要逐个 SKU 回滚,不能一次性回滚整单
正确的恢复流程:
- 检测:消费者维护
actualStock和projectedStock,两者不一致时触发告警 - 冻结:暂停该 SKU 的新订单,防止继续超卖
- 对账:比对数据库库存和事件重放结果,找出差异订单
- 补偿:对超卖订单发
OrderCancelled事件,消费者各自补偿
库存场景的事件驱动关键设计
库存类场景在事件驱动架构中有三个关键设计点:
- 预占优于直接扣减:高并发下直接扣减必然超卖,预占把可用库存和总库存分离,先把「可用」减到零,再慢慢确认或释放。
- 幂等键到 SKU 级:订单级幂等键无法支持部分退款和分批扣减,SKU 级才能精确控制每个商品的库存状态。
- 分区 key 到 SKU:保证同一商品库存事件有序,不同商品之间无顺序依赖。
这三个设计点不是可选优化,而是库存场景的达标线。缺少任何一个,系统在高并发下都会出现数据不一致。
库存预占的扩展模式
预占机制在复杂业务场景下需要扩展。三种常见扩展:
存货预留(Reservation):大客户下单时,系统预留一批库存但不锁定。预留期间其他用户仍可购买,但预留转锁定时有优先权。预留和锁定是两个独立事件:
| 阶段 | 事件 | 库存影响 |
|---|---|---|
| 预留 | StockReserved | availableStock 不变,reservedStock 增加 |
| 锁定 | StockLocked | availableStock 减少,reservedStock 减少 |
| 释放 | StockReleased | availableStock 增加 |
预留的优势是灵活性——客户可以在预留期内分批确认,每次确认触发一次 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 事件,消费者直接减库存?」
原因有三:
事件不是原子的:生产者发送
StockDeducted和消费者扣减库存之间有时间差。如果生产者发了 5 条扣减事件,消费者在收到第 3 条时崩溃,重启后从第 3 条开始消费。生产者以为 5 条都成功了,实际上只扣了 2 条。消费者可能重复消费:Kafka 的至少一次语义意味着同一条
StockDeducted可能被消费两次。如果没有幂等键,库存会被扣两次。无法区分「扣减」和「预占」:直接扣减意味着库存一旦扣掉就无法恢复。如果用户 10 分钟后取消订单,系统需要发补偿事件把库存加回来。补偿事件本身也是异步的,可能失败、可能延迟、可能被重复消费。
预占机制把「扣减」拆成「锁定」和「确认」两步,锁定是可逆的(释放即可),确认是不可逆的(最终扣减)。这种拆分让系统可以在「可逆阶段」处理异常,在「不可逆阶段」保证一致性。
库存事件的消费者设计要点
库存事件的消费者不是简单的「收到事件就减库存」。它需要维护三个状态:
- 可用库存(availableStock):还能卖多少,由
LOCK减少、RELEASE增加 - 锁定库存(lockedStock):已经被锁定但尚未确认的,由
LOCK增加、CONFIRM/RELEASE减少 - 总库存(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 场景
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。