
上个月面完那家大厂回程地铁上我脑子里还嗡嗡的。面试官最后一句是“你说你去看过源码那我再问你一句Kafka幂等性到底在幂等什么”那一刻我就知道前面聊完Spring Boot线程池那些“基础”只是开胃菜真正的硬骨头才开始。介绍一下自己网名谢飞机Java后端开发干了五年多每天都在跟Spring Boot、Kafka、中间件这些东西打交道。这次去面的是一个大厂核心业务团队的高级Java开发岗。三轮面试面试官风格完全不同第一轮是个喜欢抠底层原理的架构师第二轮是做高并发平台的大佬第三轮直接抛出一个“AI Agent 企业内部RAG知识库”的真实落地场景让我现场给方案。说实话这三轮我全都踩过坑也全都在现场补了回来。这篇就当一次完整复盘。把三个方向的核心答案、实际踩坑过程以及我回来之后重新整理的生产配置方案一次性写清楚。不管你是准备面试还是正好在负责线程池调优、Kafka可靠性改造、甚至是把AI Agent塞进业务系统里的人应该都能从这里捞到点东西。1. 第一轮施压Spring Boot线程池从Tomcat到业务线程的“连环炮”1.1 现场还原面试官为什么从类加载器一路问到这里第一轮面试官很老练上来没让我背八股先丢了一个生产问题“我们有个网关服务Tomcat默认工作线程数是200压测时发现接口RT升高CPU却跑不满线程池队列一直在堆积。你说说你会往哪几个方向排查”我当时第一反应是这是一个典型的“线程池配置和流量模型不匹配”的问题。我回答的时候先按自己的排查习惯说了一遍先看线程池状态是拒绝、是排队还是空闲再看消息链路里有没有阻塞点比如数据库连接池、外部HTTP调用超时时间最后对比压测QPS和单次请求耗时反推并发线程数需求。面试官点了点头紧接着就问了第二句“那你专门讲讲Spring Boot里的线程池Async用的是什么线程池不配置默认是什么”这个问题看似基础实际是整套连环炮的入口。真正的考点不是“知不知道默认配置”而是“知不知道默认配置在真实流量下意味着什么”。我把面试现场的关键几个问答还原一下你感受一下节奏面试官Async不指定线程池Spring Boot默认给你的是什么我TaskExecutionAutoConfiguration自动配置的ThreadPoolTaskExecutor核心线程数默认8最大线程数其实是Integer.MAX_VALUE队列用的是无界LinkedBlockingQueue空闲线程存活60秒。面试官那你说这个配置下核心线程满了以后新任务去哪我去无界队列排队。无界队列意味着队列永远不会满所以最大线程数这个参数在默认配置下形同虚设真正干活的只有核心线程那8个剩下全部排队。这就是“CPU不满、RT却一直涨”的典型症状。面试官如果要改你会怎么定核心线程数、最大线程数、队列长度我按流量模型算不能拍脑袋。比如单请求平均耗时50ms要支撑200 QPS稳态并发就是200*0.0510核心线程数至少10以上再考虑峰值和限流留出弹性余量最大线程数可以到20左右队列给一个有界值比如200不要让任务无限堆积堆满了就触发拒绝策略把压力传到上游或者直接快速失败。这一段对话结束以后我很明显感觉到面试官的反馈是正向的。他要的不是“我会用ThreadPoolExecutor”而是“我知道它底层怎么流转也清楚什么配置在什么场景下会出问题”。1.2 复盘线程池面试题背后的核心考点现在把知识拆开细讲。ThreadPoolExecutor的任务流转顺序是固定的核心线程池满了之后新任务先进队列队列满了才创建非核心线程直到最大线程数最大线程数也满了才触发拒绝策略。很多人背过这个顺序但项目里一遇到问题就忘因为大家习惯性把“核心线程数”和“最大线程数”之间的关系搞混。Spring Boot默认的TaskExecutor问题就出在无界队列上。无界队列下队列永远不会满所以“最大线程数”这个参数彻底失效。你看到的效果就是不管压测QPS多高始终只有8个线程在工作其他任务全在队列里排队CPU自然跑不满RT自然直线上升。更麻烦的是如果任务长时间积压队列里的对象还会占用大量堆内存最终引发Full GC甚至OOM。还有一层是面试官经常挖的坑Tomcat工作线程和业务线程池是两个不同的池。Tomcat的工作线程处理的是HTTP请求的接入默认maxThreads200负责从Socket里读请求、写响应而你在业务代码里用Async或者自定义线程池是请求进入Controller之后再把任务丢给另一个池子执行。两个池的配置相互独立压测时如果发现Tomcat线程堆积和业务线程池堆积排查方向完全是两回事。至于线程数怎么定我习惯用“阻塞系数”来算而不是记网上的公式。IO密集型场景下线程数大约是CPU核数2再除以(1-阻塞系数)比如8核机器、阻塞系数0.8大约需要82/(1-0.8)80个线程。这里的阻塞系数指的是线程在等待IO数据库查询、远程调用、磁盘读写上的时间占比越接近1说明等待越严重需要更多线程来掩盖延迟。CPU密集型场景则简单得多通常就是CPU核数加1因为多一点上下文切换反而更划算。这套算法不是为了精确而是让你有一个可推导的起点而不是随手填一个数字。1.3 实操配置线程池参数不靠猜靠算面完回来之后我把自己负责的那几个服务全部排查了一遍统一改成了下面这套有界队列配置实测下来比默认配置稳很多。Configuration public class BizThreadPoolConfig { Bean(bizTaskExecutor) public ThreadPoolTaskExecutor bizTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数按“稳态QPS * 单任务耗时”算出来而不是拍脑袋 // 假设稳态 QPS 150单任务平均耗时 80ms并发需求 150 * 0.08 12 executor.setCorePoolSize(12); // 最大线程数核心线程 弹性容量容量给多少要看峰值流量和限流阈值 executor.setMaxPoolSize(24); // 有界队列宁可触发拒绝策略也别让任务无限堆积占内存 // 队列长度一般设为“最大线程数稳态吞吐的1秒到2秒量” executor.setQueueCapacity(300); executor.setThreadNamePrefix(biz-worker-); executor.setKeepAliveSeconds(60); // 拒绝策略选 CallerRunsPolicy调用方线程自己跑相当于天然限流 // 比 DiscardPolicy 好任务不会丢比 AbortPolicy 好不会无脑抛异常 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 优雅停机应用关闭时先等已有任务跑完最多等30秒 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }这里面有三个细节值得多说一句。第一拒绝策略我首选CallerRunsPolicy它的副作用是调用方线程会被占用等于把压力反向传导给上游会在不丢任务的前提下天然限流对支付、对账这类不能丢消息的场景很友好。第二有界队列的长度必须跟最大线程数配合队列太小会导致频繁触发拒绝策略太大又会失去“最大线程数”的意义我一般按“最大线程数在1秒到2秒内的处理能力”来估也就是最大线程数乘以单个任务耗时再乘个系数。第三Async一定要指定自定义的bean名称否则Spring Boot还是会走默认的无界队列配置。2. 第二轮追问Kafka幂等性你说你用过那你给我讲清楚2.1 现场还原幂等性一问到底第二轮面试官是做高并发中间件平台的说话节奏快问题密度高。他上来直接问“你们项目里用了Kafka吧生产端开没开幂等”我说开了他紧接着就是三连。第一个问题开了幂等它底层是靠哪几个关键字段保证不重复的第二个问题幂等生产者能不能跨分区保证严格不重复第三个问题业务要求一条消息只能被消费一次幂等生产者能不能覆盖覆盖不了的话你怎么办这三个问题我现场答得并不顺畅尤其是第三个我当时只想到了“在消费端做去重”但没有及时说出“幂等生产者只保生产端不重复消费端在at-least-once语义下照样可能重复消费”这句话。好在面试官愿意引导我才把整个链路串起来了。2.2 原理拆解幂等生产者到底在幂等什么Kafka幂等生产者的核心机制是生产端和broker端共同维护一对状态字段PIDProduceId生产者ID和Sequence Number序列号。生产者启动后会被分配一个PID在发送消息时每个分区都会维护一个从0开始单调递增的Sequence Number每发一条消息就加1broker端收到消息后会校验这个序号只接受“当前已接收序号1”的批次如果收到的序号比已记录的小或者出现跳号就判定为重复或乱序直接拒绝并抛出异常。所以Kafka幂等性真正保证的是同一个PID、同一个分区、短暂重试场景下的不重复和有序。它的场景是解决“生产者发送消息后没收到ack于是重试导致broker收到两条相同的消息”这类问题。注意几个边界。第一PID不是永久不变的。生产者进程重启之后PID会变之前记录的Sequence Number状态全部失效重启前可能已经提交但未回调成功的那批消息是有极小概率重复的。第二跨分区没有统一的序列号一个事务里写多个分区每个分区各自维护各自的Sequence Number所以幂等生产者不能保证跨分区的原子性和全局不重复。第三分区Leader切换或者broker故障恢复的过程中如果状态日志没有完全同步也可能出现少量重复。那如果非要做到跨分区的不重不丢怎么办答案是Kafka事务。事务型生产者会额外指定一个transactional.id它和PID绑定通过事务协调器维护跨分区的事务状态消息先缓存在事务里最后统一commit后对消费者可见。消费者端还需要配合配置isolation.levelread_committed才能只读取已提交的事务消息。这个方案能解决跨分区原子写的问题但有性能损耗而且在项目里真正会用到的场景其实不多通常只有“从Kafka读数据处理后写回另一个Kafka再写数据库要保证we所有环节要么全部成功要么全部失败”这种跨系统一致性诉求。2.3 高并发场景的幂等实操生产端配置与消费端去重回来之后我把项目的Kafka生产配置整理成了下面这份可以作为标准模板。spring: kafka: bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092 producer: acks: all retries: 3 enable-idempotence: true max-in-flight-requests-per-connection: 5 key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer consumer: group-id: biz-order-group enable-auto-commit: false auto-offset-reset: earliest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializerenable-idempotencetrue之后acks会被强制提升为allretries也建议开大于0否则幂等没有意义。max-in-flight-requests-per-connection在新版本里可以设置为5但如果你在旧版本上还是保守一点设成1避免乱序触发OutOfOrderSequenceException。这个属性的作用是控制单个连接上最多有多少个未确认的请求在飞行设置越大吞吐越高但乱序风险也越大。然后是消费端。很多人以为生产端开了幂等消费端就不会重复了这是最典型的认知误区。Kafka默认的消费语义是at-least-once也就是说“消息不丢”但“可能重复”。正常流程是消费者拉取一批消息处理完业务逻辑提交offset。如果业务逻辑执行完、offset还没来得及提交消费者就挂了重启后会从旧offset重新拉取这些消息就会被再次消费。生产端幂等完全管不到这个过程。消费端去重我一般用三个方案按场景选。第一Redis幂等标记用业务唯一键比如订单号作为keysetIfAbsent成功才继续处理处理完成写入结果适合高吞吐场景但要注意Redis和业务数据库的一致性。第二数据库唯一约束直接给业务表加唯一索引重复插入会报错然后捕获异常跳过这是最硬的兜底但仅适合有唯一性特征的数据。第三状态机幂等把每一条消息按业务主键查状态只有在“当前状态允许流转到目标状态”时才执行更新否则跳过。我实际做项目时最喜欢“数据库唯一约束 Redis标记”组合一个保底一个挡流量。消费端去重代码的骨架大概是这个样子// 伪代码消费前做幂等检查 String key biz:processed: record.key(); Boolean first redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofHours(24)); if (Boolean.TRUE.equals(first)) { // 第一次消费执行业务逻辑 handle(record.value()); } else { // 已处理过跳过业务逻辑直接提交offset }注意Redis里的标记尽量设置过期时间否则存量消息会越积越多同时消费逻辑和Redis写入之间要做好失败补偿比如先执行消费逻辑再写Redis则可能出现重复执行先写Redis再执行消费逻辑则可能出现Redis已经写了但业务失败导致后续消息被跳过。经验做法是记录状态时要区分“处理中”和“已完成”两种状态处理中状态带超时时间完成后改成永久标记。这样即使业务失败超时后还能重试不会漏消息。3. 第三轮开放题AI Agent RAG落地技术细节与工程取舍3.1 情景还原面试官拿着一个质检场景让我现场设计第三轮面试官明显不是做纯Java的他更像业务架构师。他点开屏幕上的一个需求文档说“我们想做一个面向一线质检人员的智能体。用户上传产品缺陷照片智能体要能结合企业内部知识库回答这是什么缺陷、应该怎么处理。这个场景就是AI Agent加RAG你从工程角度给我一个完整方案包括图片怎么处理知识库怎么存储并发能不能扛住。”我当时心里一紧。因为这类场景我在公司内部确实调研过但没有真正上线过大规模生产系统所以回答时很谨慎。我先确认了几个关键信息知识库有哪些格式图片数量级有多大智能体是同步返回还是异步通知要不要考虑权限。然后给了下面这版方案面试官没有打断我说明大方向是对的。3.2 RAG知识库的图片存储能存图片但重点在“怎么被检索”先说结论RAG知识库当然能存储图片。OSS、S3、本地文件系统甚至数据库里的BLOB字段都能存图片。真正的难点是图片怎么被检索、怎么和文本知识统一召回。RAG的核心不是“存”而是“找”。检索阶段系统需要把文本问题和知识库内容都映射到一个向量空间用向量相似度找出最相关的片段。纯文本场景好办用Embedding模型生成文本向量就行。图片场景就需要多模态能力。实际落地有三种做法。第一种是图片生成独立向量用CLIP这类多模态模型把整张图片编码成一个向量查询时用户的图片也编码成向量做相似度检索。这种方案能感知图片的视觉内容但工程复杂度高向量模型对显卡算力有要求。第二种是图片转文本再入库先对图片做OCR再配合人工标注或视觉大模型生成一段描述文本把这段文本和图片路径一起入库检索时只走文本召回命中后返回图片。这种方式成本低、效果好目前很多企业实测下来最实用。第三种是混合方案图片向量和文本描述都存检索时多路召回再融合效果最好但也最复杂。我当时给面试官的方案是第二种为主、第三种做演进。原因是一线质检场景里缺陷类型往往集中在有限的几十种人工标注的成本是可控的OCR加描述生成的文本能让知识库的检索质量快速达到可用水平。等数据量大了、算力资源充足了再引入多模态向量。面试官追问了一句“向量库里到底存什么”我补充说向量库只存向量和metadata图片ID、文本、产品型号、缺陷编码图片本体放OSS查询结果里带着OSS的URL给前端展示。这个回答他应该是认可的因为这就是今天大多数企业知识库的常规架构。3.3 RAG落地瓶颈切分、召回、上下文一个都不能偷懒RAG项目最大的特点就是“细节多每一个环节都可能让效果断崖式下降”。我给它排了个序切分、召回、重排、上下文管理。这四个环节任何一个做不好回答质量都会拉胯。切分是第一个容易翻车的点。固定按512字符切分、重叠128字符是最常见的做法但你要知道这不是银弹。如果你的文档里大量是表格、代码块、层级标题固定长度切分会把语义拆得七零八落检索的时候经常匹配到一半内容。更好的做法是按语义边界切分比如检测到新的二级标题就自动换一个Chunk表格转成文本后再切代码块单独处理。切分时还要保留块与块之间的上下文关系比如每个Chunk里附带文档标题、父级章节检索时可以按父级信息做“二次聚类”。召回是整个RAG效果的关键瓶颈。只用向量召回偶尔会发现语义相近但关键词完全不搭的内容召不回来。业界目前比较成熟的做法是多路召回加Rerank向量召回取Top20关键词召回BM25取Top20合并去重后交给一个Rerank模型重新打分再取Top5送给大模型。Rerank模型不便宜但是这一步带来的效果提升非常明显很多从“勉强能用”到“生产可用”的跨越就是靠加了一层Rerank实现的。上下文管理是另一个隐性瓶颈。你有Top5的Chunk每个Chunk可能1000多字加系统提示词、历史对话一次性塞给大模型很容易冲爆Token上限。处理方式有几种按MaxToken限制动态裁剪Chunk只保留每个Chunk里和问题高相关的句子引入长会话缓存把历史对话压缩成摘要再重新作为上下文或采用Agentic RAG的思路让智能体自己决定需要检索几次、每次检索什么而不是把所有内容一次性塞进去。Agentic RAG的好处是让“检索”从一次性的动作变成了可决策的动作能回答更复杂的问题但也意味着单次请求的LLM调用次数变多、延迟变高这两个方向本身就是互斥的要在工程上做平衡。3.4 AI Agent怎么扛并发大模型调用是瓶颈架构要对症下药面试官问“AI Agent怎么扛并发”的逻辑其实是所有Java后端人员在做AI应用时都要面对的一道坎。传统接口的瓶颈在IO、数据库、缓存而AI Agent的瓶颈在大模型推理。一个Agent请求内部可能要调用四五次LLM理解用户意图一次、规划任务一次、检索后生成答案一次、可能还要做自我纠错再调一次。单次LLM调用就要2秒到10秒一次请求几十秒非常正常。这时候如果网关线程池傻傻地同步等待用不了多少QPS就能把整个服务拖死。我的方案分四层。第一层接口层异步化收到请求后立刻返回任务ID后端消费者把任务丢进队列处理完成后通过WebSocket、SSE或者轮询接口通知前端。这样Tomcat线程不会被长时间占用。第二层智能体任务编排层做并行化多个独立的检索、多个独立的数据查询并行执行把串行链路变成DAG能大幅缩短响应时间。第三层缓存层最直接的是结果缓存相同问题直接返回历史答案进阶一点是语义缓存把用户问题向量化和缓存里的历史问题做相似度判断相似度超过阈值的直接复用答案。这块是真的能省钱的很多知识库问答场景命中率能做到40%以上。第四层限流降级再兜底为AI调用设置专有线程池并和普通业务线程池做隔离按客户等级做限流LLM调用失败时走预设的兜底话术或规则引擎保证用户体验不至于断崖。有一点特别值得展开说线程池隔离背后的并发计算逻辑。假设每个Agent请求平均需要60秒才能拿结果一个8线程的worker池同一时刻只能处理8个请求每个线程一秒钟最多完成1/60个请求整个池子的总吞吐就是8/60约等于0.13 QPS。如果你能通过并行化把单请求耗时压缩到10秒同样的线程池吞吐直接变成8/100.8 QPS提升了6倍。这个计算说明AI Agent扛并发的核心不是无限加机器而是想方设法降低单请求持有线程的时间。这也是为什么“异步化”和“尽快释放线程”在AI场景里比在传统Web场景里重要得多。4. 三轮面试之后我整理的一份“面试避坑快查表”4.1 高频问题速查表面完第三天我把面试中涉及的关键知识点做了一张速查表发在团队内部群里几个同事说挺有用。现在贴在下面。场景高频现象根因分析解决/应对方案Spring Boot AsyncCPU不满、RT升高、线程堆积默认无界队列core8最大线程参数失效自定义有界队列线程池按QPS*耗时算核心数ThreadPoolExecutor队列满了直接抛RejectedExecutionException默认AbortPolicy按业务选CallerRunsPolicy或定制降级策略Kafka生产端开启幂等后仍担心跨分区重复幂等只保“PID分区序号”不保证全局需要全局一致性时用事务型ProducerKafka消费端数据重复入库at-least-once语义offset未提交导致重复拉取Redis标记/数据库唯一约束/状态机RAG知识库图片检索不到图片没有转成可检索的文本或向量OCR描述文本入库或CLIP多模态向量RAG检索效果差召回结果与问题不相关单一向量召回不准多路召回BM25向量 RerankAI Agent并发请求一多服务直接不可用LLM调用慢、同步阻塞持线程异步化任务并行化语义缓存线程池隔离4.2 排查现场问题的几个实操路径除了背知识点面试官更看重“遇到线上问题怎么排”。我的习惯是先看线程池的运行状态再看链路耗时最后看消息队列的堆积和消费速率。线程池可以通过Spring Boot Actuator暴露的metrics看active线程数和队列剩余量也可以通过jstack抓线程dump看线程池里到底都在执行什么。如果线程全部WAITING在某个锁上那就不是线程数的问题而是锁竞争的问题。Kafka排查延迟高时先分清楚是生产端延迟还是消费端延迟。生产端要看broker端CPU、网络带宽、acksall时的副本同步情况以及有没有频繁重试消费端要看消费组是否发生Rebalance、单条消息处理耗时、消费线程数是否小于分区数。之前遇到过一次消费端延迟高最后发现是消费组里一个实例的GC时间过长导致心跳超时频繁触发Rebalance消费根本没进度。这个排查顺序很通用先隔离再分层看指标。RAG应用出问题优先查的是“检索环节”而不是“生成环节”。如果大模型回答得不好先看Top5的召回片段里到底有没有正确答案。如果正确答案没有被召回问题在切分和检索如果正确答案被召回了但大模型没用上才是提示词或上下文组装的问题。这个排查顺序能让定位问题的效率高非常多别一上来就调prompt。5. 一些真实心里话这类面试到底在考什么三轮面完之后我最大的感受是大厂面试早就不是考“你用过什么框架”而是考“你在一线踩坑之后有没有真正形成方法论”。Spring Boot线程池那轮真正区分人的不是知不知道默认线程数是8而是能不能说清楚无界队列为什么危险、核心线程数怎么根据流量算出来。Kafka幂等性那轮区分人的也不是背不背得出PID和Sequence Number而是能不能意识到“生产端幂等≠消费端幂等”以及遇到跨分区一致性问题时有什么工具可用。AI Agent和RAG那轮面试官根本不在意你是不是AI专家他在意的是你有没有系统工程思维知不知道图片知识库的存储与检索是两件不同的事知不知道大模型调用的延迟会对整个后端线程模型造成什么冲击。回来之后我做了一件事把现在负责的系统里所有没指定线程池的Async全部抓出来改成了有界队列版本给Kafka消费者补了Redis幂等标记还把多模态RAG的图片入库方案整理成了一篇设计文档准备在下次迭代里试点。这不是为了“把面试上答不上的补回来”而是这些内容本来就是生产系统里值得做的事。面试只是把平时欠的账翻出来给你看而已。最后分享一个小心得面试遇到不会的问题别急着编也别直接说不会。先说自己的第一反应再把问题拆成两三个子问题一个一个往下推。大多数面试官愿意听思考过程因为真实工作里也没有人一口气给出标准答案。把过程讲清楚哪怕结论不完美也远好过给出一个背得滚瓜烂熟但经不起追问的标准答案。