外观
商城库存扣减方案对比
秒杀、拼团等场景下,库存扣减是商城系统最容易出问题的地方——扣慢了体验差,扣错了会超卖。本文对比三种常用方案及适用场景。
问题本质
库存是共享资源,并发下单时多个请求同时读写同一行数据,如果处理不当:
- 超卖:库存只剩 1 件,两个请求都读到"库存充足",各自扣减成功
- 少卖:扣减失败后库存没有正确回补
核心要求是扣减动作必须是原子的。
方案一:数据库条件更新
利用一条 UPDATE 语句的原子性,把"判断 + 扣减"合并:
sql
UPDATE product
SET stock = stock - #{quantity}
WHERE id = #{productId}
AND stock >= #{quantity};返回影响行数为 0 即库存不足,直接下单失败。
- 优点:实现简单,无超卖风险,单表操作易维护
- 缺点:高并发时行锁竞争激烈,吞吐量受数据库上限约束
- 适用:中小规模商城(日订单几千到几万)首选,绝大多数项目够用
方案二:乐观锁(版本号)
更新时携带读取时的版本号,版本不一致说明有并发修改:
sql
UPDATE product
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{productId}
AND version = #{version};失败后重试或放弃。
- 优点:无长事务持有行锁,冲突少时性能好
- 缺点:热点商品冲突率高,重试风暴反而更慢;库存扣减这种"递减"场景不如方案一直接
- 适用:后台改价、编辑配置等低冲突的并发修改场景
方案三:Redis 预扣减 + 异步落库
把库存放到 Redis,用 Lua 脚本保证原子预扣,再异步写入数据库:
lua
-- KEYS[1]: 库存 key,ARGV[1]: 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock < tonumber(ARGV[1]) then
return -1
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return stock - tonumber(ARGV[1])预扣成功即返回"下单成功",消息队列异步创建订单并落库。
- 优点:单机 Redis 可承载 10 万级 QPS,前端响应快
- 缺点:链路长(Redis + MQ + DB),要处理 Redis 与数据库的最终一致性、宕机恢复、订单超时回补库存
- 适用:真正的秒杀/大促场景,或预估并发极高的头部商品
对比与选型
| 方案 | 复杂度 | 抗并发 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 条件更新 | 低 | 中 | 强 | 绝大多数商城 |
| 乐观锁 | 低 | 中低 | 强 | 低冲突并发修改 |
| Redis 预扣 | 高 | 高 | 最终 | 秒杀、大促 |
选型建议:从方案一起步,遇到数据库瓶颈再针对热点商品升级方案三。不要一上来就引入 Redis + MQ,复杂度本身就是成本。
小结
库存扣减没有银弹,方案的复杂度应与业务体量匹配。我们给客户的批发商城系统中,方案一稳定支撑了日常经营的所有场景,这正是"合适优于先进"的例子。
