1. 场景题的本质:不是考中间件,是考你在乱局里的判断力
很多人复习中间件,习惯性地从“组件”入手:Redis有哪些数据结构、Kafka的ISR机制是什么、ZooKeeper的ZAB协议怎么回事。这种知识当然重要,但一到真正的场景题——比如“订单超时怎么处理”“秒杀库存怎么扣”“两个系统数据不一致怎么办”——就卡住了。
我参加过不少技术面,也给别人做过模拟面试,一个很深的体会是:场景题真正考察的,是你面对业务需求时,能不能在几分钟内把它翻译成一连串的技术决策。中间件只是你的工具库,正确答案从来不是“用Redis”或“用MQ”三个字,而是你要说清楚“为什么是它、在哪个环节用它、不用它的替代方案是什么、它的副作用你怎么兜底”。
场景题的题面往往很短,信息密度却很高。一个“某电商系统在大促期间订单量暴增,下游库存系统经常超时,怎么处理”的题目,背后至少藏着四个子问题:请求为什么要削峰、削峰放在哪一层、削峰之后请求怎么恢复、库存扣减的一致性怎么保证。如果只回答“引入消息队列”,这道题你只能拿到及格分,因为面试官的下一个问题大概率会是“消息队列积压了怎么办”。
所以这个归纳,我是按“场景驱动”来写的,不是按“中间件驱动”。先描述一个特征鲜明的典型场景,再拆解它背后的需求本质和权衡点,最后给出一套可以直接用的判断框架和落地步骤。这篇文章里我会把项目实践中踩过的坑也混着写进去,因为很多判断题,答案在书本上,但坑在线上真实环境里。
2. 消息队列场景:削峰、解耦和“扔出去之后的事”
2.1 什么迹象提醒你该上MQ了
很多团队不是一开始就需要消息队列的。微服务刚拆完那阵子,服务A调用服务B,同步HTTP请求最直接。直到有一天,你发现服务B的接口经常被调用方拖到超时,或者大促流量一上来,数据库连接池先被占满,你才意识到问题不在代码,而在调用链路的耦合方式。
说几个典型的判断信号:
- 瞬时流量远超下游处理能力,比如一场秒杀产生几十万写请求,落到库存库上就是灾难;
- 上游操作不求实时返回,比如注册成功后要发欢迎短信、送优惠券,用户不需要等这个结果,但系统需要保证这条指令最终被执行;
- 一个事件要触发多个下游动作,比如订单支付成功之后,要通知积分系统、通知物流系统、通知财务系统,让订单服务直接循环调用所有下游,每一方的失败都会拖累主流程;
- 上下游之间偶尔需要“断得开”,比如凌晨的批量对账,对账服务和订单服务不需要同时在线。
出现这些信号,再考虑MQ。消息队列的本质是把你“调用别人”变成“告诉别人”,把同步等待变成异步收账。
2.2 削峰填谷时你真正该关心的是积压
用MQ做削峰,表面上看就是“先收进来,再慢慢消化”。但有两个问题,经常在面试里被追问,也是线上最容易出事的:
第一个是写入瓶颈。大家以为MQ能扛海量请求,但生产端写消息本身也有上限。你让单Pod每秒写十万条,Broker端磁盘和网络照样会被打爆。所以削峰的第一道闸门,其实在入口——比如在应用层直接挡掉一部分非核心请求,或者把流量提前分片打到不同Topic、不同分区。
第二个是消费积压。大促峰值一过,消费端处理不过来,消息在Broker里越堆越多,积压从几万涨到几百万。这时候你会看到消费延迟从秒级变成分钟级、小时级。我遇到过最严重的一次,积压到几千万条,下游的“实时数据报表”变成了昨天数据,业务方直接找上门。
处理积压有一些成熟的操作套路,按优先级我一般这么做:
- 先扩容消费者实例,同时把消费线程池调大,工作量不大的场景下,扩Pod通常能快速缓解延迟。注意要把当前Topic下的分区数同步扩上去,否则消费者实例再多,分区的并行度上限是不变的。某次我遇到类似情况,几个人服务扩到8个Pod,但分区只有3个,消费速率一直上不去。
- 如果个别消息本身处理太慢,定位是不是有消息调用外部依赖超时被重试卡住。线上遇到这类问题,往往是把外部依赖的RPC超时从3s调成2s,把“卡死重试”改成“快速失败+重投递”。
- 真到了积压百万级、业务又可以临时忍受“晚一点处理”的时候,直接开一个临时的“快速消费”版本,批量拉取、批量写库,别一条一条处理。
还有一种会被问到的偏门情况:消费端完全挂了,消息越积越多。这属于故障而非性能问题。稳妥的处理是先把消息导出到本地,暂停消费,修复完代码后重新消费。不要在一堆坏消息里反复重启消费者,除了把环境搞得更乱没有任何收益。
2.3 顺序消息这个经典场景,为什么会做砸
题目里十有八九会出现“如何保证消息的顺序执行”。典型业务是订单状态流转——创建、支付、发货、完成,状态不能乱。一乱,用户看到“已发货”之前先看到“已完成”,业务就会出问题。
正确做法本身不复杂:同一个订单的消息进同一个分区,消费者只用一个线程去拉这个分区。问题是很多人把实现做成了“全局顺序”,所有消息都进一个分区、一个消费者线程,吞吐量直接腰斩。这在低并发下看不出来,压力一上来,消费速度跟不上,积压、延迟通通出现。
业务上根本不要求全局有序,只要求同一个实体的状态流有序。所以这里判断的关键点就是分区键的选择:订单号哈希进分区,就够了。反而要额外考虑的是“顺序”“还需不需要保证重试顺序”,比如消费失败后重投递,消息被丢回队列尾部,那之前的顺序就全乱了。这时候我会用延迟队列做“重试排队”,而不是简单重投。
2.4 可靠消费和数据一致性的账怎么算
消息发送成功不等于消息消费成功,这是MQ场景题里大家最容易忽略的一条。面试里常被问“消息丢了怎么办”,其实这背后藏着三个不同层面:
- 生产丢:发送时网络抖动,半路消息丢了。解决方向是让发送方确认Broker已经落盘,多数MQ的send接口默认是同步等待确认的,使用时要明确生产端的acks配置。
- 存储丢:Broker内存刷盘和副本复制之间出了问题。这就要聊副本数和同步复制机制了,核心指标是副本数大于1,并且要求ISR最少同步副本数配置合理。
- 消费丢:消费者拉完消息没来得及处理,先提交了offset。一旦宕机重启,这条消息就永远不消费了。解决方案很简单——先处理业务,再提交offset。
可一旦把“可靠”加进来,场景题真正的拐点就出现了:到底允不允许重复消费?这是所有消息场景躲不开的终极问题。答案是“允许”,但必须配合幂等设计。
我的方案是给每一条消息带上全局唯一ID,在消费侧检查这条ID是否已经被处理过。常见的落法有很多种,比如在业务表里加一个“去重表”,或者用Redis的SETNX做消息幂等标记。注意,Redis幂等要设置合理的过期时间,并且要保证“检查+处理”的整体原子性,或者在处理逻辑里利用数据库唯一键来兜底“并发重复插入”的冲突。
实际项目中,我见过的唯一例外是“本地消息表”的做法:业务数据库里放一张发消息表,业务操作和消息写入在同一个本地事务里完成。定时任务扫这张表,把没发出去的消息捞出来投递。这样推送MQ和业务操作要么一起成功,要么一起回滚,从源头规避了“业务做完了,消息却没发出去”的异地事务问题。这种方案业务侵入大,但胜在实现简单、不依赖分布式事务中间件,中小团队反而觉得好用。
3. 缓存场景:不是“用了Redis就完事”,是缓存保护了谁的命
3.1 什么时候缓存是必需品
判断缓存是否该引入,标准不是“热数据多”,而是“读多写少 + 一定容忍短暂不一致”。最常见的场景是商品详情页:流量大头在读,写的来源是运营后台,频率低得多。没有缓存时,几十万人同时刷详情,请求直接打到数据库行锁上,性能断崖式下跌。
但是,缓存最关键的作用不是“快”,而是“挡住”对数据库的直接冲击。数据库的并发能力有限,缓存挂掉的话,后面的数据库可能直接被打挂——这才是数据一致性之外更致命的问题。所以加缓存,你要想清楚:缓存是用来兜住底层数据库的命,而不只是让响应快几毫秒。
3.2 缓存穿透、击穿和雪崩,是三个不同的事
很多同学这三个概念混在一起,一半是因为名字相近,一半是因为它们都抢同一套“避免数据库被打爆”的处理手段。实际上机制完全不同。
穿透发生在一个请求要查的数据在缓存和数据库里根本不存在。每次请求都穿到数据库层,数据库明明没有数据,还要承受查询压力。比如恶意用一个不存在的用户ID刷接口,DB压力直接拉满。
解决穿透的做法,我按层次来:基础手段是“空值缓存”,查不到也缓存一个短TTL的空标记。进阶手段是“布隆过滤器”,把存量的ID或主键放进过滤器,请求来时先确认“可能不存在”就快速返回。布隆过滤器有误判率,但不会漏判,适合用来拦截那些100%不存在的请求。注意布隆过滤器需要定期全量重建来补新鲜数据,否则新产生的有效数据会被误拦在门外。
击穿针对的是单个热点key:缓存失效的那一刻,所有请求同时涌到数据库。处理手段集中在两条:一是“互斥锁”,同一时刻只让一个请求去重建缓存,其他请求短暂等待;二是“逻辑过期”,不设置物理TTL,缓存值里塞一个过期时间,查询时如果发现逻辑上已过期,则立即返回旧值,然后异步任务去刷新缓存。互斥锁会降低热点场景的并发吞吐,逻辑过期让数据短暂可见旧值,两者之间是取舍。
我实际项目用逻辑过期更多一些。原因很简单:热key在失效瞬间就是整个系统的命脉,宁可短暂读到旧版本的数据,也不要让某个线程锁把绝大多数请求挡在外面。
雪崩发生的范围更广——大量key同时失效,或者Redis节点整体宕机。大量key同时过期很好解决,固定过期时间上加一个随机数,比如基础时间5分钟,再扩散上下0-3分钟的随机值,让失效时间点错开。节点宕机就要看你的高可用方案了,常见做法是主从切换加哨兵,或者直接上集群方案。不管哪种,都要在应用侧做“降级预案”:缓存不可用时,接口直接返回兜底数据或开关快速失败,而不是让请求全部打到DB上。
3.3 更新数据库和更新缓存的顺序,其实是业务容忍度问题
这是缓存场景题里最经典的一道题:先删缓存还是先更库?先更库还是先更缓存?主流的、落到绝大多数团队都可以参考的,是“Cache Aside”模式。
Cache Aside的标准动作是:读的时候先读缓存,读不到就读数据库,再回填缓存;写的时候先更新数据库,然后删除缓存。注意到没,它是“更新库+删缓存”,不是“更新库+更新缓存”。
为什么删而不是更新?因为更新缓存有太多坑。一个是并发写顺序不确定,另一侧的旧值覆盖新值;另一个是缓存里可能需要放一个聚合结构(详情页是多种数据拼出来的),更新缓存要做复杂的读改写,性能不划算。而“删缓存”是一个幂等操作,就算删多了,也只是多一次缓存重建,不会有逻辑错误。
Cache Aside也有一个经典缺陷:更新数据库成功,但删缓存失败。对数据一致性要求高的场景,要用“延迟双删”,先删缓存,再更库,过几百毫秒再删一次——用时间差来兜住并发期间的脏读。延迟双删不优雅,但它简单,好解释,业务上一般也能接受。
如果是并发读写很密集的系统,我还会配合“订阅数据库变更”的方式,让变更事件来触发缓存删除,尽量避免应用层删缓存失败的问题。这个方案理解成本高,但长期运维下来比延迟双删靠谱。
3.4 小缓存能扛高并发的前提,是你得先想好“扛不住”怎么办
有一次我给一个团队复盘缓存压测,场景是热点商品详情页缓存突然全失,Redis流量上涨10倍,数据库流量涨了50倍,直接触发保护。大家复盘来复盘去,发现改动点不是算法也不是架构,而是“限流降级”规则没有预先配置。
所以无论缓存方案做得多好,都要补充两类能力:
- 对缓存访问链路加超时和熔断:缓存访问超过30-50ms就快速失败,而不是无限期等Redis返回,线程全部被阻塞。
- 对依赖缓存的接口配置兜底降级:缓存挂了,返回预设的Top热销数据或静态化页面,而不是穿透到数据库。兜底数据哪怕不够新鲜也比“打爆数据库”强得多。
4. 分布式锁场景:你以为在锁并发,其实在锁一个脆弱的假设
4.1 三种分布式锁的取舍,不是“谁更高级”的问题
分布式锁的典型场景是“多实例互斥执行”,比如定时任务在多个Pod上部署时,不允许同一时刻所有实例都去跑同一个任务;比如秒杀扣减库存时,防超卖;比如多个服务同时修改同一个资源时,保证临界区只有一个执行者。
实现分布式锁的常见工具有Redis、ZooKeeper和数据库,我要说的判断标准是“CP和AP的取舍”。
Redis锁是最常用的,性能最好。SET NX EX是基础实现,加上超时时间防止死锁。它的问题在于,Redis主从切换时可能出现“锁丢失”:一个节点写好锁,主节点还没同步给从节点就挂了,从节点顶上后这个锁就没了。但在大多数业务场景里,这个概率可以接受,配合业务侧幂等校验就够。
ZooKeeper锁的特点是强一致:客户端创建一个临时顺序节点,只有拿到最小序号的客户端才持有锁;会话断开节点自动删除,天然防止死锁。代价是性能和可用性低于Redis,频繁加锁场景下ZooKeeper会成为瓶颈。
数据库锁(比如悲观锁SELECT FOR UPDATE)可靠但性能差,一般只用于金融项目要对账这种极端严谨的场景。
我给一个非常实际的选型参考:
| 需求特征 | 选型建议 |
|---|---|
| 追求性能、并发大、可以容忍极小概率的锁失效 | Redis锁 |
| 强一致、锁不能丢、低并发 | ZooKeeper锁 |
| 没有额外中间件、业务低并发 | 数据库唯一索引或悲观锁 |
4.2 防误删和可重入:面试官等着这两问
Redis锁最基础的代码是两行:SET key value NX EX 5;然后执行完DEL key。但这代码有两个众所周知的坑,几乎每个场景题都绕不开。
第一个坑是错误删锁。线程A拿到锁之后,因为执行时间过长,锁自动过期了。线程B拿到锁开始执行。此时A执行完了,直接DEL——B的锁被A删掉了。接下来线程C也能拿到锁,临界区就凉了。解法是:删除前先比较value是不是自己的唯一标识,比较和删除的整个动作要用Lua脚本来做以保证原子性。
第二个坑是锁过期,业务没执行完。上例已经暴露了核心矛盾:锁超时只是用来兜底防止死锁,不是业务执行的真实时间预算。解决的常见方案是给锁续期,即“看门狗”机制:持有锁的线程在锁快过期时,自动续上几秒,直到业务结束才主动释放。续期动作也必须在持有者进程内持续进行,进程挂了就停止续期,锁自然过期释放。
4.3 真正的“场景判定”不在锁本身,而在并发模型
很多开发者的思路是“看到并发就去加锁”,但正确思路应该是“先问并发从哪里来”。
举个例子,“用户A的余额扣减”和“用户A的余额查询”,业务关键路径在于“对同一个用户余额的读写必须串行”,而不是所有请求都要串行。所以锁的粒度设计,大概率应该以userId为维度——同一个用户锁一个key,不同用户之间完全不需要串,否则你把系统并发水平直接降到1,还美其名曰“更强的一致”。
再举个例子,限时抢购场景,很多人上来就要给库存的扣减加锁。但如果库存是独立Redis计数器,直接用DECR命令保证原子,根本不需要一个分布式锁来包住“查库存-判断-扣减”三步。场景题的解题思路往往是:先看有没有原生的原子能力,再看是否需要锁来保证复合操作的独占性。
5. 复合场景里的一致性:把不可能三角变成可落地的折中
5.1 一次大促的订单链路,怎么串起MQ和缓存
我拿一个比较通用的场景做串联:某电商系统在大促期间要处理海量下单请求,同时要保证库存不超卖、订单状态能跟踪、积分和物流等下游能最终收到消息。
完整的设计链是:
- 请求先经过网关,按用户维度做限流,超过阈值的直接排队或返回“系统繁忙”;
- 秒杀的库存扣减放Redis里做原子操作,利用DECR或Lua脚本一次性完成“查余量-扣减”;
- 扣减成功的请求,才真正落库生成订单,并发送“订单创建”消息到MQ;
- 下游积分、物流、财务分别订阅消息,做各自的业务处理;
- 消息消费端做幂等(订单号+消息唯一ID),确保重复投递不会给用户发两次优惠券。
这个链路覆盖了缓存(扛读+扣库存)、MQ(异步削峰+事件通知)、锁(库存扣减的原子性)三个中间件,是一个很标准的“复合场景题”。面试时候如果能把一条链路串成这样一个有因果关系的方案,比单独背任何“XX中间件是什么”都要加分。
5.2 “最终一致”不是嘴上说说的四字词
复合场景里,所有中间件产生的数据最终必然面临一致性问题。一个真实发生过的场景是:用户支付成功后,积分系统也收到了消息,但积分服务直接掉线,消息没消费成功,offset每被提交。用户一看积分没到账,投诉过来了。
这类问题的标准实践其实就是“幂等+重试+对账”。
幂等前面提过;重试则需要给消费失败的消息设计延迟重投策略,比如指数退避从1秒重试到几分钟,重试N次仍失败就投递到“死信队列”,由人工或定时任务兜底。对账则是终极保险:每天晚上凌晨跑一个脚本,比对订单表和积分流水表,把差异记录捞出来重新补偿。
讲到这里,很多人会问:这不还是有延迟吗?对,它确实是延迟的。但你要想清楚,业务对一致性的要求、系统的可用性、性能三者之间的取舍,不可能三角是绕不过去的。你做分布式系统,绝大多数场景要的就是最终一致,用定时对账兜底,已经是工程上最稳的落法,而不是试图实现一版完全实时的强一致方案。
5.3 动态扩容和流量预案要提前设计
最后提醒一个大促/流量突增场景里特别容易翻车的问题:动态扩容预案必须基于场景写,而不是基于组件写。
很多团队预案写的是“Kafka加节点”“Redis加副本”,但真正等到流量来的时候,扩容不是你想加就能加的。Kafka扩容要考虑分区和副本的重新分布,Redis加副本要考虑主从同步的带宽成本,消息队列的消费端扩容还要看下游数据库能否接住增加的并发。
所以我自己写预案时,会围绕两个口径:
- 容量基线:单Pod能扛多少QPS、单消费者能消费多少条/秒、单库能承受的TP99延时,这些全部压测出基线。
- 风险动作:哪些操作在流量峰值时禁止做。比如禁止大表加索引、禁止批量改消息结构、禁止直接调大消费线程池而不评估下游压力。这些都是我在线上流过的血换来的教训。
6. 面对场景题,我的解题节奏和建议
聊了这么多,最后分享一套我自己面对中间件场景题的处理节奏,也算把上面所有内容收拢成一个可以执行的框架。
第一步,先把题目里的“业务名词”翻译成“技术名词”。比如“订单积压”是消费端吞吐不足,“库存超卖”是并发原子性的缺失,“用户看到数据不一致”是缓存和数据库的更新时序问题。
第二步,画一条主干链路:用户请求进来,经过哪几个中间件,每个中间件在哪一步解决什么问题。注意每加一个组件,就要预判它会引入的新问题——比如加MQ带来了积压问题、顺序问题、幂等问题;加缓存带来了穿透、击穿、雪崩问题。把这些问题连同解决方案一起说出来,场景题基本就答透了。
第三步,给出兜底方案。任何中间件都会出错,Broker宕机、缓存全失、锁失效都是可能事件。你自己的方案里要有“如果它挂了,整个链路怎么降级”的描述。有预案和没预案,是资深工程师和初学者的分水岭。
我面试别人时最看重的也是这个:他提到一个中间件,我追问一句“你假设它永远正常吗”,能接住这个问题的候选人,一般都有真实线上经验。
中间件场景题归纳到这就结束了。希望这套从场景出发、从权衡出发的拆解方式,能帮你少走一些弯路。