秒杀超卖问题深度解析:从数据库锁到Redis+Lua的实战方案

秒杀超卖问题深度解析:从数据库锁到Redis+Lua的实战方案 1. 秒杀超卖问题的本质其实是一道并发控制题先聊点实在的。很多做后端的朋友第一次接触秒杀系统都是从线上事故开始的——优惠券刚上线后台监控面板瞬间飘红数据库里库存字段变成了负数运营那边炸了锅。我最早踩这个坑是在一次大促活动的预热环节库存2000张券10秒内抢完最后却发现卖出了2300多单。排查到最后问题就出在库存扣减的并发控制上。所谓超卖本质就是“多个请求同时读到了同一个库存余量然后各自认为还有货各自扣减成功”。在秒杀场景下同一时刻可能涌入几千上万个请求如果扣减库存的动作不是原子的任何一步“先查后扣”都会留下超卖的口子。优惠券秒杀和普通商品秒杀在技术上没有本质区别核心矛盾都是有限的库存、瞬时的流量、必须严格的最终一致性。优惠券秒杀还有一个特点——它往往不是真正的“实物履约”所以很多团队会优先用Redis这类缓存来扛流量再用异步的方式落库。这个思路没错但链路变长了出问题的环节也就变多了。这篇文章我会从超卖问题入手拆解几种主流方案的原理、坑点和选型思路。内容偏实战适合正在做电商中台、营销系统、交易系统的后端研发同学参考也适合准备面试的时候拿来梳理并发扣减的方案体系。2. 超卖问题的根源从一次扣减库存的完整旅程说起2.1 一个优惠券扣减请求到底经历了什么要理解超卖先得把一次正常的秒杀请求链路画在脑子里。用户在App上点击“立即抢券”请求到达后端服务服务要做的事情大致分四步校验用户资格是否登录、是否已抢过、是否在黑名单查询优惠券库存判断是否还有余量扣减库存写操作生成用户券记录、返回成功或失败。大部分超卖问题出在第2步和第3步之间。经典的错误写法是先SELECT查一下库存判断大于0再执行UPDATE扣减。这个流程在单线程下没有任何问题但在高并发环境下两个甚至更多请求可能同时执行完第2步都看到库存还剩1张然后一起进入第3步都把库存扣成了0甚至-1。这里有朋友会问数据库的UPDATE本身不是有行锁吗确实有但锁只保证“同一时刻只有一个事务能改这行数据”它保证不了“业务逻辑上查询和更新的原子性”。你先查后改查和改是两条独立的SQL中间隔着网络延迟、线程调度、事务提交时间这一段时间差就是超卖的窗口。2.2 悲观锁与乐观锁两种截然不同的控制思路数据库层面解决并发扣减的思路大致分悲观锁和乐观锁两条路。悲观锁的思路是“我先把这行锁住别人别想动等我改完你再说”。具体写法是在查询语句后面加FOR UPDATE比如SELECT stock FROM coupon_stock WHERE coupon_id ? FOR UPDATE;这行SQL执行后其他事务对同一行的查询和更新都会被阻塞直到当前事务提交或回滚。这个方案在逻辑上没问题但它把并发请求直接串行化了——秒杀场景下每秒可能进来上万请求如果全部落到数据库行锁上数据库的连接池很快会被打满响应时间急剧上升系统吞吐量大幅下降。乐观锁的思路是“我不锁但我要确保我改的时候数据还是我查到的那个版本”。通过在表里加一个版本号字段更新的时候把版本号作为条件带上UPDATE coupon_stock SET stock stock - 1, version version 1 WHERE coupon_id ? AND stock 0;这个写法的巧妙之处在于stock 0这个条件本身就在数据库层面保证了“库存不会扣成负数”。多个请求同时执行这条SQL时InnoDB的行锁会保证同一行只有一个更新成功其他更新会等待锁释放但等待结束后再次执行时stock 0的条件已经不满足了更新影响行数为0程序就可以判定为抢券失败。我在实际项目中更倾向于乐观锁方案原因很直接它不需要额外的锁等待和事务控制一条SQL就能把“判断库存”和“扣减库存”合并为原子操作简单、可靠、可扩展性强。但它也有局限——在极高并发下大量请求会做无效的UPDATE数据库压力依然不小所以它适合“秒杀流量不大、或者能接受一定比例请求打到数据库”的场景。3. 代码层面最常见的三种扣减实现3.1 先查后改新手最容易写出的超卖代码这是最直观的写法也是埋坑最多的写法。伪代码如下public boolean deductStock(Long couponId) { // 第1步查询库存 CouponStock stock stockMapper.selectByCouponId(couponId); if (stock.getStock() 0) { return false; } // 第2步扣减库存 int rows stockMapper.deduct(couponId); return rows 0; }在高并发测试下这个代码几乎百分之百会超卖。原因前面说了两个线程可能同时执行完第1步都看到库存为1然后线程A扣减成功线程B也跟着扣减成功库存变成-1。那有人会问如果我把第1步和第2步放在一个事务里呢事务只能保证原子性要么全成功要么全回滚但不能解决“幻读”问题。在MySQL默认的REPEATABLE READ隔离级别下多个事务并发执行时快照读的时机不同依然可能读到相同的库存值。除非给查询加FOR UPDATE走悲观锁或者用SELECT ... LOCK IN SHARE MODE否则依然存在并发窗口。3.2 乐观锁扣减一条SQL解决大部分问题用UPDATE语句自带的行锁来保证原子性是最推荐的基础方案。核心代码就一条public boolean deductStock(Long couponId) { int rows stockMapper.deductStockByVersion(couponId); return rows 0; }对应的XML或注解SQLUPDATE coupon_stock SET stock stock - 1, version version 1 WHERE coupon_id #{couponId} AND stock 0;这里的stock 0就是防超卖的“安全阀”。数据库的行锁保证同一时刻只有一个事务在执行UPDATE事务提交后释放锁下一个事务再执行时stock 0条件不满足影响行数为0程序返回失败。实际压测下来这个方案在并发2000以内的场景表现稳定单条UPDATE的耗时在毫秒级。但要特别注意数据库连接池的大小决定了它能承接的并发上限。如果连接池只有20个连接那同一时刻最多只有20个请求在真正执行UPDATE其他请求都在排队等待获取连接表现为接口RT飙升。3.3 悲观锁扣减安全但吞吐量低悲观锁的代码比乐观锁多一个显式加锁的步骤Transactional public boolean deductStock(Long couponId) { // 第1步加锁查询 CouponStock stock stockMapper.selectByCouponIdForUpdate(couponId); if (stock.getStock() 0) { return false; } // 第2步扣减库存 int rows stockMapper.deduct(couponId); return rows 0; }SQL写法SELECT * FROM coupon_stock WHERE coupon_id #{couponId} FOR UPDATE;FOR UPDATE会对查询到的行加排他锁其他事务的读操作不受影响但写操作和加锁读操作都会被阻塞。注意这里有个细节FOR UPDATE必须走索引否则会锁全表。在coupon_id上建立唯一索引或者主键查询才能保证锁的是目标行。悲观锁的问题在于串行化。我做过一次对比测试同样的库存表乐观锁方案在并发2000下TPS大概能到800悲观锁方案只能到200左右而且数据库连接池压力明显更大。所以悲观锁一般用在后台管理系统的库存调整、订单发货等低频操作上不太适合秒杀这种高并发场景。4. 高并发场景下的主战场Redis Lua 原子扣减4.1 为什么把库存扣减前移到Redis数据库方案再优化在真正的大流量秒杀面前还是不够看。优惠券秒杀的特点是请求量在活动开始的第一秒达到峰值之后断崖式下降。如果所有请求都直接打数据库数据库就是整个系统的瓶颈无论怎么加连接池、加索引、做分库分表成本都很高。更常见的做法是把库存放在Redis里扣减操作也在Redis里完成利用Redis单线程执行命令的特性来保证原子性。Redis的DECR命令就是原子操作但单纯的DECR有个问题——它不保证库存不为负。你可能先判断再DECR但这两个操作之间依然有并发窗口。解决办法是用Lua脚本。Redis从2.6版本开始支持服务端执行Lua脚本整个脚本是原子执行的中间不会被其他命令插入。我们可以把“判断库存 0”和“扣减库存”写进同一个Lua脚本在服务端一次性完成。4.2 Lua脚本的完整写法与讲解优惠券秒杀的库存扣减Lua脚本我常用的版本是这个-- KEYS[1]优惠券库存的Redis key -- ARGV[1]本次扣减数量通常是1 local stock tonumber(redis.call(GET, KEYS[1])) if not stock then return -1 end if stock tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1这段脚本的逻辑非常清晰先GET当前库存用tonumber把字符串转成数字如果key不存在返回-1代表活动配置异常如果库存小于扣减数量返回0代表已经没有余量否则执行DECRBY扣减库存返回1代表扣减成功。Spring Boot项目中可以通过DefaultRedisScript把Lua脚本注入进来Resource private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScriptLong STOCK_DECREMENT_SCRIPT new DefaultRedisScript(); static { STOCK_DECREMENT_SCRIPT.setLocation(new ClassPathResource(scripts/stock_decrement.lua)); STOCK_DECREMENT_SCRIPT.setResultType(Long.class); } public boolean tryAcquire(String couponStockKey, int quantity) { Long result stringRedisTemplate.execute( STOCK_DECREMENT_SCRIPT, Collections.singletonList(couponStockKey), String.valueOf(quantity) ); return Long.valueOf(1L).equals(result); }这段代码的执行过程是Java客户端把整个Lua脚本和参数发送给Redis服务端Redis服务端加载并执行脚本返回结果。由于脚本是原子的多个客户端并发执行时Redis会一个接一个地执行就像单线程排队一样从根上杜绝了超卖。4.3 Lua方案落地时要注意的三个细节第一Redis的key设计要有业务隔离和过期策略。建议用coupon:stock:{couponId}这种带前缀的格式方便排查和清理。同时一定要设置过期时间比如活动结束后48小时防止无效key长期占内存。但要注意Lua脚本里如果直接执行EXPIRE逻辑会变复杂我的做法是在活动初始化时统一设置过期时间不在扣减脚本里处理。第二Redis库存的初始化和预热必须和数据库保持一致。我见过很多次事故Redis里库存设置错了或者初始化脚本重复执行导致库存翻倍。建议初始化时用SETNX只有key不存在时才写入初始库存避免重复初始化把已经扣减的库存覆盖掉。Boolean init stringRedisTemplate.opsForValue().setIfAbsent( stockKey, String.valueOf(initialStock), Duration.ofHours(48) );第三Lua脚本的执行结果一定要处理全。返回-1要记录错误日志并报警说明Redis里的key丢失了返回0是正常库存售罄直接返回用户“已被抢完”返回1才继续走下一步。有些同学只判断“是否为1”把-1和0都当成失败这在功能上没错但会掩盖key丢失的问题给排查埋雷。5. 扣减成功之后数据库落库与最终一致性保证5.1 异步落库Redis扣减成功不等于订单生成Redis扣减成功只代表“本次抢券资格”已经拿到真正要生成用户的券记录还需要把数据同步到数据库。很多团队的选择是在扣减成功后发送MQ消息由消费端异步完成订单和券记录的落库。这个流程的时序大致是这样请求到达Lua脚本扣减Redis库存扣减成功生成一个秒杀订单号或领取记录号发送MQ消息MQ消费者收到消息检查用户是否已经存在券记录防止重复然后插入券记录表同时更新数据库中的库存量如果消费失败MQ重试重试多次仍失败进入死信队列人工介入。这里会有个疑问数据库的库存和Redis的库存怎么保证最终一致我的建议是以Redis作为活动期间的“前置库存”以数据库的库存变更记录作为最终的“事实来源”。每次成功抢购都会生成一条数据库的扣减流水或者用户券记录流水表里记录了优惠券ID、用户ID、扣减数量、时间等字段。以流水表的汇总值作为“已售数量”加上数据库的剩余库存应该等于初始化库存。5.2 重复消费与幂等处理MQ场景下必踩的坑MQ消息存在“至少一次”的投递语义也就是说消费者可能收到同一条消息两次。如果消费者不做幂等处理同一个用户可能被插入两条券记录。幂等处理的常规做法是在券记录表上做唯一约束。比如在user_coupon表上给user_id和coupon_id建立联合唯一索引ALTER TABLE user_coupon ADD UNIQUE KEY uk_user_coupon (user_id, coupon_id);消费端插入前先查一次插入时如果违反唯一约束捕获DuplicateKeyException并视为“已经处理过”直接返回成功。这样即使消息重复投递也不会产生重复数据。还有一个容易被忽略的坑Redis扣减成功但MQ消息发送失败怎么办这会导致用户明明抢到了券但最终没有生成券记录。解决思路是“先本地建单再异步确认”。具体操作是Lua扣减完之后先在本地业务库插入一条状态为“待确认”的领取记录然后发送MQ消费端处理完再回调更新状态。如果MQ发送失败本地还有一条待确认记录可以用定时任务补偿扫描重新发送MQ或者直接完成发券。5.3 回滚机制库存扣了但订单没生成怎么办活动结束后会出现一部分“库存已扣减但券记录未生成”的脏数据。比如用户抢券成功但在异步落库时因为系统异常没成功消息重试也失败了。这时候库存已经扣了用户却没拿到券需要有一套对账回滚机制。我的做法是离线任务定时扫描“待确认”状态的领取记录超时比如10分钟仍未确认成功的回滚Redis库存local stock tonumber(redis.call(GET, KEYS[1])) if stock then redis.call(INCR, KEYS[1]) end return 1注意回滚只能发生在“确认失败”的前提下而且要加状态判断避免把已经成功发券的库存回滚了。更稳妥的做法是不在Redis层回滚而是把回滚逻辑放到数据库事务里——更新领取记录状态为“已回滚”同时确保这条记录对应的库存归还到数据库的剩余库存中。数据库回滚成功后再异步补偿Redis库存。这样以数据库为准避免Redis误回滚导致库存多了。6. 三种方案的选型对比与真实场景落地参考6.1 数据库乐观锁、悲观锁、RedisLua的对比把这三种方案放在一起看选型的核心变量是并发量和对一致性的容忍度。方案并发能力数据一致性实现复杂度适用场景数据库乐观锁中百级~千级强一致低小规模秒杀、普通库存扣减数据库悲观锁低百级以下强一致低后台调拨、低频修改库存Redis Lua 异步落库高万级以上最终一致高大促秒杀、优惠券抢购乐观锁适合库存量不大、并发量可控的场景比如日常的限时折扣、会员日发券。悲观锁适合后台管理操作人为控制并发。RedisLua方案是目前互联网大促秒杀的主流架构它能扛住瞬时峰值但引入了MQ、幂等、对账回滚等复杂问题需要团队有足够的技术储备。6.2 真实场景中的混合架构参考以优惠券秒杀为例比较成熟的做法是分层处理接入层用CDN和静态页面扛流量业务层用RedisLua做库存扣减async用MQ异步削峰数据库只承担最终落库。整个流程还需要配合限流、降级、风控等手段才能保证活动期间的稳定性。用户点击抢券后首先经过网关层的令牌桶限流超限的直接返回“活动太火爆”。通过限流的请求进入秒杀服务先校验活动状态和用户资格然后走RedisLua扣库存。扣减成功发送MQ消费端异步落库生成用户券记录。这个架构里数据库的库存表长期保持“剩余库存”字段但它不是实时扣减的——它是消费端根据流水确认后异步减掉的。活动期间数据库的库存数字可能滞后于Redis但最终会通过对账拉平。6.3 秒杀方案之外你还需要考虑这些配套能力光有库存扣减还不够秒杀系统要稳定运行还需要几个配套能力第一限流。不管是单机限流还是分布式限流一定要在入口处挡住大部分流量。我见过很多系统在扣减层面做得很好结果在用户查询接口上被打挂了。优惠券活动页面的查询请求往往远大于抢购请求务必在查询接口也加上缓存和限流。第二风控。秒杀场景是羊毛党的主战场。脚本刷单、多账号并发、代理IP批量请求这些都需要风控系统识别和拦截。风控方案包括设备指纹、用户行为特征、IP维度的频控等。在扣库存之前做风控校验能有效减少无效请求对系统的消耗。第三监控与告警。Redis扣减成功率、MQ消费积压量、数据库连接池使用率、接口RT等指标必须做到实时可视化。我习惯给秒杀活动单独建一个监控大盘活动开始前做全链路压测把瓶颈提前暴露出来。7. 常见问题与排查技巧实录7.1 数据库库存变成负数了怎么快速定位问题排查思路按照执行链路逐层看。先确认请求是否都走了扣减方法有没有绕过库存校验直接落库的接口再检查扣减SQL是否带了stock 0条件然后看MySQL的隔离级别和事务管理配置确认是否存在会话级别的重复读问题。从实际经验来看数据库库存变负数最常见的原因不是并发控制失效而是有人绕过统一扣减接口直接操作数据库改库存。排查时优先查数据库连接来源和慢查询日志往往能发现问题。7.2 Redis和数据库库存不一致怎么处理活动进行中Redis库存和数据库库存短期不一致是正常的因为异步落库有延迟。但如果差异持续扩大就要检查MQ消费是否积压或者消费端是否有异常导致消息频繁重试。处理不一致的思路是“先止血再对账”。活动结束后以数据库流水表为准统计实际发出的券数量和Redis中已经扣减的数量做对比。差异部分通过脚本调整数据库剩余库存或者在下次活动初始化时直接覆盖。不建议在活动过程中频繁对账纠正这会给系统增加不必要的压力。7.3 秒杀接口偶尔超时但不是数据库的问题这类问题往往出在Redis连接池或线程池上。秒杀峰值时如果Redis连接池太小大量请求会在获取连接时阻塞如果业务线程池的拒绝策略不合适请求会被直接丢弃。排查方法先看Redis连接池监控指标活跃连接数、等待队列长度再看业务线程池的活跃线程和队列深度。通常在活动配置里预留足够的连接数并且把线程池的拒绝策略设置为CallerRunsPolicy或自定义降级逻辑比默认的AbortPolicy更稳妥。7.4 用户明明没抢到券却收到了发券通知这种情况通常是异步流程中的状态错乱。用户第一次请求扣减Redis库存成功但是MQ发送失败或者消费超时用户端显示“抢券失败”。此时用户的领取记录可能是“待确认”状态补偿任务在确认时发现用户没有券就又补发了一张——业务上看起来就像“没抢到却发券了”。解决方法是发券通知必须基于数据库中的最终状态不能基于Redis扣减结果。补偿任务补发前要重新校验用户是否真的需要发券避免重复。8. 关于秒杀超卖我个人沉淀下来的几点体会踩过这么多次坑之后我最大的体会是超卖问题没有一招鲜的解决方案它是一整套技术体系共同作用的结果。数据库方案解决的是数据一致性底线Redis方案解决的是高并发吞吐MQ方案解决的是削峰填谷风控和限流解决的是流量质量。把这些能力组合起来才能稳定地支撑一场秒杀活动。另外一个容易被低估的点是压测。我见过太多系统上线前没有做过全链路压测结果活动一开始就暴露问题。压测不是简单用工具打几个请求而是要模拟真实的并发模型、数据分布和依赖延迟。只有在压测中把超卖问题逼出来才能在真实活动中做到心中有数。最后分享一个小技巧在秒杀系统的接口里把“库存扣减结果”和“最终发券结果”解耦来看。扣减成功只是“预占”发券成功才是“确认”。把这两个概念在代码和文档里明确区分团队协作时就不容易产生歧义排查问题也会清晰很多。这个思路我用了很久在多个项目里都验证了它的价值。