☰
秒杀系统高并发架构:Redis缓存、Kafka削峰与Spring Boot落地避坑
2026/10/10 17:28:12 网站建设 项目流程

1. 秒杀场景到底在“秒”什么——先拆需求再谈技术

聊到秒杀,大厂面试官的套路基本一致:给你一个X商品限量500台、0点开抢的需求,问你架构怎么搭。实际做过的人都知道,这套东西拆开看,无非三板斧——Redis扛流量、Kafka削峰、Spring Boot做业务编排。可真正上过线的人也会告诉你,三件套配齐只是入门,真正拉开差距的是那些藏在细节里的坑:缓存一致性、消息积压、分布式锁的锁粒度、还有序列化方式选错导致的内存暴涨。这篇文章就把这些从架构设计到落地避坑的细节一次讲透。

读这篇文章的人,一种是简历上写过秒杀、想补全底层细节去面试的人,一种是马上要做类似活动、想抄作业的后端开发。不管哪种,我希望你读完能回答三个问题:每一层组件到底在扛什么流量,为什么必须用这一层,挂了之后怎么保命。

1.1 流量特征分析:为什么普通接口扛不住

秒杀的本质是“把一个超大流量在几秒内夯到极小的库存上”。和普通接口最大的区别有三个:

第一,读多写少,比例悬殊。用户进详情页、看库存、看倒计时,全部是读操作,只有真正点击“立即抢购”的那一瞬间是写操作。一个秒杀场次下来,读请求可能上千万,写请求最多也就几千。所以你给我一台数据库裸奔,不用等到0点,预热流量的第一秒就能把它打满连接数。

第二,时间窗口极短且陡峭。0点一到,流量在几十毫秒内冲到峰值,和日常曲线完全不是一个数量级。系统如果要按峰值容量去准备机器,一年365天只在那一分钟内用到,成本根本扛不住。所以秒杀架构的核心思路从来不是“硬抗”,而是“削峰”和“错峰”。

第三,库存是全局强一致资源。500台库存,所有用户看到的是同一个数,这个数不能凭空多了,也不能在扣减过程中出现“超卖”。而数据库行锁虽然能保证正确性,但在高并发下等待锁本身就是灾难。用MySQL行锁保护库存,QPS顶天几百上千,根本喂不饱活动的流量。

理解了这三个特征,你才能理解后面所有的设计——缓存读、异步写、最终一致,全是为了分别解决这三个问题。

1.2 分层架构设计:让每一层只干一件事

我的秒杀系统结构是这样的,由上到下分四层:

  • 接入层:Nginx/网关做限流、鉴权、风控。同一个用户一秒钟内请求超过阈值,直接拒绝;同样的IP高频访问,直接走验证码或者丢弃。这一层的职责是“把明显不合理的流量掐死在门外”。
  • 缓存层:Redis承接所有热点读和库存预扣减。商品详情、剩余库存、活动配置提前预热进Redis;真正开抢时,先在这里做资格校验和库存扣减,只有扣减成功的人才有资格进入下一步。
  • 异步层:Kafka承接下单消息,把“抢到资格”和“生成订单”解耦。用户侧立即返回“已提交,排队中”,真正落库的动作交给消费端异步完成。
  • 存储层:MySQL最终落订单、落库存流水,并承担对账和补偿的职责。

这个分层最大的优点,就是每一层只处理自己能干的事。Redis只能做单点小数据快速操作,那就让它干计数和校验;Kafka吞吐量大,那就让它当消息管道慢慢吐给数据库;MySQL事务能力强,但高并发弱,那就把写流量降下来让它慢慢处理。你永远不会指望Redis帮你存订单,也永远不会让Kafka替你扛热读。

分层之后还有一个明显的好处:每一层都可以独立降级。Redis挂了,可以挡住新的秒杀请求,而不是让请求穿透到数据库;Kafka积压了,可以提示用户排队等待,但数据库不会被打死。保障核心不崩溃,这就是分层架构最大的价值。

2. 用Redis做缓存预热、分布式锁与序列化,记住这三个坑

2.1 预热先做,用户才不会打穿数据库

很多新手写秒杀,上来就写一个查询商品库存的接口,Redis里没有就读数据库。结果活动一开始,Redis缓存没命中,几千个并发同时打数据库,瞬间击穿。这就是典型的“缓存击穿”事故:单个热点key过期失效,重建缓存的压力直接打到数据库上。

正确的做法是提前把秒杀商品的详细信息和初始库存写进Redis。预热通常分两步:

第一步,活动配置创建后,运维脚本或后端定时任务把商品ID、库存数、限购数、活动时间等写入Redis。用Hash结构最合适:一个key对应一个商品,field存库存、价格、商品名等信息,比多个String key管理起来清晰得多。代码如下:

@Autowired private StringRedisTemplate stringRedisTemplate; public void preheatSeckillItem(SeckillItem item) { String key = "seckill:item:" + item.getSkuId(); Map<String, String> map = new HashMap<>(); map.put("stock", String.valueOf(item.getStock())); map.put("price", item.getPrice().toPlainString()); map.put("name", item.getName()); map.put("status", "0"); // 0未开始 1进行中 2已结束 stringRedisTemplate.opsForHash().putAll(key, map); }

第二步,活动开始前做一个“可抢标记”。用户请求进来先看标记,标记为未开始就返回活动未开始,标记为进行中才允许继续。这个标记位本身就是读缓存,不会打数据库。

为什么用Hash而不用String?因为秒杀商品信息往往是多个字段一起读取,Hash一次HGETALL或HMGET就能拿到全部字段;而String你要么为每个字段建一个key,多一次网络请求,要么把整个对象序列化成一坨JSON,改一个字段就要重写整条缓存。Hash在字段级别的操作上灵活太多,这也是Redis官方文档推荐用Hash存对象的原因。

2.2 分布式锁的实现细节:锁粒度与过期时间别乱设

秒杀必然涉及库存扣减,而分布式环境下多个服务实例同时扣一个SKU的库存,必须保证原子性。业界最经典也是最容易踩坑的方案就是用Redis分布式锁。

我见过不少人自己写一个SETNX加锁、DEL解锁的“简易锁”,然后上线后各种翻车:忘记设置过期时间导致死锁、业务执行超过锁过期时间导致锁提前释放、解锁时误删了别人的锁。说实话,除非你是在面试手写Redis锁,生产环境我建议直接用Redisson,它把大部分坑都填平了:

@Autowired private RedissonClient redissonClient; public boolean deductStock(String skuId) { RLock lock = redissonClient.getLock("seckill:lock:" + skuId); boolean locked = false; try { // waitTime 100毫秒,超过100ms没抢到锁就放弃 // leaseTime 30秒,锁自动释放时间 locked = lock.tryLock(100, 30, TimeUnit.SECONDS); if (!locked) { return false; } // 到这里说明拿到了锁,检查库存并扣减 String stockKey = "seckill:item:" + skuId; Long stock = stringRedisTemplate.opsForHash().increment(stockKey, "stock", -1); return stock >= 0; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

这里有两个关键细节,面试必问。

第一个是锁粒度。锁key必须精确到SKU,不能搞一个全局锁把所有商品串行化。一个锁只保护一个SKU的库存扣减,不同SKU之间完全互不干扰。锁粒度越小,并发度越高。这个道理说出来简单,但很多人在设计锁key时随手写了个“seckill:lock”,导致所有商品抢一把锁,系统性能直接退化成单线程。

第二个是线程安全问题:判断锁是否由当前线程持有,再解锁。很多人直接lock.unlock(),如果锁已经因为超时被自动释放,而后线程又拿到了锁,你这一梭子解掉的就是别人刚拿到的锁,后面所有扣减都会乱套。Redisson的isHeldByCurrentThread()在底层就是帮你判断这个持有关系,别省这一步。

另外,Redisson的锁默认有看门狗机制:如果没指定leaseTime,锁续期线程每10秒扫描一次,业务没执行完就自动续期到30秒。这是它比原生SETNX高级的地方。但要注意,如果你显式指定了leaseTime,看门狗就不生效了,所以不要一边设个超短的leaseTime一边又希望锁能自动续期。

2.3 Redis数据类型选型与序列化:选错会白白浪费内存

Redis不只存字符串,这句话面试官也爱考。秒杀场景里,我常用的数据类型有四个:

  • String:存验证码、令牌、标记位,比如限流计数器“user:request:count:10001”。
  • Hash:存商品详情、库存、用户抢购资格记录。字段多、单体修改频繁时选它最合适。
  • List:存简单队列,比如把未支付的过期订单ID丢进List里,定时取出来做关闭处理。
  • ZSet:秒杀排行榜、按时间排序的订单队列都能用。score存时间戳,ZRANGEBYSCORE取某个时间段的记录,比遍历Set快得多。

数据类型选型只是第一步,更隐蔽的是序列化问题。Spring Boot默认的RedisTemplate如果不做配置,用的是JdkSerializationRedisSerializer,它会把对象序列化成带上类信息的二进制数据。带来的直接后果是:可读性差、序列化体积大、内存占用高。我曾经在一个项目里排查过,同一个用户对象用JDK序列化存进去240字节,改成JSON序列化后只有120字节,Redis内存占用直接砍半。

我的RedisConfig里是这样配置的:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

key统一用String序列化器,value用JSON序列化器。这样在Redis里看到的key是干净的“seckill:item:10001”,value是可读的JSON字符串,排查问题时一眼就能看清。如果项目里只存String类型数据,用StringRedisTemplate就行,不要硬套这个配置,因为value序列化成JSON后String类型反而多了一层不必要开销。

3. Kafka削峰填谷:异步下单与消费配置的实战经验

3.1 为什么选Kafka,以及Producer端如何保证消息不丢

用户点“立即抢购”,Redis库存扣减成功,然后要生成订单、写数据库。这个“生成订单”的动作如果同步做,用户就得一直等着数据库写成功,而你数据库QPS撑不住,网络也白白空等。所以我把后续动作打包成一条消息发给Kafka,用户端立刻收到“排队成功”的响应。

Kafka在这里承担的就是典型的削峰填谷:高峰期的几千个下单请求瞬间写入Kafka,消息堆积在Broker里,消费端按自己能力慢慢处理。Kafka是顺序写磁盘,单机吞吐十万级,做这个场景的管道完全够用。

Producer端不丢消息的配置,核心是三件事:ack机制、重试、幂等。

spring.kafka.producer.acks=all spring.kafka.producer.retries=3 spring.kafka.producer.enable.idempotence=true spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer

acks=all表示Leader和ISR里的所有副本都写入成功才返回,不丢消息的代表性配置。retries=3配合enable.idempotence=true,让重复发送同一批消息也不会产生重复数据,避免网络抖动重试写入多条。但要注意,acks=all意味着写入延迟上升,秒杀场景里这几毫秒的延迟完全可以接受,换来的是一致性保障。

还有一个关键点是消息体大小。Kafka默认单条消息上限约1MB,如果你往消息里塞订单的整个JSON快照、商品快照,一个订单一两K还好,但如果塞了个花哨的富文本或者图片Base64,那分分钟撞上限。遇到能接收1m大消息的问题,结论是能,但要在Broker和Consumer两端都调大参数,而且大消息对网络、内存、吞吐都是负担,能用引用ID就尽量用引用ID,让消费端回查,不要在消息里带全量数据。

3.2 消费端多线程如何保证消息顺序性

这是面试里高区分度的问题,也是项目里最容易出事的点。Kafka本身保证的是单个分区内的消息顺序,但一个消费者组里有多个消费者,每个消费者拉到的分区集合不同;一个消费者内你还想开多线程加速处理,那消息就乱套了。

比如用户先点了一次“提交订单”,又立刻点击“取消”,这两条消息如果被不同的线程并发处理,可能取消先执行、下单后执行,最终订单状态错乱。要保序又想要吞吐,我给出一个可行方案:按订单ID或用户ID做哈希路由,把同一个业务实体的消息固定路由到同一个单线程消费者队列里,串行处理。

public class OrderMessageHandler { private static final int SLOT_COUNT = 8; private final ExecutorService[] executors = new ExecutorService[SLOT_COUNT]; public OrderMessageHandler() { for (int i = 0; i < SLOT_COUNT; i++) { // 每个slot一个单线程池,保证同一槽位的消息串行执行 executors[i] = Executors.newSingleThreadExecutor(); } } public void onMessage(ConsumerRecord<String, String> record) { int slot = (record.key().hashCode() & Integer.MAX_VALUE) % SLOT_COUNT; executors[slot].submit(() -> handle(record)); } private void handle(ConsumerRecord<String, String> record) { // 真正的订单处理逻辑 } }

这里有几个值得注意的细节:

一是executor队列必须是有界队列。如果用无界队列,消息积压时内存会暴涨。我后来加了一个线程池拒绝策略:当某个槽位队列满时,直接阻塞拉取,让Kafka消费暂停,等队列消化完再继续,宁愿消费慢,也不愿内存被打爆。

二是重启时的顺序问题。JVM重启后,线程池里的排队消息会丢失,同一槽位可能恢复后处理顺序和原计划不同。所以消费端必须做幂等,靠订单号唯一索引或Redis SETNX去重,保证同一订单的重复消息不会产生副作用。

三是消费者线程数不要盲目等于分区数。分区多但处理逻辑慢,消费者线程再多也只是空转;分区少但消费者线程多,多余线程闲置。最合理的办法是分区数=消费者实例数×每个实例的消费线程数,然后通过实际压测调优。

3.3 消息延迟高不等于消费慢,先看这四步

“Kafka消息延迟高”算是运维场景的经典问题。每次秒杀结束,监控上Lag数字飙升,大家第一反应是消费端代码写得太慢。但排查下来,一半以上的情况不是消费端的问题。

第一步,看消费组的Lag趋势。用命令直接查:

# Linux kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group seckill-order-group # Windows kafka-consumer-groups.bat --bootstrap-server localhost:9092 --describe --group seckill-order-group

如果Lag持续增长,说明消费速度低于生产速度;如果Lag停止不动,说明消费者已经不再拉取新消息。

第二步,看消费者的线程状态。消费卡住了往往不是业务慢,而是发生了阻塞。对JVM做一次线程Dump,看线程是RUNNABLE还是BLOCKED/WAITING。如果是WAITING,看是不是在等数据库连接、等Redis资源。我见过一次事故,消费者线程池的队列满了,任务提交主线程一直阻塞等待,结果消费组心跳超时,分区被重新分配,整个消费组短暂停止了消费。

第三步,看数据的派发是否均衡。多个消费者实例如果订阅同一个group,但某个实例分区数不均,一个实例扛了70%的分区,它就成了瓶颈。这种时候要重新分配分区,或者提高消费者实例数。

第四步,看下游,也就是数据库。秒杀消费端的主要动作是写订单、扣数据库库存,如果数据库慢查询、锁等待飙高,那消费端再快也没用,全卡在SQL上。先看数据库慢日志,再看消费端,顺序别搞反。

顺便说一句,很多同事喜欢用可视化Kafka工具来排查,什么Kafka UI、Kafka Eagle之类的。看Lag和消费组状态确实直观,日常开发查一下没问题,但要明白这类工具本身也在消耗集群资源,生产环境尤其是大促期间别开着自动刷新狂刷后台,会干扰Broker正常网络。真正出问题时的第一事实来源,永远是命令行工具的原始输出。

4. Spring Boot落地:版本差异、第三方接口与监控方案

4.1 Spring Boot 2.3.x还是2.6.x?版本跃迁带来的配置差异

很多人的老项目还停在Spring Boot 2.3.x,新项目已经往2.6.x甚至3.x迁移了。面试官问版本差异,不是在考背诵,而是在看你能不能意识到“升级带来的隐性成本”。

2.3到2.6有几个特别容易踩的点:

第一,路径匹配策略变了。2.6默认从AntPathMatcher切换成PathPatternParser,部分老接口的路径规则(比如/api/**/seckill这种带通配符的写法)可能匹配不上。如果升级后接口莫名404,先检查这个。临时兼容办法是在配置里加一行:

spring.mvc.pathmatch.matching-strategy=ant_path_matcher

但这是兜底方案,治标不治本,长期还是要把自定义路径匹配规则改成新语法。

第二,循环依赖不再默认允许。2.6版本开始,Spring Boot默认禁止循环依赖。以前两个Service互相注入也能正常启动,升级后直接启动报错。这其实是好事,它逼着你把代码从“互相依赖”改成“依赖抽象接口”或“用事件解耦”。

第三,配置加载方式调整。2.4开始Spring Boot引入了spring.config.import,多环境配置不再是简单粗暴的application-dev.yml优先级覆盖,而是配置文件里显式声明导入。升级后如果某些自定义配置文件没生效,大概率是这个问题。

第四,Redis客户端切换。Spring Boot 2.x默认用Lettuce,3.x开始的标准是Lettuce为主,Jedis需要额外引入。如果你项目里原来用的是Jedis连接池参数,升级后会发现一堆配置失效,需要重新适配。

我的建议是:老项目不追求最新版本,稳定优先,2.3.x能跑就不动;但新项目直接用2.6.x或者3.x,不要新项目还在用2.3,后续维护成本会越来越高。

4.2 第三方接口设计:签名鉴权与独立部署

秒杀系统有时候需要给第三方平台开放接口,比如让合作伙伴查询活动状态、上报数据。很多同学会问,第三方接口放在哪里?是单独服务还是放在原有业务模块?

我的答案很明确:独立一个openapi模块,对外暴露统一网关路径。原因有两个:第三方的流量模式和正常C端用户完全不一样,第三方可能是定时任务集中调用,也可能并发极小,混在一起会影响核心C端接口的稳定性;另外第三方接口的安全要求(签名、限流、审计日志)和内部RPC接口完全不一样,拆开之后逻辑隔离,权限控制也清晰。

第三方接口的签名设计,我一般这么做:

  • 调用方申请一个AppId和AppSecret,AppSecret只保存在服务端。
  • 请求头带上AppId、Timestamp、Nonce、Sign字段。Sign = MD5(请求参数 + AppSecret + Timestamp + Nonce)。
  • 服务端校验:Timestamp与当前时间差超过5分钟直接拒绝,Nonce在Redis里SETNX做幂等,同一个Nonce30秒内只能使用一次。然后重新计算Sign对比,相等才放行。
public class SignInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 取请求头 String appId = request.getHeader("X-App-Id"); String timestamp = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); // 校验时间戳防重放 long gap = Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)); if (gap > 5 * 60 * 1000) { throw new BizException("请求已过期"); } // 校验nonce防重复 Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent("openapi:nonce:" + nonce, "1", Duration.ofSeconds(30)); if (Boolean.FALSE.equals(success)) { throw new BizException("重复请求"); } // 查AppSecret,重新计算签名 String secret = appSecretService.getByAppId(appId); String serverSign = md5(body + secret + timestamp + nonce); if (!serverSign.equalsIgnoreCase(sign)) { throw new BizException("签名校验失败"); } return true; } }

这套签名方案不复杂,但把防篡改(签名)、防重放(时间戳+nonce)、防伪造(AppSecret)三个核心安全问题全都覆盖了。

4.3 用Spring Boot Admin把秒杀服务监控起来

秒杀系统上线最怕什么?内存飙高、线程池打满、Kafka消费堆积,这些问题不看到监控,等到用户投诉就晚了。Spring Boot Admin是目前最轻量的监控方案,服务端和客户端两个项目加起来不到半小时能搭完。

服务端很简单,引入spring-boot-admin-starter-server并加@EnableAdminServer注解。客户端引入spring-boot-admin-starter-client,配置服务端地址:

spring.boot.admin.client.url=http://admin-server:9000 management.endpoints.web.exposure.include=health,info,metrics,threaddump,heapdump,logfile

有了Admin以后,你能在Web界面看到每个实例的实时堆内存、GC次数、线程状态、HTTP接口调用次数。秒杀期间我特别关注三个指标:

  • 活跃线程数和队列积压数,判断线程池是否被打满;
  • 老年代GC频率,GC频繁会带来明显的停顿,秒杀这种时延敏感场景抖一下用户体验就没了;
  • HTTP接口的P99耗时,如果某接口耗时突变,立刻打开线程Dump看热点方法。

Spring Boot Admin不只能展示默认指标,也能做自定义指标。比如我想知道“Redis库存扣减成功数”,可以自己暴露一个Meter:

@Bean public MeterBinder seckillSuccessMeter() { return registry -> Gauge.builder("seckill.success", seckillStats::getSuccessCount) .description("秒杀成功次数") .register(registry); }

在监控面板就能看到这个指标曲线,秒杀结束复盘时真的比看日志快太多。顺带一提,秒杀结束后你拿到的第一份数据报告,应该来自监控而不是业务方给你的Excel,这样排查性能问题才有据可依。

5. 数据一致性:缓存、库存和订单的最后一公里

5.1 延迟双删的适用边界

缓存和数据库的一致性是个老生常谈。常见做法是延迟双删:更新数据库 → 删除缓存 → 等待几百毫秒 → 再次删除缓存。

为什么要删两次?因为并发下可能存在“请求A更新数据库,请求B读旧数据回填缓存,请求A删缓存,但B的回填发生在A删除之后”的时间差,导致缓存里存了旧数据。第二次删除能把B回填的旧缓存清掉,但代价是几百毫秒内读请求可能拿到旧值,在秒杀这种对实时性要求极高的场景里是不能接受的。

所以秒杀项目里我不把延迟双删用在库存扣减这种核心链路上。库存扣减的正确姿势是:Redis扣减作为一个“准入凭证”,数据最终以数据库扣减为准,双方通过消息和定时任务对账。缓存的一致性由异步任务持续修正,而不是靠一次删除搞定。

就算要在普通商品模块用延迟双删,也要注意删除失败的兜底。延迟双删如果第二次删除失败,旧缓存还是留在Redis里。所以一定要配合TTL,缓存的过期时间不能太长,即使删除失败,过期后也会自然淘汰,不会长期是脏数据。

5.2 最终一致:把对账做成常态

秒杀场景里,用户点了抢购,Redis库存扣掉了一个,Kafka消息发出去了,但消费端可能因为数据库死锁、网络抖动、Consumer宕机等原因没有成功落库。这时Redis显示库存少了,但数据库订单还没生成。对用户来说“抢到了”却没订单,对系统来说Redis库存和数据库库存对不上,必须有一套机制兜底。

我的做法是:每一笔库存扣减都生成一条库存流水记录,数据库落库时依赖流水号做唯一性约束。消费端正常消费时写入订单和库存流水;如果消费失败,异常重试仍然无法成功,消息进入重试队列。每天凌晨跑一个对账任务,扫描Redis库存和数据库库存的差异,把不一致的数据反向修正,同时给用户发补偿通知。

“最终一致”这个词听着高大上,落到工程上无非就是:核心链路先保证用户主体验,数据库作为最终事实源,追加对账和补偿闭环。面试里你把“对账任务”“流水表”“补偿机制”这三个词说出来,比空谈“缓存一致性”扎实得多。

6. 高频故障排查实录:一张速查表搞定大部分问题

秒杀上线后出的问题,其实翻来覆去就那么几类,我整理了一个速查表,开发同学对着查能省下大量排查时间。

现象可能原因排查手段与解法
缓存穿透,数据库压力暴增请求了不存在的商品ID,缓存永远不命中用布隆过滤器拦截不存在的ID,或缓存空值并设短TTL
缓存击穿,单key建缓存瞬间DB被打挂热点key刚好过期,大量请求同时回源预热时不让热点key过期;用互斥锁重建缓存,同一时刻只放一个请求回源
缓存雪崩,大范围key同时过期大量key设置了相同过期时间过期时间加随机值打散;秒杀场景核心key直接主动刷新
Redis库存扣减后DB库存没少消费端异常、消息丢失或重复用流水号幂等落库;查看Lag和死信队列;对账任务修正
用户明明抢到了却提示失败Redis锁因GC停顿自动释放,其他线程抢到锁配置更合理的leaseTime;用Redisson看门狗;保证锁内代码执行时间远小于锁超时时间
Kafka消息积压不消费消费端线程池队列打满,或DB慢SQL阻塞线程池改有界队列并做拒绝策略;查慢SQL;提高分区数和消费者数
同一订单出现两条记录消费端重复消费了同一条消息订单表对订单号加唯一索引;消费前Redis SETNX幂等
本地起Kafka/Redis太慢环境配置不统一,安装依赖各种版本冲突macOS直接Homebrew安装Redis和Kafka,Windows下Kafka建议用WSL或Docker Compose起服务,别在Windows裸跑

调试秒杀服务,学会看监控比学会写代码更重要。如果你没配Spring Boot Admin,至少要把Actuator端点开开,出事时/heapdump和/threaddump能救你一命。我曾经在压测时发现某台节点Redis连接数飙到几千,排查下来就是某个线程池没设置最大连接数,线程池一膨胀,连接池跟着膨胀,内存就这样被耗光了。

7. 面试追问风暴:把“我会用”变成“我懂原理”

7. 面试追问风暴:把“我会用”变成“我懂原理”

我模拟一下面试官会怎么追问秒杀项目,基本是按下边这个套路连环打:

第一问:你为什么用Redis做库存扣减,而不是直接用数据库?表面考Redis,实际考你对并发模型的理解。你要答出数据库行锁的并发瓶颈、Redis单线程模型下的原子操作、以及库存扣减用增量(DECR/INCR)而不是读改写(GET+SET)的原因。

第二问:Redis里的库存是准的吗?不是,Redis只是准入拦截,数据库才是最终事实源。答到这里,面试官很大概率追问:那Redis和数据库不一致怎么办?把第5章的对账、补偿、流水号幂等讲清楚,这一问你就过关了。

第三问:Kafka消息重复消费怎么办?除了消费前幂等(唯一索引、SETNX去重),还可以讲Kafka的幂等Producer和消费端手动提交的配合。手动提交的核心原则是:处理完业务逻辑再提交offset,处理失败就不提交,让同一批消息重新消费。但要注意,处理失败如果每次重试都失败,消息会在同一offset反复消费,必须配合最大重试次数和死信队列。

第四问:Redis分布式锁过期了怎么办?这里不要背概念,直接说实操:用Redisson的看门狗自动续期;同时在业务设计上锁内代码尽量短,不让锁成为性能瓶颈;再在最后加一个库存流水唯一索引兜底,即使锁失效也不会超卖。

第五问:如果你的整个秒杀服务全挂了,用户怎么办?这题考的是降级和容错。我会说:网关层拦流量,返回“系统繁忙,请稍后再试”,绝不拖垮其他业务域;Kafka积压的消息保留,服务恢复后继续消费,用户状态从排队变成成功或失败;数据库和Redis的差异靠对账补偿修正。秒杀的本质是限量,宁可少卖一点,也绝不能多卖并出事故。

面试官想知道的是你有没有真正在线上跑过这套系统,而不是照着博客背答案。所以我在回答里尽量穿插真实经历,比如压测时遇到过GC停顿导致锁失效、Kafka消费线程池队列溢出导致OOM等,这些细节比任何理论都更有说服力。

说一个我踩过最深的坑:有一次上线前压测,Redis库存扣减和Kafka消息发送之间没有做事务性保证,Redis扣减成功了,但Kafka发送超时,消息没发出去,用户抢购资格直接丢失。后来在发送消息前加了一步“本地消息表”或者“Redis记录待发消息”,定时任务扫描补偿,才把这个洞堵上。这事的教训就是:分布式系统里,跨组件操作的原子性必须靠中间状态和补偿设计来保证,不能想当然地以为“先扣库存再发消息,顺序对了就行了”。

如果这篇文章能帮你在面试时多撑住两轮追问,或者在下一个秒杀活动上线时少一个通宵,那这功夫就没白花。最后分享一个小习惯:每次秒杀结束,把监控截图、日志报错、问题复盘写成一个带时间线的文档,下次做活动之前把它翻出来看看,你会发现自己进步比看十篇技术文章都大。

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

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

立即咨询