引言:超卖的代价

超卖:卖出的数量超过实际库存,导致无法发货。

超卖的后果

  • 客户投诉、差评
  • 平台处罚(Amazon可能封店)
  • 紧急采购成本高
  • 品牌信誉受损

防止超卖的核心:库存预占机制。


一、库存模型设计

1.1 库存类型

┌─────────────────────────────────────────────────────┐
│                    库存类型                          │
├─────────────────────────────────────────────────────┤
│  实物库存 = WMS系统中的实际库存数量                   │
│                                                     │
│  可售库存 = 实物库存 - 预占库存 - 锁定库存            │
│                                                     │
│  预占库存 = 订单已预占但未发货的库存                  │
│                                                     │
│  锁定库存 = 因其他原因锁定的库存(盘点、质量问题等)   │
│                                                     │
│  在途库存 = 采购已下单但未入库的库存                  │
└─────────────────────────────────────────────────────┘

1.2 库存计算公式

可售库存 = 实物库存 - 预占库存 - 锁定库存

其中:
- 实物库存:从WMS同步
- 预占库存:OMS计算(订单预占)
- 锁定库存:手动锁定或系统锁定

1.3 数据模型

-- SKU库存汇总表(OMS)
CREATE TABLE t_sku_inventory (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    sku_id VARCHAR(32) NOT NULL COMMENT 'SKU编码',
    warehouse_id VARCHAR(32) NOT NULL COMMENT '仓库ID',

    physical_qty INT NOT NULL DEFAULT 0 COMMENT '实物库存(从WMS同步)',
    reserved_qty INT NOT NULL DEFAULT 0 COMMENT '预占库存',
    locked_qty INT NOT NULL DEFAULT 0 COMMENT '锁定库存',
    available_qty INT NOT NULL DEFAULT 0 COMMENT '可售库存(计算字段)',

    in_transit_qty INT NOT NULL DEFAULT 0 COMMENT '在途库存',

    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id),
    KEY idx_sku_id (sku_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SKU库存汇总表';

-- 库存预占明细表
CREATE TABLE t_inventory_reservation (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    reservation_id VARCHAR(32) NOT NULL COMMENT '预占ID',
    order_id VARCHAR(32) NOT NULL COMMENT '订单号',
    sku_id VARCHAR(32) NOT NULL COMMENT 'SKU编码',
    warehouse_id VARCHAR(32) NOT NULL COMMENT '仓库ID',

    reserved_qty INT NOT NULL COMMENT '预占数量',
    status VARCHAR(16) NOT NULL DEFAULT 'RESERVED' COMMENT '状态:RESERVED/RELEASED/DEDUCTED',

    reserved_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '预占时间',
    released_at DATETIME COMMENT '释放时间',

    UNIQUE KEY uk_reservation_id (reservation_id),
    KEY idx_order_id (order_id),
    KEY idx_sku_warehouse (sku_id, warehouse_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存预占明细表';

二、预占流程设计

2.1 预占时机

下单 ──> 支付 ──> 审核 ──> 拆单 ──> 下发WMS ──> 发货
  │                                    │
  └──────── 预占库存 ─────────────────>│
                                       │
                                  扣减库存

两种预占策略

策略预占时机优点缺点
下单预占创建订单时防止超卖更彻底未支付订单占用库存
支付预占支付成功后库存利用率高支付时可能库存不足

推荐:支付成功后预占(跨境电商通常是先付款后发货)

2.2 预占流程

@Service
@Slf4j
public class InventoryReservationService {

    @Autowired
    private SkuInventoryRepository inventoryRepository;

    @Autowired
    private InventoryReservationRepository reservationRepository;

    /**
     * 预占库存
     * @return 预占是否成功
     */
    @Transactional
    public boolean reserve(String orderId, List<ReserveItem> items) {
        List<InventoryReservation> reservations = new ArrayList<>();

        for (ReserveItem item : items) {
            // 1. 获取库存记录(加锁)
            SkuInventory inventory = inventoryRepository.findBySkuAndWarehouseForUpdate(
                item.getSkuId(), item.getWarehouseId());

            if (inventory == null) {
                log.warn("库存记录不存在: skuId={}, warehouseId={}",
                    item.getSkuId(), item.getWarehouseId());
                throw new InventoryNotFoundException(item.getSkuId());
            }

            // 2. 检查可售库存
            if (inventory.getAvailableQty() < item.getQuantity()) {
                log.warn("库存不足: skuId={}, available={}, required={}",
                    item.getSkuId(), inventory.getAvailableQty(), item.getQuantity());
                throw new InsufficientInventoryException(item.getSkuId(),
                    inventory.getAvailableQty(), item.getQuantity());
            }

            // 3. 扣减可售库存,增加预占库存
            inventory.setAvailableQty(inventory.getAvailableQty() - item.getQuantity());
            inventory.setReservedQty(inventory.getReservedQty() + item.getQuantity());
            inventoryRepository.save(inventory);

            // 4. 记录预占明细
            InventoryReservation reservation = new InventoryReservation();
            reservation.setReservationId(generateReservationId());
            reservation.setOrderId(orderId);
            reservation.setSkuId(item.getSkuId());
            reservation.setWarehouseId(item.getWarehouseId());
            reservation.setReservedQty(item.getQuantity());
            reservation.setStatus("RESERVED");
            reservations.add(reservation);
        }

        reservationRepository.saveAll(reservations);
        log.info("库存预占成功: orderId={}, items={}", orderId, items.size());
        return true;
    }
}

2.3 释放流程

释放场景

  • 订单取消
  • 订单超时未支付
  • 订单审核不通过
/**
 * 释放库存
 */
@Transactional
public void release(String orderId) {
    // 1. 查询预占记录
    List<InventoryReservation> reservations = reservationRepository
        .findByOrderIdAndStatus(orderId, "RESERVED");

    if (reservations.isEmpty()) {
        log.warn("无预占记录: orderId={}", orderId);
        return;
    }

    for (InventoryReservation reservation : reservations) {
        // 2. 恢复库存
        SkuInventory inventory = inventoryRepository.findBySkuAndWarehouseForUpdate(
            reservation.getSkuId(), reservation.getWarehouseId());

        inventory.setAvailableQty(inventory.getAvailableQty() + reservation.getReservedQty());
        inventory.setReservedQty(inventory.getReservedQty() - reservation.getReservedQty());
        inventoryRepository.save(inventory);

        // 3. 更新预占状态
        reservation.setStatus("RELEASED");
        reservation.setReleasedAt(LocalDateTime.now());
    }

    reservationRepository.saveAll(reservations);
    log.info("库存释放成功: orderId={}, count={}", orderId, reservations.size());
}

2.4 扣减流程

扣减时机:WMS确认发货后

/**
 * 扣减库存(发货后)
 */
@Transactional
public void deduct(String orderId) {
    List<InventoryReservation> reservations = reservationRepository
        .findByOrderIdAndStatus(orderId, "RESERVED");

    for (InventoryReservation reservation : reservations) {
        // 1. 扣减预占库存和实物库存
        SkuInventory inventory = inventoryRepository.findBySkuAndWarehouseForUpdate(
            reservation.getSkuId(), reservation.getWarehouseId());

        inventory.setReservedQty(inventory.getReservedQty() - reservation.getReservedQty());
        inventory.setPhysicalQty(inventory.getPhysicalQty() - reservation.getReservedQty());
        inventoryRepository.save(inventory);

        // 2. 更新预占状态
        reservation.setStatus("DEDUCTED");
    }

    reservationRepository.saveAll(reservations);
    log.info("库存扣减成功: orderId={}", orderId);
}

三、并发控制

3.1 并发问题

场景:同一SKU库存只剩1件,两个订单同时预占

时间线:
T1: 订单A读取库存 = 1
T2: 订单B读取库存 = 1
T3: 订单A预占成功,库存 = 0
T4: 订单B预占成功,库存 = -1  ← 超卖!

3.2 解决方案

方案1:数据库行锁(SELECT FOR UPDATE)

@Query("SELECT i FROM SkuInventory i WHERE i.skuId = :skuId AND i.warehouseId = :warehouseId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
SkuInventory findBySkuAndWarehouseForUpdate(String skuId, String warehouseId);

优点:简单可靠 缺点:并发性能差

方案2:乐观锁(版本号)

@Transactional
public boolean reserveWithOptimisticLock(String skuId, String warehouseId, int quantity) {
    int maxRetry = 3;
    for (int i = 0; i < maxRetry; i++) {
        SkuInventory inventory = inventoryRepository.findBySkuAndWarehouse(skuId, warehouseId);

        if (inventory.getAvailableQty() < quantity) {
            throw new InsufficientInventoryException(skuId);
        }

        // 使用版本号更新
        int updated = inventoryRepository.updateWithVersion(
            skuId, warehouseId,
            inventory.getAvailableQty() - quantity,
            inventory.getReservedQty() + quantity,
            inventory.getVersion()
        );

        if (updated > 0) {
            return true; // 更新成功
        }
        // 版本冲突,重试
    }
    throw new ConcurrentModificationException("库存更新冲突");
}

// Repository方法
@Modifying
@Query("UPDATE SkuInventory SET availableQty = :availableQty, reservedQty = :reservedQty, " +
       "version = version + 1 WHERE skuId = :skuId AND warehouseId = :warehouseId AND version = :version")
int updateWithVersion(String skuId, String warehouseId, int availableQty, int reservedQty, int version);

优点:并发性能好 缺点:需要重试机制

方案3:Redis + Lua脚本(推荐)

@Service
public class RedisInventoryService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String RESERVE_SCRIPT = """
        local key = KEYS[1]
        local quantity = tonumber(ARGV[1])
        local current = tonumber(redis.call('get', key) or 0)
        if current >= quantity then
            redis.call('decrby', key, quantity)
            return 1
        else
            return 0
        end
        """;

    /**
     * Redis预占库存
     */
    public boolean reserve(String skuId, String warehouseId, int quantity) {
        String key = "inventory:" + warehouseId + ":" + skuId;

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(RESERVE_SCRIPT, Long.class),
            List.of(key),
            String.valueOf(quantity)
        );

        return result != null && result == 1;
    }

    /**
     * Redis释放库存
     */
    public void release(String skuId, String warehouseId, int quantity) {
        String key = "inventory:" + warehouseId + ":" + skuId;
        redisTemplate.opsForValue().increment(key, quantity);
    }
}

优点:性能极高、原子性保证 缺点:需要保证Redis与数据库一致

3.3 推荐方案:Redis预占 + 数据库异步同步

┌─────────────────────────────────────────────────────┐
│                    预占流程                          │
├─────────────────────────────────────────────────────┤
│  1. Redis预占(Lua脚本,原子操作)                   │
│  2. 预占成功 → 发送MQ消息                           │
│  3. 消费者异步更新数据库                             │
└─────────────────────────────────────────────────────┘
@Service
public class InventoryService {

    @Autowired
    private RedisInventoryService redisInventoryService;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 预占库存(高并发场景)
     */
    public boolean reserve(String orderId, String skuId, String warehouseId, int quantity) {
        // 1. Redis预占
        boolean success = redisInventoryService.reserve(skuId, warehouseId, quantity);

        if (!success) {
            return false;
        }

        // 2. 发送异步消息,更新数据库
        InventoryReserveEvent event = new InventoryReserveEvent();
        event.setOrderId(orderId);
        event.setSkuId(skuId);
        event.setWarehouseId(warehouseId);
        event.setQuantity(quantity);
        event.setAction("RESERVE");

        rocketMQTemplate.asyncSend("inventory-reserve", event, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                log.info("预占消息发送成功: orderId={}", orderId);
            }

            @Override
            public void onException(Throwable e) {
                log.error("预占消息发送失败,回滚Redis: orderId={}", orderId, e);
                redisInventoryService.release(skuId, warehouseId, quantity);
            }
        });

        return true;
    }
}

四、超卖处理

4.1 超卖预警

@Service
public class InventoryAlertService {

    /**
     * 库存预警检查
     */
    @Scheduled(fixedRate = 60000) // 每分钟检查
    public void checkInventoryAlert() {
        // 检查可售库存为负的SKU
        List<SkuInventory> negativeInventories = inventoryRepository.findByAvailableQtyLessThan(0);

        for (SkuInventory inv : negativeInventories) {
            log.error("发现超卖: skuId={}, warehouseId={}, availableQty={}",
                inv.getSkuId(), inv.getWarehouseId(), inv.getAvailableQty());

            // 发送告警
            alertService.sendUrgent("超卖告警",
                String.format("SKU %s 在仓库 %s 超卖,可售库存=%d",
                    inv.getSkuId(), inv.getWarehouseId(), inv.getAvailableQty()));
        }

        // 检查低库存SKU
        List<SkuInventory> lowInventories = inventoryRepository.findLowStock(10);
        for (SkuInventory inv : lowInventories) {
            alertService.send("低库存预警",
                String.format("SKU %s 库存不足,当前=%d", inv.getSkuId(), inv.getAvailableQty()));
        }
    }
}

4.2 超卖订单处理

@Service
public class OversoldOrderHandler {

    /**
     * 处理超卖订单
     */
    public void handleOversoldOrder(String orderId) {
        Order order = orderRepository.findByOrderId(orderId);

        // 1. 标记订单为超卖
        order.setOversold(true);
        order.setStatus(OrderStatus.PENDING_STOCK);
        orderRepository.save(order);

        // 2. 通知采购紧急补货
        purchaseService.createUrgentPurchase(order);

        // 3. 通知客服
        customerServiceNotifier.notifyOversold(order);

        // 4. 如果无法补货,考虑取消订单
        // ...
    }
}

五、库存同步

5.1 WMS库存同步到OMS

@Component
@RocketMQMessageListener(topic = "wms-inventory-sync", consumerGroup = "oms-inventory-consumer")
public class WmsInventorySyncConsumer implements RocketMQListener<WmsInventoryEvent> {

    @Override
    public void onMessage(WmsInventoryEvent event) {
        String skuId = event.getSkuId();
        String warehouseId = event.getWarehouseId();
        int wmsQty = event.getQuantity();

        // 更新OMS实物库存
        SkuInventory inventory = inventoryRepository.findBySkuAndWarehouse(skuId, warehouseId);
        if (inventory == null) {
            inventory = new SkuInventory();
            inventory.setSkuId(skuId);
            inventory.setWarehouseId(warehouseId);
        }

        int oldPhysicalQty = inventory.getPhysicalQty();
        inventory.setPhysicalQty(wmsQty);

        // 重新计算可售库存
        int availableQty = wmsQty - inventory.getReservedQty() - inventory.getLockedQty();
        inventory.setAvailableQty(availableQty);

        inventoryRepository.save(inventory);

        // 同步到Redis
        redisInventoryService.set(skuId, warehouseId, availableQty);

        log.info("库存同步完成: skuId={}, warehouseId={}, physical: {} -> {}, available={}",
            skuId, warehouseId, oldPhysicalQty, wmsQty, availableQty);
    }
}

六、总结

6.1 核心要点

  1. 库存模型:实物库存、可售库存、预占库存、锁定库存
  2. 预占时机:支付成功后预占
  3. 并发控制:Redis + Lua脚本保证原子性
  4. 超卖处理:预警机制 + 紧急补货
  5. 库存同步:WMS实时同步到OMS

6.2 防超卖检查清单

  • 预占使用原子操作(Redis Lua或数据库锁)
  • 预占失败要正确处理
  • 订单取消要释放库存
  • 发货后要扣减库存
  • 建立库存预警机制
  • 定期对账WMS和OMS库存

系列文章导航

本文是《跨境电商数字化转型指南》系列的第16篇

  • 13-15. OMS前序文章
  • 16. 库存预占与释放(本文)
  • 17. 履约路由与调度