1. 秒杀这个场景的难点与瓶颈分析
1.1 一条下单链路上的真实压力分布
优惠券秒杀听起来是个很轻的功能,无非就是"点一下领券"。但把它放到流量洪峰下,它就是整套系统里最容易被打穿的一环。我做过几个类似的促销项目,最开始也天真地以为加个索引、开个事务就完事了,结果第一次压测就把数据库连接池打满,接口平均响应从 30ms 直接飙到 8 秒以上,前端大量请求超时重试,压力反而被放大了一轮。
问题出在链路上。一次秒杀请求,正常要经过网关鉴权、活动校验、库存判断、库存扣减、生成订单、写明细、发通知这一串动作。其中真正消耗数据库资源的,是库存扣减和订单落库这两步,而这两步恰好都在写操作上。数据库的行锁、事务日志、索引维护,每一项都在洪峰下被放大。一张几十万行的小表,扛个一两千的并发写就已经很吃力,何况秒杀经常是几万甚至几十万的瞬时请求。
所以秒杀的优化本质不是"把接口写快",而是"把不该到达数据库的请求提前挡掉"。我在实际项目里总结下来,一个完整的秒杀链路至少要做四层拦截:前端按钮置灰和倒计时、网关层限流、应用层本地缓存与校验、Redis 层原子扣减。这四层每挡掉一批请求,后面一层就轻松一分。
1.2 为什么库存放进 Redis 只是起点
很多人对秒杀的理解是"库存放 Redis,扣减用它就够了"。这个认知只对了一半。Redis 单实例的 QPS 确实能到十万级别,但秒杀请求打进来,除了扣库存,还要判断用户是否重复领取、要校验活动时间、要生成订单号、要通知下游。这些动作如果都塞进一次 Redis 交互里,网络往返次数会成倍增长,实际吞吐远低于理论值。
更麻烦的是数据一致性问题。Redis 扣减成功,不代表订单一定落地成功。消费者服务挂了、消息丢了、数据库主从切换,任何一个环节出问题,都会导致"库存扣了,订单没有"或者"订单有了,库存没扣"。这就是我在标题里提到的实战部分最花时间的点——扣减只是开始,一致性收尾才是难点。
还有一个常被忽略的细节:库存预热。活动开始前要把库存从数据库同步到 Redis,如果预热的时机不对,比如活动前十分钟才写,碰上一波提前刷新的用户,就可能出现 Redis 有库存但数据库没同步完的错位。我一般会在活动开始前半小时做预热,预热完成后做一次双端比对,把差异记日志告警,确认无误才放流量进来。
1.3 适合谁来读这篇内容
如果你已经能写 CRUD,但对"高并发""原子性""最终一致性"这些词只有模糊印象,这篇会帮你把这些概念落到具体代码和具体参数上。如果你已经做过秒杀,但一直停留"能跑就行"的阶段,文中关于 Lua 脚本缓存失效、库存回滚补偿、热点 Key 打散的几个坑,应该能对上你曾经遇到过的现象。下面所有代码和配置都基于我实际调试过的环境整理,参数值你可以按自己的机器规格调整,但思路和顺序基本可以直接搬。
2. 整体方案选型与架构分层设计
2.1 为什么选择 Redis + Lua + 异步落库的组合
秒杀库存扣减的方案,市面上常见的有三种,我把它们的对比整理成表格,方便你按项目体量选。
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 纯数据库 | update 带条件扣减 | 实现简单、天然强一致 | 并发低,行锁竞争严重 | 小流量、内部活动 |
| Redis + 同步落库 | 扣减后立即写订单 | 实现直观 | Redis 与数据库两次写,成功率受数据库影响 | 中等流量、要求强实时 |
| Redis + 异步落库 | 扣减后发消息,消费者落库 | 削峰明显、响应快 | 需要补偿机制,实现复杂 | 大促、高并发秒杀 |
我选第三种,理由很直接:秒杀的用户体验核心是"点下去要有反馈",响应时间必须压在百毫秒级。同步落库等于把数据库的响应时间直接暴露给用户,一旦数据库抖动,整个接口就跟着抖。异步化之后,Redis 扣减成功就立刻返回"排队中",用户看到结果,数据库按自己的节奏消费,两边解耦。
Lua 脚本的作用是把"查库存、判断是否重复、扣减、记录用户"这四个动作打包成一次原子执行。Redis 单线程执行 Lua,中间不会插入其他命令,这就从根本上杜绝了"查到有库存、扣的时候没了"的竞态。
2.2 分层拦截的具体职责划分
我习惯把整条链路拆成五层,每层干什么写清楚,后面排查问题的时候直接对号入座:
- 接入层:按钮防抖、倒计时结束后才允许点击,减少无效请求;网关按用户 IP 和接口维度做令牌桶限流。
- 应用层:活动状态本地缓存(用 Caffeine 之类,设置 1 到 3 秒过期),避免每次请求都查活动表;参数合法性快速校验,非法请求直接返回。
- Redis 层:原子扣减、一人一单判定、幂等令牌校验,这三件事尽量合并到一个 Lua 脚本里。
- 消息层:扣减成功后投递下单消息,消息体带用户 ID、券 ID、扣减流水号。
- 数据层:消费消息落库,靠唯一索引兜底一人一单,靠条件更新兜底库存不为负。
这套分层的关键在于:越靠前的层,成本越低,拦截效率越高。本地缓存的一次判断是纳秒级,Redis 是毫秒级,数据库是十毫秒级往上走,让便宜的操作先做,贵的操作后做,是最朴素的优化原则。
2.3 库存预热与热点 Key 的取舍
预热这件事,我的做法是写一个独立的初始化任务,把活动库存、活动起止时间、限购数量统一写进 Redis,key 的设计要带上活动维度,避免不同活动互相覆盖。结构上库存用一个 String 存剩余量,用DECR更快;领取用户用 Set,方便SISMEMBER判断。
# 库存预热示例,活动维度 + 券维度 SET seckill:stock:1001 500 SET seckill:limit:1001 1 HSET seckill:meta:1001 startAt 1735660800 endAt 1735747200 EXPIRE seckill:stock:1001 172800EXPIRE这一句千万别漏。我见过不少项目预热时忘了设过期,活动结束后这些 key 一直挂在内存里,日积月累把实例内存撑爆,最后触发淘汰策略,把别的业务热点数据挤出去,故障排查了半天才发现是秒杀留下的垃圾数据。
至于热点 Key,单活动单库存 key 在十万级 QPS 下其实还能扛,因为 Redis 单实例处理简单命令的能力足够。真正需要分桶的场景是百万级并发,把 500 个库存拆成 10 个桶,每个桶 50,请求按用户 ID 取模落到某个桶。分桶换来的是吞吐,代价是"某个桶空了但整体还有库存"的碎片问题,需要在扣减失败时做一次跨桶重试。我的建议是:没到瓶颈就别分桶,分桶带来的复杂度和排查难度,比它省下的那点性能更贵。
3. 库存扣减的原子性实现细节
3.1 为什么普通 GET + DECR 一定会超卖
先看一段很多人第一版都会写的代码:
Integer stock = redisTemplate.opsForValue().get(stockKey); if (stock != null && stock > 0) { redisTemplate.opsForValue().decrement(stockKey); // 继续下单 }这段代码在单线程下没问题,但在并发下必然超卖。GET和DECR是两次独立的网络往返,A 线程读到库存是 1,B 线程同时也读到 1,两边都判定"有库存",然后各自DECR,库存变成 -1,两份订单都成功。这就是典型的检查与执行分离导致的竞态。
有人会说,那我直接用DECR的返回值判断不就行了?返回值小于 0 说明扣超了,再INCR补回去。这个思路能减少超卖,但仍然有问题:补回去的过程中,库存短暂变成负数,别的请求会读到负库存;而且在补回之前,如果服务宕机,负数就永久留在 Redis 里了。
正确的解法只有一条路:把判断和扣减放到 Redis 服务端的同一次执行里。Lua 脚本就是干这个的,脚本执行期间 Redis 不会处理其他命令,原子性天然成立。
3.2 一段可以直接复用的扣减脚本
下面这段脚本是我在多个项目里反复用过的版本,把库存判断、重复领取判断、扣减、记录用户四件事合并成一次执行,通过不同的返回值区分结果,方便上层做不同处理。
-- KEYS[1] 库存 key -- KEYS[2] 已领取用户 set key -- ARGV[1] 用户标识 -- ARGV[2] 限购数量(当前场景固定为 1) -- 返回:0 成功 -1 库存不足 -2 重复领取 -3 参数异常 local stock = redis.call('GET', KEYS[1]) if not stock then return -3 end if tonumber(stock) <= 0 then return -1 end if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 end redis.call('DECR', KEYS[1]) redis.call('SADD', KEYS[2], ARGV[1]) -- 给用户集合一个比活动更长的过期时间,防止无限膨胀 redis.call('EXPIRE', KEYS[2], 172800) return 0上层调用时用 Java 的DefaultRedisScript,把返回类型声明成Long:
private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("lua/seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } public Long trySeckill(Long couponId, Long userId) { String stockKey = "seckill:stock:" + couponId; String userKey = "seckill:users:" + couponId; return redisTemplate.execute(SECKILL_SCRIPT, Arrays.asList(stockKey, userKey), userId.toString(), "1"); }返回值处理要写全,很多人只判断了 0 和 -1,把 -2、-3 当成失败随便返回,结果用户重复提交时看到的提示是"库存不足",体验很差。我在日志里给每个返回码打了标签,方便统计各分支的占比,压测时能很直观看出限购拦截率。
3.3 注意 EVALSHA 的 NOSCRIPT 陷阱
脚本内容比较长的时候,每次传输脚本正文会浪费带宽。Redis 提供了SCRIPT LOAD加EVALSHA的机制,客户端先加载脚本拿到 SHA1,后续只传 SHA1。Spring Data Redis 的DefaultRedisScript默认会尝试走这条路。
坑在于:Redis 重启、主从切换、或者执行了SCRIPT FLUSH,缓存里的脚本就没了,此时EVALSHA会抛NOSCRIPT No matching script异常。如果没有兜底,秒杀接口会直接报错。
try { return redisTemplate.execute(SECKILL_SCRIPT, keys, args); } catch (RedisSystemException e) { if (e.getCause() instanceof RedisCommandExecutionException && e.getCause().getMessage().contains("NOSCRIPT")) { // 触发一次 EVAL 重新加载,再重试 redisTemplate.execute(new DefaultRedisScript<>(SCRIPT_TEXT, Long.class), keys, args); return redisTemplate.execute(SECKILL_SCRIPT, keys, args); } throw e; }更省事的做法是在应用启动时主动执行一次SCRIPT LOAD,同时保留上面的重试逻辑。别指望"配置了就不会出问题",主从切换这种事,凌晨三点发生的时候你不会想爬起来改代码。
4. 一人一单与幂等控制的三道防线
4.1 Redis 层的快速拦截
一人一单最直接的实现就是上面脚本里的SISMEMBER。用户集合用 Set 存,判断和写入都在 Lua 里完成,不会出现"两个请求同时判断都没领过"的情况。
集合的膨胀问题要提前想。一张券如果有 50 万人领,Set 里就是 50 万个成员,按每个成员几十字节算,大概十几兆到几十兆。单看不多,但同时开十个活动就很可观了。我的处理是给集合设过期时间,到期自动清掉,另外活动结束后跑一个清理任务,把结果归档到数据库,Redis 里只保留活动期内的数据。
还有一种实现是用SETNX建一个seckill:order:{couponId}:{userId}的 key,写入成功才算领到。这种方式的优点是单 key 粒度细,缺点是 key 数量多,几十万用户就是几十万个 key,内存碎片和 key 过期扫描都会带来额外开销。相比之下 Set 更紧凑,我一般优先用 Set。
4.2 幂等令牌防止重复提交
前端防抖只能挡住手快的用户,挡不住脚本和重放。我的做法是下单接口要求带一个客户端生成的请求令牌,服务端用SETNX抢占,抢到才继续,抢不到直接返回"请勿重复提交"。
SET seckill:token:{token} 1 NX EX 60NX保证只有第一个请求能成功,EX 60保证令牌 60 秒后自动清理,不会无限堆积。这个令牌同时起到两个作用:防重放,以及给下游消费者提供幂等依据——消息体里带上令牌,消费端落库前先查这个令牌是否已处理过。
这里有个细节值得说一下:令牌的过期时间要和业务的最大处理时长匹配。设太短,用户网络慢一点重试就绕过了防重;设太长,内存占用高。我一般按接口 P99 耗时的 10 倍左右设置,秒杀场景 60 秒是个比较稳的值。
4.3 数据库唯一索引的最终兜底
前两道防线都在 Redis,一旦 Redis 出问题或者消息重复投递,还是可能产生重复订单。所以数据库层面必须有一个唯一索引兜底,一般建在(user_id, coupon_id)上。
ALTER TABLE coupon_order ADD UNIQUE KEY uk_user_coupon (user_id, coupon_id);消费端落库时捕获唯一键冲突异常,捕获到就说明是重复消息,直接确认消费不再重试。这个兜底看着笨,但它是最可靠的一道,因为它不依赖任何外部组件的正确性。我一直坚持一个原则:分布式场景下的最终正确性,一定要落在数据库约束上,中间件再强也只是加速,不是保证。
库存扣减同理,落库时用条件更新:
UPDATE coupon_stock SET available = available - 1 WHERE coupon_id = #{couponId} AND available > 0;判断影响行数是否为 1,为 0 说明库存已经没了,此时要把 Redis 里多扣的那一份补回去(INCR加SREM),补偿动作放到一个专门的回滚方法里,带上重试。
5. 异步下单链路与最终一致性处理
5.1 消息投递的时机与消息体设计
Redis 扣减成功后立刻投递消息,这里的关键是投递失败要能被感知。我的做法是同步发送带确认的消息,发送失败就把这次扣减标记为待补偿,写进一张本地补偿表,由定时任务扫描重发。
消息体我一般包含这些字段:
{ "traceId": "s-20250101-0001", "couponId": 1001, "userId": 88231, "token": "tk_9f3a", "deductTime": 1735660800123, "retry": 0 }traceId贯穿整条链路,排查问题时靠它串起日志。retry记录重试次数,超过阈值就进死信队列人工处理,避免无限重试把队列堵死。
消息的顺序性不用太纠结。同一用户的两次请求,因为 Redis 层的 Set 判断,本来就只有一次能通过,重复消息也只会在唯一索引那里被拦掉,跟顺序无关。
5.2 消费端的落库与失败补偿
消费端的逻辑要写得"不信任任何输入"。先查幂等表或令牌,已处理直接返回;再执行库存条件更新,影响行数为 0 就说明数据库库存已经没了,此时要触发 Redis 回滚;然后插入订单,捕获唯一键冲突当成功处理;最后写一条消费记录,同一个事务里提交。
事务边界要划清楚:库存更新和订单插入放同一个本地事务里,要么都成,要么都败,这样不会出现"订单有了库存没扣"的中间态。
补偿这块,我踩过一个坑:一开始补偿逻辑写成"Redis 扣减失败就 INCR 加一",但消息重试和补偿任务可能同时触发,导致库存被加了两次,反而超卖。后来改成每条补偿记录带一个唯一编号,回滚前先SETNX一个补偿标记,同一个编号只能回滚一次,问题才消失。
6. 压测方案与常见故障排查实录
6.1 压测该看哪些指标
压测不是看 QPS 数字好看就行,我关注的是这几个:
| 指标 | 关注点 | 我常用的阈值参考 |
|---|---|---|
| 接口 P99 | 用户实际体感 | 小于 200ms |
| 错误率 | 是否有雪崩迹象 | 小于 0.1% |
| Redis CPU | 是否成为瓶颈 | 持续低于 70% |
| Redis 连接数 | 连接池是否打满 | 低于最大连接数的 80% |
| 消息积压 | 消费能力是否够 | 5 分钟内能清空 |
| 数据库连接池 | 是否被打穿 | 活跃连接低于上限的 70% |
压测时我会刻意做两种场景:一种是均匀流量,看系统稳态;另一种是脉冲流量,模拟开抢瞬间的峰值。脉冲场景最容易暴露问题,我在一次压测里就遇到过 Redis 响应正常但应用层线程池被打满的情况,原因是异步投递消息时用了同步阻塞的线程池,队列满了之后业务线程全部卡住。后来改成独立的线程池并加了拒绝策略,才把这个问题解决掉。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查方式 | 处理办法 |
|---|---|---|---|
| 库存出现负数 | 判断与扣减非原子 | 检查是否用了 Lua | 改用脚本,禁止 GET 后再 DECR |
| 用户能领两张券 | 一人一单判断被绕过 | 看是否有并发窗口 | Set 判断放进 Lua,加唯一索引 |
| 接口偶发 NOSCRIPT 报错 | 脚本缓存丢失 | 看 Redis 是否重启或切换 | 启动预热脚本 + 异常重试 |
| 订单数量少于扣减数量 | 消息投递失败无感知 | 核对扣减日志与订单表 | 加本地补偿表和定时重扫 |
| 活动结束后内存不降 | 预热 key 没设过期 | TTL检查相关 key | 统一加过期,跑清理任务 |
| 压测 QPS 上不去 | 连接池或线程池限制 | 看连接等待时间 | 调大池子,缩短超时 |
6.3 我遇到过的两个真实问题
第一个是"Redis 扣减成功,消息发出了,但订单没落库,也没报错"。查了两个小时才发现,消费者那边的反序列化配置和生产者不一致,消息被当成未知类型直接 ack 丢弃了。这个坑的教训是:消费端遇到无法解析的消息,绝对不能静默 ack,要么进死信队列,要么抛异常让它重试,不然数据丢了都不知道。
第二个是活动刚开始几十秒内,Redis 监控显示 QPS 暴涨到平时的五倍,但实际成交量没变。原因是大量用户在倒计时结束后疯狂点击,前端按钮置灰有 300ms 延迟,后端又没有做请求去重,同一个用户一秒内打进来七八个请求。加了令牌校验之后,无效请求在第一层就被挡掉了,Redis 压力直接降了七成。
7. 几个提升稳定性的实操细节
7.1 库存分桶什么时候才值得上
分桶这个概念被讲得很多,但真正需要它的场景没那么多。单 key 的库存在十万级 QPS 下是能扛的,因为DECR是 O(1) 操作,Redis 单线程处理极快。真正需要分桶,是在单 key 已经打满实例 CPU、且不能水平扩容的场景。
分桶的实现思路是把总库存 N 拆成 M 个桶,每个桶 N/M,用户按userId % M定位到桶,桶内还是用 Lua 扣减。某个桶扣完了,回退到其他桶再试一次。这个回退逻辑要写对,我见过写成随机重试的,结果在库存快耗尽时疯狂空转,把 CPU 又拉高了。
我的建议是:先用单 key 压测,看 Redis 实例 CPU 到多少。没到 70% 就别分桶。到了再考虑,而且优先考虑的是加实例、读写分离这类更简单的方案。
7.2 灰度与降级预案
秒杀功能一定要有开关。我习惯在配置中心放一个seckill.switch,默认关闭,活动开始前手动打开,异常时一键关闭。关闭后接口直接返回"活动太火爆,请稍后再试",保护后端不被拖垮。
降级分几档:第一档关掉非核心链路,比如领券后的通知、积分发放;第二档把异步改成只入队不消费,先把请求接住;第三档直接熔断接口。每一档的触发条件写成配置,靠监控自动触发,不要等人来决策,洪峰面前人的反应速度是不够的。
7.3 数据核对不能省
活动结束后,我一定会跑一次核对:Redis 剩余库存 + 已生成订单数 是否等于 初始库存。不相等就说明有漏单或者多扣,需要从扣减日志里逐条对。这个核对脚本用 Python 写几十行就够,但它是发现隐性问题的最后一道网。我在一个项目里靠这个核对发现了 37 笔"扣了库存但订单丢了"的记录,都是消息投递超时没重试造成的,后来补上了补偿任务才彻底解决。
# 简化的核对逻辑示意 init_stock = 500 redis_left = int(r.get("seckill:stock:1001")) db_orders = db.query("select count(*) from coupon_order where coupon_id = 1001") if redis_left + db_orders != init_stock: print(f"对账异常: 剩余{redis_left} 订单{db_orders} 差额{init_stock - redis_left - db_orders}")说到底,秒杀这个场景考的不是 Redis 用得多花哨,而是你对"并发下的原子性"和"分布式下的最终一致性"这两件事理解得够不够扎实。 Lua 脚本解决的是前者,补偿机制和数据库约束解决的是后者,剩下的都是在这两个基础上做工程化的打磨。我在实际项目里反复验证下来,只要把库存扣减的原子性、一人一单的判定、异步链路的可补偿这三件事做扎实,剩下的性能问题基本都是调参数和加机器能解决的。