在高并发电商系统中,库存超卖是典型的分布式一致性问题。可以采用数据库行级锁配合预扣减机制,在业务层引入幂等与重试控制,同时通过异步校验兜底,在保证性能的同时杜绝超卖。
超卖的根本原因与约束条件
超卖并非单纯由并发引起,而是由“读-改-写”操作在无原子性保障下的竞态条件导致。典型场景如下:
- 用户 A 查询库存为 10;
- 用户 B 同时查询库存也为 10;
- A 扣减 1,更新为 9;
- B 扣减 1,仍基于 10 更新为 9;
- 实际应为 8,但数据库记录为 9,超卖发生。
该问题的解决依赖于三个硬性约束:
- 数据一致性;
- 高吞吐;
- 低延迟。
任何方案若无法同时满足上述三点,则不具备生产可行性。常见方案及其在一致性、吞吐量、复杂度上的表现如下。
| 方案 | 一致性保障 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 数据库乐观锁(version) | 弱(高冲突下失败率高) | 低 | 低并发、低冲突商品 |
| Redis + Lua 原子操作 | 强(单机) | 中 | 热点商品、允许短暂不一致 |
| 消息队列串行化 | 强 | 高 | 对延迟不敏感场景 |
| 数据库行锁 + 预扣减 | 强 | 中 | 高并发、强一致性要求 |
基于行锁预扣减方案
整体流程分为三阶段:
- 预扣减(Pre-deduct):在事务中通过
SELECT ... FOR UPDATE获取行锁,执行扣减; - 创建订单:在同事务中插入订单记录;
- 异步校验:定时任务扫描异常状态,修复数据。
关键在于将库存扣减与订单创建置于同一数据库事务,利用 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>
注意 freezeStock 的 WHERE 条件包含 available_qty >= #{qty},这是双重保险:即使应用层漏判,数据库也会拒绝非法更新。
幂等性保障
重复请求(如用户多次点击)会导致重复扣减。我们通过订单 ID 唯一索引实现幂等:
ALTER TABLE `order` ADD UNIQUE KEY `uk_order_id` (`order_id`);
若重复插入,数据库抛出 DuplicateKeyException,应用层捕获后返回成功(因首次已成功)。
支付与释放逻辑
- 支付成功:将
frozen_qty转为实际消耗,available_qty不变,frozen_qty减少; - 超时未支付:定时任务扫描
order表中status=0且create_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)。可以部署每日凌晨执行的校验任务,校验冻结库存与未支付订单是否一致,若发现不一致,自动修复并告警。
总结
我们通过数据库行锁实现强一致库存扣减,引入分段库存缓解锁竞争,结合幂等订单与异步校验构建完整闭环。工程上需严格遵循事务边界、索引设计与监控覆盖,方能保障长期稳定。