1. 秒杀超卖问题的本质与场景拆解
秒杀超卖这个问题,我从第一次接触电商项目到现在,前前后后踩了不下十次坑。每次觉得自己已经想清楚了,换个业务场景又会冒出新问题。所以这篇内容我不打算只丢一段扣减库存的代码出来,而是把整个思考链路和实战方案完整摊开讲。
先明确一下这个内容适合谁看:如果你正在做或者准备做电商、抢购、票务、预约类系统,需要处理高并发下的库存扣减,那这篇内容基本可以覆盖你80%以上的场景。如果你只是听说过Redis但没怎么用过,也没关系,我会把每个关键决策背后的原因讲清楚,你照着思路走也能落地。
秒杀超卖的核心矛盾其实就一句话:多个请求同时读到相同的库存值,然后各自判断“还有库存”,于是都执行了扣减。比如库存只剩1件,A和B两个请求同时查到库存为1,都认为可以买,结果两个人都扣减成功,库存变成-1。这就是超卖。
这个问题的根源不在于Redis快不快,而在于**“读-判断-写”这三步不是一个原子操作**。只要这三步之间存在时间窗口,并发请求就会钻进去。所以所有解决方案的核心思路,都是想办法把这个窗口堵死,或者让请求串行化。
常见的业务场景有这么几类:整点秒杀(比如某手机品牌首发)、限量优惠券领取、演唱会选座购票、医院专家号预约。不同场景对一致性的要求不一样,比如票务系统绝对不能超卖,但优惠券偶尔多发一张可能还能接受。这个差异会直接影响你选哪种方案。
我见过太多项目一开始用数据库行锁扛,QPS一上来就崩;也见过直接上Redis但没做原子操作,照样超卖。所以下面我会从方案选型开始,一层一层往下拆。
2. 方案选型:为什么Redis是首选,以及几种主流思路的取舍
2.1 数据库方案为什么扛不住秒杀
最朴素的方案是用数据库的SELECT ... FOR UPDATE加行锁,或者用UPDATE stock = stock - 1 WHERE stock > 0这种带条件的更新。这两种在低并发下没问题,但秒杀场景下有几个致命伤。
第一,数据库连接是稀缺资源。假设你的连接池配了200个连接,秒杀瞬间进来5000个请求,大部分请求连数据库的门都摸不到,直接在连接池排队超时。第二,行锁会把并发请求串行化,每次扣减都要等前一个事务提交,响应时间线性增长。第三,即使你用条件更新避免了超卖,数据库的磁盘IO和事务开销也撑不住每秒上万次的扣减。
我实测过一个中等配置的数据库实例,用条件更新扣减库存,单表大概能扛住每秒800到1200次扣减,再往上延迟就明显飙升。而秒杀场景动辄每秒几万甚至几十万请求,数据库方案从根上就不匹配。
2.2 Redis方案的核心优势
Redis是内存操作,单机轻松扛住每秒十万级操作。更重要的是,Redis提供了原子命令和Lua脚本,可以把“读-判断-写”打包成一个不可分割的操作。这是解决超卖的关键武器。
但Redis方案也不是没有代价。最大的问题是数据一致性:Redis里的库存扣了,数据库里的库存也得扣,这两个操作怎么保证一致?如果Redis扣成功但数据库扣失败,就会出现Redis显示没库存但数据库还有库存的情况。这个问题后面会专门讲。
2.3 几种主流思路对比
我把常见的几种方案列出来,方便你根据自己场景选:
| 方案 | 核心原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库条件更新 | UPDATE ... WHERE stock > 0 | 实现简单,强一致 | 并发低,连接消耗大 | 低并发内部系统 |
| Redis原子递减 | DECR命令 | 性能极高,天然原子 | 无法处理复杂判断 | 简单计数场景 |
| Redis+Lua脚本 | 脚本内完成判断和扣减 | 原子性+复杂逻辑 | 脚本需谨慎编写 | 大多数秒杀场景 |
| Redis分布式锁 | 加锁后串行扣减 | 逻辑直观 | 锁竞争开销大,有死锁风险 | 对一致性要求极高的场景 |
| 消息队列削峰 | 请求入队后串行消费 | 彻底削峰 | 响应有延迟,用户体验差 | 允许异步处理的场景 |
我个人最推荐的是Redis+Lua脚本方案,它在性能、原子性和实现复杂度之间取得了最好的平衡。分布式锁方案我不太建议用在秒杀主链路上,因为锁的获取和释放本身就是开销,而且一旦锁服务出问题,整个秒杀就挂了。
3. Redis+Lua脚本方案的核心细节与实操要点
3.1 为什么必须是Lua脚本而不是多条命令
有人会想,我用GET读库存,判断大于0,再用DECR扣减,不就行了吗?问题就出在这三步之间。Redis虽然是单线程处理命令,但它处理的是单条命令。你的GET和DECR是两条独立命令,中间可能插入了其他客户端的命令。
举个例子:库存为1。客户端A执行GET得到1,还没来得及DECR,客户端B也执行GET得到1,然后A和B都执行DECR,库存变成-1。这就是典型的超卖。
Lua脚本的价值在于,Redis会把整个脚本当作一个原子操作执行,脚本执行期间不会插入其他命令。这就把“读-判断-写”三步锁死在了一起。
3.2 Lua脚本的编写要点
先看一个最基础的扣减脚本:
-- KEYS[1]: 库存key -- ARGV[1]: 扣减数量 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 -- 库存key不存在 end stock = tonumber(stock) local num = tonumber(ARGV[1]) if stock < num then return -2 -- 库存不足 end redis.call('DECRBY', KEYS[1], num) return stock - num -- 返回扣减后的库存这段脚本看着简单,但有几个细节必须注意。
第一,返回值的设计。我用-1表示key不存在,-2表示库存不足,正数表示扣减后的库存。这样调用方可以根据返回值区分不同情况,而不是笼统地认为“失败”。实际项目中你还可以返回更细的错误码。
第二,类型转换。Redis返回的库存是字符串,必须用tonumber转成数字再比较,否则字符串比较会出问题。比如字符串"10"和"9"比较,"10"是小于"9"的,因为按字典序比较。这个坑我踩过,当时排查了半天才发现是类型问题。
第三,库存key的过期时间。秒杀结束后库存key应该自动清理,否则会一直占内存。但过期时间不能设太短,否则秒杀还没结束key就没了。我的经验是设为活动结束后24小时,留足对账和排查的时间。
3.3 库存预热与key设计
秒杀开始前,必须把库存从数据库同步到Redis。这个动作叫库存预热。预热的时机很关键:太早的话,如果活动前有人退货或者运营调整了库存,Redis里的数据就过期了;太晚的话,秒杀开始时key还没准备好,请求全部落空。
我的做法是在活动开始前5分钟执行预热,并且预热脚本要加一个版本号。比如key设计成seckill:stock:{activityId}:v{version}。如果运营临时调整库存,就递增版本号,重新预热,旧版本的key自然废弃。这样避免了直接修改库存导致的数据混乱。
key的命名我习惯用冒号分隔,前缀统一,方便用SCAN命令批量管理。比如seckill:stock:1001:v1,一看就知道是秒杀活动1001的库存。
3.4 扣减数量与限购逻辑
有些秒杀允许一次买多件,这时候扣减数量就不是1了。Lua脚本里要判断stock >= num而不是stock > 0。同时还要考虑限购:同一个用户最多买几件。
限购逻辑可以放在Lua脚本里一起做。用一个Set记录已购买的用户,脚本里先检查用户是否在Set中,如果在就拒绝,不在就加入Set并扣减库存。这样限购和扣减也是原子的。
-- KEYS[1]: 库存key -- KEYS[2]: 已购用户Set key -- ARGV[1]: 用户ID -- ARGV[2]: 扣减数量 -- ARGV[3]: 限购数量 local bought = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if bought == 1 then return -3 -- 已购买过 end local stock = tonumber(redis.call('GET', KEYS[1]) or '-1') if stock < 0 then return -1 end local num = tonumber(ARGV[2]) if stock < num then return -2 end redis.call('DECRBY', KEYS[1], num) redis.call('SADD', KEYS[2], ARGV[1]) return stock - num注意这里SISMEMBER和SADD也在同一个脚本里,保证了限购判断和库存扣减的原子性。如果分开做,用户可能用两个请求同时通过限购检查,然后都扣减成功。
4. 完整实操流程:从请求到落库的全链路实现
4.1 整体架构设计
一个完整的秒杀链路大概是这样:客户端请求先经过网关限流,然后到应用层,应用层执行Lua脚本扣减Redis库存,扣减成功后发送消息到消息队列,由消费者异步写入数据库。同时返回用户“抢购成功,等待支付”。
这个架构里,Redis承担了流量过滤的角色。假设库存1000件,但来了10万请求,Lua脚本会让前1000个请求扣减成功,后面的全部返回库存不足。这样只有1000个请求会进入后续的数据库写入流程,数据库压力骤降。
网关限流是另一道防线。我一般用令牌桶算法,限制每个用户每秒最多请求几次,防止有人用脚本刷接口。限流阈值根据活动规模调整,比如预计10万人参与,可以设成每用户每秒5次。
4.2 应用层调用Lua脚本的代码实现
以Java为例,用Spring Data Redis调用Lua脚本:
@Autowired private StringRedisTemplate redisTemplate; private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } public Long doSeckill(Long activityId, Long userId, int num) { String stockKey = "seckill:stock:" + activityId + ":v1"; String userSetKey = "seckill:users:" + activityId; Long result = redisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(stockKey, userSetKey), userId.toString(), String.valueOf(num), "1" ); return result; }这里有几个实操细节。第一,DefaultRedisScript建议在静态块里初始化,避免每次调用都重新加载脚本文件。第二,脚本文件放在resources目录下,打包时会自动包含。第三,execute方法的参数顺序是keys在前、args在后,别搞反了。
返回值处理:
Long result = doSeckill(activityId, userId, 1); if (result == null) { // 脚本执行异常 throw new BizException("系统繁忙,请重试"); } else if (result == -1) { throw new BizException("活动不存在或已结束"); } else if (result == -2) { throw new BizException("库存不足"); } else if (result == -3) { throw new BizException("您已购买过"); } else { // 扣减成功,发送消息到队列 mqProducer.send(new SeckillMessage(activityId, userId, result)); return result; }4.3 异步落库与最终一致性
Redis扣减成功后,必须把扣减记录同步到数据库。我选择用消息队列异步处理,原因是同步写数据库会拖慢响应,而且数据库在秒杀高峰期本身就是瓶颈。
消息队列用RocketMQ或者Kafka都行,核心是保证消息不丢。生产者发送消息时要设置同步发送,确保消息写入broker成功后再返回。消费者端要做幂等处理,因为消息可能重复投递。
幂等的实现方式是在数据库建一张秒杀记录表,用activityId + userId做唯一索引。消费者收到消息后先插入记录,如果唯一索引冲突就说明已经处理过,直接跳过。这样即使消息重复,也不会重复扣减数据库库存。
数据库扣减用条件更新:
UPDATE seckill_stock SET stock = stock - #{num}, update_time = NOW() WHERE activity_id = #{activityId} AND stock >= #{num}如果影响行数为0,说明数据库库存不足,这时候要回滚Redis库存。回滚就是把之前扣减的数量加回去,同时把用户从已购Set里移除。这个回滚操作也要用Lua脚本保证原子性。
4.4 库存回滚与异常补偿
回滚是秒杀系统里最容易被忽略但最重要的环节。除了数据库库存不足要回滚,还有几种情况需要回滚:用户超时未支付、订单被取消、系统异常导致扣减后流程中断。
我的做法是给每个扣减成功的记录设置一个状态机:扣减成功 -> 待支付 -> 已支付/已取消。如果超过支付时限还是待支付状态,就触发回滚。回滚操作通过定时任务扫描超时订单来执行。
回滚的Lua脚本:
-- KEYS[1]: 库存key -- KEYS[2]: 已购用户Set key -- ARGV[1]: 用户ID -- ARGV[2]: 回滚数量 redis.call('INCRBY', KEYS[1], ARGV[2]) redis.call('SREM', KEYS[2], ARGV[1]) return 1注意回滚时库存key可能已经过期了。如果key不存在,INCRBY会从0开始加,这会导致库存虚高。所以回滚前要先检查key是否存在,不存在的话需要从数据库重新加载库存再回滚。这个边界情况我在生产环境遇到过,当时是因为活动延期,key过期了但还有未支付订单。
5. 常见问题与排查技巧实录
5.1 超卖问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 库存扣成负数 | Lua脚本判断逻辑错误 | 检查脚本中stock < num判断 | 修正比较逻辑,加日志 |
| 并发下超卖 | 没用Lua脚本,用了多条命令 | 检查代码是否直接调DECR | 改用Lua脚本 |
| 限购失效 | 限购判断和扣减不在同一脚本 | 检查脚本是否包含SISMEMBER | 合并到同一Lua脚本 |
| 回滚后库存虚高 | 库存key已过期 | 检查key的TTL | 回滚前判断key是否存在 |
| 消息重复消费导致多扣 | 消费者没做幂等 | 检查是否有唯一索引 | 加唯一索引或分布式锁 |
5.2 Lua脚本执行超时问题
Redis的Lua脚本有执行时间限制,默认是5秒。如果你的脚本逻辑太复杂,比如循环处理大量数据,可能会超时。超时后Redis会返回错误,但脚本可能已经部分执行了,这就很危险。
我的经验是Lua脚本里绝对不要写循环,尤其是循环次数不确定的。所有逻辑都应该是O(1)的。如果确实需要批量处理,拆成多次调用,每次处理一小批。
另外,脚本里不要调用KEYS命令,这个命令会扫描所有key,在数据量大时极慢。用SCAN代替,但SCAN在Lua里也不推荐,最好在应用层做。
5.3 Redis集群下的key分布问题
如果你的Redis是集群模式,Lua脚本涉及的所有key必须在同一个slot上,否则会报CROSSSLOT错误。解决办法是用hash tag,把相关的key用{}括起来,让它们落到同一个slot。
比如库存key是seckill:stock:{1001}:v1,用户Set key是seckill:users:{1001},大括号里的1001就是hash tag,Redis会根据它计算slot,保证两个key在同一个节点上。
这个坑我在第一次上集群时踩得很惨,本地单机测试没问题,一上集群就报错。后来把所有相关key都加了hash tag才解决。
5.4 库存预热数据不一致
预热时从数据库读库存,写入Redis。如果预热过程中数据库库存发生了变化,Redis里的数据就是旧的。解决办法是预热时记录数据库的版本号或更新时间,写入Redis时一起存进去。扣减前对比版本号,不一致就重新预热。
更简单的做法是预热期间锁定库存,不允许运营修改。等预热完成后解锁。这个方案实现简单,但需要运营配合,适合活动前有明确准备时间的场景。
5.5 实操心得与避坑建议
第一条心得:永远不要相信客户端的库存显示。客户端显示的库存可能是缓存的旧数据,真正的扣减结果以服务端Lua脚本返回为准。我见过前端显示有库存,用户点击后却提示库存不足,引发客诉。后来改成前端库存只做展示,点击后以服务端返回为准,并在页面上说明“库存以提交结果为准”。
第二条心得:压测一定要用真实并发。很多人压测时用JMeter开100个线程循环请求,这跟真实秒杀的瞬时并发完全不是一回事。真实场景是10万个请求在1秒内同时到达。压测时要用工具模拟瞬时并发,比如用wrk配合Lua脚本,或者用专业的压测平台。
第三条心得:监控要覆盖Redis和数据库。Redis要监控QPS、内存使用、慢查询、连接数。数据库要监控扣减SQL的执行时间和影响行数。我一般会加一个对账任务,每隔几分钟对比Redis库存和数据库库存,不一致就告警。
第四条心得:降级方案要提前准备好。如果Redis挂了怎么办?我的做法是准备一个开关,Redis不可用时自动降级到数据库条件更新。虽然性能差,但至少能保证不超卖。降级开关要能手动控制,方便在Redis维护时提前切换。
第五条心得:日志要打全但别打太多。每次扣减成功或失败都要记录关键信息:活动ID、用户ID、扣减数量、扣减前库存、扣减后库存、返回值。但不要在每个请求里打大量日志,否则日志IO会成为瓶颈。我一般用异步日志,并且只记录关键节点。
6. 进阶优化:从能用到好用的几个关键点
6.1 分段库存与本地缓存
当秒杀规模特别大时,单个Redis key会成为热点。所有请求都打到一个key上,即使Redis是单线程处理,网络IO和CPU也会成为瓶颈。解决办法是分段库存:把1000件库存拆成10个key,每个key 100件。请求进来先随机选一个key扣减,扣减失败再试其他key。
分段库存的Lua脚本需要遍历多个key,但遍历次数是固定的(比如10次),不会超时。这个方案能把热点分散到多个key上,提升吞吐量。
本地缓存则是把库存数据缓存在应用服务器内存里,减少Redis访问。但本地缓存的一致性很难保证,我一般只用来做库存预判:本地缓存显示没库存就直接拒绝,不再请求Redis。本地缓存显示有库存才去Redis扣减。这样能过滤掉大部分无效请求。
6.2 令牌桶限流与队列削峰
网关层用令牌桶限流,每个用户每秒最多拿几个令牌。令牌桶的实现可以用Redis的INCR加过期时间,也可以用专门的限流组件。
队列削峰是把请求先写入消息队列,然后由消费者慢慢处理。这个方案适合对实时性要求不高的场景,比如预约挂号。用户提交后返回“排队中”,过几秒再查询结果。这样能把瞬时并发削成平稳的消费流。
但队列削峰有个问题:用户不知道要等多久。我的做法是给用户一个预估时间,比如“当前排队人数1234,预计等待15秒”。这个预估时间根据队列长度和消费速度动态计算。
6.3 对账与补偿机制
对账是保证最终一致性的最后一道防线。我一般会写一个定时任务,每隔5分钟执行一次,对比Redis库存和数据库库存。如果不一致,记录差异并告警。
对账的逻辑是:数据库库存 = 初始库存 - 已支付订单数量 - 待支付订单数量。Redis库存 = 初始库存 - 已扣减数量。两者应该相等。如果不相等,可能是消息丢失、回滚失败或者重复扣减。
补偿机制是针对对账发现的差异进行修复。如果是Redis库存少了,就加回去;如果是数据库库存少了,就补扣。补偿操作要记录日志,方便追溯。
6.4 压测数据与性能参考
我在一台4核8G的服务器上做过压测,Redis单机,Lua脚本扣减库存。结果是:单key情况下,QPS大概在8万左右;分段成10个key后,QPS能到15万。延迟方面,P99在5毫秒以内。
数据库方面,用条件更新扣减,单表QPS大概1000左右。所以Redis和数据库之间的性能差距是数量级的,这也是为什么必须用Redis做前置扣减。
压测时要注意,Redis的QPS会受网络带宽影响。如果应用服务器和Redis不在同一台机器,网络延迟会增加。我一般建议应用和Redis部署在同一机房,最好在同一台物理机上用不同端口。
6.5 个人经验总结
做了这么多秒杀项目,我最大的体会是:没有银弹,只有取舍。Redis+Lua方案性能好,但引入了数据一致性问题;数据库方案一致性强,但性能差。选择哪种方案,取决于你的业务能容忍多大的不一致,以及你的流量规模有多大。
另一个体会是:测试比开发更重要。秒杀系统的bug往往在高压下才暴露,平时测试根本发现不了。所以一定要做全链路压测,从网关到Redis到数据库,每个环节都要压。压测时还要模拟各种异常:Redis超时、消息队列堆积、数据库连接池满。只有把这些异常都测过,上线才能放心。
最后分享一个小技巧:给Lua脚本加版本号。脚本逻辑变更时,递增版本号,新老脚本并存一段时间。这样即使新脚本有问题,也能快速回滚到旧脚本。版本号可以放在key里,也可以放在脚本的返回值里。我一般放在key里,比如seckill:stock:1001:v2,切换时改一下版本号就行。