面试官这活儿干久了,其实挺矛盾的。一方面天天吐槽候选人都是“背题家”,八股文一套一套的;另一方面,真到了面试现场,很多号称“三年经验”的Java开发,连Spring的Bean生命周期都说不利索。今天我把最近一次面试的完整过程整理出来,这场面试从Spring全家桶一路问到消息队列、缓存,最后还加了点AI RAG的实战题,基本上把当前Java后端岗位的高频考察点都覆盖了。候选人最后是被问得有点破防,但说实话,这些问题本身并不偏,全是生产环境里每天都会遇到的真实场景。这篇文章既是给准备面试的人一份自查清单,也是给日常工作里“会用但说不清原理”的朋友一份系统梳理。
我先把这次面试的考察主线列一下,后面所有内容都围绕这条线展开:
- Spring IoC/DI、AOP、Bean生命周期、三级缓存、Spring Boot自动配置
- 缓存设计:穿透、击穿、雪崩、缓存一致性、热点Key
- 消息队列:重复消费、顺序消费、消息堆积、分布式事务
- AI RAG:从概念到落地,检索流程、Embedding、重排、Spring AI实操
1. 第一轮连环炮:Spring全家桶从背题到理解的分水岭
1.1 开场第一问:IoC和AOP,你是真懂还是只会说概念
我一般不会直接问“什么是IoC”,这种问题太容易背了。我习惯先扔一个场景:“假设你现在要开发一个订单服务,需要调用库存服务和优惠券服务,你如何组织这些依赖关系?”
有经验的候选人会说出“通过构造器注入”“用接口隔离依赖”“配合Spring管理Bean生命周期”这类回答。但水货候选人的典型表现是:背出“控制反转是将对象的创建和管理交给容器”的标准定义,然后当你追问“那容器是怎么创建的?Bean的实例化过程发生了什么?”他就开始支支吾吾。
这里我多说一句,IoC的本质不是“把new对象改成注解注入”,而是依赖关系的所有权转移。你不用Spring,你new一个OrderService,就要自己new InventoryService,还要管理它们的生命周期和销毁顺序。Spring做的事情是:通过BeanDefinition读取配置,用反射实例化对象,处理依赖注入,最后把完整可用的Bean放进容器。理解了这个链路,你才算真正理解IoC。
至于AOP,我通常会追问“你项目里AOP用过哪些场景”。回答“日志、事务、权限校验”的算及格,但我会继续追问:“Spring AOP和AspectJ是什么关系?代理对象是怎么生成的?”
核心点在于:Spring AOP默认是运行时动态代理,接口用JDK动态代理,类用CGLIB。而AspectJ是编译期/加载期织入。很多候选人只知道“Spring AOP基于代理”,但不知道CGLIB的底层用了ASM字节码生成,也不知道Spring Boot 2.x之后默认强制使用CGLIB代理(即使有接口也不再优先JDK代理)。这些细节,才是区分背题和真懂的关键。
1.2 三级缓存:一个让无数候选人翻车的“送命题”
说到Spring,三级缓存几乎是必考点。我面试时习惯直接点名:“Spring为什么要设计三级缓存?只靠两级行不行?”
这个问题的标准回答链路是:解决循环依赖。但光说“解决循环依赖”不够,我会继续追:“那二级缓存能不能解决?为什么非要三级?”
这里我说点人话解释一下。假设有两个Bean:A依赖B,B依赖A,且都是单例。Spring实例化A时,发现A需要B,于是先去创建B;创建B时发现B需要A,这时如果A还没创建完,就死循环了。Spring的解法是:A在实例化后、属性填充前,先把A的“早期引用”暴露到一个缓存里,这样B创建时能拿到A的引用先完成自己的创建,然后A再完成属性填充。
那为什么需要三级缓存,而不直接用两级?这就要说到代理对象了。如果A被AOP代理,那么B拿到的应该是A的代理对象,而不是原始对象。三级缓存里存的是ObjectFactory,在真正需要暴露引用时才调用getEarlyBeanReference生成代理。如果没有AOP,二级缓存确实够用;但有了AOP,就需要这个“延迟处理”的机制来保证代理对象能正确暴露出去。
我给的记忆锚点是这样:一级缓存是成品Bean,二级缓存是早期暴露的原始Bean(或代理),三级缓存是生成早期引用的工厂。面试时能把“三级缓存存的是工厂而不是对象”这个点说出来,就已经超过大多数候选人了。
1.3 Spring Boot自动配置:你天天用@SpringBootApplication,但你知道它背后干了什么吗
现在Java后端开发几乎人人用Spring Boot,但很多候选人问“Spring Boot是怎么做到自动配置的”,只能答出“有个@EnableAutoConfiguration注解”。
完整链路是这样的:@SpringBootApplication组合了@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。其中@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class),会去读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里的配置类列表。每个自动配置类上面通常有@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty这类条件注解,只有满足条件时才生效。比如你引入了spring-boot-starter-web,ServletWebServerFactoryAutoConfiguration才会生效;你没引入Redis依赖,RedisAutoConfiguration就不会加载。
这里我经常让候选人现场推演一个问题:“如果我自己写一个Starter,需要做哪些事?”核心就三步:写一个自动配置类,用@ConditionalOnMissingBean提供默认Bean;在META-INF下创建自动配置文件,声明配置类;定义好spring.factories或新的imports文件。能把这个流程说清楚,说明你是真在项目里封装过公共组件,而不是只看过教程。
1.4 Spring AI:当面试官开始问“AI时代Java开发者怎么办”
说句实话,2025年之后我在面试里问Spring AI的频率明显变高了。很多候选人以为AI只是Python工程师的事,但Spring官方已经把Spring AI做成了Spring生态的一等公民。
我会问一个最基础的问题:“在你现有的Spring Boot项目里,如果想接入一个LLM,你会怎么做?”常规回答是“用HTTP调OpenAI接口”。这个没错,但Spring AI的意义在于:它把ChatClient、EmbeddingModel、VectorStore这些都抽象成了Spring风格的API,让你不用关心具体厂商的SDK差异。你换模型厂商,不需要改业务代码,只改配置就行。
包括热词里提到的Spring AI Alibaba,就是阿里系适配Spring AI的实现,对国内开发者来说,集成通义千问这类模型会更顺手。这部分我在后面讲RAG的时候会展开,因为面试连环炮的最后几问,正好落在AI RAG的实战上。
2. 第二层推进:缓存连环炮,从Redis“会用”到“会设计”
2.1 缓存穿透、击穿、雪崩:这三个你分得清吗
缓存这关我从不手软,上来就是三连问:“穿透、击穿、雪崩分别是什么?你项目里遇到过哪个?怎么解决的?”
先说穿透:查询一个根本不存在的数据,缓存和数据库都没有,请求直接打到数据库。如果有人恶意用不存在的ID刷接口,数据库压力就上来了。解决方案有三个层级:缓存空值(设置短TTL)、布隆过滤器前置过滤、参数校验(比如ID格式不对直接拒绝)。
再说击穿:某个热点Key过期了,一瞬间大量请求同时打到数据库。关键在于“单个Key失效导致高并发压到DB”。解决思路是互斥锁重建缓存,或者把热点Key的过期时间拉长,甚至设置为逻辑过期。
然后是雪崩:大量Key在同一时间段集中过期,或者Redis实例宕机,导致请求全部落到数据库。解决方向有两个:过期时间加随机扰动,不要所有Key同一时刻过期;Redis高可用,用集群或哨兵。缓存降级和限流也是兜底手段。
这里我总结一个表,方便大家对照记忆:
| 问题 | 核心特征 | 典型解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器、参数校验 |
| 击穿 | 单个热点Key过期 | 互斥锁、逻辑过期、永不过期 |
| 雪崩 | 大量Key同时过期/实例宕机 | TTL随机化、集群高可用、降级限流 |
2.2 缓存一致性:Cache Aside、延迟双删,到底怎么选
缓存和数据库的数据一致性,是生产环境里最头疼的问题之一,也是面试官最爱的追问点。
我先问:”更新数据库和删除缓存,你先做哪个?为什么?“标准答案是Cache Aside Pattern:读的时候先读缓存,读不到读数据库再回填;写的时候先更新数据库,再删除缓存。为什么不先删缓存?因为你先删缓存,另一个线程读了旧数据回填,然后你再更新数据库,缓存里就是永远无法更新的旧值。
那删除缓存这一步会不会失败?会。所以有了延迟双删:更新数据库后删除缓存,等几百毫秒再删一次。目的是解决“A删缓存、B读旧数据回填、A更新数据库”这个时间差。但延迟双删的问题在于“延迟多久”不好把握,所以更推荐的做法是:订阅MySQL的binlog,通过Canal解析后异步删除缓存。这样业务代码里连删除操作都不用显式写。
说实话,在面试里能把“先更新库再删缓存+binlog兜底”这套组合拳讲出来,基本就是高分的回答。因为这说明你不仅知道理论,还在生产环境里踩过坑。
2.3 热点Key和缓存治理:线上那些“看似没问题,其实迟早出事”的细节
缓存问题不只是面试题,更是线上事故的源头。我面试时会故意说:“你项目里的热点数据,比如一个爆款商品的详情,几万QPS打过来,Redis扛得住吗?数据库扛得住吗?”
这题有几个层次。低层次的回答是“加缓存”。中层次的回答是“热点Key做本地缓存”。高层次的回答是“本地缓存+分布式缓存+数据库三级架构,配合限流和熔断”。比如用Caffeine做JVM层缓存,Guava的LoadingCache也行,第一层挡住大部分请求;第二层Redis挡住次热点;只有两级都没命中的请求才打到数据库。
还有一个经常被忽略的点:缓存Key的设计。很多人把用户ID、商品ID直接拼进Key,没有统一前缀和版本号。结果上线新逻辑时发现缓存里的旧数据格式不兼容,只能全量清理。正确的做法是Key带上业务前缀和版本号,比如product:detail:v2:{id},这样升级时只要切换版本号,旧缓存自然淘汰。
另外热词里有“旧版edge浏览器怎么修改缓存路径”和“chrome关闭后再打开闪退缓存导致”这种话题,虽然说的是浏览器缓存,但底层逻辑是共通的:缓存目录膨胀、缓存文件损坏、缓存路径权限问题,都会导致应用异常。对应到服务端,就是Redis内存满了触发淘汰策略、缓存数据序列化格式变了反序列化报错、本地缓存目录磁盘满了导致应用崩溃。本质都是缓存治理问题,只是场景不同。
3. 第三层深水区:消息队列连环炮,从重复消费到分布式事务
3.1 重复消费:为什么说这是MQ躲不开的宿命
消息队列的面试题,我最先问的是重复消费。这个问题为什么必考?因为只要用MQ,就一定会遇到,而且没有一劳永逸的解法。
我先问:”你们的消费者是怎么保证不重复处理的?“如果候选人回答”MQ不重复投递“,那就说明他连消息队列的至少一次投递语义都没搞懂。Kafka、RocketMQ、RabbitMQ这些主流MQ,默认都是至少一次投递,也就是说消费端可能收到重复消息。重复的原因很多:消费端处理完消息、还没提交offset就宕机了;网络超时,broker重试发送;消费者批量拉取时部分提交失败。
正确地回答是:消费幂等。幂等的实现方式有几种,我按常用程度排个序:
- 数据库唯一约束:插入前先查,或直接利用唯一索引去重,重复插入直接报错后捕获即可
- Redis分布式锁:处理前先尝试加锁,处理完释放,重复消息拿不到锁直接跳过
- 业务状态机:订单状态从“待支付”到“已支付”是单向的,重复消费时发现状态不对直接丢弃
- 消息表去重:消费端建一张消息消费记录表,用消息ID做唯一键,判断是否已消费
我需要强调一下:很多人以为加个“消费前查一下是否处理过”就是幂等,但并发场景下“查”和“处理”之间是有时间差的,两个线程可能同时查到“未处理”,然后都去执行,最终产生两条脏数据。所以幂等更好是依赖数据库唯一索引或Redis原子操作。
3.2 顺序消费:为什么说订单状态流转场景最容易踩坑
消息顺序问题,是第二个高频追问点。我会给一个特别具体的场景:“用户下单、支付、取消三个事件,依次发到了Kafka的三个分区里,消费者分别处理,你会不会遇到状态错乱?”
答案是会。因为同一个订单的消息如果分散到不同分区,消费并发度一上来,就可能出现“取消通知先到,支付通知后到”的情况,最终数据库里订单状态反而变成了已支付。Kafka内部只能保证同一个分区内的消息有序,所以解决思路就是:把需要保持顺序的消息发送到同一个分区。
具体做法是:发送消息时指定消息Key,订单消息的Key用订单ID,这样同一个订单的所有消息必然落在同一个分区。接着消费端也要注意:同一个分区的消息虽然是顺序拉取的,但如果你消费端开了多线程去处理,顺序还是会乱。所以要么单线程消费,要么按Key哈希到不同的处理线程里,保证同一个订单的消息被同一个线程处理。
如果你用的是RocketMQ,它有个更好的机制叫消息队列选择器,可以做到同一个业务ID的消息进入同一个消息队列,效果和Kafka指定Key类似。这个细节如果候选人能讲出来,说明他在项目里是真的处理过顺序场景。
3.3 消息堆积和分布式事务:看似不相关,其实是一件事
消息堆积这题,通常会以“如果消费者处理速度跟不上生产速度怎么办”来问。排查思路应该是有层次的:先确认堆积在哪个环节,是网络IO瓶颈、消费者并发度不够,还是下游数据库慢查询;然后用RocketMQ的CONSUME_THREAD_NUM提高消费线程数,或Kafka里增加分区数和消费者实例数;再做批处理优化,一次拉取多条消息批量入库。
这里有个容易犯的错误:很多人一堆积就立刻扩容消费者,但如果你的分区数不超过消费者数,增加消费者其实是无效的。Kafka的机制是一个分区同时只能被同一个消费组里的一个消费者消费,消费者数量大于分区数时,多余消费者会闲着。所以扩容之前,先看分区数够不够。这个是线上排查实打实的经验,文档里通常不会写这么细。
至于分布式事务,我会问RocketMQ事务消息的原理。核心流程分三步:先发半消息,broker只存储不投递;本地执行事务,执行成功提交确认,broker才真正投递消息;如果本地事务执行成功但提交消息确认时网络超时,broker会回调检查本地事务状态。这套机制的价值在于:消息确认和本地事务绑定在同一个事务里,要么都成功,要么都失败,避免了“库更新了,消息没发出去”的不一致问题。
4. 第四层前沿题:AI RAG实战,从Demo到线上踩坑全记录
4.1 RAG到底是什么?能解决什么问题
要理解RAG,得先回答一个问题:大模型的知识是有截止日期的,而且它不知道你公司内部的业务数据。那你让它回答“我们公司最新的退款政策是什么”,它只能瞎编。这时候有两种方案:微调,或者RAG。
微调的成本很高,而且要持续更新模型权重。RAG的思路则完全不同:不修改模型,而是在回答前先检索相关资料,把资料拼进Prompt里,再让模型基于这些资料回答。这样做的好处是:知识更新成本低,新增文档只要重新入库就行;可解释性强,回答里可以附上引用来源;不用担心模型遗忘知识。
RAG的完整链路可以用五步来概括:文档解析、切分(Chunk)、向量化(Embedding)、存入向量库、检索+生成。面试时能把这个链路画出来、讲清楚每一步的目的,就已经说明你系统了解过RAG。
4.2 检索质量的核心:Chunk切分、Embedding、重排
RAG的效果好坏,六个字告诉你:垃圾进,垃圾出。很多人以为RAG效果差是模型问题,其实大部分时候是检索环节出了问题。具体来说,三个细节决定成败。
第一是Chunk切分。切得太小,语义不完整;切得太大,检索回来一堆噪音。我实践下来比较推荐的做法是:按标题/段落结构切分,设置200-500个token一个块,并且让相邻块之间有10%-15%的重叠。这样既能保住语义边界,又能避免检索时刚好把关键信息劈成两半。开源工具LangChain里就有现成的RecursiveCharacterTextSplitter,但要理解它为什么好用:递归优先级切割,先按段落切,再按句子切,最后按字符切,保证每块尽量完整。
第二是Embedding模型的选择。不同的向量模型,检索效果差异很大。中文场景下,我之前用过text2vec系列和BAAI/bge系列,整体上bge在语义匹配上表现更稳。不要迷信大模型的Embedding能力,很多时候一个专门训练的中小规模Embedding模型效果反而更好。更关键的是,选好模型后不要随便换,因为换模型意味着所有向量数据都要重新生成。
第三是重排。向量检索的召回结果里,真正相关的可能排在第5名第10名。如果直接截取TopK送进大模型,噪音太多,回答质量自然差。标准做法是先用向量检索召回Top50到Top100,再用重排模型精排取Top5。我用的比较多的是bge-reranker,效果显著,但要注意它的推理延迟,所以一般只对候选结果做重排,不对全库做。
4.3 Spring AI实操:在Spring Boot项目里快速搭一套RAG
前面说了这么多理论,最后落到本轮面试的高潮:用Spring AI搭一个RAG服务。这也是热词里Spring AI Alibaba、Spring AI搭建相关搜索特别热门的原因。
我要求候选人现场口述这个流程,能说完整的人极少,但这套流程其实很清晰:
第一步,引入依赖。Spring AI提供了spring-ai-starter,向量库和模型通过配置切换。第二步,配置Embedding模型和VectorStore,比如用SimpleVectorStore做本地向量存储,或者接入Redis作为向量库。第三步,文档入库:加载PDF/Word/Markdown,切分成Chunk,调用embeddingModel.embed()生成向量,存入VectorStore。
第四步最关键,问答阶段:用户提问时,先生成用户问题对应的Embedding,然后用VectorStore做相似度检索,取TopK块,拼接到PromptTemplate里,最后交给ChatClient生成回答。严格来说,完整流程还应该加一步重排,Spring AI里有QuestionAnswerAdvisor可以帮助把检索结果传递给大模型。
有不少人问过我spring ai structured out格式化输出的问题,举例来说就是你希望模型返回JSON而不是自由文本。Spring AI提供了BeanOutputConverter,可以传入一个实体类,让模型按这个结构返回。核心原理是:构建Prompt时,把实体类的JSON Schema塞进去,要求模型严格按Schema输出。这样你把返回的字符串反序列化一下,就是类型安全的Java对象。
我给段简单示例伪代码,方便理解:
public class RAGService { private final ChatClient chatClient; private final VectorStore vectorStore; public Answer ask(String question) { // 1. 问题向量化并检索相关片段 List<Document> documents = vectorStore.similaritySearch(question); // 2. 把片段拼成上下文 String context = documents.stream() .map(Document::getText) .collect(Collectors.joining("\n")); // 3. 让模型基于上下文回答 return chatClient.prompt() .system("你只能基于以下资料回答,不知道就说不知道:\n" + context) .user(question) .call() .entity(Answer.class); // 结构化输出 } }这段代码里的similaritySearch是核心,它内部会做向量比对,返回最相近的TopK文档。实际操作时,你会发现检索结果排序、相似度阈值、上下文长度这三个参数调整,比模型本身对回答质量的影响还大。
4.4 RAG上线后的那些坑:评估、安全与成本
RAG能做出来是一回事,能稳定跑到线上是另一回事。我在面试最后会加几个开放性问题来辨别候选人的实战深度。
第一个问题是:“你怎么评估RAG的效果?”如果候选人回答“人工看几条觉得还行”,那就只能打50分。严谨的做法是准备一份测试集,分成三类评测指标:检索召回率,看正确答案是否在召回结果中;回答准确率,看生成内容是否正确;幻觉率,看模型是否编造了检索不到的信息。开源工具里有RAGAS专门干这个。
第二个问题是:“用户输入了和业务无关的恶意问题怎么办?”这涉及RAG的输入侧安全,除了大模型本身的安全能力外,业务侧可以做敏感词过滤和意图分类,避免不合规内容进入生成链路。
第三个问题是成本。RAG的消耗不只是Token费用,向量化的调用也有成本,文档更新越频繁成本越高。优化方向是:对检索到的长文档做摘要;只对增量文档做向量化,不做全量重算;加一层本地缓存,相同或相似问题的结果直接命中缓存,不用每次都走一遍RAG链路。
5. 整场面试复盘:哪些回答让我眼前一亮,哪些话一说就露馅
5.1 两类候选人的典型表现对比
面试多了以后,你会发现能把“水货”和“有经验”区分开的,往往不是某个知识点的精确答案,而是一个人的表达方式。
水货候选人的典型表现是:
- 每个概念都能说出关键词,但追问一句“为什么”就卡壳
- 全程在背术语,比如“解耦”“高可用”“微服务”,但问到你项目里怎么做的,就含糊其辞
- 遇到不会的问题,第一反应是慌了,然后开始东拉西扯
有经验候选人的表现则完全不同:
- 会主动说出方案背后的取舍,比如“这里我没用缓存,因为这个数据实时性要求太高,缓存带来的收益小于一致性风险”
- 对每个核心问题都能讲出至少一种解决过的真实案例
- 坦白承认自己的盲区,然后给出自己的排查思路,而不是硬编
我举个印象非常深刻的例子。有个候选人被问到“缓存击穿时互斥锁应该加在哪个层面”,他直接说:“我加在Service层,用Redis的SETNX做了一把分布式锁,拿到锁的线程去查数据库回填缓存,其他线程先睡眠一段时间再读缓存。后来发现一个问题,如果数据库查询太慢,后面的线程等待时间就会很长,所以把缓存重建改成了异步刷新。”就这一小段话,几乎把互斥锁方案的优缺点、优化方向全说清了。这种回答是背不出来的,一定是在真实业务里踩过坑、优化过才说得出来。
5.2 给候选人和开发者的面试准备建议
这一套连环炮问下来,我最大的感受是:现在的Java岗位面试,已经不再是单纯的“背面试题”就能蒙混过关了。考察重点正在从“你知不知道”转向“你有没有真正做过”。
我给准备面试的读者几个实操建议:
第一,把知识按“是什么—为什么—怎么办”三层结构去整理。比如Spring三级缓存,不能只知道三级缓存存在,要能解释为什么需要三级,以及项目中循环依赖怎么排查。第二,每个技术点都要和真实业务场景挂钩。消息队列的重复消费问题是和订单系统、积分系统怎么结合的,你项目里怎么处理的,处理方案有什么优缺点。第三,别忽略AI这块的新趋势。Spring AI在实际项目中已经越来越多地被问到,就算你当前项目没用过,也应该能说出RAG的完整链路和核心挑战。
我在工作里也见过不少背题比较熟的候选人,基础概念能答得一字不差,但当你把一个实际场景抛给他,比如“数据库QPS突增,你怎么定位是不是缓存失效导致的”,他就开始东拉西扯。原因很简单:他只是把面试答案输入了大脑,却没在业务里做过“输入—输出”的训练。
5.3 面试官视角的真心话
写到最后,说点真心话。一个面试官想招的,从来不是“什么都会”的人,因为技术上总会有盲区,哪怕是我自己,也经常在面试中被候选人问倒。我更看重的是:遇到没见过的技术问题,你会不会慌?你能不能快速拆解问题、给出可行的排查思路?你对自己的技术选型,能不能说出为什么这么选,而不是“别人都这么用”。
面试是双向的,候选人面试时也可以反问面试官。比如问“你们遇到缓存一致性是怎么解决的”“RAG的检索召回率目前大概什么水平”,这些反问反而会加分。因为它说明你不只是来应试的,而是真的关心技术方案怎么落地。
这场面试最后,我和那位候选人聊了很久,从他Spring基础的薄弱点,聊到他们项目的消息队列架构,又聊到AI来了之后Java开发者的学习路径。他走之前说了句话让我印象很深:“回去得把项目里的每个技术选型都重新问一遍自己为什么。”这句话还挺对的。技术这东西,你骗得了简历,骗得了面试官,但骗不了自己手里的每一行代码。