Skip to content

商城库存扣减方案对比

秒杀、拼团等场景下,库存扣减是商城系统最容易出问题的地方——扣慢了体验差,扣错了会超卖。本文对比三种常用方案及适用场景。

问题本质

库存是共享资源,并发下单时多个请求同时读写同一行数据,如果处理不当:

  • 超卖:库存只剩 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,复杂度本身就是成本。

小结

库存扣减没有银弹,方案的复杂度应与业务体量匹配。我们给客户的批发商城系统中,方案一稳定支撑了日常经营的所有场景,这正是"合适优于先进"的例子。