前几天帮组里一个准备跳槽的小兄弟做模拟面试,一上来我就甩了这道题:“Spring Boot + Redis + Kafka,请你设计一个电商秒杀场景的高并发方案。”
他愣了两秒,脱口而出:“这不就是 Redis 减库存,Kafka 削峰吗?接口请求先进 Redis 把库存扣了,扣成功的丢进 Kafka,消费者异步落库。”
逻辑没毛病,但等我接着往下问——“ Redis 的库存扣减怎么保证原子?”“Kafka 消费丢消息了怎么办?”“用户重复点击秒杀按钮你会怎么挡?”“库存 Key 被击穿了你如何兜底?”——他就逐渐开始卡壳了。
这道题之所以高频出现在一线平台的面试里,恰恰不是因为它是标准答案式八股,而是它能把一个候选人关于高并发系统的真实认知深度一层层扒干净。这篇文章不是为了让你背答案,而是想把我陪练过程中整理出来的、面试官真正想听的那套完整设计逻辑,以及实战里踩过的坑,一次性说清楚。文章适用对象很明确:准备后端方向面试的朋友,或者正在从零搭秒杀、抢购类方案、被线上流量打疼过的开发者。
1. 面试开场:当面试官抛出“秒杀设计”时,他真正想听的不是你背过多少组件
1.1 秒杀和普通商品交易的本质区别:流量形态与容错红线
很多候选人把秒杀当成“普通下单接口的高并发版本”,这是第一个致命误解。普通交易场景里,流量是分散的、稀疏的,哪怕双十一大促也是整体流量高,但分摊到每个商品SKU上并没有那么极端。秒杀则完全不同:一场秒杀活动往往在开放前几秒涌入平时几十倍甚至上百倍的请求,而且这些请求几乎全部打在同一个商品、同一个库存Key上。
这种流量特征带来三个绕不开的矛盾。
第一是读多写少但读也爆炸。秒杀页面是典型的一万人点同一个按钮,查询同一个商品状态。你如果让这些请求全部打到数据库上,任何一个 MySQL 实例都扛不住这种热点访问。
第二是库存有限导致“先到先得”必须在极短时间内判定。库存可能只有几百件,但请求是几万几十万,你必须在毫秒级别告诉用户“抢到了”还是“没抢到”。这个判定如果串行化执行,响应时间会放大到无法接受。
第三是超卖是绝对红线。对电商系统来说,库存扣成负数就是重大事故。而有这个红线在前面压着,各种“为了性能牺牲一致性”的骚操作都会受到限制。
所以在开口前,你应该先告诉面试官一句话:“秒杀设计的本质,是在极端流量下同时保证不超卖、快速返回、系统不雪崩。后面我所有方案都围绕这三条展开。”这句话一出来,面试官对你的印象就不再是“背过组件”,而是有系统性的问题拆解能力。
1.2 候选人嘴里那句“Redis减库存Kafka削峰”到底缺了什么
回到小兄弟那句回答。拆开看它确实覆盖了两层关键组件:Redis 负责在内存中快速判定“有没有资格”,Kafka 负责把“资格确认”和“真实落库”两个动作解耦。这也是绝大部分秒杀架构的基本骨架。
但面试官真正追问的通常是这几个层面,一个比一个深:
第一层:Redis 扣库存安全吗?“减库存”三个字写起来是一行代码,但并发场景下这一行代码能玩出花。不能是get()+decr()两步,那在并发下必炸;也不能加一个全局分布式锁把整个秒杀接口串行化,那性能又回去了。这里面的关键点是Lua 脚本,把“判断库存是否够、扣减库存、标记用户已购”这三步在一个原子操作里完成。这个知识点绝大多数候选人只会背结论,说不清为什么。
第二层:Kafka 消息可靠吗?消息中间件不是保险箱。生产者发送失败、消费者处理失败、消息重复投递,每一种情况都可能发生。面试官会一直追问到“到底怎么才算不丢不重”——你需要具体说出生产端acks=all、幂等开启、消费端手动提交 offset、消费业务做幂等落库,这些是实打实能落地的配置和设计。
第三层:Redis 和 MySQL 最终一致,怎么保证?这是整条链路里最容易翻车的一环。Redis 里扣了库存,消费者在数据库里建单失败怎么办?Redis 扣减成功了但数据库扣库存超卖怎么办?没有状态机和补偿逻辑,这套架构就是空中楼阁。
概括下来,面试官其实是在验证三件事:你有没有瓶颈意识,你有没有一致性与兜底意识,以及你有没有真正上线过这套系统、知道它哪块最容易坏。
2. Redis 挡在第一线:库存预扣不是“setnx一下”那么肤浅
2.1 为什么不直接写数据库:一点算力账和一笔并发账
要讲清楚方案,你得先讲清楚为什么把 Redis 放在最前面。最直白的理由是:数据库扛不住这种瞬时写并发。
我们算一笔账。假设一场秒杀 5 万用户同时请求,库存 300 件。每个请求进来第一件事都是“查一下库存还有没有”,然后“尝试扣库存”。如果用 MySQL 直接扣,标准的 UPDATE 语句会锁住该 SKU 对应的库存行,更新完才释放。在高并发下,这行记录就是全场的串行瓶颈,MySQL 的 TPS 能到几千已经不错,而秒杀瞬时 QPS 往往是几万到几十万。结果就是大量请求排队等锁,接口超时,然后用户疯狂重试,又制造更多请求,最终把数据库打瘫。
Redis 为什么适合干这件事?首先它是一个内存数据库,单线程事件循环模型决定了它天然没有锁竞争的问题,单机 QPS 可以达到十万级甚至更高;其次它支持Lua 脚本,可以在一次调用里完成复杂逻辑且原子执行。
这里我常用一个类比:MySQL 就像一家只有一个现金柜台的银行,再多人排队也快不起来;Redis 则是在银行门口先设一个“取号机”,大多数人在取号阶段就被快速分流,只有少数真正能办理业务的人进入银行内部。秒杀的“取号”就是 Redis 库存预扣,“进银行办理”就是异步落库。
2.2 用 Lua 脚本做库存预扣:防超卖的核心动作
先看数据结构。我习惯的做法是为每个秒杀商品维护两个 Key:
- 库存 Key:
seckill:stock:{activityId}:{skuId},类型 String,值就是剩余库存。 - 已购买用户 Key:
seckill:bought:{activityId}:{skuId},类型 Set,存放抢购成功的用户标识。
为什么需要一个 Set 而不是只用库存减一?因为秒杀必须防重复购买。同一个用户在同一场活动里只能中一次,如果只靠库存计数器,用户按两次按钮就能拿到两个资格。所以需要 Set 来记录谁已经成功了,后续判断用户是否已在集合中,是或不是,都是 O(1) 操作。
Lua 脚本流程如下:
-- KEYS[1]: 库存key seckill:stock:{activityId}:{skuId} -- KEYS[2]: 已购用户set seckill:bought:{activityId}:{skuId} -- ARGV[1]: 用户ID -- 返回 -2 表示重复秒杀,-1 表示已售罄,1 表示成功 local exists = redis.call('sismember', KEYS[2], ARGV[1]) if exists == 1 then return -2 end local stock = tonumber(redis.call('get', KEYS[1])) if stock == nil or stock <= 0 then return -1 end redis.call('decr', KEYS[1]) redis.call('sadd', KEYS[2], ARGV[1]) return 1这段脚本的核心价值在于“判断 + 扣减 + 标记”三步在 Redis 里是原子执行的,不会被其他客户端命令插入打断。因为 Redis 执行 Lua 脚本时是单线程的,整个脚本在执行期间不会穿插其他命令,所以根本不需要再加分布式锁。这一点很多面试者没转过弯:防超卖靠的是原子操作,不是锁。
Spring Boot 里调用这段脚本也很简单,核心代码如下:
public SeckillResult trySeckill(Long activityId, Long skuId, Long userId) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("seckill.lua")); script.setResultType(Long.class); List<String> keys = Arrays.asList( "seckill:stock:" + activityId + ":" + skuId, "seckill:bought:" + activityId + ":" + skuId ); Long result = redisTemplate.execute(script, keys, userId.toString()); if (result != null && result == 1L) { return SeckillResult.success(); } return SeckillResult.fail(result); }这里有一个容易踩的坑:Redis Key 过期时间要谨慎设置。秒杀活动结束后,库存 Key 和已购 Set 都建议设置一个合理的 TTL(比活动结算时间稍长),比如活动结束 24 小时后自动清理。如果 TTL 设太短,活动还没结束 Key 就没了,库存数据会丢失得莫名其妙;如果设太长,大量历史活动 Key 堆积在 Redis 里,浪费内存。
2.3 分布式锁有没有必要:别让你的锁成为秒杀的性能杀手
很多人在回答秒杀防超卖时会顺嘴抛出一句“加分布式锁”。每次听到这句话我都想问:你要锁的是什么?如果是全局锁,把整个秒杀接口串行化,那并发能力会退化到和数据库差不多;如果锁粒度是“用户 + 商品”,那实际上 Lua 脚本已经实现了这个效果,再加锁反而徒增复杂度。
所以我的结论是:能用原子脚本解决的场景,不要用分布式锁。分布式锁在秒杀链路里真正有价值的地方,是缓存重建和后台库存同步这类低频操作。比如 Redis 中的库存数据需要从数据库初始化加载,这个过程如果不加控制,大量请求同时发现缓存为空,会一起回源数据库,把数据库击穿。这个时候才需要一把粒度在“SKU 维度”的锁,让只有一个线程去加载缓存,其他线程短暂等待。
如果必须使用分布式锁,比如用 Redisson,有几个细节不能忽略:
- 锁过期时间一定要远大于业务实际耗时。Redisson 的看门狗机制会默认每 10 秒续期一次,如果你是自己实现 Redis
SET NX EX这种原生锁,就要把过期时间设置成业务耗时的 5 到 10 倍,并保留“执行完后手动删除锁”的代码。 - 锁的 Key 设计要分散。如果所有 SKU 的锁都落在同一个 Key 或同一个 Redis 分片上,这个分片会成为热点,极端情况下 Redis 集群某个节点 CPU 被打满。
- 锁失效后的兜底依然是必须的。无论锁做得再稳,缓存重建后数据依然要通过 Lua 脚本的逻辑进行校验,不能因为加了锁就认为万无一失。
3. Kafka 削峰:不是简单“插一条消息队列”,而是要解决顺序、幂等、积压
3.1 Kafka 在秒杀链路里的真实角色:缓冲池与解耦器
Redis 预扣库存成功后,用户拿到的是“秒杀资格”,但订单还没有真正生成。如果此刻直接写数据库,数据库承受的写并发等于 Redis 成功放行的请求量。假设库存 300,Redis 成功放行 300 个请求,这 300 个写请求如果同时涌入数据库,MySQL 也能扛——真正的问题在于,有些大促场景下会设置“候补”或“预放行”逻辑,Redis 成功放行的量可能远超真实库存,或者用户量巨大导致数据库连接池被占满。
更合理的做法是:Redis 扣减成功后,把一条“待创建订单”的消息发送到 Kafka,然后立刻给用户返回“抢购成功,正在排队生成订单”的结果。后续消费者异步处理消息,完成真正的订单创建和数据库库存扣减。这就是削峰填谷——用户端感知到的等待时间很短,而数据库的压力被拉平到一个更长的时间窗口内。
需要注意:这里 Kafka 解决的是写流量的削峰,不是读流量。用户读取商品状态、查看是否抢到,这些读请求在秒杀场景下同样巨大,它们应当走 Redis 或本地缓存,不要让读请求穿过 Kafka 链路。
3.2 消息不丢不重:生产端配置与消费端幂等缺一不可
延迟和吞吐是队列的基本盘,但秒杀对消息还有一个刚性要求:不能丢,也不能因为重试而重复建单。
先从生产端说起。我用 Spring Boot 集成 Kafka 一般会在配置里这样设置生产者:
spring: kafka: producer: acks: all retries: 3 max.in.flight.requests.per.connection: 1 enable.idempotence: true几个参数背后的理由:
acks: all:要求分区 Leader 和所有 ISR(同步副本)都确认消息写入,最大程度避免消息写入 Leader 后还没同步副本就返回成功,Leader 随后崩溃导致消息丢失。enable.idempotence: true:开启生产端幂等,Kafka 会为每条消息分配序列号,即使网络抖动导致生产者重试,Broker 也不会重复追加。max.in.flight.requests.per.connection: 1:保证同一个连接上最多只有一个未确认的请求,配合幂等可以保证消息的顺序不被重试打乱。
消费端就更有讲究了。核心两点:关闭自动提交,业务处理成功后手动提交 offset;消费逻辑必须幂等。
@Component public class SeckillOrderConsumer { @KafkaListener(topics = "seckill-order-topic", groupId = "seckill-order-group") public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) { try { SeckillMessage msg = JSON.parseObject(record.value(), SeckillMessage.class); // 幂等校验,见 4.2 防重表 orderService.createSeckillOrder(msg); ack.acknowledge(); // 处理成功再提交 } catch (Exception e) { // 记录到重试表或死信,而不是无限重试 log.error("消费秒杀订单消息失败", e); retryService.saveRetryRecord(record); } } }这里有一个实战中很常见的错误:把ack.acknowledge()放在 try 块外面,或者放在消息处理之前。一旦消费者拉取到消息就先提交 offset,但业务处理抛异常了,重启后这条消息永远不会被再次消费——消息就丢了。先处理完业务,再提交 offset,这是消费端不丢消息的底线。
与此同时,消费端还要防重复。Kafka 在“至少一次”的语义下,同一个消息可能因为消费端崩溃重启、分区再均衡而被重复投递。防重的关键不是靠 Kafka,而是靠业务侧的幂等设计,这个我放到第 4 节详细说。
3.3 顺序性:同一个用户的消息必须进同一个分区
秒杀相关消息的顺序问题,比普通业务更敏感。用户可能在同一场活动里先秒杀成功,再主动取消,然后再秒杀(如果业务允许)。如果这两条消息被分发到不同分区,消费顺序一旦错乱,数据库里可能出现“订单已取消但跟着又创建成功”的脏状态。
Kafka 保证的是分区内有序、分区间无序。所以设计分区键时,必须把同一个用户的关联消息打到同一个分区。
// 以用户ID作为分区键 ProducerRecord<String, String> record = new ProducerRecord<>( "seckill-order-topic", userId.toString(), // key JSON.toJSONString(message) );同一用户 ID 的 hash 值稳定对应到同一个分区,这个分区由同一个消费者线程串行拉取消费,顺序就保住了。如果业务设计上同一个用户同时只能有一个有效秒杀资格,顺序问题不会太复杂;一旦涉及“创建—取消—再创建”这种多状态流转,顺序保障就是刚需,面试时能主动说出这一层,含金量会明显高出一截。
3.4 积压与降级预案:不要心存侥幸
秒杀流量洪峰过后,Kafka 消费 lag 可能维持在高位。面试中聊到这里,如果你能提出下面这套监控与降级策略,基本可以让面试官确认你真正经历过线上场景。
第一,盯 lag。消费 lag 的绝对值参考意义不大,要看趋势。如果 lag 持续上涨,马上定位消费者处理耗时,看是不是数据库连接池不够、SQL 慢查询、还是下游接口超时。
第二,设置降级开关。当 lag 超过阈值时,可以临时关闭“新秒杀请求进入”,已经拿到资格但还没落库的用户看到的状态是“排队处理中”。这个状态是业务上允许的,因为 Redis 已经预扣库存,资格已经被锁定,用户不会因为你延迟建单而丢失资格。
第三,准备快速落库模式。如果消费者处理速度跟不上,一个可选的降级是让消费者只做“最小必要动作”,直接把消息内容写到本地 MySQL 的订单临时表,跳过耗时的远程调用,后续再异步补齐数据。这属于降级但保订单的策略,能极大缓解消费瓶颈。
4. 数据库终态落库:最终一致性里最容易被忽略的三件事
4.1 落库时机与状态机:别在一个大事务里塞太多动作
当消费者从 Kafka 拿到一条“创建订单”的消息时,落地动作不是无脑 insert。我会把它拆成以下几步:
- 幂等校验(查防重表,判断消息是否已处理过)。
- 创建订单记录,状态为
CREATED。 - 扣减数据库库存,通过 UPDATE 返回行数判断是否成功。
- 如果失败,订单状态改为
CANCELLED,并触发 Redis 库存回补。
这个过程中,订单有一个清晰的状态机:CREATED是初始态,后续支付成功转PAID,超时未支付或库存扣失败转CANCELLED。状态机的好处是,无论中间哪一步出问题,系统都知道“现在这条数据是什么状态,下一步能做什么”。
还有一个实战细节:事务里只放必要的数据库操作,发消息、发通知、调远程接口这些尽量放到事务提交之后。如果把发 Kafka 消息写进事务里,一旦事务提交前网络抖动,就可能出现“数据库订单已经创建,但通知消息没发出去”的尴尬局面。Spring 里可以用TransactionSynchronizationManager.registerSynchronization在事务提交后执行这些非核心动作。
4.2 防重表与唯一订单号:把重复请求挡在数据库门外
秒杀链路里“重复”是常态:用户手抖点两下按钮、Kafka 重复投递、消费者重试。防重表就是最后一道闸门。
订单号生成规则我建议这样设计:活动ID + SKU ID + 用户ID + 随机因子,然后在订单表或独立的防重表上对这个业务订单号建立唯一索引。消费者处理消息时先尝试插入,插入时如果撞了唯一索引,说明这条消息已经被处理过,直接跳过,不返回异常。
CREATE TABLE seckill_order_unique ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_id VARCHAR(64) NOT NULL COMMENT '业务幂等号', user_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这套设计同时解决了两类问题:Kafka 重复投递不会造成重复订单,用户重复点击不会产生多单。面试时如果能主动讲出防重表和唯一索引这层,说明你真的处理过“分布式系统里重复消息”这个脏活。
防重表也不是只入不出的,它是无界增长的大敌。建议定期归档清理,比如超过 90 天的数据迁移到历史表,避免主表越来越大影响查询和写入性能。
4.3 扣减库存的 SQL:受影响行数比 SELECT 再 UPDATE 可靠得多
数据库库存扣减是不允许超卖的。最朴素的写法:
UPDATE seckill_sku_stock SET stock = stock - 1 WHERE activity_id = ? AND sku_id = ? AND stock > 0;注意条件里带着stock > 0,并且判断受影响行数是否为 1。这种写法能保证:库存不够时即使并发执行,也不会扣成负数,因为 UPDATE 的条件在数据库层面对行加了写锁,后到的请求会进行条件判断,无法越过stock > 0的约束。
但这又会引出一个新的问题:所有并发 UPDATE 都集中在同一个 SKU 的同一行上,行锁竞争激烈会让数据库压力陡增。这是生产环境会真实遇到的问题。一个进阶做法是把库存分片存储——比如 100 件库存拆成 10 个逻辑仓位,每仓 10 件,落库随机选仓扣减,把热点行从 1 拆成 10。代价是逻辑变复杂:仓位不足时要做二次路由,仓位间的数据一致性也要靠补偿任务核对。面试能聊到这里,说明你不只是背了“乐观锁/悲观锁”这个级别的话术。
还有一点必须想清楚:Redis 预扣和数据库扣减之间不是强事务关系。Redis 里预扣的库存是“资格”,数据库扣减的库存是“真实库存”。消费者成功落库后,Redis 库存无需回补;消费者处理失败导致订单取消时,才需要回补 Redis。这就是最终一致性的含义,不要试图用分布式事务把这两段强绑定在一起——秒杀链路的可用性要求远大于强一致要求,只要在秒级内最终对齐就是合格设计。
5. 面试追问阶段的高频展开:缓存击穿、接口防刷、页面降级
5.1 热门 SKU 的缓存击穿:库存为 0 也是一种需要缓存的结果
秒杀场景最有翻车风险的地方,不是流量本身,而是流量瞬间打到已经过期的 Key 上。假设秒杀开始后 Redis 中的库存 Key 因为某种原因失效了(比如缓存过期策略配置失误),下一秒所有请求同时发现缓存 miss,就会一起回源数据库查库存——数据库大概率直接被打垮。
防御手段有三个层次:
- 互斥锁重建缓存:缓存 miss 后,只有一个线程去数据库加载数据并写回 Redis,其他线程短暂自旋等待。这个锁的粒度要控制在 SKU 维度,不能全局一把锁。
- 逻辑过期:库存 Key 永不过期,但 value 里带上一个逻辑过期时间。查询时发现逻辑过期,不阻塞请求,直接返回旧值,由后台异步任务更新缓存。这种方案对秒杀这种读多写少、允许短暂陈旧数据的场景很合适。
- 本地缓存挡热点:在应用进程内用 Caffeine 缓存秒杀商品的状态,查询优先走本地缓存,未命中再查 Redis。秒杀商品的规模有限,本地缓存能极大减少 Redis 的访问压力。
这里有个反直觉的点值得强调:库存为 0 这个结果也要缓存。商品卖完后,如果不去缓存“已售罄”,而是让请求反复穿透 Redis 和数据库,那一万个没抢到的人会变成一万次数据库查询。正确的做法是把“已售罄”状态也写入 Redis 并设置一个合理的过期时间(比如活动结束前一直有效)。
5.2 用户维度限流与接口防刷:秒杀接口的“快失败”哲学
秒杀接口的行为设计上有个很重要原则:对超出的流量要快速失败,而不是让用户长时间等待。一个用户点击秒杀按钮,如果系统超过两三秒还没返回,他会焦虑、重试,然后制造更多垃圾请求。
常规的限流方案包括三层:
- 网关层全局限流:使用令牌桶或者滑动窗口,保护后端整体容量。比如 Nginx
limit_req或网关路由策略。 - 用户维度限流:同一用户在同一场活动里只允许一个并发请求,后续请求直接返回“正在处理中”。这个可以用 Redis 计数器实现,INCR + EXPIRE 组合,再配合 Lua 保证原子性。
- 业务前置校验:验证码、答题、风控标签、账号状态,这些虽然简单但非常有效。秒杀最怕的不是技术扛不住,而是大量脚本和机器人耗尽资源。你不需要在面试里展开风控算法,但必须提到这一层存在。
5.3 动静分离与页面降级:秒杀链路不只有后端
如果你只说后端接口,面试官可能会觉得你想得不够全。秒杀场景的流量大头其实在页面:商品详情、倒计时、按钮状态、价格信息。这些资源如果是纯静态的,就应该走 CDN;如果必须动态渲染,也应该通过服务端渲染并输出到边缘节点,而不是每次都由后端实时生成。
秒杀按钮的“点击前 / 点击中 / 点击后”三种状态,通常会拆成静态页面 + 动态接口异步刷新。用户在秒杀开始前不断刷新页面,命中的是 CDN 静态资源;秒杀按钮可点的那一瞬间,前端才向后端发起真正的动态请求。这套动静分离的做法在面试里说出来,会显得你有端到端的架构视角,而不是只盯着后端中间件联动。
6. 复盘谢飞机式追问:选型逻辑、方案边界和面试话术
6.1 为什么选 Kafka 而不选 RabbitMQ / RocketMQ:选型不是单选题
面试官大概率会追问:“为什么是 Kafka?”我常用的回答思路是这样的:
Kafka 的核心优势是吞吐量高、堆积能力强、分区模型天然支持并行消费。秒杀场景的消息特点是量大但逻辑简单,消费者只要拿到消息后建单即可,不需要复杂的路由和死信策略,这正好是 Kafka 的主场。RabbitMQ 功能很丰富,但单机吞吐和积压能力相对有限;RocketMQ 的事务消息确实能提供更强的消息事务语义,秒杀其实也可以用,但如果团队已经有成熟的 Kafka 基础设施,通常不会为了秒杀业务单独再维护一套 RocketMQ。
这句话听起来像和稀泥,但恰恰是面试官想听的:技术选型取决于团队现状、运维成本和技术栈惯性,而不是简单比参数“谁最强”。你能说出这个层面,比背下“Kafka 吞吐百万级”这种数据要高明得多。
6.2 这套架构的边界:什么业务不该硬套 Redis + Kafka
聊完方案,我会主动补一句:这套架构有成本,不是所有秒杀都值得上。
中小型活动、几十万用户量级的抢购,可能直接 Redis 扣库存 + 定时任务同步数据库就够了。引入 Kafka 意味着要处理消费者幂等、积压监控、分区扩容、消费异常重试等一堆运维问题。这些隐性成本对一个偶尔举办一次的小型活动来说完全不划算。架构的本质是取舍,不是堆组件。面试里主动说出“这套方案在什么样的量级下是过度的”,反而会被认为是真正独立做过架构的人。
6.3 面试节奏与话术:把话说在面试官提问之前
根据我陪练过程的复盘,整理出几条很实用的回答节奏:
第一,开头必须给主线。不要一开始就钻进细节。先说一句:“整体链路是:请求进来先过网关限流和前置校验,然后进 Redis 做原子扣减,成功的进入 Kafka,由消费者异步落库,数据库做最终兜底。下面我按这条链路展开。”
第二,主动抛出方案的缺点。比如说“Redis 库存和 MySQL 库存存在最终一致性问题,所以数据库必须有状态机和补偿”“Kafka 消费无法完全避免重复,所以订单表必须有唯一约束”。这种主动暴露短板的话术,会明显拉开你和其他只会背优点的候选人之间的差距。
第三,解释参数背后的原因。背住acks=all、enable.idempotence=true只是第一步,能解释为什么它们能保障不丢不重、以及代价是什么(比如吞吐会下降),才说明你真的处理过大流量下的消息可靠性问题。
写到这里,我其实最想分享的一点个人体会是:这套秒杀设计看起来组件很多,但落到真实项目里的优先级排序永远是清晰的——先保证不超卖(用原子扣减),再防止缓存击穿(让 Key 不轻易失效),最后才谈吞吐优化(队列削峰)。前两条做不到,后面再花哨都是空中楼阁。
如果你正在准备面试,试着不用这篇文档,自己把整条链路在白板上画一遍,然后从每一个节点的故障问自己一句“它挂了怎么办”。能扛住这一连串自我追问,这道题你就真的吃透了。