库存扣减方案#
一句话答案#
Redis Lua 原子预扣库存→MQ 异步→DB 乐观锁落库(WHERE stock>0),防超卖靠原子性+乐观锁双保险。
核心要点
流程:
- Redis Lua 原子:判断库存 → 扣减 → 返回
- 成功后发 MQ
- 消费者扣减 DB:
UPDATE SET stock=stock-1 WHERE stock>0
防超卖: Redis Lua 原子性 + DB 乐观锁 防少卖: Redis 扣成功但 DB 失败 → 补偿回滚 Redis
原子性原理深挖#
Redis Lua 为何原子: 关键认知——这里的”原子”不是数据库那种事务隔离级别(ACID 的 I),而是执行不可分割。
- Redis 是单线程串行处理命令的,所有命令进同一个队列排队执行。
- 一段 Lua 脚本被当作一个命令整体投递,脚本从开始到结束这段时间内,Redis 不会插入执行任何其他客户端的命令——没有线程切换、没有命令交错。
- 所以脚本里「读库存 → 判断 >0 → 扣减」三步天然串行,不可能出现 A 读到库存 1、B 也读到库存 1、两个都扣的竞态。这跟”事务回滚”无关:Lua 脚本中途报错不会回滚已执行的写命令,它保证的是”不被打断”而非”全或无”。
DB 乐观锁本质是 CAS: UPDATE SET stock=stock-1 WHERE stock>0 看似一条普通 UPDATE,实则是一次 Compare-And-Swap。
- 为何不丢失更新: UPDATE 语句在 InnoDB 里是当前读(读取记录的最新已提交版本,而非 MVCC 快照),并对命中的行加排他行锁(X锁)。并发的多个 UPDATE 会在行锁上排队串行执行——线程 A 扣完提交释放锁,线程 B 才拿到锁、基于 A 提交后的最新值再次判断
stock>0。每个扣减都基于前一个的结果,不会两个线程读到同样的旧值各扣一次(丢失更新)。 - 为何不超卖:
WHERE stock>0是 CAS 里的 compare。当库存被扣到 0,后续 UPDATE 的 WHERE 条件不成立,影响行数返回 0,应用层据此判定”扣减失败/已售罄”。无需显式版本号——库存值本身就是版本号。 - 对比
SELECT ... FOR UPDATE再 UPDATE:那是悲观锁,两次往返且锁持有时间长;乐观锁单条 UPDATE 把判断和扣减合一,行锁只在语句执行瞬间持有,并发吞吐高得多。
Redis 与 DB 为何最终一致而非强一致: Redis 预扣和 DB 落库是两个独立系统的两次写,跨系统强一致要靠分布式事务(2PC/TCC),会把高并发的预扣链路拖成同步阻塞、性能崩塌,与”用 Redis 抗并发”的初衷矛盾。所以工程上选择:预扣成功立即返回用户,DB 落库走 MQ 异步;中间窗口的不一致由对账补偿兜底——定时任务比对 Redis 与 DB 库存差异,发现 Redis 扣了但 DB 没落则补偿,反之回滚。用”短暂不一致 + 最终修复”换取吞吐。
面试回答(2分钟版)
库存扣减的核心目标是防超卖,高并发场景下我们采用三层架构。第一层是Redis Lua原子预扣,把库存判断和扣减写在一个Lua脚本里原子执行,既保证了不会超卖又能扛住高并发,因为Redis单线程执行Lua不存在竞态问题。第二层通过MQ异步传递扣减消息,起到解耦和削峰的作用。第三层是数据库乐观锁落库,用UPDATE SET stock=stock-1 WHERE stock>0来兜底,即使Redis和DB之间出现不一致也不会超卖。防超卖靠Redis原子性加DB乐观锁双保险,防少卖靠补偿机制——如果Redis预扣成功但DB落库失败,就回滚Redis库存。退单场景则反向操作,先恢复DB库存再恢复Redis库存,同样要保证幂等性,用订单号做去重避免重复回补。
追问与易错
追问方向:
- “超卖根本原因?”→ 「查询-判断-扣减」不是原子操作,并发下多个线程同时读到库存>0 都通过判断然后都去扣减;解决核心是保证原子性(Redis Lua)或排他性(DB 行锁/乐观锁)
- “Redis 预扣成功 DB 失败怎么办?”→ 消费者扣减 DB 失败后发补偿消息回滚 Redis 库存(INCRBY 加回去),同时记录异常日志人工排查;关键是保证最终一致性而非强一致
- “退单库存恢复怎么做?”→ 先恢复 DB 库存(UPDATE stock=stock+1)再恢复 Redis 库存(INCRBY),用订单号做幂等键防止重复回补;退单和正向扣减要用同一把逻辑保证一致
易错点:
- ❌ SELECT FOR UPDATE 就行——高并发行锁竞争严重
- ❌ Redis 预扣就不超卖——Redis→DB 可能不一致