☰
Redis防超卖实战:从秒杀抢红包场景解析Lua原子扣减方案
2026/10/22 9:15:56 网站建设 项目流程

做电商或者活动系统的朋友,对“秒杀”“抢红包”这两个场景应该都不陌生。高峰期流量可能是平时的几十倍甚至几百倍,而业务上最不能出错的就是超卖——明明库存只有100件,结果卖出去了120件;或者红包总共只发了100个,却被抢走了130个。Redis凭借高并发、低延迟的特性,几乎成了这类系统的标配,但Redis用不好,超卖照样发生。这篇文章我就结合自己做秒杀/抢红包系统的实际经历,把Redis防止超卖的核心原理、方案对比、代码实现和上线踩坑一次讲清楚。适合刚接触高并发系统设计、或者正在设计抢购类功能的开发者参考。

1. 先说清楚:超卖到底是怎么发生的?

1.1 一个抢红包场景的典型事故复盘

我最早接手的一个抢红包项目,上线第一天就出了事故。当时红包池里放了10万个红包,活动开始后一小时内被抢走了11万多个,超卖1万多个。大家第一反应是“数据是不是同步出问题了”,排查到最后发现,问题出在扣减逻辑写得太天真。

简化后的原代码大概是这样的:

def grab_red_packet(user_id): balance = get_balance_from_redis(packet_id) # 读余额 if balance <= 0: return "已抢完" new_balance = balance - 1 set_balance_to_redis(packet_id, new_balance) # 写回余额 return "抢成功"

注意这里是最典型的“先读再写”。在高并发下,多个请求同时读到balance=1,然后各自减1写回0,实际上两个请求都成功了,但库存已经变成负数。这就是超卖最直接的来源。

很多人觉得“读改写”这种低级错误不会出现在自己身上,但实际情况是:分布式环境下没人愿意用数据库锁去压住全流程,于是就会自然地把判断逻辑放到Redis层,而Redis层如果不用原子操作,本质上和单机多线程的竞态问题一模一样,只是换了个地方犯错。

1.2 库存扣减的三种常见错误写法

我在评审别的团队代码时,见过不少类似问题,总结下来是这三类:

  • 先查数据库再扣库存:如果两条SQL之间没有事务和锁的保护,等于把风险原封不动地搬到数据库。并发高一点,数据库行锁会拖垮QPS,并发不高时会直接出现脏读。
  • 用Redis GET然后SET:就是我上面说的读改写三步,非原子,属于最经典的坑。
  • 用分布式锁包住整个流程:锁本身能解决原子性问题,但锁的粒度、超时时间、续约机制都很难控制。锁加得太粗,性能退化成串行;锁超时设置太短,业务没执行完锁就释放了,另一个线程进来照样超卖。

为了更直观,我把常用实现方式放在一张表里对比:

实现方式是否原子并发安全性能适用场景
先读再写(GET+SET)否低看似高不推荐
数据库行级锁是高低低并发兜底
Redis + 分布式锁视锁实现而定中中对一致性要求高但并发不极端
Redis 原子命令 / Lua是高高秒杀/抢红包标配

1.3 用压测复现一次超卖

光讲道理不够直观。我曾经用一个简化脚本模拟过“先读再写”的抢购逻辑:Redis里初始库存设成100,用并发工具同时发起500个请求。实测结果中,成功数从100到300不等,有时候甚至出现负数库存。问题严重程度完全取决于并发时序的“运气”,这比稳定地失败更可怕——因为你可能在压测时没发现,上线后某个瞬间就爆了。

那次压测之后我就定了一条规矩:任何库存扣减逻辑,必须保证操作原子性,不接受“测试环境没复现问题”作为上线理由。

2. Redis 防超卖的几套主流方案与取舍

2.1 DECR 原子命令的边界问题

Redis本身提供了很多原子命令,最简单的就是DECR/DECRBY。比如用key存剩余红包数量,每次抢的时候直接DECR。Redis是单线程执行命令的,DECR不会被并发插入打断,所以“两个请求同时减”的问题天然不存在。

DECR packet:stock:1001

这个命令执行后,返回的是扣减后的值。如果返回值小于0,说明已经扣超了,需要把多扣的加回来。但“判断超了之后回补”这个动作,如果不封装成原子操作,又会产生新问题:请求A执行DECR后拿到-1,判断超了准备INCR回补;请求B此时也执行DECR拿到-2,也判断超了,也准备回补。两个请求一起回补,库存可能变成0,但实际已经超卖了1个或更多。

所以更稳妥的做法是:把“读库存、判断、扣减、返回结果”全部塞进Lua脚本。Redis执行Lua脚本时同样具有原子性,脚本执行期间不会有其他命令插入,这是目前最主流、最可靠的防超卖做法。

2.2 Lua 脚本方案与分布式锁方案的对比

分布式锁看起来逻辑清晰:加锁、读库存、判断、扣减、释放锁。但我实际用下来有几点体验不太舒服:

  • 锁粒度不好定:锁住整个商品key,会让所有用户串行化,吞吐量上不去;锁住用户维度,又防不住不同用户之间的并发扣减。
  • 锁超时难掌控:业务耗时超过锁的过期时间,锁就失效了,另一个线程进入后照样超卖。
  • 引入额外组件:用Redisson还好,自己实现锁就容易在“续期”“可重入”“主从切换”上埋雷。

相比之下,Lua脚本以非常小的实现成本,把多步操作变成了一步原子操作。它没有锁竞争,也没有释放锁的时机问题,Redis本身就是单线程,跑完脚本才处理下一个命令。这也是我最终选择Lua方案的原因。

2.3 令牌/预减库存方案的设计思路

除了直接扣减,还有一种思路是先给用户发一个“令牌”或“名额”,拿到令牌的用户再去走后面的下单、支付流程。这个方案适合扣减逻辑重、强烈依赖数据库事务的场景。

简单来说,就是在Redis里维护一个令牌池,抢购时先从池子里取令牌,取到了才算抢购资格成功,后续拿着令牌去落库、扣正式库存。这种思路把“资格”和“扣减”解耦,高峰期的大部分压力被Redis扛住,数据库只处理真正拿到资格的那一小批用户,压力大幅降低。

这个方案的代价是增加了异步对账的复杂度。因为用户拿到了令牌但可能不付钱、不落库,Redis里的令牌数和数据库订单数天然不一定相等,必须配合定时对账来保证两个系统最终一致。适用场景更像“抽奖”“预约”“限量券”,而不是必须立刻扣库存的传统秒杀。

3. 我最终采用的 Lua 脚本方案:完整实现与拆解

3.1 整体架构与设计思路

我当时做抢红包系统,最终用的是“Redis Lua脚本扣减 + 异步落库”的组合。核心设计是这样的:

  1. Redis里用两个key:一个存剩余红包数量,另一个用Set记录已经抢过的用户ID。
  2. 用户请求进来后,先执行Lua脚本,脚本内部完成“判断库存是否充足、判断用户是否重复、扣减库存、记录用户”。
  3. 抢成功的用户信息写入异步队列,由消费者批量落库,保证最终一致性。
  4. 定时任务做Redis和数据库的对账,两边数据不一致时触发告警。

这个设计的核心约束是:所有判断必须写在脚本内部,不能从外部先GET再调另一个命令。外部每次调用只需要传用户ID和扣减数量,脚本一次返回最终结果。

3.2 脚本代码与逐行解释

下面是我实际用过的Lua脚本,键名和参数做了简化:

-- KEYS[1] 剩余库存key -- KEYS[2] 已扣减用户集合key -- ARGV[1] 用户ID -- ARGV[2] 本次扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[2]) then return -1 end local is_member = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if is_member == 1 then return -2 end redis.call('DECRBY', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return 1

逐行解释一下:

  • 先GET库存并转成数字。如果库存不存在,或者库存小于本次扣减数量,直接返回-1,对应“已抢完”。
  • 用SISMEMBER判断用户ID是否已经在已抢集合里。如果在,返回-2,对应“重复抢”。
  • 库存和重复性都通过之后,执行DECRBY扣减库存。
  • 用SADD把用户ID写入已抢集合,用于后续防重。
  • 返回1表示成功。

由于整个脚本在Redis里以原子方式执行,执行期间不会有其他客户端的命令插入,因此这个方案的防超卖能力是可靠的。脚本内的两次判断和两次写操作,对任何并发请求来说都是“一气呵成”的。

Java侧的调用示例:

String luaScript = "……"; // 上面那段Lua脚本代码 List<String> keys = Arrays.asList("packet:stock:1001", "packet:users:1001"); List<String> args = Arrays.asList(userId, "1"); Long result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), keys, args.toArray() ); if (result != null && result == 1L) { // 抢成功,走异步落库 } else if (result != null && result == -1L) { // 已抢完 } else if (result != null && result == -2L) { // 重复参与 }

有一点要提醒:Lua脚本返回的类型要固定。如果你的返回值设计成Lua的nil,在Java侧会被转成null,然后判断逻辑就乱了。所以我在脚本里所有失败路径都返回确定的负数,不返回nil。

3.3 压测结果与边界验证

脚本写完之后必须压测。我当时用多台模拟客户端对同一个Redis key发起并发请求,总量5万次,红包总数是1万个。压测结果很干净:

  • 最终成功数:10000,不多不少。
  • 失败数:40000,全部被正确拦截。
  • Redis平均响应时间在1.2ms左右。
  • 整个压测过程中没有出现一次负数库存。

我还专门验证了几个边界场景:

  • 红包被抢完后,后续请求全部返回-1,库存停留为0。
  • 同一个用户连续发起抢购,第二次返回-2,库存没有被重复扣减。
  • 用户在库存剩1个时并发抢购,只有一个人返回1,其他人返回-1。

这些边界测试做完,我才敢把脚本放上生产环境。

4. 从“防超卖”到“不丢单”:Redis 与数据库的一致性保障

4.1 异步落库与对账机制的配合

Redis防住了超卖,其实只解决了一半问题。真实的红包/秒杀系统里,用户抢到了但数据库没有记录,或者数据库记录了但Redis那边扣多了,都是隐患。所以必须在Redis和数据库之间做对账。

我当时的做法是:

  • 抢成功的用户信息先放入消息队列,消费者批量写入数据库。
  • 每次写库之前,先查数据库里是否已经存在该用户的抢购记录,保证幂等。
  • 每分钟跑一次定时任务,统计Redis的已售数量和数据库的订单数量,两个数对不上就触发告警,人工介入排查。

这套机制的核心是“对账”,它不追求每一毫秒数据都完全一致,而是保证在一个时间窗口内收敛。比如Redis扣减了但数据库还没写入,短时间内两边对不上是正常的,但超过一定时间还对不上,就要怀疑消息队列有没有积压或丢消息。

4.2 超时、重试与幂等的处理

异步落库最大的风险是消费者挂了或者消息丢失。我的经验是:消息队列必须开启持久化,消费时带上唯一的业务ID,数据库表对业务ID建唯一索引。这样即使消费者因为网络问题重复收到了同一条消息,也不会插入两条记录。

这里有一个容易被忽略的细节:用户在超时情况下重试,前一次请求可能已经写库成功了。如果幂等没做好,用户重试时会再次进入队列,导致同一个用户被记录两次。用唯一的业务ID加数据库唯一索引,是最简单粗暴的兜底方案。

4.3 缓存击穿与雪崩的兜底措施

秒杀场景下,如果Redis里的库存key刚好过期了,一瞬间所有请求都会打到数据库,这就是缓存击穿。库存key的过期时间不要设置得太短,最好是活动开始前提前把key载入内存,活动结束后再在低峰期清理。

雪崩则是大量key同时失效导致请求全部打到数据库。做法是把不同商品、不同红包的key过期时间错开,再加上数据库端的限流和降级。虽然这些不属于“防超卖”本身,但系统整体稳定性出问题,一样会间接导致账目对不上。

5. 上线前容易被忽略的坑与实战经验

5.1 集群模式下 Lua 脚本的 slot 问题

如果你的Redis是集群模式,Lua脚本里操作的多个key必须落在同一个slot上。比如我的脚本同时操作了stock key和users key,如果这两个key的hash tag不同,Redis集群会直接报错。

解决办法是给key加上相同的散列标签,强制让它们落到同一个slot:

packet:stock:{1001} packet:users:{1001}

花括号内的内容会被用来计算slot,这样两个key就会落在同一个节点上。这个细节在单机Redis上完全看不出来,一上集群就立刻暴露,所以我后来把key命名规范里直接加了一条:同一个业务操作涉及的key,必须带相同的hash tag。

5.2 热点 key 的拆分思路

秒杀场景下,所有请求都打在同一个库存key上,这是很重的单点压力。虽然Redis单机可以扛几万QPS,但当流量达到百万级别时,单个key就成了瓶颈。

一种常见的做法是“库存分片”:把10000个红包拆成10个key,每个key存1000。用户请求时,先根据用户ID的hash取模选一个key,再对这个key执行Lua扣减。这样可以大幅提升并发能力,但相应地引入了“不同key之间库存不均衡”的问题。极端情况下,某个key被抢光了,但其他key还有库存,用户可能抢不到。需要额外逻辑处理,比如二次路由。

我的习惯是:红包数量特别大、并发特别极端时才做分片;普通秒杀场景,单key配合Lua脚本已经完全够用,不要为了炫技增加系统的复杂度。

5.3 几个真实踩过的坑

最后分享几个我实际踩过的坑,给后来者提个醒。

第一个是库存预热的问题。活动开始前必须确保Redis里的库存key已经正确初始化,否则脚本返回-1,用户会莫名抢不到。我见过有同事把库存初始化放到活动开始前几秒才执行,结果缓存还没生效,一瞬间流量就把系统打懵了。

第二个是Lua脚本的发布问题。Redis会缓存脚本,但每个节点都会缓存一次。脚本内容如果频繁变动,会导致缓存失效,每次都要重新发送脚本,影响性能。所以线上脚本要尽量稳定,改脚本要走完整的发布流程,不要在活动期间临时改脚本。

第三个是库存回补的坑。如果用户抢到红包后支付失败或者超时取消,是否需要把库存加回来?答案是必须,但一定要通过异步消息来做,而且必须做幂等。如果你直接调INCR,一个取消消息被重复处理,库存就会虚高,最终导致超卖。

提示:库存回补的幂等非常简单。给每条回补消息带上原始订单号,在Redis里维护一个“已回补订单号”的集合,回补前用SISMEMBER判断一下就行。判断和INCR之间如果也想保证原子,可以直接写进Lua脚本里一起执行。

我在实际项目中对这套方案的验收标准就两条:一是并发压测下Redis里的库存永不为负;二是活动结束后,Redis的扣减总数和数据库的订单总数完全一致。只要这两条达标,线上基本就不会出大问题。最后一次大促,我们顶着几百万的流量跑完,库存和数据库两边一分不差,那一刻你就知道,前面那些反复的压测和踩坑,都值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询