防止库存超卖的几种方案
1. 乐观锁(Optimistic Locking)
✅ 适用场景
- 适用于 低并发、库存更新不频繁的场景(如普通商品购买)。
- 适合 读多写少 的场景,避免锁的开销。
⚙ 实现方式
- 在数据库表中添加
version
(版本号)或stock
字段。 - 读取库存时获取
version
,更新时使用WHERE version = ?
来确保库存未被修改。
📌 代码示例
1 | // 1. 查询当前库存和版本号 |
📌 SQL
1 | UPDATE stock_table |
❌ 可能遇到的问题
- 高并发下可能频繁更新失败(因为多个线程修改相同数据,只有一个会成功)。
- 需要应用层进行重试,增加请求开销。
- 不适用于高并发抢购,会导致大量失败请求。
⚠️注意
高并发下可能出现超卖
在高并发环境中,多个实例或多个线程同时查询库存并认为库存充足,但在执行UPDATE
(扣减库存)之前,其他线程已经更新了库存。
例如,假设当前库存为10,两个用户 A 和 B 同时查询库存:
- 用户 A 和 用户 B 都查询库存,发现库存为 10。
- 用户 A 和 用户 B 都进入了扣减库存的流程,并准备执行
UPDATE
操作。 - 在 用户 A 执行
UPDATE
时,库存减少到 9,但 用户 B 并不知道库存已被减少,依然认为有 10 件库存可以扣减。 - 用户 B 也执行了
UPDATE
,库存减少到 9,但此时实际上库存已经不足,因为用户 A 已经扣减了库存。
为什么会超卖?
这是因为:
- 用户 A 和用户 B 并没有及时同步库存数据,它们并没有基于实时的库存状态进行操作,反而是各自独立查询并各自更新。
- 虽然查询时发现库存充足,但在执行更新时,实际的库存已经被其他线程或实例修改过,导致库存不足的情况没有被检测到,最终多扣减了库存,造成了超卖。
为什么这个问题会出现在乐观锁场景下?
乐观锁是在修改数据时进行检查,它假设大部分情况下没有竞争。因此,它会在执行时使用版本号或时间戳来校验库存是否发生过变化。如果没有发生变化,才会进行库存的扣减操作。
但在高并发情况下,多个实例或线程的操作仍然可能存在时间差,在检查和更新之间发生竞争。此时即使使用乐观锁(如通过版本号校验库存),也无法有效避免并发情况下的冲突,因为版本号和数据的更新时间存在延迟,不同线程可能在不同时间修改数据,导致并发冲突,最终导致库存超卖。
2. 悲观锁(Pessimistic Locking)
✅ 适用场景
- 适用于中等并发的场景,确保库存更新时的线程安全。
- 适合 关键资源竞争激烈 的情况(如限量抢购)。
⚙ 实现方式
- 采用
SELECT ... FOR UPDATE
加行锁,确保同一时刻只有一个线程可以修改库存。
📌 代码示例
1 |
|
📌 SQL
1 | SELECT stock FROM stock_table WHERE product_id = ? FOR UPDATE; |
❌ 可能遇到的问题
- 数据库并发性能低,锁竞争激烈时会导致线程阻塞,影响吞吐量。
- 事务超时问题,如果锁未释放,可能导致数据库死锁。
⚠️注意
不推荐在分布式架构中使用!
- 分布式数据库无法保证全局悲观锁
- 在分库分表场景下,MySQL 无法直接加全局锁,不同节点的事务隔离,导致锁机制难以同步。
- 示例:
- A 服务器在数据库1锁住商品库存
- B 服务器在数据库2也锁住相同商品(但库存数据不一致)
- 结果:库存状态可能不一致,影响数据完整性。
- 数据库性能瓶颈
- 悲观锁一般是
SELECT ... FOR UPDATE
,在高并发下可能导致数据库出现大量锁等待,影响整体性能。 - 多个服务器竞争锁,导致用户下单等待时间变长,甚至数据库死锁。
- 悲观锁一般是
- 事务超时
- 分布式架构中,单机事务较短,但分布式事务涉及多个节点,可能导致锁超时问题,影响系统稳定性。
- 导致分布式死锁
- 不同应用节点可能在不同数据库实例或 Redis 实例上获取锁,导致分布式死锁问题,影响可用性。
3. 分布式锁(Redis + Redisson)
✅ 适用场景
- 适用于分布式部署的情况,防止多个应用实例同时操作库存。
- 适用于 高并发但不适合数据库锁的业务(如订单支付场景)。
⚙ 实现方式
- 使用 Redis SETNX(SET if Not Exists) 机制来加锁。
- 使用 Redisson 进行自动续期和锁释放,防止死锁。
📌 代码示例
1 | public void deductStock(Long productId) { |
❌ 可能遇到的问题
- 锁超时释放问题,如果业务逻辑执行过长,锁可能提前释放导致并发问题。
- 网络分区问题,可能导致多个节点同时获取锁,发生库存超卖(需要用 Redisson 解决)。
4. Redis 预扣库存
✅ 适用场景
- 适用于 秒杀、抢购等高并发场景,避免数据库压力。
- 适用于 库存实时性要求不高的业务(如电商促销)。
⚙ 实现方式
- 先在 Redis 里存储库存值,每次扣减时先修改 Redis,后异步同步到数据库。
📌 代码示例
1 | String key = "stock:" + productId; |
❌ 可能遇到的问题
- 缓存一致性问题,Redis 和数据库可能不同步(需使用 定时任务或消息队列 补偿)。
- 数据库库存回滚问题,Redis 预扣成功但支付失败时需归还库存。
5. 消息队列(MQ)异步扣减库存
✅ 适用场景
- 适用于超高并发(如双十一秒杀)。
- 适用于流量削峰,让请求先进入消息队列,异步消费。
⚙ 实现方式
- 订单请求先进入 Kafka / RabbitMQ 队列,后台消费者顺序扣减库存。
📌 代码示例
1 | // 订单下单时,发送库存扣减消息 |
❌ 可能遇到的问题
- 消息丢失问题,需要启用 消息持久化。
- 消息顺序问题,需要确保一个商品的扣减消息按照 FIFO 顺序处理。
方案对比总结
方案 | 适用场景 | 并发性能 | 可靠性 | 实现难度 |
---|---|---|---|---|
乐观锁 | 低并发 | 中 | 高 | 低 |
悲观锁 | 中等并发 | 低 | 高 | 中 |
分布式锁 | 分布式 | 中 | 高 | 中 |
Redis 预扣库存 | 高并发 | 高 | 中 | 高 |
消息队列 | 极高并发 | 最高 | 高 | 高 |
场景举例
以下单场景,不同实现方案可能出现的问题:
方案 | 用户体验 | 可能遇到的问题 |
---|---|---|
乐观锁 | 体验一般 | 订单可能失败,用户需多次尝试 |
悲观锁 | 体验较差 | 下单卡顿,可能等待较长时间 |
分布式锁 | 体验较好 | 短时间超卖可能性,锁竞争可能稍慢 |
Redis 预扣库存 | 体验较好 | 支付后可能发现库存不足(回滚问题) |
MQ 异步扣减库存 | 体验最好 | 订单处理稍有延迟,可能排队失败 |
结论
- 如果并发量较低,推荐乐观锁(简单高效)。
- 如果有分布式架构,推荐Redis 分布式锁。
- 如果是高并发秒杀,推荐Redis 预扣库存 + 消息队列,提高性能。
你的业务并发量是多少?我们可以进一步优化 😊