Java面试现在最怕遇到的,不是手写单例,也不是让你讲 HashMap 源码,而是甩给你一个具体场景:线上接口突然变慢,你怎么排查?相机重复上报车辆数据,你怎么保证不重复入账?库存扣成负数,怎么避免超卖?这类题目就是典型的 Java 面试场景题。
很多人的真实困境是:八股文背得很熟,一到场景题就只能把背过的概念往外倒,什么缓存穿透、消息队列、分布式锁说了一堆,面试官再追问一句“你们项目里怎么落地”,当场卡住。原因很简单,场景题考察的不是记忆,而是你有没有真正处理过问题、有没有自己的排查路径和取舍逻辑。
这篇文章我按实际面试节奏拆一遍:先讲场景题和八股文的差别,再拿一个非常常见的停车场车牌识别项目作为案例,把重复上报、 MQTT 断线重连、并发扣库存、接口超时、消息堆积、OOM 排查这些高频场景逐个拆开。后面也会给出我自己准备场景题的方法,适合那些想要在简历项目里挖出面试题的 Java 开发者。
1. 面试场景题的本质:从“背过什么”进化到“处理过什么”
1.1 八股文和场景题的分界
八股文问的是“是什么”,比如:
- HashMap 底层结构是什么?
- volatile 保证可见性还是原子性?
- Spring 的 Bean 生命周期有哪几步?
这类题有标准答案,背下来就能得分。但场景题问的是“怎么办”,比如:
- 项目上线后,某个接口响应时间从 200ms 涨到 5s,你怎么排查?
- 支付回调重复发来,怎么保证只处理一次?
- 优惠券发多了,你怎么在现有代码上快速止血?
场景题没有唯一标准答案,但面试官心里有一套判断标准。他们要看你三个东西:
第一,遇到问题时的分析顺序。是不是先看现象,再确认影响范围,再查日志,再定位代码,而不是上来就改参数。
第二,对技术边界的理解。你知道缓存穿透可以通过缓存空值解决,但你知道空值会带来脏数据吗?你知道 MQTT 可以设置 QoS,但你知道 QoS1 底层也可能重复投递吗?知道边界,才说明真用过。
第三,方案落地的颗粒度。只回答“加 Redis 分布式锁”不够,要能说清锁的 key 怎么设计、过期时间设为多少、获取锁失败后是等待还是直接返回、锁释放的异常兜底怎么做。
我经常遇到一种候选人:能把 Redis、RabbitMQ、微服务这些单词说得很溜,但让他结合自己的项目,只能说出“我们用了 Redis 做缓存”。这时候面试官基本就知道了,项目要么不是他亲自写的,要么他从来没有深入思考过设计原因。
1.2 场景题的通用答题框架:先定位,再动手
我给一个自己常用的答题框架,适用于绝大多数线上故障类场景题。
第一步,复述现象,限定范围。面试官说完题目后,先不急着答,而是确认:“你说的是接口偶尔变慢,还是一直变慢?是所有接口都慢,还是单个接口慢?”很多时候,题目本身留了很大的模糊空间,你多问一句,反而显得有经验。
第二步,按链路排查,而不是跳步。常见的排查顺序是:监控告警 -> 服务状态 -> 日志 -> 数据库 -> 中间件 -> 代码。每一步都要说清“我通过什么手段看什么指标”,比如通过 Arthas 看接口调用链,通过 JVM 命令看线程状态,通过慢查询日志看 SQL。
第三步,给出临时方案和长期方案。面试官喜欢听到“先止损,再治本”的思路。比如缓存雪崩,临时方案可以是直接把服务降级或者限流,长期方案才是 TTL 加随机值、多级缓存、热点 key 预热。
第四步,补充验证方式。方案做完了,怎么知道问题解决了?看哪些指标、跑什么测试、观察多久?这一步很多人漏掉,但恰恰是考察项目经验的关键。
这套框架不用背,平时在项目里遇到问题,主动按这个顺序走几遍,面试时自然能说出来。
2. 一个真实项目案例:停车场车牌识别对接里的场景题
2.1 项目背景怎么讲才不像背简历
简历上经常看到“本人参与停车场项目,基于 Spring Boot 实现车牌识别系统”。但如果面试官追问项目细节,很多人只会讲“我们用了海康相机、用了 MQTT、把车牌结果存到了 MySQL”。这个回答等于没讲。
正确讲法是要把场景说清楚:停车场有入口和出口,每个车道装一台车牌识别相机。相机识别到车辆后,把车牌号、过车时间、入场方向、相机编号、车道号等数据通过 MQTT 协议上报到后端服务。后端收到消息后,需要判断车辆是否可以入场或者出场,然后控制闸机抬杆,同时把过车记录写入数据库,用于后续计费。
为什么用 MQTT 而不是 HTTP?因为停车场网络环境不稳定,相机数量可能几十台甚至上百台,设备需要保持长连接,并且需要支持服务端向相机下发指令,比如远程开闸。MQTT 本身就是为低带宽、不稳定网络场景设计的即时通讯协议,适合设备上报和控制下发。
这个背景一说完,面试官基本就能判断你真的接触过这个业务。接下来面试官大概率就会顺着项目往下挖场景题,下面几个问题是我整理过的高频追问。
2.2 同一辆车被相机重复上报,如何保证数据唯一
这是一个非常经典的接口幂等场景,我几乎每次都会问。
现象是什么?相机识别到车辆后,可能因为网络抖动、设备端重试、或者 MQTT QoS1 协议本身的重发机制,导致同一条过车记录被后端收到两次甚至多次。如果不去重,带来的后果是:停车记录多算一条、计费重复、同一个车牌同一时间重复入场,闸机还可能重复抬杆。
排查链路是:先看日志,找到同一辆车在同一时间点的重复记录,对比消息体里的某个字段,看是完全一样的消息,还是消息里带了一个共同标记。如果完全一样,说明是设备重发;如果消息体里有相同业务单号,说明是同一个业务事件被投递多次。
我的处理思路通常分三层:
第一层,在消息消费入口做幂等拦截。给每条过车消息生成一个全局唯一标识,这个标识可以由“相机编号 + 车道号 + 过车时间 + 方向 + 唯一随机数”组成。消费端先查这个标识是否已经处理过,如果处理过,直接返回。
第二层,在数据库层面加上唯一约束。比如在过车记录表里对“相机编号、车道号、过车时间、车牌号”建唯一索引。就算应用层漏判,数据库也会拦住重复插入。这里要注意,唯一索引是一种兜底,不能只靠它。
第三层,对短时间内的重复消息做窗口抑制。可以用 Redis 的 setNx 命令设置一个 key,名称为car_pass:{唯一标识},过期时间设为 10 到 30 秒。如果 key 已经存在,说明短时间内已经处理过,直接丢弃。
这个回答的价值在于,它把幂等分成了不同层次,并且每层都有对应的落地手段,面试官会觉得你真的在项目里处理过重复数据。
2.3 MQTT 断线重连,消息丢了怎么办
第二个高频追问是:如果停车场网络不稳定,相机和服务端之间的 MQTT 连接断了,消息会不会丢?丢了怎么办?
要回答这个问题,先得理解 MQTT 的三个 QoS 级别:
- QoS0:最多一次,发送方发出去就不管,消息可能丢。
- QoS1:至少一次,接收方收到后要回 ack,发送方没收到 ack 就重发,所以会有重复消息。
- QoS2:只有一次,通过更复杂的握手流程保证不丢不重,但性能开销大。
在停车场这种设备接入场景里,一般不会选择 QoS0,因为丢消息会影响过车记录。但 QoS1 又会产生重复消息,所以必须配合作业幂等去处理。QoS2 通常用得少,因为部分设备端对 QoS2 支持不完善,而且高并发下性能不一定扛得住。
真正要设计的是断线重连和补偿机制。首先,服务端要监听连接断开事件,记录设备离线时间和原因。其次,客户端要设置心跳间隔,默认可以设 60 秒,如果网络差,可以调到 20 秒,但不是越短越好,太短会增加设备端功耗和服务端压力。第三,消息确认机制要可靠,不能收到消息就直接删掉,要先持久化或者做异步落库,再返回 ack。
如果消息已经丢了,怎么补偿?在停车场场景里,可以定期调用相机 SDK 查询某段时间内的过车记录,做一次增量拉取。这个动作可以放在一个定时任务里,比如每 5 分钟扫描一次离线设备,拉取离线期间的记录。这样即使 MQTT 链路丢了消息,也能通过补偿任务补回来。
面试官听到这里,基本已经认可你有处理过真实项目问题的能力。上面这个案例也从侧面说明,场景题不是靠背,而是要靠对业务细节的理解。
3. 并发和缓存是场景题重灾区:知道原理,也要能选型
3.1 缓存穿透、击穿、雪崩:别只背三个定义
这三个词很多 Java 面试者都会背,但一到场景题就出问题。我经常问一句话:“你项目里如果大量请求同时打到一个热点商品 key 上,这个 key 过期了,你会怎么处理?”如果回答“加锁”,我会继续追问“用什么锁?锁的范围是什么?获取锁失败后怎么办?”
先理清三个概念对应的场景:
缓存穿透:查询一个数据库里根本不存在的数据。因为缓存里没有,请求直接落到数据库,如果有人恶意构造大量不存在的 key,数据库可能被打垮。处理方案有:参数校验、缓存空值、布隆过滤器。
缓存击穿:一个热点 key 刚好过期,此时大量并发请求同时去数据库查。处理方案有:互斥锁重建缓存、逻辑过期、热点 key 提前预热。
缓存雪崩:大量 key 在同一时间过期,或者 Redis 实例重启,导致所有缓存查询都落到数据库。处理方案有:TTL 加随机值、多级缓存、服务限流降级、Redis 高可用。
这里我要强调一个很多人忽略的点:布隆过滤器和缓存空值并不是等价的。缓存空值实现简单,但会给缓存里塞入大量无效 key,需要设置较短的 TTL,同时要做好业务过滤,避免空值缓存被攻击者利用。布隆过滤器不需要存空值,误判率可以通过参数调整,但实现成本更高,而且布隆过滤器只能判断“一定不存在”和“可能存在”,不能完全替代数据库查询。
真正选型时,要结合项目实际情况。如果系统请求量不大,直接把不存在的数据拦截在业务代码里就够了。如果确实有热点 key 问题,可以先做 key 预热,把热点数据在活动开始前提前写入缓存。只有请求量非常大的时候,才需要上互斥锁或者布隆过滤器。
3.2 并发扣库存和接口幂等:怎么设计才不超卖
“高并发下扣库存如何避免超卖”是最经典的并发场景题。先想清楚问题链:用户下单 -> 扣减库存 -> 创建订单 -> 支付。每一步都可能出现并发问题。
最简单的错误做法是:
// 错误示例,并发下会超卖 int stock = getStockById(goodsId); if (stock > 0) { stock = stock - 1; updateStockById(goodsId, stock); }这种写法在并发请求下,两个线程都读到了库存为 1,都认为可以扣,结果库存被扣成 -1,超卖。原因是“读-判断-写”不是原子操作。
常见的解决方案有三种。
第一种,数据库乐观锁。更新时判断库存是否大于 0,受影响行数为 0 则说明竞争失败。
UPDATE t_stock SET stock = stock - 1 WHERE goods_id = #{goodsId} AND stock > 0;int rows = stockMapper.deductStock(goodsId); if (rows == 0) { // 扣减失败,库存不足或者并发冲突 }这种方案的优点是实现简单,缺点是数据库行锁会带来一定的压力,适合库存操作本身不是极高并发的场景。
第二种,Redis 预扣减。把库存放到 Redis,用 Lua 脚本保证原子性。
local stock = redis.call('GET', KEYS[1]) if not stock or tonumber(stock) <= 0 then return -1 end redis.call('DECR', KEYS[1]) return 1扣减成功后,再异步把订单落库。如果订单最终没有支付或超时取消,需要回补库存。这里要注意,Redis 扣减是“预占”,不是最终扣减,后续必须对账。
第三种,消息队列串行化。把同一个商品的扣库存请求全部发给同一个队列,由一个消费者串行处理。缺点是吞吐量受限,如果单个商品请求量极大,需要做更细粒度的拆分。
真正写代码时,还要考虑接口幂等。用户重复点击“提交订单”按钮,后端不能生成两个订单。常见做法是前端生成一个 requestId,后端收到请求后先查 Redis 里有没有这个 requestId,没有则处理并写入,有则直接返回上次处理结果。这个 requestId 也是幂等键。
现场写这段逻辑时,要主动说明:扣减库存和创建订单不能拆成两个完全独立的事务,否则会出现库存扣了订单没创建,或者订单创建了库存没扣。通常做法是本地事务 + 事务消息,或者先扣库存,再创建订单,再发消息。
3.3 现场写伪代码时要展示哪些点
面试官让你现场写代码,一般不是想看规范到你连分号都检查一遍,而是想看思路清晰度和边界处理能力。
写之前先把思路说出来:“我准备先做库存预扣减,再创建订单,最后用幂等键做去重。”说完思路再动笔,显得有条理。写的时候要注意几个点:
- 方法参数要考虑幂等键、商品 ID、数量。
- 返回值要能区分成功、失败、重复提交。
- 数据库操作要考虑事务注解,但要知道事务的作用范围。
- 异常处理要区分业务异常和系统异常。
- 最后自己写一个测试场景:第一次请求成功,第二次请求返回重复提示。
如果能够在五分钟内把这段逻辑写得完整,并且主动说出“这个方案在并发量特别大的时候,瓶颈可能在 Redis 或数据库,可以做分片”,面试官对你的评价通常会很高。
4. 微服务、接口调用和 MQ 场景题:最容易翻车,也最值得抠细节
4.1 接口超时不是换个超时时间就结束
微服务场景里有一个高频问题:调用下游服务超时了,怎么办?很多人第一反应是“把超时时间调大”。这属于典型的没有找到根因。
“调用下游超时”只是现象,原因可能有:
- 下游服务负载过高,线程池被打满。
- 接口出现慢 SQL,导致响应变慢。
- 网络带宽占用高,或者防火墙丢包。
- 下游服务被某个慢接口拖垮,引起雪崩。
- 服务重启或发布过程中连接被断开。
正确的排查顺序是:
先确认超时发生在哪个环节。如果是从网关到服务 A 超时,还是服务 A 到服务 B 超时,要在调用链上打点。然后看下游服务的监控面板,看 CPU、内存、线程数、活跃连接数、GC 频率。再看下游数据库,查慢查询日志,看是不是因为某个 SQL 扫描了大量数据。
我在实际项目里就遇到过一次:某个接口偶尔超时,排查了半天,最后发现是下游服务有一个大批量导出任务,每隔一段时间把 CPU 打满,影响了正常接口响应。这种情况如果把超时时间调大,只会让更多请求堆积在线程池里,问题更严重。
所以回答这类题的时候,要突出“超时只是一个信号,不是根因”。同时要给出降级方案,比如调用下游时设置合理的超时时间和重试次数,但重试要小心,不能无限重试,否则下游已经快挂了,你还在加重它的压力。
4.2 消息堆积和重复消费怎么排查
“消息队列里消息堆积了,你怎么处理?”这也是很常见的场景题。
首先要分清堆积发生在哪一侧。生产者生产太快,消费者消费太慢,还是消费者实例挂了?从现象排查:
- 看消费者组状态,确认消费者在线数量。
- 看每个分区的积压消息数,判断是否集中到某个分区。
- 看消费耗时,如果每条消息处理时间很长,需要优化消费逻辑。
- 看消费者日志,确认是否有大量消费失败导致进入重试流程。
如果是瞬时流量导致堆积,短期内可以扩容消费者实例,提升消费并行度。但要注意,并行度的上限往往受限于下游存储的写入能力。比如消费端直接把消息写入 MySQL,如果把消费线程从 10 个调到 50 个,数据库可能先被压垮。
更合理的方案是:消费端先把消息批量落入本地存储或者中间表,然后异步异步处理,或者合并多条消息一次性写入。比如推送通知类任务,可以先攒一批,再批量推送。
重复消费的问题要结合业务来看。如果消费逻辑实现了幂等,重复消费不会产生数据错误。幂等怎么实现?最简单的是在业务表里放一个唯一的业务单号,插入前先判断,或者用数据库唯一索引兜底。这也是前面反复强调的幂等设计。
4.3 分布式事务别一上来就 Seata
涉及跨库、跨服务的数据一致性,面试官会问分布式事务。这里最容易犯的错是,一开口就是 Seata、RocketMQ 事务消息、TCC,但根本说不清什么时候该用。
分布式事务的核心问题是:多个服务或多个数据库之间的数据一致性怎么保证。强一致方案有 2PC、3PC、Seata AT、TCC,最终一致方案有本地消息表、事务消息、Saga、对账补偿。
在大多数互联网业务里,强一致其实不是第一选择。比如订单创建、扣库存、发积分,这三个动作分布在三个服务,没必要要求所有服务在同一时刻都成功,只要最终积分能补上、库存能对上就行。这种场景用最终一致更合适。
如果项目里确实需要强一致,那就要考虑 TCC 的 Confirm 和 Cancel 怎么实现。比如库存服务,Try 阶段预扣库存,Confirm 阶段确认扣减,Cancel 阶段回补库存。但 TCC 的开发复杂度和维护成本很高,如果不是资金类、强对账类业务,不推荐硬上。
回答时最好反客为主,先说一句“我不太建议一上来就引入分布式事务框架,我会先看业务能不能接受最终一致”。这句话比直接背 Seata 概念有用得多。
5. JVM 与线上排查:用日志和命令回答,比背参数有用
5.1 OOM 现场:先看现象,再看日志,再调参数
Java 服务最常见的故障之一就是 OutOfMemoryError。很多人遇到报错,第一反应是“把 -Xmx 调大”。这个思路在少数情况下有效,比如确实低估了业务高峰期内存需求,但大多数 OOM 都是程序有问题,盲目调大只是延迟崩溃时间。
我先列一个通用的排查顺序:
第一步,看报错日志。确认是堆内存溢出、Metaspace 溢出,还是直接内存溢出。如果是java.lang.OutOfMemoryError: Java heap space,说明是堆内存不够;如果是insufficient memory,可能是进程直接申请不到系统内存。原因不同,处理方向完全不一样。
第二步,看系统资源。用jps找到对应 Java 进程,用jstat -gc <pid>观察 GC 频率和堆占用,用jstack <pid>看线程状态,用jmap导出堆 dump 文件分析对象分布。
第三步,在 dump 文件里找大对象和疑似泄漏点。最常见的泄漏来源有:
- 静态集合不断往里放数据,没有移除机制。
- HTTP 连接池、线程池使用完没有释放。
- 本地缓存无限增长,比如往 ConcurrentHashMap 里塞 key,没有过期时间。
- 数据库结果集一次查询太大,强行加载到内存。
我之前遇到过一个非常典型的案例:一个定时任务每 5 分钟同步一次数据,把结果放到一个静态 Map 里,但 map 的 value 没清理,相当于每天都在涨内存。最终表现为每过几天服务就 OOM 一次,重启后又能撑几天。这种问题靠调大 -Xmx 根本无法解决。
回答 OOM 场景题时,最好可以补一句:如果业务确实需要大量本地缓存,优先用 Caffeine 这类带淘汰策略的本地缓存,而不是自己用 Map 硬扛。这句话一出来,面试官就知道你有实际经验。
5.2 一个很常见的环境题:源发行版 17 需要目标发行版 17
这个报错在 Java 项目里非常常见:
java: 警告: 源发行版 17 需要目标发行版 17很多人遇到这个报错以为项目代码有问题,其实通常是编译配置和本机 JDK 版本不匹配。
具体原因是什么?项目 pom.xml 里设置的 maven.compiler.source 和 maven.compiler.target 可能是 17,但 IDE 的 Java Compiler 或者当前 JDK 版本低于 17,编译时无法识别更高版本的源码语法。
排查顺序:
第一步,查看本机当前 JDK 版本,命令行执行java -version。
第二步,查看 pom.xml 里 maven compiler 插件配置。
第三步,查看 IDE 的 Project Structure 里 SDK 和 Language Level。
第四步,查看 IDEA 的 Settings -> Build Tools -> Maven -> Runner,确认 JRE 是不是也指向了同一个 JDK。
如果本地 JDK 是 17,但 Language Level 还是 8,就会报版本不足。解决办法是把 Language Level 和 pom.xml 的 source/target 统一,或者调整 JDK 版本。
这个场景题反映出来的核心能力是:遇到编译问题,不要只盯着代码,要有环境排查的意识。面试官喜欢听你把“版本不匹配、IDE 配置、maven compiler、JDK 切换”这几个环节讲清楚,因为这比单纯说“重新导入依赖”扎实得多。
6. 一周刷完不是目的,把项目变成你的题库才有效
6.1 用 STAR 模型重新整理项目经验
准备场景题,最忌讳按题目列表一个一个背。正确做法是,先把自己的项目按 STAR 模型重新过一遍。
S(Situation):这个项目当时是什么场景?比如停车场项目要解决的是传统停车场车辆进出效率低、计费不准的问题。 T(Task):你在这个项目里负责什么?是负责后端服务开发,还是负责设备接入模块,还是负责整个系统的架构设计? A(Action):你具体是怎么做的?用了什么技术,为什么选它,遇到了什么困难,怎么解决? R(Result):结果怎么样?有没有可量化的指标?千万别说“性能提升了”,要说“接口平均耗时从 800ms 降到 200ms”“消息重复率从 5% 降到 0.1%”。
每一条 Action 后面,都可以延伸出几个场景题。比如你写了“用 Redis 做车牌缓存”,面试官可能会问“缓存 key 怎么设计”“Redis 挂了怎么办”“缓存和数据库数据不一致怎么处理”。你要提前把这些问题准备好。
6.2 给自己出“数据量放大”和“故障场景”题目
很多人项目里的数据量其实不大,这没关系。面试官不会因为你是中小项目就否定你,但会问假设性问题。你要学会自己提前推演:
- 如果现在出入口流量涨了 10 倍,你的服务哪里会先扛不住?
- 如果 Redis 集群宕机了,你的服务怎么降级?
- 如果数据库连接池满了,你怎么排查?
- 如果相机上报的消息量突然暴增,消费者处理不过来,怎么办?
- 如果服务在凌晨 2 点 OOM 宕机了,你现在有什么手段快速定位?
这些题目不需要你把完整架构写出来,但你至少要能说出:先看哪些指标、哪个环节最容易出问题、临时方案和长期方案分别是什么。
自己对着简历项目出 20 个问题,每个问题都写出一页纸的答案,比盲目刷题有效得多。面试中遇到类似问题,直接套用自己提前梳理好的框架就行。
6.3 表达节奏比答案长度更重要
面试场景题的时候,表达节奏非常关键。很多候选人回答问题时,从头到尾一口气说不停,中间没有任何停顿,面试官想追问都插不进去。这样反而会给面试官留下“背题”的印象。
我建议的节奏是:
- 先一句话给结论:“我认为这个问题的核心是保证消息只被成功处理一次,我会从应用层幂等和数据库唯一约束两层来解决。”
- 再说思路:“先讲一下我的排查思路,最后再补充具体参数。”
- 中间留出停顿,给面试官提问的机会。
- 如果面试官追问了某个细节,说明他对这个方向感兴趣,要抓住机会展开。
回答不出的时候不要慌,先缩小范围:“这个问题的原因可能有很多,我先讲我遇到过的一种情况。”这样既保持了主动性,又不会因为答错某个点而全盘崩。
把项目里的真实问题讲清楚,比背十道标准答案有用得多。因为场景题本来就没有标准答案,面试官想看到的,是你面对未知问题时是否能保持冷静、按顺序排查、合理取舍。
我自己带人面试的时候,最忌讳的答案不是“我不会”,而是“我没遇到过,但我觉得可以调大内存”。技术可以不会,排查问题的意识必须有。如果你能在项目每个环节都问一句“这里挂了怎么办”,你的场景题能力会自动涨一个台阶。