基于行锁预扣减的高并发库存一致性方案

在高并发电商系统中,库存超卖是典型的分布式一致性问题。可以采用数据库行级锁配合预扣减机制,在业务层引入幂等与重试控制,同时通过异步校验兜底,在保证性能的同时杜绝超卖

超卖的根本原因与约束条件

超卖并非单纯由并发引起,而是由“读-改-写”操作在无原子性保障下的竞态条件导致。典型场景如下:

  1. 用户 A 查询库存为 10;
  2. 用户 B 同时查询库存也为 10;
  3. A 扣减 1,更新为 9;
  4. B 扣减 1,仍基于 10 更新为 9;
  5. 实际应为 8,但数据库记录为 9,超卖发生。

该问题的解决依赖于三个硬性约束:

  • 数据一致性
  • 高吞吐
  • 低延迟

任何方案若无法同时满足上述三点,则不具备生产可行性。常见方案及其在一致性、吞吐量、复杂度上的表现如下。

方案 一致性保障 实现复杂度 适用场景
数据库乐观锁(version) 弱(高冲突下失败率高) 低并发、低冲突商品
Redis + Lua 原子操作 强(单机) 热点商品、允许短暂不一致
消息队列串行化 对延迟不敏感场景
数据库行锁 + 预扣减 高并发、强一致性要求

基于行锁预扣减方案

整体流程分为三阶段:

  1. 预扣减(Pre-deduct):在事务中通过 SELECT ... FOR UPDATE 获取行锁,执行扣减;
  2. 创建订单:在同事务中插入订单记录;
  3. 异步校验:定时任务扫描异常状态,修复数据。

关键在于将库存扣减与订单创建置于同一数据库事务,利用 InnoDB 行锁保证原子性。

数据模型

引入 frozen_qty 字段用于隔离已预扣但未支付的库存,避免直接减少 available_qty 导致其他用户无法下单。

CREATE TABLE `stock` (
  `sku_id` BIGINT NOT NULL,
  `available_qty` INT NOT NULL DEFAULT 0,
  `frozen_qty` INT NOT NULL DEFAULT 0,
  PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB;

CREATE TABLE `order` (
  `order_id` BIGINT NOT NULL,
  `sku_id` BIGINT NOT NULL,
  `qty` INT NOT NULL,
  `status` TINYINT NOT NULL, -- 0: created, 1: paid, 2: cancelled
  `create_time` DATETIME NOT NULL,
  PRIMARY KEY (`order_id`),
  INDEX `idx_sku_status` (`sku_id`, `status`)
) ENGINE=InnoDB;

扣减逻辑实现

@Transactional
public boolean deductStock(Long skuId, Integer qty, Long orderId) {
    // 1. 获取行锁并检查可用库存
    Stock stock = stockMapper.selectForUpdate(skuId);
    if (stock == null || stock.getAvailableQty() < qty) {
        return false;
    }

    // 2. 预扣减:冻结库存
    int updated = stockMapper.freezeStock(skuId, qty);
    if (updated != 1) {
        throw new RuntimeException("库存冻结失败");
    }

    // 3. 创建订单(状态为 created)
    Order order = new Order();
    order.setOrderId(orderId);
    order.setSkuId(skuId);
    order.setQty(qty);
    order.setStatus((byte) 0);
    order.setCreateTime(new Date());
    orderMapper.insert(order);

    return true;
}

对应 MyBatis Mapper:

<select id="selectForUpdate" resultType="Stock">
    SELECT sku_id, available_qty, frozen_qty
    FROM stock
    WHERE sku_id = #{skuId}
    FOR UPDATE
</select>

<update id="freezeStock">
    UPDATE stock
    SET available_qty = available_qty - #{qty},
        frozen_qty = frozen_qty + #{qty}
    WHERE sku_id = #{skuId}
      AND available_qty >= #{qty}
</update>

注意 freezeStockWHERE 条件包含 available_qty >= #{qty},这是双重保险:即使应用层漏判,数据库也会拒绝非法更新。

幂等性保障

重复请求(如用户多次点击)会导致重复扣减。我们通过订单 ID 唯一索引实现幂等:

ALTER TABLE `order` ADD UNIQUE KEY `uk_order_id` (`order_id`);

若重复插入,数据库抛出 DuplicateKeyException,应用层捕获后返回成功(因首次已成功)。

支付与释放逻辑

  • 支付成功:将 frozen_qty 转为实际消耗,available_qty 不变,frozen_qty 减少;
  • 超时未支付:定时任务扫描 order 表中 status=0create_time < NOW() - INTERVAL 30 MINUTE 的记录,释放冻结库存。

释放 SQL:

UPDATE stock s
JOIN (
    SELECT sku_id, SUM(qty) as release_qty
    FROM `order`
    WHERE status = 0
      AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
    GROUP BY sku_id
) o ON s.sku_id = o.sku_id
SET s.available_qty = s.available_qty + o.release_qty,
    s.frozen_qty = s.frozen_qty - o.release_qty;

其他

InnoDB 行锁在高并发下可能成为瓶颈。我们通过以下手段优化:

  • 分段库存(Inventory Sharding):将单一 SKU 库存拆分为 N 个逻辑段,每段独立计数。扣减时随机选择一段,降低锁竞争。
  • 连接池与事务调优:设置 innodb_lock_wait_timeout=2,避免长时间阻塞;事务隔离级别保持 READ COMMITTED,避免间隙锁。

尽管主流程强一致,仍需应对极端情况(如事务回滚失败、JVM Crash)。可以部署每日凌晨执行的校验任务,校验冻结库存与未支付订单是否一致,若发现不一致,自动修复并告警。

总结

我们通过数据库行锁实现强一致库存扣减,引入分段库存缓解锁竞争,结合幂等订单与异步校验构建完整闭环。工程上需严格遵循事务边界、索引设计与监控覆盖,方能保障长期稳定。

本站简介

聚焦于全栈技术和量化技术的技术博客,分享软件架构、前后端技术、量化技术、人工智能、大模型等相关文章总结。