首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >秒杀库存防超卖实战:Redis 预扣、Lua 原子脚本与数据库最终一致

秒杀库存防超卖实战:Redis 预扣、Lua 原子脚本与数据库最终一致

原创
作者头像
用户5598620
发布2026-09-11 09:02:15
发布2026-09-11 09:02:15
10
举报

秒杀场景的核心矛盾是:极短时间内大量请求抢同一份有限库存,稍有不慎就会超卖——卖出的数量超过真实库存,后续要么砍单要么赔付。单纯靠数据库"查一下再减"在并发下必然出错,单纯靠 Redis 又会面临缓存与数据库不一致。可靠的方案是分层的:Redis 做高速预扣挡住大部分流量,消息队列做异步削峰,数据库做最终一致的落库。本文拆解这套链路和每一层的关键细节。

一、为什么"先查后减"一定超卖

最常见的错误写法是先 SELECT 出库存、在应用层判断够不够、再 UPDATE 扣减。两个并发请求可能同时查到库存为 1,都判断"够",于是各扣一次,超卖就此发生。问题的根源是"判断"和"扣减"不是一个原子操作。

正确的数据库扣减必须把条件写进同一条语句,让数据库在行锁内完成判断:

代码语言:sql
复制
UPDATE sku_stock
SET available = available - :qty
WHERE sku_id = :skuId AND available >= :qty;

影响行数为 1 才算扣减成功,为 0 说明库存不足。这是防超卖的最后一道底线,但数据库直接扛秒杀的全部并发并不现实,所以需要在它前面加缓存层。

二、Redis 预扣:把绝大多数请求挡在数据库之外

活动开始前把可售库存预热到 Redis,下单时先在 Redis 原子扣减,扣不到就直接返回"已售罄",根本不打到数据库。原子性靠 Lua 脚本保证——把"判断余量 + 扣减 + 记录"放进一个脚本里,Redis 单线程执行,天然互斥:

代码语言:lua
复制
-- KEYS[1]=库存key  KEYS[2]=已购用户集合  ARGV[1]=用户  ARGV[2]=数量
local stock = tonumber(redis.call('GET', KEYS[1]))
local buyQty = tonumber(ARGV[2])
if stock == nil then return -1 end                 -- 库存未初始化
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 end  -- 限购
if stock < buyQty then return 0 end                -- 库存不足
redis.call('DECRBY', KEYS[1], buyQty)
redis.call('SADD', KEYS[2], ARGV[1])
return 1                                           -- 预扣成功

返回值区分了"未初始化、超出限购、库存不足、成功"几种结果,前端可以给出准确提示。限购判断必须和扣减放在同一个脚本里,否则一个用户并发发起多个请求仍可能突破限购。

三、异步下单:消息队列削峰填谷

Redis 预扣成功后,并不立即写订单库,而是发一条消息到队列,由后端消费者按数据库能承受的速率平稳落库:

代码语言:javascript
复制
async function onSeckill(req) {
  const code = await redis.eval(DEDUCT_SCRIPT, keys, [req.userId, 1]);
  if (code !== 1) return failHint(code);
  await mq.send("order.create", { userId: req.userId, skuId: req.skuId });
  return { accepted: true, tip: "排队中,结果稍后通知" };
}

这样秒杀入口的响应只取决于 Redis 和 MQ,数据库不会被瞬时洪峰打穿。用户侧用"排队/处理中"状态承接,落库成功后再通过轮询或推送告知最终结果。

四、最终一致:Redis 扣减与数据库落库怎么对齐

引入缓存预扣和异步落库后,最大的问题变成"Redis 说扣了,数据库订单却没建成",这时库存就对不上。需要处理好三个一致性环节:

1. 落库失败要回补 Redis。 消费者创建订单时若因校验失败(地址异常、风控拦截等)没建成,必须把预扣的库存加回去,并移除已购标记:

代码语言:lua
复制
-- 回补脚本,与扣减对应
redis.call('INCRBY', KEYS[1], ARGV[2])
redis.call('SREM', KEYS[2], ARGV[1])
return 1

2. 订单状态机要幂等。 MQ 可能重复投递,创建订单要以业务唯一键(用户+商品+活动批次)做幂等,避免同一预扣生成两笔订单。

3. 定期对账兜底。 定时任务比对"Redis 已扣量、已建订单量、数据库实际扣减量",差异超过阈值告警并触发校准。任何分布式方案都不能只靠运行时自觉,对账是最后安全网。

五、售罄后的流量怎么处理

库存为 0 之后仍会有大量请求涌来,不能让它们反复执行 Lua 甚至回源。常见做法:

  • 本地缓存一个售罄标记,Redis 扣到 0 时通过订阅/广播通知所有应用实例,后续请求在内存里直接拒绝;
  • 对售罄商品做请求限流和降级页,避免无效流量占用连接;
  • 若有订单超时未支付释放库存,要同步把库存加回 Redis 并解除售罄标记,防止"其实有货却一直显示售罄"。

六、踩坑清单

  • 用"先查库存再扣减"的两步写法,并发下必然超卖;
  • 只在数据库扣、不做 Redis 预削峰,高峰期数据库连接被打满;
  • 只在 Redis 扣、落库失败不回补,缓存库存凭空消失、少卖;
  • 限购判断和扣减分离,被脚本并发绕过一人一单;
  • MQ 消费不幂等,重复消息生成重复订单;
  • 售罄后不做本地拦截,无效请求持续消耗资源;
  • 没有对账任务,Redis 与数据库长期漂移却无人发现;
  • 把扣库存和减余额、加订单放在多个无补偿的远程调用里,部分失败留下脏数据。

七、工程落地建议

一套完整的秒杀链路应当是"网关限流 → Redis Lua 原子预扣与限购 → MQ 异步削峰 → 消费者幂等落库 → 失败回补 → 定时对账",层层过滤、层层兜底。Redis 库存务必在活动前预热并设置合理的过期与兜底值,防止缓存被穿透;消费者并发数要按数据库承载能力反推,宁可排队也不要压垮落库。压测不能只测"全部成功"的理想路径,要专门构造库存临界并发、重复投递、消费者宕机、缓存与库不一致这几类故障,确认不会超卖、不会少卖、最终能自动对齐。技术选型上,Lua 脚本、可靠消息、本地售罄标记都是通用手段,重点是把状态流转和补偿路径设计闭环。

八、上线前复盘清单

  1. 数据库扣减是否为带 available>=qty 条件的单条原子语句;
  2. Redis 预扣与限购是否在同一个 Lua 脚本内完成;
  3. 预扣成功到订单落库是否异步化,消费是否幂等;
  4. 落库失败是否可靠回补 Redis 库存与已购标记;
  5. 售罄后是否在应用本地拦截、释放库存后能否解除售罄;
  6. 是否有 Redis 与数据库的定时对账与告警;
  7. 是否压测过临界并发、重复投递、消费者宕机等故障路径。

结语

秒杀防超卖的本质,是用"原子操作"消灭并发判断的时间差,用"分层缓存加异步队列"把洪峰削平,再用"回补加对账"保证缓存与数据库最终对齐。没有哪一层能单独解决全部问题,真正可靠的系统是每一层都假设上一层可能出错、并为它准备好兜底。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、为什么"先查后减"一定超卖
  • 二、Redis 预扣:把绝大多数请求挡在数据库之外
  • 三、异步下单:消息队列削峰填谷
  • 四、最终一致:Redis 扣减与数据库落库怎么对齐
  • 五、售罄后的流量怎么处理
  • 六、踩坑清单
  • 七、工程落地建议
  • 八、上线前复盘清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档