ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot+Redis+Kafka:高并发秒杀系统设计复盘

Spring Boot+Redis+Kafka:高并发秒杀系统设计复盘 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和argsprivate final DefaultRedisScriptLong 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的keyARGV对应普通参数两者不能混用否则维护脚本时会非常容易看晕。3. 分布式锁的选型演进从setnx到Redisson3.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而不是MQRedis扛住了读和预扣库存接下来真正要落库的是一笔一笔的订单。如果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扣减记录丢失消费者需要在事务里校验数据库库存并扣减。这块我用一个事务方法切面包住根据唯一索引判断订单是否已存在存在则直接返回。校验数据库库存表当前库存充足则扣减。插入订单记录。模拟提交整个过程在一个数据库事务里。万一第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万级别的读QPSLua扣减命令单实例写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负责在两者之间做一个可靠的异步桥接每个组件都把自己最擅长的事情做好系统才能整体稳定。最后再分享一个不算起眼但很重要的细节无论你设计的链路多漂亮真正上线前一定要把回退方案想好。秒杀系统遇到极端情况时哪怕牺牲部分用户体验也要保证不会超卖、不会产生脏数据。先把数据库的约束兜底做好再去谈高并发优化顺序一定不能反。
返回列表