Redis 库存超卖问题与解决方案
一、什么是库存超卖?
库存超卖是指在高并发情况下,多个线程同时判断库存充足,导致实际销售数量超过可用库存。
例如:商品库存只有 1 件。
线程 A 线程 B
| |
读取库存:1 读取库存:1
| |
判断库存充足 判断库存充足
| |
创建订单成功 创建订单成功
| |
└──────────┬───────────────┘
↓
库存1件,却卖出2件需要注意:如果两个线程都读取库存 100,分别扣减后都写入 99,这属于丢失更新,也会导致库存数据错误,但不一定已经发生超卖。
核心原因:库存判断与扣减操作没有保证原子性。
二、解决方案
主要可以采用悲观锁、乐观锁或原子操作。
1. 悲观锁
悲观锁认为并发操作可能产生冲突,因此先加锁,再执行库存扣减。
常见实现:
Java:
synchronized、ReentrantLockMySQL:
SELECT ... FOR UPDATE(事务中使用)Redis:
SET NX PX实现分布式互斥锁
注意: Java 本地锁只能保护同一个 JVM 中的线程。如果 Spring Boot 部署多个 Docker 容器,本地锁无法实现跨实例互斥。
2. 乐观锁
乐观锁假设并发冲突较少,更新时再判断数据是否发生变化。
方式一:版本号机制
为商品库存增加 version 字段。
UPDATE goods
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0
AND version = 5;原理:
多个线程读取相同的库存和版本号。
第一个线程更新成功,版本号加 1。
其他线程使用旧版本号更新时,影响行数为 0,更新失败。
更新失败后,可根据业务决定重试或返回失败。
方式二:库存条件更新
如果只是扣减库存,通常不需要额外增加版本号。
UPDATE goods
SET stock = stock - 1
WHERE id = 1
AND stock > 0;通过判断受影响行数确定是否成功:
1:库存扣减成功。0:库存不足或商品不存在。
这种方式利用数据库单条 SQL 的原子性,防止库存被扣成负数。
推荐:普通 MySQL 库存扣减,优先使用条件更新。
三、Redis 如何防止超卖?
当库存保存在 Redis 中时,可以使用 Lua 脚本实现原子扣减。
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < 1 then
return 0
end
redis.call('DECR', KEYS[1])
return 1返回值:
1:库存扣减成功。0:库存不足或不存在。
Redis 会原子执行整个 Lua 脚本,避免多个请求分别读取、判断和扣减造成并发问题。
注意:SET NX PX 属于互斥锁实现方式,不是乐观锁。
四、悲观锁与乐观锁的区别
注意:乐观锁不一定始终比悲观锁快,高并发下频繁重试同样会产生性能问题。
五、实际开发建议
普通库存扣减:
使用 MySQL 条件更新:
UPDATE goods
SET stock = stock - #{quantity}
WHERE id = #{id}
AND stock >= #{quantity};库存扣减和订单创建应放在同一个数据库事务中,保证数据一致性。
高并发秒杀:
可以采用:
请求进入
↓
Redis Lua 原子扣减库存
↓
扣减成功
↓
创建订单(可通过消息队列异步处理)
↓
数据库事务处理库存与订单
↓
失败时进行库存补偿还需要处理重复下单、消息重复消费、数据库失败和 Redis 与数据库的数据一致性。
六、总结
库存超卖的根本原因是并发情况下库存校验与扣减缺乏原子性。
悲观锁:通过互斥保证并发安全。
乐观锁:通过版本号检查并发冲突。
MySQL 条件更新:利用单条 SQL 原子扣减库存。
Redis Lua:保证 Redis 内库存校验与扣减的原子性。
实际开发中,普通库存优先考虑 MySQL 条件更新 + 数据库事务;高并发秒杀可以考虑 Redis Lua + 异步订单处理 + 幂等与补偿机制。
