Redis防超卖实战:从秒杀抢红包场景解析Lua原子扣减方案

发布时间:2026/10/22 9:16:01
Redis防超卖实战:从秒杀抢红包场景解析Lua原子扣减方案 做电商或者活动系统的朋友对“秒杀”“抢红包”这两个场景应该都不陌生。高峰期流量可能是平时的几十倍甚至几百倍而业务上最不能出错的就是超卖——明明库存只有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 抢成功注意这里是最典型的“先读再写”。在高并发下多个请求同时读到balance1然后各自减1写回0实际上两个请求都成功了但库存已经变成负数。这就是超卖最直接的来源。很多人觉得“读改写”这种低级错误不会出现在自己身上但实际情况是分布式环境下没人愿意用数据库锁去压住全流程于是就会自然地把判断逻辑放到Redis层而Redis层如果不用原子操作本质上和单机多线程的竞态问题一模一样只是换了个地方犯错。1.2 库存扣减的三种常见错误写法我在评审别的团队代码时见过不少类似问题总结下来是这三类先查数据库再扣库存如果两条SQL之间没有事务和锁的保护等于把风险原封不动地搬到数据库。并发高一点数据库行锁会拖垮QPS并发不高时会直接出现脏读。用Redis GET然后SET就是我上面说的读改写三步非原子属于最经典的坑。用分布式锁包住整个流程锁本身能解决原子性问题但锁的粒度、超时时间、续约机制都很难控制。锁加得太粗性能退化成串行锁超时设置太短业务没执行完锁就释放了另一个线程进来照样超卖。为了更直观我把常用实现方式放在一张表里对比实现方式是否原子并发安全性能适用场景先读再写GETSET否低看似高不推荐数据库行级锁是高低低并发兜底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脚本扣减 异步落库”的组合。核心设计是这样的Redis里用两个key一个存剩余红包数量另一个用Set记录已经抢过的用户ID。用户请求进来后先执行Lua脚本脚本内部完成“判断库存是否充足、判断用户是否重复、扣减库存、记录用户”。抢成功的用户信息写入异步队列由消费者批量落库保证最终一致性。定时任务做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脚本代码 ListString keys Arrays.asList(packet:stock:1001, packet:users:1001); ListString 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加上相同的散列标签强制让它们落到同一个slotpacket: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的扣减总数和数据库的订单总数完全一致。只要这两条达标线上基本就不会出大问题。最后一次大促我们顶着几百万的流量跑完库存和数据库两边一分不差那一刻你就知道前面那些反复的压测和踩坑都值了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询