☰
Spring Boot+Redis+Kafka:高并发秒杀系统设计复盘
2026/10/8 4:03:53 网站建设 项目流程

1. 面试开场:秒杀系统的核心矛盾与考察意图

那天面试官推过来一张纸,上面就一句话:"一个商品库存只有100件,但瞬间可能来10万请求,你用Spring Boot + Redis + Kafka怎么设计下单链路?"

这个问题其实是一个经典的面试组合拳。它考察的不是你背过多少面试题,而是你能不能把Redis的高并发读写能力、Kafka的异步削峰能力、MySQL的事务一致性约束,在一个真实业务场景里串成一个闭环。我在这次面试里被追问了将近四十分钟,从缓存策略问到消息积压,再从分布式锁问到数据库兜底,几乎把秒杀链路的所有关键节点都过了一遍。今天就把我当时的思路、代码设计、以及被面试官抓住反复追问的细节完整复盘出来。

先说结论,这套方案的核心思路可以概括成一句话:前端拦截 + Redis抗量 + Kafka削峰 + MySQL最终落库。也就是用Redis挡住绝大部分读和写的压力,用Kafka把瞬时流量转化成可控的异步处理节奏,让数据库只承受真正需要落库的那一小部分请求。

但如果你只是在面试里把这个框架背出来,大概率会被接着追问:"Redis挂了怎么办?""缓存穿透了你拿什么挡?""消息重复消费了怎么处理?""锁超时商品还没减完怎么办?"这些问题才是真正拉差距的地方。所以我这篇复盘不只是讲架构图,而是把每一个被追问的细节都拆开,还原我当时是怎么想的、边界问题是怎么处理的,以及哪些地方我事后复盘发现自己答得还不够好。

2. Redis层:从缓存预热到库存扣减的完整链路

2.1 为什么秒杀一定要先过Redis,而不是直接打数据库

秒杀场景最典型的特征是"高并发读 + 强约束写"。10万请求同时打过来,如果直接透传到MySQL,数据库的连接池很快就会被打满,剩下的所有正常业务都会被拖垮。这不是数据库性能调优能解决的问题,而是在业务架构层面就应该把流量挡在前面。

我当时给面试官画了一条链路:用户请求先进入网关做限流,然后请求到达业务层(Spring Boot),业务层做的第一件事不是去数据库查库存,而是去Redis里查商品信息和当前库存水位。

这里有一个很多人容易忽略的点:秒杀商品信息这类数据,是典型的读多写少场景。商品信息在活动开始前就已经确定,活动中基本不会变,非常适合放在缓存里。而库存这种数据是写多读多,但因为要求极端原子性,也放在Redis里用Lua脚本来做检查与扣减。所以Redis在秒杀链路里扮演了双重角色:热点数据的读缓存 + 库存扣减的原子计数器。

缓存读写我用的是最经典的Cache Aside模式,业务层先查Redis,命中直接返回,没命中再回源数据库。这个模式的关键在于缓存更新时机,秒杀场景里商品信息是启动前预热好的,活动期间不走"查询回填"这条线,避免极端情况下多个线程同时回源数据库打穿缓存。

2.2 缓存预热:活动开始前把数据准备好

秒杀活动的缓存预热一般通过一个后台管理接口触发,在活动开始前把商品详情、库存数量提前写入Redis。我当时的实现大概是这样:

@Service public class SeckillCachePreheatService { private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; public void preheatSeckillGoods(Long seckillId, SeckillGoodsDTO goods) { // 商品详情放入缓存,有效期覆盖整个秒杀时段并加缓冲 String goodsKey = "seckill:goods:" + seckillId; stringRedisTemplate.opsForValue().set(goodsKey, objectMapper.writeValueAsString(goods), 3600, TimeUnit.SECONDS); // 库存预减用的剩余库存键 String stockKey = "seckill:stock:" + seckillId; stringRedisTemplate.opsForValue().set(stockKey, String.valueOf(goods.getStockCount())); // 已售数量标记,用于后续对账 String soldKey = "seckill:sold:" + seckillId; stringRedisTemplate.opsForValue().set(soldKey, "0"); } }

有三个细节值得展开说:

第一,商品详情缓存的有效期必须覆盖秒杀时段,同时留出缓冲,不能出现"活动还在跑,缓存先过期"的尴尬情况。活动前半小时预热,有效期设为2小时,活动时长1小时,足够安全。

第二,库存数据不设置过期时间。为什么?因为库存是秒杀过程中的核心状态,如果它过期了,Redis会把键删掉,后续所有扣减请求都直接失败或者全部回源数据库,这就等于把流量重新引到数据库上。库存数据的安全保障靠的是活动结束后的主动删除,而不是依赖过期时间。

第三,预热之后必须做一次完整演练,模拟活动时压力把热key的读写放大几十倍,确认Redis的CPU、内存、连接数都在合理水位。很多线上事故不是代码逻辑错了,而是预热不完整、Redis key分布不均导致某个分片节点先被打爆。

2.3 缓存穿透、击穿、雪崩:面试官必问的三件套

我在面试里说完预热方案,面试官立刻追问:"缓存穿透怎么防?"这个问题几乎是条件反射,但想答得完整其实需要分清楚三个概念。

缓存穿透是查一个根本不存在的数据,缓存没有,数据库也没有。秒杀场景里最典型的是恶意用户伪造一个不存在的秒杀id反复请求,每次都打数据库。解决方案有三个层次:接口层做参数校验(id必须存在且格式合法)、缓存空值(即使是null也短暂缓存几秒)、布隆过滤器做前置拦截。我当时的回答是先校验参数,再给空值加短缓存。布隆过滤器对于秒杀这种id集合可控的场景也合适,但因为商品数量不大,空值缓存已经够了。

缓存击穿是某个热点key在过期瞬间,大量请求同时发现缓存失效,一起涌向数据库。秒杀活动里的商品天然就是超级热点key,必须防空。我当时用的方案很简单:热点数据不过期,也就是逻辑上管理缓存生命周期,而不是依赖Redis的物理过期时间。再加上互斥锁,当缓存失效时只允许一个线程回源数据库,其他线程等待或重试。

缓存雪崩是大批key在同一时间过期,导致流量全部压到数据库。这个秒杀场景稍微少见一点,因为预热数据基本是同一时间写入的,但依然要注意两点:一是给预热数据的物理过期时间加随机偏移,二是秒杀商品热点key直接设置为不过期,从根上规避。

2.4 库存扣减为什么必须用Lua脚本

Redis解决高并发库存扣减,最核心的一句话是:扣减操作必须原子。如果先查询库存再在Java代码里判断做减法,那么在查询和减法之间一定有并发窗口,两个线程同时读到库存为1,都认为可以扣减,结果是超卖。

解决原子性的办法是Redis的Lua脚本。Lua脚本在Redis中是原子执行的,脚本运行期间不会插入其他命令,所以判断库存和扣减库存这两步可以做到不可分割。我当时写的脚本类似这样:

local stock = tonumber(redis.call('get', KEYS[1])) local sold = tonumber(redis.call('get', KEYS[2])) local requested = tonumber(ARGV[1]) if stock >= requested then redis.call('decrby', KEYS[1], requested) redis.call('incrby', KEYS[2], requested) return 1 else return 0 end

这里有个容易被忽略的点,就是库存扣减和记录已售数量要放在同一个脚本里完成。为什么要这样?因为如果只减库存不记已售数据,后面做订单对账和数据分析的时候,你手里只有"库存从100变成了多少"这个间接结果,而没有一个直接计数器告诉你"今天实际秒杀成功了多少人"。把两个操作放进同一个原子脚本,就是为了在读和写上都保持一致的视图。

在Spring Boot里调用这个脚本,一般用DefaultRedisScript先注册脚本,然后通过stringRedisTemplate.execute传入keys和args:

private final DefaultRedisScript<Long> seckillScript; public boolean tryAcquireStock(Long seckillId, int count) { String stockKey = "seckill:stock:" + seckillId; String soldKey = "seckill:sold:" + seckillId; Long result = stringRedisTemplate.execute(seckillScript, Arrays.asList(stockKey, soldKey), String.valueOf(count)); return Long.valueOf(1).equals(result); }

执行成功说明Redis层已经预占了库存,可以继续往下发Kafka消息;执行失败直接返回"已抢完"。

提示:Lua脚本在Redis中虽然原子,但要注意KEYS和ARGV的传递方式——KEYS对应Redis的key,ARGV对应普通参数,两者不能混用,否则维护脚本时会非常容易看晕。

3. 分布式锁的选型演进:从setnx到Redisson

3.1 为什么有了Lua脚本,还需要分布式锁

面试官问到这里话锋一转:"库存扣减你已经用Lua保证了原子性,那分布式锁还有必要吗?"

这个问题问得好,因为它逼着你把两件事分清楚。Redis的Lua保证的是单个key操作的原子性,而分布式锁保证的是跨多个业务步骤的互斥。秒杀链路里确实有需要分布式锁的地方,但不是扣库存,而是处理那种"同一个用户、同一个商品、同一时间段只能下一单"这类非原子操作。

举个例子:用户狂点抢购按钮,请求可能从网关的不同节点进来,落到不同的Spring Boot实例上。如果没有任何互斥机制,同一个用户可能生成多张订单。虽然Redis库存只减了一次,但订单表里生成了多条重复记录,整体数据一样是脏的。

这种场景就需要分布式锁,锁的粒度不是"整个秒杀商品",而是"用户 + 商品"。Java里最常见的实现是基于Redis的setnx命令。早期方案是setnx lock_key value,谁设置成功谁就拿到锁,用完之后del释放。但这个方案有几个坑,面试时一定要能讲清楚。

3.2 setnx原始方案的三个坑

第一个坑:锁没有过期时间,拿到锁的线程挂了,锁永远不释放。所以后来的写法是set lock_key value EX 10 NX,加上过期时间。但注意,这个命令只解决了"异常时锁能自动过期"的问题,并没有解决所有问题。

第二个坑:误删别人的锁。假设线程A拿到锁,业务执行超过锁的过期时间,锁自动失效了。线程B拿到同一把锁开始执行。此时A执行完,调用del释放锁,直接就把B的锁删掉了。解决方案是value里放一个唯一标识(通常用UUID或者请求id),删除前先比对,只有值匹配才删。这个"先比对再删除"的操作本身还需要原子性,所以要借助Lua脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

第三个坑:Redis主从切换导致锁丢失。锁写入主节点还没同步到从节点,主节点挂了,从节点顶上,锁就丢了。这个问题在纯setnx方案里无解,只能在更高维度规避,比如引入RedLock,或者干脆承认分布式锁在极端情况下不保证绝对互斥,业务层做兜底。

3.3 Redisson为什么更省心:看门狗机制的本质

我实际项目里用的是Redisson,原因特别简单:它把上面这些坑都封装好了。Redisson加锁的写法是:

RLock lock = redissonClient.getLock("seckill:user:" + userId); boolean locked = lock.tryLock(0, 30, TimeUnit.SECONDS); if (!locked) { throw new SeckillException("操作太频繁,请稍后再试"); } try { // 下单业务逻辑 } finally { lock.unlock(); }

Redisson最关键的设计是看门狗(WatchDog)机制。默认leaseTime不传的情况下,锁的过期时间默认30秒,但后台有一个定时任务每10秒给锁续期一次,只要业务线程还活着,锁就不会过期。这就解决了"业务没执行完但锁提前被释放"的问题。

但用Redisson也不是无脑用。有两个经验:

第一,tryLock的超时时间要结合业务耗时来设计。用户抢购的下单链路通常要求低延迟,如果30秒抢不到锁可以直接放弃返回"稍后重试",而不是无限等待。waitTime设为0可以避免请求堆积,具体要看业务是否允许排队。

第二,锁的粒度一定要控制好。锁的key越粗,并发度越低。秒杀场景里如果锁整个商品,那么即使Redis库存Lua判断通过了,Java层的锁也会把所有请求串行化,性能大打折扣。锁"用户 + 商品"粒度就能保证同一用户不重复下单的同时,不同用户之间完全并行。

3.4 面试时如何回答"分布式锁失效了怎么办"

面试官通常还会追加一句:"分布式锁不是绝对安全的,你怎么兜底?"

这是分布式领域的老话题。任何分布式锁在极端情况下(主从切换、GC停顿、网络分区)都可能失效,所以架构上必须接受"锁可能不可靠"的前提,然后在业务层做最后一道防线。秒杀场景里最后一道防线就是数据库唯一索引。

下单表设计时,对user_id + seckill_id建唯一索引。哪怕分布式锁因为极端情况失效了,同一个用户重复插入订单时,数据库也会因为唯一约束而拒绝第二条记录。这样一来,分布式锁负责拦截"绝大多数"重复请求,数据库唯一索引负责兜底"漏网之鱼"。面试里能答出这一层,说明你真的理解"分布式系统没有绝对可靠性"这件事。

4. Kafka削峰:异步下单链路与消息可靠性

4.1 为什么是Kafka而不是MQ

Redis扛住了读和预扣库存,接下来真正要落库的是一笔一笔的订单。如果Spring Boot在接收到请求后同步去写数据库,数据库依然会面临大的瞬时写入压力。所以要把下单动作异步化。异步化首选消息队列,我在方案里选的是Kafka。

面试时被问"为什么选Kafka",我给的理由有三点:

第一,吞吐量高。Kafka基于顺序写盘和零拷贝机制,单机就能支撑每秒几十万条消息的写入。秒杀场景本质是短时间超大流量,Kafka的吞吐能力正好匹配。

第二,分区机制天然支持局部顺序。秒杀需要保证同一个用户的下单请求有序,把用户id哈希到同一个分区就能做到。这是很多其他MQ不容易做好的点。

第三,消费端可以自由扩展。Kafka的消费者组支持横向扩容,秒杀活动流量高峰期增加消费者实例,平时可以减少,资源弹性好。

4.2 消息生产端:Redis扣减成功后再发消息

我在生产端的逻辑是:Redis的Lua脚本扣减库存成功之后,才向Kafka发送一条下单消息。这条消息里带着用户id、秒杀id、商品id、价格、时间戳等关键信息。注意这里有个顺序问题——一定是先扣减Redis库存,再发Kafka消息。如果把顺序反过来,可能出现消息已经发出去,但Redis扣减失败,消费者那边就产生了实际订单,造成超卖。

这里还有一个我踩过的坑:消息发送失败怎么办。订单消息丢了,用户明明抢到了库存,却永远收不到下单成功的通知。这种场景不能直接把消息丢弃,而是要把消息先写入本地消息表,然后通过定时任务补偿发送。Kafka发送结果回调失败后,把消息标记为待重试状态,后续重新投递。

Spring Boot里发送消息的代码大致是这样:

public void publishSeckillOrderMessage(SeckillOrderRequest request) { String topic = "seckill-order-topic"; SeckillOrderMessage message = new SeckillOrderMessage(); message.setUserId(request.getUserId()); message.setSeckillId(request.getSeckillId()); message.setGoodsId(request.getGoodsId()); message.setCreateTime(LocalDateTime.now()); // 用userId作为key,保证同一个用户的消息进入同一个分区 kafkaTemplate.send(topic, String.valueOf(request.getUserId()), JSON.toJSONString(message)); }

用userId作为消息key是关键细节。Kafka同一个分区的消息是顺序写入、顺序消费的,如果同一个用户的两条请求消息落到了不同分区,消费并发处理时就会乱序,可能出现后下的单先处理完、先下的单反而后处理,这对订单号生成和库存核对都不友好。

4.3 消费端:消息幂等和最终一致

消费者从Kafka拉取订单消息后,执行真正的下单逻辑:写订单表、扣减数据库库存(兜底校验)、更新订单状态。这个过程的每一步都要考虑失败重试和重复消费。

Kafka在"至少一次"的投递语义下,消费者可能收到重复消息。比如消费者处理完订单但还没来得及提交offset(偏移量)就崩溃了,重新上线后会从上次未提交的位置继续消费,同一个消息会被再处理一遍。

所以幂等是必须做的。我在订单表设计时已经加了user_id + seckill_id唯一索引,消费者处理消息前先查这个唯一约束,如果订单已存在,直接跳过,相当于把数据库唯一索引同时作为幂等机制。

但这里还有数据库库存兜底扣减的细节。消费者拉取到订单消息后,还不知道Redis库存是否被真正扣减过——正常情况下已经扣过了,但如果在极端情况(比如发送消息前Redis扣减记录丢失),消费者需要在事务里校验数据库库存并扣减。这块我用一个事务方法切面包住:

  1. 根据唯一索引判断订单是否已存在,存在则直接返回。
  2. 校验数据库库存表当前库存,充足则扣减。
  3. 插入订单记录。
  4. 模拟提交,整个过程在一个数据库事务里。

万一第2步发现库存不足(理论上不应该发生,因为Redis已经预减了),说明Redis层和数据库层状态不一致,需要主动抛出异常让消息重试,并且记录告警日志人工介入。

4.4 消息积压与监控:怎么证明你的链路扛得住

面试官问了一个非常实战的问题:"你怎么知道Kafka消费速度跟得上生产速度?如果积压了你怎么办?"

我当时的回答分三步:监控、定位、扩容。

监控层面,我用的指标是Kafka消费者组的Lag(消费滞后量)。通过JMX暴露给监控系统实时展示。正常情况下,秒杀活动开始阶段Lag会快速上涨,因为生产者瞬时灌入大量消息,但只要消费者持续消费,Lag应该在几十秒内回落。如果Lag持续高位,就说明消费端有瓶颈。

定位层面,第一看消费耗时。下单消息处理里耗时最大的是数据库写入,如果订单表锁竞争严重、索引设计不合理,消费速率就会下降。第二看是否出现了较多重试消息,如果重复消费率高,要排查业务代码的幂等逻辑是否正确。

扩容层面,Kafka消费者的横向扩容非常方便,同一消费组内增加实例即可自动重新分配分区。但扩容前要先确认topic分区数足够,如果只有3个分区,你最多也就3个消费者并发消费,盲目扩容没用。所以topic的分区数要在创建时就按峰值预留,比如预估并发消费线程需要10个,分区数就至少建12个。

提示:Kafka不是越多分区越好,分区过多会带来ISR同步压力和文件句柄开销。秒杀场景一般按峰值吞吐量估算,预留20%-30%余量即可。

5. 面试追问链:数据库兜底与整链路的软肋

5.1 MySQL在秒杀链路里到底承担什么角色

面试官最后把问题聚焦到数据库:"按你的设计,MySQL只承受了少量写操作,但它依然是最终一致性保障的关键,你能说说数据库层是怎么兜底的?"

我的理解是,MySQL在秒杀链路里承担的不是抗并发,而是保最终一致。Redis库存扣减成功,不代表订单数据已经正确落库了。Redis只是临时状态层,MySQL里的订单和库存流水才是业务的最终结果。

数据库层第一个兜底是唯一索引防重复下单,前面已经讲过。第二个兜底是库存扣减放在事务里,和订单插入原子执行。第三是订单状态要有清晰的流转:待支付、已支付、已取消、超时未支付自动关闭。秒杀订单如果用户迟迟不支付,库存不能一直占着,需要通过定时任务检测超时订单,释放库存并回补Redis。

支撑这套兜底逻辑,数据库不需要每秒处理上万请求,但需要保证每笔订单都是准确的。这也是为什么秒杀链路里Redis和MySQL的角色不同——Redis追求高吞吐,MySQL追求强一致。

5.2 库存回补的链路一致性

面试官抓住"超时未支付自动关闭"追问:"订单关闭了,Redis库存怎么回补?如果回补不及时,本来有人能买的商品却显示抢完了,怎么办?"

这个问题是整条链路最容易出现一致性裂缝的地方。我的方案是分两步回补:

第一步,定时状态扫描回补。每分钟扫描订单表里已经超时仍未支付的秒杀订单,把订单置为取消状态,然后发一条"库存回补消息"到Kafka,消费者回补Redis库存。注意回补也要用Lua脚本原子地增加库存并扣减已售数量。

第二步,实时回补增强。用户主动取消订单时,直接走一次回补逻辑,不需要等定时扫描。

两个回补操作都通过Kafka异步执行,因此需要考虑消息乱序和重复。比如同一个订单既被定时任务关闭,又被用户主动取消,会生成两条回补消息,回补两次库存就变成"越卖越多"。所以回补消息必须以订单id做幂等键,Redis里用一个seckill:refund:orderId的标记位确保每个订单最多回补一次。

5.3 压测数据与扩容思路:面试官想要的数字

面试里聊到最后,面试官问:"你有没有压过这套链路?大概能扛多少QPS?"

这个问题我当时答得不够好,因为确实没有在一个标准的全链路压测环境里拿到精确数据。复盘之后,我认为正确的回答方式应该是给出一个基于组件能力推算的估算:Redis单实例可以支撑约10万级别的读QPS,Lua扣减命令单实例写QPS约5-8万(受线程模型和网络往返影响);Kafka单分区写吞吐约每秒1-2万条,如果一个秒杀topic分配12个分区,理论写吞吐可以达到10万级别;MySQL经过缓存和消息队列削峰后,真正落库的请求被控制在一个存量级别,比如每秒几百到一千笔订单,这个压力完全可控。

压测的思路是:先用JMeter从网关入口打流量,分成几个梯度(1万、2万、5万、10万),同时观察Redis的CPU和内存、Kafka的Lag、消费者线程池负载、数据库连接池活跃连接数。当Redis CPU超过70%或Kafka消费Lag持续不回落时,就是系统的瓶颈点,再做针对性扩容。

压测还有一个很容易踩的坑:本地开发机和线上环境的数据量完全不在一个数量级,压测结果不能线性外推。线上秒杀商品的Redis key数量不多,但单个key的访问热度非常高,这种访问模式极易触发Redis热key问题,压测时一定要模拟这种热点倾斜。

5.4 复盘:这场面试真正筛的是什么

后来我自己重新想了一遍,发现这场面试表面上在问Redis、Kafka、Spring Boot,本质上考察的是工程师有没有完整的系统设计思维。

如果你只背出了"Redis缓存 + Kafka异步 + MySQL落库"这套框架,却解释不清楚为什么库存扣减要用Lua,为什么同一个用户必须走同一个Kafka分区,为什么Redis扣减成功之后才能发消息,为什么订单表要加唯一索引——那么面试官就能判断你只是听过方案,没有真正从零设计过一个秒杀系统。

在我个人这几年的实践体感里,秒杀这种业务最大的难点从来不是任何一个组件的API怎么用,而是状态一致性的边界到底画在哪里。Redis负责短期状态,MySQL负责最终事实,Kafka负责在两者之间做一个可靠的异步桥接,每个组件都把自己最擅长的事情做好,系统才能整体稳定。

最后再分享一个不算起眼但很重要的细节:无论你设计的链路多漂亮,真正上线前一定要把回退方案想好。秒杀系统遇到极端情况时,哪怕牺牲部分用户体验,也要保证不会超卖、不会产生脏数据。先把数据库的约束兜底做好,再去谈高并发优化,顺序一定不能反。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询