恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
高并发秒杀系统实战:Spring Boot 3.x + Redis + RabbitMQ 架构详解
首页
资讯中心
/
高并发秒杀系统实战:Spring Boot 3.x + Redis + RabbitMQ 架构详解
高并发秒杀系统实战:Spring Boot 3.x + Redis + RabbitMQ 架构详解
发布时间:2026/10/16 6:52:39
秒杀系统很多初学者觉得它就是“库存扣减订单插入”真自己动手做一遍才发现里面的门道远比想象的多。我先说我最近这段时间做的事用一个 Spring Boot 3.x 后端 Vue3 前端中间用 Redis 做库存预热与分布式锁用 RabbitMQ 削峰填谷从零搭了一个完整的秒杀商城系统。整套代码跑下来千级并发压测下数据库依然稳没有超卖也没有重复订单消息队列的消费速率跟得上。这篇文章不打算写一堆理论而是把整套方案拆开为什么选这套技术栈、核心代码怎么落、实际压测时踩了哪些坑、出了问题到底怎么定位。适合有一定 Java 基础、想往高并发方向进阶的开发者也适合准备用秒杀场景做毕业设计或项目实战的同学。1. 秒杀系统的核心难题与技术选型思路1.1 秒杀场景到底难在哪超卖、热点、刷单三大致命伤先说为什么秒杀这类项目会被当成“高并发试金石”。它不是 CRUD真正的压力来自三个地方。第一个是超卖。库存只有 100 件但同一时刻有 1000 个人在抢。如果代码是先查库存、判断大于 0、再更新库存那并发请求完全可能同时读到库存还是 99接着同时扣成 98最终卖出去 200 件。这是最经典的读-改-写竞态问题纯靠数据库的普通 UPDATE 也不保险因为如果不在事务里加锁两个请求照样能穿插执行。第二个是热点数据。秒杀开始后所有人都在抢同一个商品意味着巨大的流量全部集中在同一个 Redis Key 或数据库行上。Redis 单实例能扛的 QPS 虽然高但面对几十万用户同时刷一个 Key网络带宽和 CPU 也会被打满如果请求绕过缓存直接打到数据库数据库连接池几百条连接瞬间耗尽整个服务直接雪崩。第三个是脚本刷单。真实秒杀场景里开售前 3 秒就会有一批自动化脚本提前发请求普通用户手速再快也拼不过机器。这时候如果不做用户维度的防重复校验和接口限流系统里全是无效请求真正的用户根本进不来。这三个问题环环相扣也是对技术选型最直接的考验。1.2 技术栈选型为什么偏偏是 Spring Boot 3.x Redis RabbitMQ秒杀系统的核心诉求很简单读请求要极快写请求要平滑。对应到技术栈上每个组件都有明确分工。Spring Boot 3.x 是目前 Java 后端的主流选择要求 JDK 17 起相比 2.x 性能提升了不少自动配置也省去了大量 XML 配置。用它做接口层、业务层和数据库访问层开发效率高生态也最成熟。网上很多老旧教程还在用 2.x所以我这个项目直接踩在 3.x 上顺便把 Jakarta 命名空间、最新 Redis 客户端这些坑也一起趟了一遍。Redis 在这个系统里承担两件事一是库存预热把 DB 里的库存提前加载到内存秒杀请求只打 Redis 不打数据库二是通过 Lua 脚本保证库存扣减的原子性解决超卖问题。Redis 本身是单线程模型执行一段 Lua 脚本时不会插入其他命令天然具备原子性这是它做秒杀扣库存的核心优势。RabbitMQ 解决的是流量削峰问题。秒杀接口收到请求后不要立刻去写数据库而是先把“谁抢到了”这个信息扔进消息队列由消费者以稳定的速率创建订单。这样即使瞬时涌入 1 万个请求数据库每秒可能只收到几十上百个订单插入请求压力完全可控。选用 RabbitMQ 而不是 Kafka是因为秒杀场景对消息可靠性要求高RabbitMQ 的确认机制和死信队列更直观对中小团队也更友好。Vue3 则负责秒杀页面的展示与交互组合式 API 写状态逻辑非常顺手倒计时、按钮禁用、轮询结果这些状态切换都很清晰。1.3 请求链路全解析一次秒杀点击背后发生了什么把整体流程串起来看思路会更清晰。假设用户已经登录秒杀商品已经预热到 Redis完整链路是这样的用户点击“立即抢购”后前端先做按钮置灰和倒计时校验但前端只是体验优化真正的防刷在后端。请求到达后端秒杀接口先解析登录态拿到用户 ID然后执行一个 Lua 脚本脚本里同时做两件事检查该用户是否已经参与过这场秒杀以及 Redis 里的库存是否还有剩余。如果库存充足且用户未抢购过就原子性地扣减库存并标记该用户已参与。扣库存成功后接口立刻返回“排队中”同时把一条包含用户 ID、秒杀商品 ID、场次 ID 的消息发送到 RabbitMQ。到这里用户侧请求就已经结束了整个过程不碰数据库。MQ 的消费者从队列里取消息真正去数据库创建订单、扣减 DB 库存、写入用户订单记录。由于 Redis 的库存是提前从 DB 加载的这里会出现一个短暂的不一致窗口但秒杀场景允许最终一致。消费者跑完订单创建后前端轮询“查询秒杀结果”接口发现订单已生成就提示抢购成功。步骤表我给你整理出来了阶段核心动作使用的组件耗时预估请求接入校验登录态、限流网关 / 拦截器毫秒级库存扣减Lua 原子扣减 用户标记Redis毫秒级异步通知发送订单消息RabbitMQ毫秒级订单落库创建订单、扣除 DB 库存MySQL几十毫秒结果同步前端轮询查询查询接口 Redis 状态秒级这套链路最大的收益是用户点击后毫秒级得到响应数据库在整场秒杀期间都处于低压力运行状态。2. 库存预热与 Redis 防超卖核心设计2.1 开售前 10 分钟商品库存怎么用一条命令预热进 Redis库存预热是秒杀系统的前置条件。如果秒杀开始时 Redis 里压根没有库存数据所有请求都会穿透到数据库前面说的削峰就白做了。预热一般在秒杀开始前由定时任务或运营后台触发。我这里做了一个简单的预热接口秒杀场次创建后调用它把商品的初始库存写入 Redis。这里有个关键细节要保证多次预热不会重复覆盖。比如运营手误点了两次预热库存被重置成初始值那就等于让多卖了两倍的人抢到所以预热时我用 SETNX 而不是 SET。核心逻辑是这样的遍历秒杀场次的商品列表对每个商品执行setIfAbsent(seckill:stock: seckillId, stock)。如果返回 false说明这个 Key 已经存在不覆盖如果返回 true说明第一次初始化同时设置一个合理的过期时间比如秒杀结束后再过 2 小时自动清理防止 Key 堆积。为了安全预热完成后还应该有一个 Redis 库存与 DB 库存的对账任务每 5 分钟跑一次如果差异超过阈值就告警。实际项目中这个对账可以在秒杀结束后再人工核对但系统里留一个定时对账任务会稳妥很多。2.2 扣减库存防超卖Lua 脚本的原子性原理与代码实现秒杀接口最核心的代码就是那段 Lua 脚本。我见过很多新手在 Java 里写“先 get 再 decr”的逻辑这只是低并发下碰巧没问题一旦并发上来必出超卖。正确的做法是把“查库存、查用户是否已买、扣库存、标记用户已买”合并成一个 Lua 脚本交给 Redis 原子执行。Redis 是单线程模型执行脚本期间其他任何命令都没法插进来所以这一个脚本里的多条命令不会被并发打散。我用的脚本如下-- KEYS[1]: seckill:stock:{seckillId} 商品库存 Key -- KEYS[2]: seckill:user:{seckillId}:{userId} 用户参与标记 if redis.call(exists, KEYS[1]) 0 then return -1 -- 没有该商品或尚未预热 end local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -2 -- 库存已售罄 end if redis.call(exists, KEYS[2]) 1 then return -3 -- 该用户已参与过本轮秒杀 end redis.call(decr, KEYS[1]) redis.call(setex, KEYS[2], 7200, 1) return 1 -- 扣减成功Java 端调用时用 Spring Data Redis 的DefaultRedisScript封装注意指定返回类型是Longprivate final DefaultRedisScriptLong seckillScript new DefaultRedisScript(scriptText, Long.class); public SeckillResult doSeckill(Long seckillId, Long userId) { ListString keys Arrays.asList( seckill:stock: seckillId, seckill:user: seckillId : userId ); Long result redisTemplate.execute(seckillScript, keys); // result 为 -1/-2/-3 时分别处理 // result 为 1 时发送 MQ 消息 }这里返回 -1 时有个细节容易忽略如果商品尚未预热就直接返回“活动未开始”但更好的做法是触发一次异步预热回源避免运营漏预热导致全场面失败。当然这个回源要防止并发搞个分布式锁保护。用户标记的过期时间为什么会设成 7200 秒因为一场秒杀通常持续十几分钟过期时间设长一点是防止用户在同一场活动中反复抢购但要覆盖整场活动时间。如果设太短活动没结束就过期用户就能重复抢了。2.3 分布式锁什么时候需要它Redisson 与 SETNX 怎么选有人会问Lua 脚本已经保证扣库存原子性了为什么还要分布式锁这是两个层面的问题。Lua 脚本只保护了“库存扣减”这一个动作但如果有别的业务逻辑也需要互斥比如预热回源、DB 库存回补、或者防止同一用户并发发送多条 MQ 消息这就需要一把跨越多个操作的锁。最常见的场景是预热回源两个请求同时发现 Redis 里没有库存同时去 DB 查库存并回填。如果不加锁库存可能被回填两次。还有更隐蔽的情况用户疯狂点击前端按钮置灰不一定拦得住两个携带相同 userId 的请求同时到达虽然 Lua 脚本里setex用户标记有原子性理论上不会重复但在某些极端时序下还是有必要在调用 Lua 之前做一层用户维度的防并发。分布式锁有两条路线。一条是自己用 Redis SETNX 实现Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:seckill: seckillId : userId, 1, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { // 执行预热或下单逻辑 } finally { redisTemplate.delete(lock:seckill: seckillId : userId); } }这条路线的坑很多锁过期时间设多长业务没执行完锁就过期了怎么办删锁时删到别人的锁怎么办新手自己写很容易留下隐患。另一条是直接用 Redisson它提供的RLock是分布式锁的标准解自带看门狗自动续期默认 30 秒过期每 10 秒续一次业务没结束锁不会提前释放同一线程可以重入释放锁时还会校验持有者避免误删别人刚拿到的锁。Redisson 的代码比手写 SETNX 简洁得多RLock lock redissonClient.getLock(lock:seckill: seckillId : userId); if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { try { // 执行需要互斥的逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } else { return Result.fail(操作太频繁); }我建议在秒杀项目里优先用 Redisson不是因为 SETNX 不行而是把锁续期、锁释放这些细节交给成熟组件处理比自己在生产环境踩坑强得多。锁的粒度也千万注意尽量锁用户 ID 级别不要锁商品全局 Key否则所有用户的请求都会互相阻塞秒杀性能直接废掉。3. RabbitMQ 异步下单与消息可靠性设计3.1 为什么下单必须异步化同步写库的悲剧后果一开始做这个项目时我特意写了个同步版本的秒杀接口做对比压测到 500 并发时数据库连接池直接被打满接口响应时间从几十毫秒飙升到十几秒一堆超时重试把系统拖到崩溃。同步写库的问题在于数据库每秒能处理的写事务有限而秒杀的瞬时流量是数据库能力的几十上百倍。异步化之后秒杀接口只负责校验和扣库存然后把订单消息丢给 RabbitMQ消费者线程池慢慢消费。这就像演唱会散场如果几万人同时涌向唯一出口肯定踩踏但如果大家先被引导到大厅排队再按批放行秩序就稳住了。RabbitMQ 就是那个“大厅”。消费者这边要注意消费速率的控制核心参数是消费者的并发线程数。我创建队列时在注解里配了并发消费者数量RabbitListener(queues seckill.order.queue, concurrency 10-20)这样消息会同时被 10 到 20 个线程消费数据库落库压力相对平稳。如果线程数太少消息消费速度慢用户轮询结果就要等很久线程数太多数据库又扛不住。具体数字要根据数据库性能压测来定我压到 20 个消费者时 MySQL 还能稳定在每秒几百个插入问题不大。3.2 消息不丢生产者确认、队列持久化、消费者手动 ACK 三步配齐异步化一个隐藏问题就是消息可靠性。如果 RabbitMQ 把消息弄丢了用户明明抢到了库存但订单永远创建不出来这是不可接受的。所以我在三个环节都做了可靠性保障。第一步生产者端开启发布确认。在application.yml里配置publisher-confirm-type: correlated和publisher-returns: true然后在发送消息时设置回调rabbitTemplate.setConfirmCallback((correlationData, ack, cause) - { if (!ack) { log.error(消息发送失败: {}, cause); // 进入本地重试队列或落库补发 } });确认回调是异步的不能影响主线程。实战中我会把发送失败的消息记录到一张发送失败表由定时任务扫描重发保证不丢。第二步队列和消息都做持久化。声明队列时设置durable true发送消息时设置消息的投递模式为持久化。Spring AMQP 里可以这么声明Bean public Queue seckillOrderQueue() { return new Queue(seckill.order.queue, true); }注意durable只是让队列重启后还在消息内容本身也要持久化默认convertAndSend发的消息投递模式已经是持久化的但显式设置更稳妥。第三步消费者手动 ACK。Spring Boot 默认是自动 ACK消息一到达就确认但如果后续业务处理失败消息就丢了。所以要改成手动确认模式RabbitListener(queues seckill.order.queue) public void handleOrder(Channel channel, Header(AmqpHeaders.DELIVERY_TAG) long tag, OrderMessage message) throws IOException { try { orderService.createOrder(message); channel.basicAck(tag, false); } catch (Exception e) { channel.basicNack(tag, false, false); } }basicNack的第三个参数是是否重回队列。我一般设成 false配合死信队列处理这样失败消息不会无限重试把好消息都堵住。3.3 消费幂等与重复下单数据库唯一索引的最后一道防线消息队列本身不保证消息只投递一次重复投递是完全可能发生的。比如消费者处理订单的过程中挂了RabbitMQ 恢复后会把未确认的消息再次投递。所以消费者处理逻辑必须幂等。我在订单表上设计了一个联合唯一索引MySQL 层直接挡住重复下单ALTER TABLE seckill_order ADD UNIQUE KEY uk_user_seckill (user_id, seckill_id);消费者创建订单时如果数据库已经存在该用户在该场次的订单记录INSERT会抛出唯一键冲突异常。我把这个异常捕获后直接当作成功处理不进行basicNack否则消息会被无限重投。配合唯一索引我在 Redis 里也存了一个订单状态的标记。消费者收到消息后先查 Redis 里seckill:order:status:{seckillId}:{userId}这个 Key如果是 SUCCESS 就直接确认消息如果没有则继续创建订单。这样幂等校验在缓存层先挡一道DB 唯一索引是最终兜底双重保险。4. Vue3 秒杀页面与轮询机制4.1 页面状态机倒计时、抢购按钮与结果反馈前端虽然不能承担防刷职责但它的交互体验直接影响用户感知。我用 Vue3 组合式 API 实现了秒杀页面的完整状态机。页面上主要就是四个状态活动未开始、可抢购、抢购处理中、抢购完成。script setup import { ref, onMounted, onUnmounted } from vue const status ref(PRE) const countdown ref(0) let timer null function formatTime(seconds) { const h String(Math.floor(seconds / 3600)).padStart(2, 0) const m String(Math.floor((seconds % 3600) / 60)).padStart(2, 0) const s String(seconds % 60).padStart(2, 0) return ${h}:${m}:${s} } async function startSeckill() { status.value PROCESSING const res await fetch(/api/seckill, { method: POST, headers: { Authorization: Bearer localStorage.getItem(token) }, body: JSON.stringify({ seckillId: seckillId }) }).then(r r.json()) if (res.code 0) { // 排队中启动轮询 timer setInterval(pollResult, 1000) } else { status.value FAIL } } async function pollResult() { const res await fetch(/api/seckill/result?seckillId seckillId) .then(r r.json()) if (res.data res.data.status 1) { clearInterval(timer) status.value SUCCESS // 展示订单编号、支付入口 } else if (res.data res.data.status 2) { clearInterval(timer) status.value FAIL } } onMounted(() { // 从服务端获取倒计时时间不用本地时间差 }) onUnmounted(() { if (timer) clearInterval(timer) }) /script这里最容易被忽视的就是定时器清理。如果用户抢购成功或者失败后没有清掉定时器组件销毁后它还在后台跑会有无谓的请求严重的会造成内存泄漏。我在onUnmounted里统一清了。还有一个安全细节倒计时的基准时间必须从服务端获取不能直接用本地时间。原因很简单本机时间可以改。前端可以请求一个/api/server-time接口或者从任意接口的响应头里拿Date然后用它跟活动开始时间做差。我之前就是这么接的把本地时间跟服务器时间相差 5 分钟的用户直接挡掉。4.2 轮询查询秒杀结果接口设计要短小精悍秒杀订单是异步创建的前端拿到“排队中”之后不能干等所以要轮询查询秒杀结果。轮询接口的设计有几个点要注意。首先是接口路径尽量短、参数尽量少。我设计成GET /api/seckill/result?seckillIdxxx用户 ID 从 Token 里解析不放在参数里防止别人伪造。这样做还有一个好处就是查询接口数据量小、逻辑简单Redis 就能返回结果不会打到数据库。其次是轮询频率。一般建议 1 到 2 秒一次太频繁会白白消耗服务资源。从用户体验看1 秒一次已经比较流畅我压测时试过 500ms 一次效果没明显提升反而让 Redis 的读压力翻倍。最后是返回数据结构。我的实现里状态码做了枚举0 表示处理中1 表示成功并携带订单号2 表示失败售罄或未抢到。前端根据状态码切换按钮文案和页面效果。这里其实还有一个小技巧就是成功后前端不要再去查订单详情了直接把订单号拿到手跳转到订单详情页或支付页省一次网络往返。5. 限流、防刷与细节优化5.1 接口限流三板斧用户维度、IP 维度、全局限流前面讲的预热、扣库存、异步下单都是围绕“扛住流量”来做的。但真正的高并发系统还需要控制流量否则恶意请求一样能把 Redis 打垮。限流要分维度做。第一个是用户维度限流。利用 Redis 的 INCR 加过期时间实现一个简单的滑动窗口计数器String key rate:user: userId; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, Duration.ofSeconds(1)); } if (count 5) { return Result.fail(操作太频繁); }这个方案简单有效每个用户每秒最多 5 次请求超过直接拒绝。注意 INCR 后如果是第一次需要设置过期时间否则 Key 会永远留在 Redis 里内存被刷爆。第二个是 IP 维度限流。方式跟用户维度类似Key 换成rate:ip: 客户端 IP。不过只限 IP 也有问题同一个公司出口 IP 下可能有几十个正常用户容易被误杀所以 IP 限流的阈值要放宽一点。第三个是全局限流。可以用 Redis Lua 实现令牌桶算法也可以简单点用每秒最大请求数限制。令牌桶的思路是有一个桶以固定速率生成令牌每个请求要拿一个令牌才能通过桶满则拒绝。我用 Lua 实现了关键逻辑确保获取令牌和扣减令牌是原子操作。在秒杀项目里我的优先级是用户维度限流最严格IP 维度其次全局限流作为最后一道保险。三层防线叠加普通脚本基本就刷不动了。5.2 防重复提交前端控制与后端兜底双保险前端会在用户点击秒杀后立即把按钮置灰并且显示“处理中”防止用户在 1 秒内连点好几次造成重复请求。但前端只能防君子防不了脚本所以后端必须也做一遍防重复。前端按钮置灰只是体验优化后端真正靠的是两样东西一是 Redis 里用户参与标记的原子性这在前面的 Lua 脚本里已经实现了——每个用户在一个场次里只能成功扣一次库存二是数据库订单表的唯一索引这是最终兜底防止极端情况下消费者重复创建订单。这两个防重复机制加在一起效果是脚本就算绕过前端直接打秒杀接口也会被 Lua 脚本里的用户标记挡住就算消息在 MQ 侧重复投递也会被唯一索引挡住。这就是典型的双层防护设计。5.3 容易被忽略的优化项页面静态化、连接池、JVM 参数代码逻辑做完再抠几个提升明显的优化点位。秒杀商品的详情页面尽量静态化。商品名称、图片、描述、秒杀时段这些信息在一场秒杀里是不变的完全可以把页面预渲染成 HTML 放到 CDN 上用户请求根本不会到达后端。动态的部分只有库存状态和秒杀结果而这些数据量很小走接口即可。我实测下来静态页面配合 CDN 能分担掉秒杀页面 80% 以上的请求。数据库连接池要调参。Spring Boot 默认的 HikariCPmaximum-pool-size是 10对秒杀场景来说太少。但因为异步化之后数据库压力不大一般调到 20 到 50 之间就够了。如果调太大反而会让数据库的连接管理开销变大效果适得其反。核心思路是连接池的大小应该跟业务放大的并发规模匹配而不是跟接口的瞬时 QPS 匹配。JVM 参数也值得关注。秒杀接口不应有大量耗时计算堆内存分配比例要合理避免频繁 Full GC。我一般在启动命令里加-Xms1g -Xmx1g -XX:UseG1GC线上 4G 内存的机器跑这个项目基本稳定。6. 常见问题排查与避坑速查6.1 超卖问题排查清单按这五条逐个检查超卖是秒杀系统最恐怖的故障。一旦出现排查顺序是这样的第一确认库存扣减是否走 Redis 原子脚本。把DefaultRedisScript换成普通的 get decr 九成会超卖这不只是并发问题更是设计问题。第二检查 Redis 库存与数据库库存是否一致。Redis 扣了库存后消费者会扣 DB 库存但如果 DB 扣库存失败比如订单创建异常Redis 库存就永久少了。这时候需要消费者失败时补回 Redis 库存或者定时任务做库存对账。第三检查用户防重复标记的过期时间。前面说过过期时间太短用户就能在活动期间重复抢购过期时间太长Key 堆积浪费内存。我一般设置成活动时长加 2 小时。第四检查数据库唯一索引是否存在。有些项目只靠代码逻辑判断“用户是否已下单”并发下这个判断本身就不准。没有唯一索引的订单表超卖重复订单只是早晚的问题。第五排查压测脚本是不是自己把扣库存和下单搞成同步了。我见过有人做压测为了图方便把异步下单改成同步结果压出来超卖这其实是测试方法错了不是系统问题。6.2 RabbitMQ 消息堆积、丢失、重复消费排查表消息堆积和丢失是异步化之后最常遇到的两类问题。我整理成了一张速查表遇到对应现象直接照单排查现象可能原因排查方式消息堆积订单迟迟不创建消费者并发设置太小或消费者宕机看 RabbitMQ 管理界面的 Ready 数量调大 concurrency消息丢失用户抢到但订单不存在生产者未开启 confirm或消费者自动 ACK检查 yml 的 publisher-confirm-type消费者改手动 ACK消息重复消费订单创建两次消费者处理超时后消息重新投递订单表加唯一索引消费者做幂等处理失败消息无限重试堵住队列basicNack 设置了 requeuetrue改成 false配置死信队列接收失败消息这里我想重点说一个容易被忽略的情况消息发送成功到交换机但路由到队列时失败。如果只开 confirm 模式确认回调里 ack 是 true但消息其实没进队列。所以还要配合publisher-returns: true处理 return 回调。我自己就差点在这个地方栽跟头后来在 return 回调里加了告警日志才算是彻底闭环了。6.3 缓存穿透、击穿、雪崩秒杀场景如何一次说清秒杀系统里 Redis 承担了大量读请求但它本身也可能成为故障点。三个经典问题分别是缓存穿透查询不存在的 Key、缓存击穿热点 Key 失效瞬间、缓存雪崩大量 Key 同时失效。秒杀场景里缓存穿透其实不常见因为秒杀商品数量有限但为了安全我仍然会在查询秒杀活动详情时用布隆过滤器或者在缓存值为空时短暂缓存空值。布隆过滤器引入略重对秒杀场景来说空值缓存就可以满足需求查不到数据时缓存一个null过期时间只设 30 秒。缓存击穿在秒杀场景里非常危险。某个热点商品的库存 Key 正好在开售瞬间失效数据还没回填所有请求瞬间打到数据库。解决方案有两种一种是回源时加互斥锁同一时间只允许一个请求去 DB 查询并回填缓存其他请求先短暂等待另一种是逻辑过期方案逻辑上设置一个过期时间过期后由后台线程重建缓存请求直接返回旧值。我平时偏向互斥锁方案代码简单、误伤小用 Redisson 就能实现。缓存雪崩则常见于秒杀活动大量 Key 同时过期。比如我给每个商品 Key 设置的过期时间都一样那整点过期时大批请求同时落库。解决办法很简单初始化库存时给过期时间加一个随机抖动比如基础过期时间加 0 到 5 分钟的随机数这样 Key 就不会整批失效了。我个人在实际操作中的体会是秒杀系统真正难的不是某个单独的技术点而是把 Redis 的原子性、MQ 的可靠性、数据库的最终一致性串成一条完整的链路。很多问题单独看都懂联调压测时才暴露出真正的水土不服。如果你也是第一次搭这种系统建议不要急着优化性能先把消息不丢、订单不重、库存不超这三条底线守住再去做限流、页面静态化这些锦上添花的事。这样哪怕压测出问题定位范围也会小很多。