秒杀系统库存扣减实战:Redis+Lua脚本实现原子性防超卖

发布时间:2026/10/7 23:47:08
秒杀系统库存扣减实战:Redis+Lua脚本实现原子性防超卖 秒杀系统大概是电商场景里最能让后端人“一晚上睡不着”的业务了瞬时流量能达到日常的几十上百倍写操作高度集中在少数几个商品上而最要命的是库存一旦扣成负数超卖造成的资损和客诉能把人逼疯。很多同学一听到秒杀就想到各种高深的中间件、分库分表、MQ削峰但真正落到“扣库存”这个最核心的动作上底层基本都绕不开Redis和Lua脚本这套组合拳。今天这篇文章就专门讲清楚一件事在电商秒杀场景下如何用Redis加Lua脚本实现原子性的库存扣减并附上可以直接抄的完整代码。这个方案的核心价值在于把“查库存、判断库存、扣减库存”这三个动作合并成一个不可分割的操作从根上杜绝并发扣减导致的超卖问题。同时因为Redis本身是纯内存操作吞吐量远高于数据库行锁方案扛住几万甚至十几万的瞬时QPS是没问题的。适合正在做秒杀、抢购、限量销售等业务的开发同学也适合想彻底搞懂Redis原子性玩法的朋友作为实战参考。1. 秒杀场景的技术特征与超卖问题剖析1.1 秒杀系统为什么难做秒杀和其他常规业务最大的区别就一个字猛。某个商品在开售瞬间可能涌进几百万请求但这些请求都集中打在同一批热点商品上。常规接口虽然平时也能支撑几千QPS但几万并发同时去改同一个库存字段时数据库第一个扛不住连接池很快被打满紧接着就是接口大面积超时。更麻烦的是秒杀业务对“正确性”的要求极高。用户在前端看到库存还剩1件点击购买大概率就会期望自己能抢到。如果两个人同时买了最后一件库存超卖订单就产生了。这在普通业务里可能只是一条脏数据在秒杀场景里就是真金白银的资损和用户的差评。秒杀场景的核心矛盾可以总结成三句话请求量极高热点非常集中数据正确性要求极其严格。这三个特征叠加在一起就必须在不牺牲正确性的前提下想办法把热点数据的读写压力从数据库转移到更快的存储层。这也是为什么Redis会成为秒杀系统的基础设施——它既快又具备实现原子性扣减的能力。1.2 常规扣减方案的三个典型坑很多第一次做秒杀的同学会想当然写代码先查库存判断库存大于0再执行库存减一。这种“读-判-写”三段式的代码在低并发下完全没问题一旦并发上来问题立刻暴露。第一个坑是超卖。查库存和扣库存之间有时间差两个请求可能同时读到库存为1然后都认为自己可以扣最终把库存扣成-1。虽然数据库层面可以用UPDATE stock SET count count - 1 WHERE count 0再兜底但数据库在秒杀流量下的更新能力有限而且如果前端接口已经返回“抢购成功”数据库扣减失败会造成更大的链路问题。第二个坑是数据库被打垮。就算用了UPDATE ... WHERE count 0防住了超卖数据库行锁在并发下也会导致大量请求堆积等待事务延迟飙升最终接口大面积超时、雪崩。第三个坑是数据不一致。有人会尝试用分布式锁把并发请求串行化看似解决了超卖但锁本身有网络开销加锁、解锁失败又要考虑重试和过期问题。更关键的是如果锁的粒度过大秒杀性能会急剧下降如果粒度不够又容易漏掉同一个商品的并发保护。1.3 超卖的代价到底有多大超卖不是一个可以通过“事后对账”轻松处理的问题。用户下单成功但后续被告知无货要么强制取消订单要么让用户等补货两边都是极差的体验。一旦量大平台需要批量赔付优惠券或现金这是一笔不小的资损。更难受的是这类问题会在社交平台上被快速放大口碑损失比资金损失更难挽回。所以秒杀场景下的库存扣减不能靠“先扣了再说错了再改”必须在扣减时就保证绝对正确。这也是“原子性”这个关键词比“性能优化”更重要的原因。2. 为什么选RedisLua原子性的底层原理2.1 Redis单线程模型是原子性的基础要理解RedisLua为什么能解决超卖得先从Redis的单线程模型说起。Redis处理客户端命令时本质上是一个单线程事件循环命令是一条一条按顺序执行的。也就是说任意两条Redis命令之间不会并行交错后面的命令一定等前面的命令执行完才开始。这就带来一个天然的好处单个Redis命令具有原子性。比如DECRBY stock 1没有任何其他客户端请求能在它执行的过程中插入操作。问题是秒杀扣库存逻辑不是一个命令能完成的——至少要“查库存”和“扣库存”两步而这两步之间就会有窗口期。单条命令的原子性并不能解决多步操作的原子性问题。Redis官方很早就意识到这一点所以提供了事务命令和Lua脚本两个方案。事务命令用MULTI/EXEC把多条命令打包执行Lua脚本则把一段逻辑整体交给服务端执行。两者都能保证“多条命令之间不被打断”但Lua脚本在灵活性和可控性上明显更胜一筹。2.2 Lua脚本在Redis里的执行机制Redis 2.6版本开始内置Lua解释器可以把一段Lua脚本作为一个整体发送给服务端。服务端会缓存并解析这段脚本然后以不可分割的方式逐条执行脚本里的Redis命令。在执行整个脚本期间Redis不会处理任何其他客户端请求直到脚本全部执行完毕。这意味着无论脚本里有几条读写命令从外部看都像一个单独的命令一样具备完整的原子性。秒杀扣库存的“查库存、判断库存、扣减库存”三步写入一个Lua脚本就能从原理上消除并发窗口。另外Lua脚本在Redis里还有一个好处脚本逻辑在服务端执行不需要客户端多次网络往返。否则即便客户端依次执行GET、比较、DECRBY每次都有网络延迟窗口期会被拉大而且网络异常还会导致逻辑中断。使用Lua脚本之后一次网络请求就完成了所有操作性能也更好。2.3 为什么不用MULTI/EXEC或WATCH有些同学会问Redis不是有MULTI/EXEC事务和WATCH乐观锁吗为什么不用它们先说MULTI/EXEC。它在执行后会把命令按顺序打包执行保证隔离性但它没有“读取当前值然后做条件判断”的能力。也就是说你没法在事务内部根据查询结果决定是否执行后续命令。虽然可以配合WATCH去实现乐观锁但一旦被修改就得重试并发冲突高的时候重试次数飙升性能和体验都会很差。再说WATCH。它在读之前WATCH住keyEXEC时检查key是否被修改过被改就放弃事务返回空。这在低冲突场景下没问题但秒杀场景恰恰是超高冲突场景。假设1万并发抢1个库存乐观锁大概只让1个事务成功剩下9999个请求全部白跑一轮然后各自重试Redis的压力会被放大好几倍。Lua脚本则完全不同服务端串行执行没有重试机制没有“读和写之间被别的事务插入”的问题。整个判断和扣减一次性完成既正确又高效。所以只要涉及“先读后写且需要条件判断”的逻辑Lua脚本几乎就是Redis场景下的最优解。3. 完整代码实现Lua脚本与Java接入3.1 Lua脚本源码与逐行解析直接上一段生产环境验证过的扣库存脚本我把它命名为stock_decr.lua-- 扣减库存的Lua脚本 -- KEYS[1]: 库存对应的Redis Key -- ARGV[1]: 本次扣减数量 local stockKey KEYS[1] local buyCount tonumber(ARGV[1]) -- 1. 获取当前库存 local stock redis.call(get, stockKey) if not stock then return -1 -- 库存Key不存在 end stock tonumber(stock) -- 2. 库存不足直接返回失败 if stock buyCount then return -2 -- 库存不足 end -- 3. 执行扣减并返回成功 redis.call(decrby, stockKey, buyCount) return 1 -- 扣减成功这个脚本的核心逻辑其实很简单但它有三个值得注意的细节第一redis.call(get, stockKey)返回的是字符串所以必须用tonumber转成数字否则后续比较会出错。tonumber函数接收Redis返回的字符串再转成整数这一步一定不能省。第二当库存key不存在时redis.call(get, stockKey)返回的是falseLua里表现为nil。这里我显式返回-1方便Java端区分“库存key不存在”和“库存不足”两种错误场景。第三脚本里stock buyCount是纯内存比较速度极快。因为整段脚本原子执行这里判断到扣减完成之间不可能有别的请求插入所以一旦判断通过后面的decrby就必然成功。这就是整个方案的灵魂。3.2 Spring Boot工程接入代码Lua脚本准备好了接下来就是在Java工程里调用它。绝大多数业务都基于Spring Boot这里给出用Spring Data Redis接入的完整示例。第一步在Mapper层或者Service层定义一个静态的脚本常量public class StockService { private static final String STOCK_DECR_LUA local stockKey KEYS[1]\n local buyCount tonumber(ARGV[1])\n local stock redis.call(get, stockKey)\n if not stock then\n return -1\n end\n stock tonumber(stock)\n if stock buyCount then\n return -2\n end\n redis.call(decrby, stockKey, buyCount)\n return 1; }第二步在扣库存的方法里把脚本和参数通过StringRedisTemplate执行Autowired private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScriptLong STOCK_DECR_SCRIPT; static { STOCK_DECR_SCRIPT new DefaultRedisScript(); STOCK_DECR_SCRIPT.setScriptText(STOCK_DECR_LUA); STOCK_DECR_SCRIPT.setResultType(Long.class); } public boolean decrementStock(String stockKey, int buyCount) { Long result stringRedisTemplate.execute( STOCK_DECR_SCRIPT, Collections.singletonList(stockKey), String.valueOf(buyCount) ); if (result null) { return false; } if (result -1L) { throw new IllegalStateException(库存Key不存在请先初始化库存); } if (result -2L) { return false; // 库存不足 } return result 1L; }这段代码有几个容易踩坑的地方。DefaultRedisScriptLong必须显式设置setResultType(Long.class)否则Spring不知道把Lua返回的数字映射成什么类型。stringRedisTemplate.execute的第二个参数是key列表第三个参数是可变参数形式的ARGV这里传入扣减数量。Spring Data Redis对DefaultRedisScript做了封装第一次执行时会调用SCRIPT LOAD把脚本缓存到Redis之后自动使用EVALSHA所以不用担心每次请求都把完整脚本体传给Redis。这也是为什么生产环境里用Spring框架会比直接调Jedis/Lettuce的裸API方便很多。3.3 库存预热与初始化操作扣库存之前必须确保库存key已经存在。很多同学在联调时遇到返回-1八成就是忘了初始化库存。初始化库存时需要注意不能直接用set覆盖否则秒杀期间如果有人误操作会把正在扣减的库存重置。建议使用setnx只在key不存在时写入public boolean initStock(String stockKey, int totalStock) { Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(stockKey, String.valueOf(totalStock)); return Boolean.TRUE.equals(result); }setnx在秒杀场景里还有一个隐含的好处幂等。即使多个服务实例同时调用初始化方法也只有一个能成功不会出现库存被反复重置的问题。库存预热最好在秒杀开始前由专门的后台任务完成。实际项目中我会在秒杀活动发布时把活动商品信息和库存量同步到Redis并做一次完整性校验。如果秒杀是定时开始的预热时间可以放在开始前5到10分钟然后通过开关控制秒杀接口的可见性避免预热期间就有流量进入。4. 秒杀接口工程化落地限流、防重复、最终一致性4.1 请求限流与防刷设计原子性扣减只能保证“扣库存”这个动作正确但拦不住恶意刷单和流量暴增。秒杀接口前面一定要加限流和防刷否则大量无效请求会把下游打到挂了。常见的做法是两层限流。第一层在网关或Nginx用limit_req模块做接口级别的限流比如每秒只放行指定数量的请求进入后端剩下的直接返回“排队中”。第二层在应用层用Redis做窗口限流。最简单的实现是滑动窗口比如统计同一用户ID在1秒内的请求次数超过阈值就拒绝。防刷的重点是识别用户。最简单的办法是用户登录态加IP双重校验同一个用户和同一IP都设置访问频控。更严格的做法是引入设备指纹、行为验证码等机制这些就要根据业务封禁程度来权衡了。需要提醒的是限流是保护系统不是业务主流程所以限流组件的超时时间必须设得很短比如100到200毫秒。否则一旦Redis出现抖动限流本身会拖慢正常请求反而引发雪崩。4.2 幂等与防重复下单有了原子性扣库存还得防用户疯狂点击导致的重复下单。比如用户手速快一秒内点了5次下单按钮如果不加控制前端即使做了按钮置灰后端还是可能收到多次请求。在秒杀场景里我习惯在下单入口加一个用户维度的幂等锁以seckill:buy:userId:goodsId为key用SETNX加锁并设置合理过期时间比如10秒。只有拿到锁的请求才允许进入后续的库存扣减流程。这样即使用户连点多次也只有第一次请求能真正扣库存后面的请求直接返回“请勿重复提交”。这个幂等锁可以和库存扣减Lua脚本放在一起设计。更严谨的做法是把“防重复”和“扣库存”合并在同一个Lua脚本里用同一个原子操作完成彻底避免加锁和扣减之间的窗口期。不过这样会把业务逻辑和库存逻辑耦合在一个脚本里对脚本的复用性有影响。我的建议是在并发量可控的前提下先分开实现在系统压测后如果还有问题再把它们合并更容易排查问题。4.3 Redis扣减与数据库扣减的最终一致性Redis扣库存只是第一道防线数据库里同样需要扣减库存否则活动结束库存账单对不上。这里涉及一个经典的“Redis和数据库一致性”问题。我的思路很简单Redis负责扛流量数据库负责兜底。具体流程是Lua脚本扣Redis库存成功之后发送一条MQ消息MQ的消费端在数据库事务里执行UPDATE stock SET count count - ? WHERE id ? AND count ?数据库扣减失败则记录告警并触发库存回补。也就是说数据库才是最终库存的权威来源Redis库存本质上是一个“预扣”状态。如果用户下单后取消订单或者支付超时需要回补Redis库存。回补操作同样要走Lua脚本不能直接incr因为要控制回补的上限不能超过初始库存。这个上限校验可以放在Lua脚本里防止因为重复退款或消息重复消费导致库存越补越多。最终一致性的核心原则是所有库存变更都走MQ保证顺序性消费端要做幂等失败消息要进入重试队列超过重试次数后进入死信队列人工处理。这套流程看起来麻烦但为了账实相符完全值得。5. 常见问题排查与实测避坑指南5.1 秒杀场景常见问题速查表把我在实践中遇到过的问题整理成一张速查表遇到类似现象可以直接对照排查现象可能原因排查思路扣减返回-1库存key未初始化检查活动预热任务是否执行setnx是否成功扣减返回-2库存确实不足查看当前库存值确认是否存在并发预扣扣减成功但数据库没扣MQ消费失败或未发送检查MQ消费日志、重试队列是否堆积库存变负数脚本逻辑被绕过确认所有入口是否都走Lua脚本排查有没有直接decrby的地方加锁成功但库存没扣幂等锁与扣减分离导致窗口检查两个操作之间是否有异常分支提前返回Redis集群报CROSSSLOT脚本中多个key不在同一slot检查KEYS是否使用了相同hashtag响应延迟明显升高Lua脚本体过大或执行了耗时长命令压测脚本执行耗时避免在脚本里写循环、大key操作这里我特别想强调“库存变负数”这个坑。线上出现负数往往不是Lua脚本有问题而是有人绕过了Lua脚本直接用命令改库存比如运营后台手动调整、补偿任务写错逻辑。所以库存key一定要统一收口所有变更都必须经过同一个脚本或者同一组接口。5.2 性能与稳定性实测数据我在测试环境压测过这个方案单台Redis实例8核心配置模拟1万个并发用户抢100件库存使用Lua脚本扣减QPS能达到8万以上平均响应时间不到2毫秒而且库存最终结果完全准确没有一次超卖。对比看如果不用Lua脚本改用WATCH加MULTI重试同样环境下QPS只有2万到3万冲突率越高性能越差。因为绝大多数请求都会在事务提交时发现冲突然后重新执行Redis的CPU和网络开销翻倍暴涨。当然真实秒杀流量不会全都集中在单台Redis上还需要考虑水平扩展和分片。Redis Cluster分片之后同一个商品的库存key只会落在某一个slot上Lua脚本依然能保证单key操作的原子性。所以这个方案在集群模式下同样成立。需要提醒的是如果脚本涉及多个key这些key必须用同一个hashtag否则Redis Cluster会报CROSSSLOT错误。5.3 我在实战中踩过的坑第一Lua脚本的返回值映射问题。早期我用DefaultRedisScriptInteger去接收返回值结果脚本里返回的是数字Spring却包装成了Long导致类型转换直接报错。后来统一改成Long再也没有出过问题。第二库存预热和秒杀启动的竞态。有一次活动运营同学在秒杀开始前几秒才配置活动预热任务还没执行完用户就能访问接口结果大量请求返回“库存key不存在”。后来我把预热结果校验和接口开关绑定预热不完成秒杀入口就不会打开。第三Lua脚本的复杂度要克制。Redis单线程执行脚本脚本里一旦有死循环或者超大key的操作整个Redis就卡住了。我的原则是脚本只做“原子性需要的才做”像发送消息、写日志这种动作绝不能进脚本。曾经有人在脚本里写了redis.call(keys, *)直接把线上Redis拖到接近不可用这种操作要绝对禁止。第四库存回补一定要限制上限。退款触发回补时如果不加校验消息重复消费会让库存越加越多。我在回补Lua脚本里加了“回补后不能超过初始库存”的判断超出就直接丢弃既保证幂等又防止数据膨胀。秒杀系统的技术方案看起来很多但库存扣减永远是那条最核心的生命线。建议第一次做秒杀的同学不要在架构上过度设计先把Redis加Lua这一套跑通、压测通过再逐步考虑限流、MQ、分片这些外围能力。实践下来这套方案的正确性和性能都已经足够支撑绝大多数秒杀业务剩下的更多是工程细节和运维保障。最后再分享一个小经验所有库存扣减相关的代码务必在压测环境用真实并发量跑一遍并且压测时要监控Redis的慢查询日志一旦出现超过10毫秒的脚本执行记录一定要追查到底。库存无小事细节决定成败。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询