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(); MapString, 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 RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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.acksall spring.kafka.producer.retries3 spring.kafka.producer.enable.idempotencetrue spring.kafka.producer.key-serializerorg.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializerorg.apache.kafka.common.serialization.StringSerializeracksall表示Leader和ISR里的所有副本都写入成功才返回不丢消息的代表性配置。retries3配合enable.idempotencetrue让重复发送同一批消息也不会产生重复数据避免网络抖动重试写入多条。但要注意acksall意味着写入延迟上升秒杀场景里这几毫秒的延迟完全可以接受换来的是一致性保障。还有一个关键点是消息体大小。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(ConsumerRecordString, String record) { int slot (record.key().hashCode() Integer.MAX_VALUE) % SLOT_COUNT; executors[slot].submit(() - handle(record)); } private void handle(ConsumerRecordString, 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-strategyant_path_matcher但这是兜底方案治标不治本长期还是要把自定义路径匹配规则改成新语法。第二循环依赖不再默认允许。2.6版本开始Spring Boot默认禁止循环依赖。以前两个Service互相注入也能正常启动升级后直接启动报错。这其实是好事它逼着你把代码从“互相依赖”改成“依赖抽象接口”或“用事件解耦”。第三配置加载方式调整。2.4开始Spring Boot引入了spring.config.import多环境配置不再是简单粗暴的application-dev.yml优先级覆盖而是配置文件里显式声明导入。升级后如果某些自定义配置文件没生效大概率是这个问题。第四Redis客户端切换。Spring Boot 2.x默认用Lettuce3.x开始的标准是Lettuce为主Jedis需要额外引入。如果你项目里原来用的是Jedis连接池参数升级后会发现一堆配置失效需要重新适配。我的建议是老项目不追求最新版本稳定优先2.3.x能跑就不动但新项目直接用2.6.x或者3.x不要新项目还在用2.3后续维护成本会越来越高。4.2 第三方接口设计签名鉴权与独立部署秒杀系统有时候需要给第三方平台开放接口比如让合作伙伴查询活动状态、上报数据。很多同学会问第三方接口放在哪里是单独服务还是放在原有业务模块我的答案很明确独立一个openapi模块对外暴露统一网关路径。原因有两个第三方的流量模式和正常C端用户完全不一样第三方可能是定时任务集中调用也可能并发极小混在一起会影响核心C端接口的稳定性另外第三方接口的安全要求签名、限流、审计日志和内部RPC接口完全不一样拆开之后逻辑隔离权限控制也清晰。第三方接口的签名设计我一般这么做调用方申请一个AppId和AppSecretAppSecret只保存在服务端。请求头带上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.urlhttp://admin-server:9000 management.endpoints.web.exposure.includehealth,info,metrics,threaddump,heapdump,logfile有了Admin以后你能在Web界面看到每个实例的实时堆内存、GC次数、线程状态、HTTP接口调用次数。秒杀期间我特别关注三个指标活跃线程数和队列积压数判断线程池是否被打满老年代GC频率GC频繁会带来明显的停顿秒杀这种时延敏感场景抖一下用户体验就没了HTTP接口的P99耗时如果某接口耗时突变立刻打开线程Dump看热点方法。Spring Boot Admin不只能展示默认指标也能做自定义指标。比如我想知道“Redis库存扣减成功数”可以自己暴露一个MeterBean 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和KafkaWindows下Kafka建议用WSL或Docker Compose起服务别在Windows裸跑调试秒杀服务学会看监控比学会写代码更重要。如果你没配Spring Boot Admin至少要把Actuator端点开开出事时/heapdump和/threaddump能救你一命。我曾经在压测时发现某台节点Redis连接数飙到几千排查下来就是某个线程池没设置最大连接数线程池一膨胀连接池跟着膨胀内存就这样被耗光了。7. 面试追问风暴把“我会用”变成“我懂原理”7. 面试追问风暴把“我会用”变成“我懂原理”我模拟一下面试官会怎么追问秒杀项目基本是按下边这个套路连环打第一问你为什么用Redis做库存扣减而不是直接用数据库表面考Redis实际考你对并发模型的理解。你要答出数据库行锁的并发瓶颈、Redis单线程模型下的原子操作、以及库存扣减用增量DECR/INCR而不是读改写GETSET的原因。第二问Redis里的库存是准的吗不是Redis只是准入拦截数据库才是最终事实源。答到这里面试官很大概率追问那Redis和数据库不一致怎么办把第5章的对账、补偿、流水号幂等讲清楚这一问你就过关了。第三问Kafka消息重复消费怎么办除了消费前幂等唯一索引、SETNX去重还可以讲Kafka的幂等Producer和消费端手动提交的配合。手动提交的核心原则是处理完业务逻辑再提交offset处理失败就不提交让同一批消息重新消费。但要注意处理失败如果每次重试都失败消息会在同一offset反复消费必须配合最大重试次数和死信队列。第四问Redis分布式锁过期了怎么办这里不要背概念直接说实操用Redisson的看门狗自动续期同时在业务设计上锁内代码尽量短不让锁成为性能瓶颈再在最后加一个库存流水唯一索引兜底即使锁失效也不会超卖。第五问如果你的整个秒杀服务全挂了用户怎么办这题考的是降级和容错。我会说网关层拦流量返回“系统繁忙请稍后再试”绝不拖垮其他业务域Kafka积压的消息保留服务恢复后继续消费用户状态从排队变成成功或失败数据库和Redis的差异靠对账补偿修正。秒杀的本质是限量宁可少卖一点也绝不能多卖并出事故。面试官想知道的是你有没有真正在线上跑过这套系统而不是照着博客背答案。所以我在回答里尽量穿插真实经历比如压测时遇到过GC停顿导致锁失效、Kafka消费线程池队列溢出导致OOM等这些细节比任何理论都更有说服力。说一个我踩过最深的坑有一次上线前压测Redis库存扣减和Kafka消息发送之间没有做事务性保证Redis扣减成功了但Kafka发送超时消息没发出去用户抢购资格直接丢失。后来在发送消息前加了一步“本地消息表”或者“Redis记录待发消息”定时任务扫描补偿才把这个洞堵上。这事的教训就是分布式系统里跨组件操作的原子性必须靠中间状态和补偿设计来保证不能想当然地以为“先扣库存再发消息顺序对了就行了”。如果这篇文章能帮你在面试时多撑住两轮追问或者在下一个秒杀活动上线时少一个通宵那这功夫就没白花。最后分享一个小习惯每次秒杀结束把监控截图、日志报错、问题复盘写成一个带时间线的文档下次做活动之前把它翻出来看看你会发现自己进步比看十篇技术文章都大。