整理完整版(保留大白话理解,适合面试口述背诵,内容全部保留)
Spring是底层框架,SpringBoot是基于Spring开发的脚手架。Boot帮我们省去Spring里Tomcat的手动配置,内置了web服务器,还省去大量xml配置;通过starter启动器简化pom依赖的编写。
Bean常用作用域是singleton单例和prototype多例。singleton是默认,整个Spring容器里面只创建这一个对象;prototype每次从容器获取Bean都会新建一个对象。在web环境还有request,一次http请求生成一个Bean,只在本次请求内生效;另外还有session、application、websocket作用域。
RAG 库里存:某一类题型,容易踩什么坑、易错点、分析角度、给用户的建议要点。 比如:二分题型,易错点是边界处理、左右区间开闭;链表题型易错点是断链、空指针。 用户传入一道具体代码题,向量检索是把用户题目,去匹配对应的【题型易错知识点】,召回这一类题通用的分析素材,再结合用户自己做题历史、沙箱运行报错,Agent 再拼装出个性化回复。
MCP是模型上下文协议,规定大模型该怎么调用外部工具。在我理解里MCP就是专门给大模型做远程调用工具用的协议。本地调用走stdio标准输入输出通信,远程可以用SSE或者WebSocket。有了它工具调用不再局限只能调用本地写好的工具。
我的RAG库里主要存的是题型,对应做题容易出现的错误情况,还有分析的时候要考虑哪些点,从哪些角度给出代码建议。项目里的工具一个是沙箱执行工具,另一个是查看用户历史做题记录工具。通过历史记录看这道题是不是用户之前就错过,判断是不是同一个错误点,找到用户经常踩坑的地方,给出更贴合的建议。
代码必须丢到沙箱里面强制执行,代码运行结果、报错全部以沙箱返回为准。沙箱是隔离的真实运行环境,沙箱返回成功或者报错,才代表代码真正执行的情况。
路由这块直接在服务层根据传入条件,路由分发到不同AI回复逻辑,其余逻辑差别不大,只是注册的工具不一样,底层共用同一个Agent。
限流熔断:用Redis+Lua实现令牌桶限流,Resilience4j做熔断。 令牌桶限流放在登录、注册接口。key设计:注册是register:{邮箱},登录是login:{用户名}。熔断只加在调用沙箱的地方。
Resilience4j熔断器配置:滑动窗口大小20,失败占比达到50%触发熔断,熔断打开状态持续30秒。30秒过后新请求进入半开状态,放行3个探测请求;3个请求全部成功,熔断器恢复正常;只要任意一个请求失败,立刻重新打开熔断。 这里有个默认参数最小调用数10,要累计够10次请求之后,才按照50%失败比例判断要不要熔断,不是必须凑齐20次请求才判断。滑动窗口20只是最多保存最近20次调用记录,超过20条就把最早的记录丢掉,10‑20次这个区间就有可能触发熔断。
为什么限流逻辑要用Lua脚本: 限流只是查询令牌、扣减令牌,中间没有掺杂别的Java业务代码,整套逻辑只操作Redis,很适合Lua脚本。 性能上Lua比分布式锁开销更小:Lua只要一次网络往返,脚本交给Redis,服务端一次性全部执行完再返回结果。 如果用分布式锁实现令牌桶,要多次网络交互:先获取锁、读取令牌数量、扣减令牌、释放锁,至少两轮网络请求,并发高还会有锁竞争等待。 而且我的Lua脚本逻辑很简单,只有简单判断和数值计算,执行速度很快,不会把Redis主线程阻塞住。网络波动只会影响脚本请求能不能送到Redis;只要脚本在Redis内部开始运行,就会完整执行完毕,要么全部成功要么全部失败,不会出现执行一半的脏数据。
Sentinel适合大规模微服务集群,可以管控很多服务节点,功能比较重,自带可视化控制台。我们项目场景简单,只针对调用沙箱这一个下游依赖做熔断,不需要复杂的集群管控能力,轻量的Resilience4j就够用,没必要引入Sentinel增加项目组件复杂度。
@CircuitBreaker注解两个属性:name绑定配置文件里的熔断器实例,fallbackMethod用来指定降级兜底的方法,底层依靠AOP实现,在普通SpringMVC同步代码用着很方便。 但是我们项目是WebFlux响应式,方法只是返回Mono对象,真正的异常是后面订阅数据流的时候才延迟产生,AOP抓不到这个异常,注解直接失效。另外Resilience4j的starter新版本SpringBoot有自动装配兼容风险,所以我们用编程式写法。Mono响应式链路用CircuitBreakerOperator嵌入数据流;同步阻塞代码用executeSupplier包裹,两套调用共用同一个熔断器实例做统计。
我当初自己的思考理解(大白话,面试可以直接说自己踩坑理解): 我一开始还以为把注解加到这个调用沙箱的主方法上就完事。 我本来以为:主方法要等内部逻辑跑完,主方法才结束。但是WebFlux不一样,主方法里面只是把异步任务组装好,主方法直接执行完毕就返回Mono,真正干活、真正报错是后面才异步执行的。 那只看外层这个主方法,它本身代码没有报错,是执行成功的,熔断注解加在主方法上面,是不是就管用? 后来才明白不是这样。WebFlux主方法只是组装Mono对象就return,主方法本身不会抛任何异常,对AOP来说这个方法执行就是成功。真正调用沙箱、超时报错,是后续订阅Mono的时候异步才发生的,这时候主方法早就跑完结束,AOP切面已经退出了,根本捕获不到数据流里面的异常。熔断器就会误以为所有请求全部成功,统计完全失效。 CircuitBreakerOperator不一样,它是直接嵌到Mono数据流链路里面。它不去看外层Java方法有没有正常返回,它统计的是将来订阅Mono之后真实执行的结果。就算外层方法正常跑完,只要数据流内部请求失败,Operator就能捕获到,正常统计失败次数,所以WebFlux必须用编程式Operator,不能直接用AOP注解。
Redis 向量检索 + Caffeine 热点缓存 + MySQL 兜底:这个设计主要是先判断这个题是不是热 key 用的是滑动窗口 60s 访问 10 次就算是热 key 然后热 key 就直接去 caffine 查这个题型对应的数据,这个数据就是存入 rag 中对应的这个题型的数据,然后相当于 rag 会有误差因为有的题可能涉及到多种解法多种知识点,我这里是只规定了一种但是只是 ai 输出没有那么详细,然后非热 key 走 rag,如果・rag 出现了毛病会查 mysql 进行兜底这个原理和查 caffine 那个是一样的
热 key 识别我采用单机滑动窗口,窗口 60 秒,同一个题型 60 秒内访问达到 10 次,就判定为热 key。 选择 60 秒窗口,是因为刷题热点一般是短时爆发的,60 秒可以捕捉短时间集中刷题的流量,窗口不会太短造成缓存抖动,也不会太长导致冷数据长期驻留在 Caffeine 占用内存。 阈值设置成 10 次,是为了过滤零星访问,避免少量用户偶尔刷题就误判定成热 key;只有短时间多人集中访问同一题型,才升级为热 key,走本地 Caffeine 缓存,减少 Redis 向量检索的压力。 但这个方案是单机维度统计,多实例部署会有缺陷:全局是热点题型,但流量分散到多个实例,单个实例达不到 10 次阈值,识别不到热 key。我们权衡后,优先保证接口性能,接受这个识别偏差;如果后续热点流量变大,也可以改成 Redis 实现全局滑动窗口来统计。
RAG 叫检索增强生成。首先把用户 query 做向量化,去向量知识库检索,拿到相似度最高的 Top‑K 候选文档。 向量检索只是粗匹配,之后可以用交叉编码器做二次相似度校验,从 Top‑K 里筛选出更少一部分文档;再经过 Rerank 重排序,调整文档先后顺序。 最后把筛选排序后的参考文档,拼接到 Prompt 里面,连同用户问题一起传给大模型,大模型结合检索到的外部资料生成答案。
如果直接把问题丢给大模型,大模型只会依靠它训练时学到的参数知识来回答。 第一会产生幻觉,容易编造不存在的内容; 第二模型训练数据是固定的,我们自己业务的专属知识,比如我们项目里的题型易错点,大模型本身不知道。 使用 RAG,就是把我们私有知识库的真实资料检索出来,拼到 prompt 里约束大模型,让它依据我们给的材料作答,提升答案准确性,减少幻觉,同时可以使用业务独有的私有数据。
我这边选用 Redis Stack 做向量检索,没有用 Milvus、PGVector、Faiss。 首先业务体量不大,只有几千到几万条知识库,Redis 完全能承载。Redis 本身是缓存,查询速度很快,生态成熟,项目里别的模块本来就在用 Redis,比如令牌桶限流,技术栈统一,不用额外维护一套新服务,运维成本更低。
但是它有局限性:如果后续数据涨到百万级别,或者需要向量 + 结构化字段混合检索、复杂过滤这类能力,就需要换成专业向量数据库,比如 Milvus。Faiss 更多是单机库,一般嵌入在服务内部,不适合分布式场景。
Redis 向量检索分为离线建库和在线检索两部分,我使用 Redis Stack 的 RediSearch 模块实现。 离线阶段:首先准备好题型易错点原始文本,使用 LangChain4j 的RecursiveCharacterTextSplitter做文本切片,配置 chunk 最大 512 token,chunk 重叠 100 token。分割器会按换行、段落、句号优先级递归切割,尽量保证语义完整。切出 chunk 之后,调用 Embedding 模型把每一段 chunk 文本转为向量,把 chunk 原文和对应的向量存入 Redis,接着创建向量索引,选择余弦距离作为相似度度量。 在线检索阶段:用户传来查询问题,同样调用 Embedding 模型把 query 转为向量,向 Redis 发起 KNN 向量检索,Redis 根据建好的向量索引返回相似度最高的 TopK chunk。拿到召回结果之后,我再做 Rerank 重排序,过滤掉相关性差的片段,最后把筛选后的上下文拼入 Prompt 传给大模型。
chunkSize 设 512 token,是因为我业务里面题型易错点每段文本本身篇幅不大,512 token 完全足够容纳一份完整知识点。重叠 100 token 属于行业经验,一般取 chunk 大小的 10%‑20%,我本地测试下来效果也挺好。重叠太小容易丢失边界处的关键信息,重叠过大又会造成大量文本重复,增加后续上下文冗余。
Caffeine 和 Redis 最核心区别就是存储位置不一样。Caffeine 是 JVM 进程内本地缓存,数据放在堆内存;Redis 是独立的中间件,数据存在 Redis 服务端内存。所以访问速度差距很大,Caffeine 会快成百上千倍。
项目里两个都使用,主要做冷热分离:热点题型直接走 Caffeine,不用访问 Redis,提升接口响应性能。而且 Redis 是单线程模型,如果大量热 key 请求全部打到 Redis,会加重 Redis 压力。把热 key 拦截在本机 Caffeine,就能减轻 Redis 负载,不影响 Redis 其他业务查询。 我的 key 是以题型作为维度,数据量不大,本机内存完全存得下;非热点请求再去走 Redis 向量检索,MySQL 兜底。
Caffeine 里面存的是前端传入题型 key 对应的固定一套素材,只能拿到这一种解法的易错点。 用户的同一道题目其实可能存在多种解题思路。如果非热 key 也直接走 Caffeine,只会返回这一套固定内容。 而走 RAG 向量检索,是拿用户真实问题去做语义匹配,有机会检索到其他相关题型素材,可以给用户补充别的解题角度,回答会更全面。 热 key 访问量很高,优先追求响应性能,直接用 Caffeine 返回固定内容;非热流量对性能压力小,我选择走 RAG,尽量丰富输出内容。
我的场景是离线更新数据,不存在用户在线实时修改题型素材。 更新流程:离线脚本先更新 Redis,再更新 MySQL。 Caffeine 是单机本地缓存,不会自动监听 Redis 变更,所以 Caffeine 里可能残留旧数据。
处理方案: 方案一:重启应用,Caffeine 缓存清空,启动时重新加载最新数据,拿到 Redis 里更新后的内容。
我 Redis 这边题型数据没有设置 TTL 过期时间。 缓存雪崩是大量 key 同时过期造成的,我没有 TTL,自然不会出现批量失效引发的雪崩。 缓存击穿是热点 key 过期,大量请求打到数据库,这里 Redis 数据永不过期,再加上上层 Caffeine 本地缓存,击穿风险也很低。
缓存穿透方面,前端传入的题型 key 都是系统预先定义好的,几乎不会查询不存在的 key,所以没有引入布隆过滤器,避免额外复杂度。
脏数据不会依靠过期自动清理,我们走离线运维更新:离线修改 MySQL,脚本主动删除或者更新 Redis 里面旧的题型数据,再滚动重启服务刷新 Caffeine 本地缓存,把脏数据清理掉。
检索我采用粗召回加精排的链路。第一步向量检索 topK 取 5 条候选结果; 之后把 query 和这 5 条候选文档送入交叉编码器做 rerank 重排,依据完整语义对候选重新打分排序; 重排完成后设置分数阈值过滤掉低质量、相关性差的内容,最后保留质量最好的 3 条交给大模型。
评估 RAG 效果的时候,我自己构造一批 query 测试案例,一共 50 条 query。针对每一条 query 去做检索,统计 Recall@1、Recall@2、Recall@3。Recall@1 代表目标文档排在第 1 位;Recall@2 代表目标文档出现在前 2 条里面;Recall@3 代表目标文档出现在前 3 条里面。通过这 20 条测试 query,计算各个 Recall@k 的命中率,以此评估整套检索链路的效果。
RAG 能减少凭空编造的幻觉,但也会引入新风险。一方面检索可能返回无关、碎片化内容,知识库有错也会被模型照搬;另一方面输入超过 LLM 上下文窗口会发生截断,语义残缺同样会造成错误输出。 我通过相似度过滤、限制传入文档条数、合理切分文档,再配合提示词约束,来缓解这些问题。我会用提示词约束还有 rag 文本每条数据尽量最短然后保留最核心意思并且之后还会使用交叉编码器进行 rerank
- 准备好 50 条测试 query,每条都知道对应的目标正确文档;
- 循环设置不同相似度阈值(比如 0.7、0.75、0.8、0.85);
- 每个阈值,全部 50 条 query 跑一遍检索,统计 Recall@1/2/3;
- 画出阈值 - Recall 变化:找到Recall 开始断崖下跌的临界点;
- 阈值选在临界点略低一点,保住 Recall,同时过滤尽可能多噪声。
- hotCounter :
Cache<String, AtomicInteger>,用来统计每个题型 key 访问次数,构建时配置 expireAfterWrite(60s) 。- expireAfterWrite:写入后过期 。当 key 不存在,第一次 get 新建 AtomicInteger 时开始计时;后续单纯执行
incrementAndGet自增,不会重置过期倒计时。距离第一次写入满 60 秒,这个计数器 key 会被 Caffeine 清理。
- expireAfterWrite:写入后过期 。当 key 不存在,第一次 get 新建 AtomicInteger 时开始计时;后续单纯执行
- localCache :存放热点对应的 RAG 检索结果,同样配置 expireAfterWrite(60s)。key 写入开始计时,60 秒后热点数据过期清除,实现热点自动冷却。
请求链路: 请求进来优先查询 localCache,如果命中热点数据,直接返回结果,跳过 RAG 向量检索;如果没有命中,则执行 RAG 检索,检索完成之后回调 afterQuery 方法更新访问计数器。 afterQuery 里使用hotCounter.get(key, k -> new AtomicInteger(0)),这个方法等价 computeIfAbsent,key 不存在时原子创建 AtomicInteger。调用incrementAndGet原子计数,窗口内访问次数达到阈值 10 次,就把 RAG 结果写入 localCache,晋升为热 key。
优点:实现简单,不需要手动维护时间桶;两个缓存都配置写后过期,数据到期自动清理,不会长期占用内存。 缺点:属于单 key 独立 TTL 窗口,不是全局对齐的固定时间桶,存在流量边界毛刺;并发场景下多个线程同时到达阈值,会多次执行 localCache.put,不过写入相同数据,业务不受影响,刷题场景这个代价可以接受。
Spring MVC 是同步阻塞模型,一个请求对应一个线程。当遇到 Redis、数据库、远程调用这类 IO 操作时,线程会原地阻塞等待结果,等待期间这个线程不能处理别的请求。IO 返回之后,线程才继续执行剩下的业务逻辑。请求量上来,就需要更多线程,线程多了上下文切换开销大,资源占用高。
WebFlux 是响应式非阻塞模型,底层基于 Netty。遇到 IO 的时候,线程不会原地卡住等待,发起 IO 之后就转去处理其他就绪的请求;等 IO 数据准备好了,触发回调回来继续处理当前请求剩下的逻辑。少量线程就可以承载大量请求,CPU 利用率更高。
SpringMVC 是同步阻塞,遇到 IO 线程原地等待,一个请求占用一个线程;WebFlux 是非阻塞响应式,发起 IO 后线程去处理别的请求,IO 就绪回调回来继续执行。Mono 代表 0 或 1 个元素,可以类比单个实体;Flux 代表 0 到多个元素,可以类比集合,二者属于内部响应式数据流。向前端持续推送可以用 SSE 或者 WebSocket,但 Flux 本身不代表前端推送。WebFlux 想要非阻塞,必须使用响应式驱动,否则依然会阻塞线程。
WebFlux 不一定比 SpringMVC 快。在非 IO 密集场景下,WebFlux 要维护订阅关系、事件回调调度、操作符链路,这些都会额外消耗 CPU。所以 IO 密集型场景适合用 WebFlux,高并发下资源利用率更高;但 CPU 密集型场景 WebFlux 帮不上忙,反而会带来额外开销,起到反效果。
Mono 和 Flux 都是 Reactor 的响应式数据流。可以简单理解成:Mono 类似单个实体,Flux 类似集合。返回单条数据或者空结果就用 Mono;要返回一连串多条数据,可以用 Flux,如果需要向前端持续流式推送,就搭配 SSE。 但要注意这只是类比。普通集合是一次性把所有数据加载完成;Mono 和 Flux 必须订阅之后,元素才会逐个产生。就算返回 Flux,如果不配置 SSE 对应的响应头,也不会向前端推送,会收集全部数据一次性返回。
SSE 只能服务端向客户端单向推送消息;WebSocket 建立双向通道,客户端和服务端可以互相推送消息。SSE 基于 HTTP 长连接;WebSocket 握手是 HTTP,升级之后使用独立协议,后续传输没有 HTTP 头部开销,传输开销更小。 我们项目选 SSE,是因为只需要后端向前端流式推送大模型输出,不需要双向交互。单次请求只需要后端持续推内容给前端,前端不用回传消息,单向场景 SSE 足够,浏览器原生支持还自带断线自动重连,开发更简单
SSE、长轮询、WebSocket 怎么选。长轮询严格来说不是服务端主动推送:前端先发请求挂住,等服务有数据了才返回,返回后前端要再发一次请求,没法持续流式不断输出,不适合大模型逐字流式返回。 我这里是大模型流式输出,需要持续向后端拿生成的文本,SSE 刚好满足单向推送,用户体验好,也是 AI 流式输出的常规方案。 WebSocket 虽然也能实现推送,但它是全双工通道,我们业务只需要后端往前端单向发消息,不需要前端持续回传数据,引入 WebSocket 会带来不必要的连接管理开销,能力过剩。所以最终选用 SSE。
subscribeOn:订阅触发之后,由指定线程去调用大模型(上游拿数据)publishOn:大模型吐出 token 之后,交给另一个线程,负责后面的加工、SSE 推送输出
阻塞 JDBC 查 MySQL 的真实过程: 代码一调用查 MySQL,线程就去找数据库要数据。 MySQL 那边要找数据,需要花一点时间。 👉 跑这段代码的线程,就站在原地不动,啥别的事都不干,就在这儿干等着 MySQL 把结果给回来。这就是阻塞。
咱们那个事件循环线程,是专门处理用户网络请求的工人。 如果不让弹性线程池插手,就是这个网络工人亲自在这死等 MySQL 给结果。 它在这儿傻等的时候,别的用户发请求、SSE 推送消息过来,它顾不上处理。一共就没几个网络工人,全都在等数据库,整个服务就卡死。
弹性线程池就是临时工。外包出去的活就是:去查 MySQL + 在那儿死等 MySQL 返回结果。
写上 subscribeOn 之后流程:
- 网络工人(事件循环线程),只干一件事:把 "去查 MySQL" 这个任务交给临时工。
- 交完活,网络工人直接走开,继续接待别的用户,处理接口、SSE 消息,不在这里等待。
- 临时工(弹性线程池的线程)上去,由这个临时工蹲在那儿死等 MySQL 的查询结果。 临时工本来就允许原地傻等,把临时工卡住没关系,不会影响网络。
- MySQL 把数据返回给临时工,临时工拿到数据再跑后面业务逻辑。
非阻塞是什么样子(比如 R2DBC): 网络工人向 MySQL 要完数据,不用站在原地等结果 ,直接转头去接待别的用户。 MySQL 那边慢慢查,等 MySQL 把结果准备好了,系统再喊这个网络工人回来拿数据继续干活。 👉 所以这种情况不需要临时工(弹性线程池),网络工人自己就能搞定。
总结为啥要用弹性线程池
就是怕管网络的工人被困在等待数据库这件事上。 等待数据库这个耗时间的破活交给临时工干,网络工人专心处理用户请求,服务就不会卡死。
需要用弹性线程池:JDBC、Jedis、sleep、读本地磁盘文件,这些都要线程原地傻等外部资源。 不需要:WebClient、Lettuce、R2DBC,干完请求直接走开,不靠线程死等,等消息通知再回来处理。开发的时候一般直接用默认的 boundedElastic 弹性线程池,默认最大线程数是 CPU 核心数乘以 10。像 4 核服务器,上限就是 40 个线程。线程按需创建,空闲 60 秒会自动销毁;线程满了之后新任务排队,不会无限创建线程导致 OOM。我们通过 subscribeOn,把 JDBC 这类阻塞查库的任务交给这个池子执行,保护数量很少的 Netty 事件循环线程。
- 弹性线程池(boundedElastic)本身不会自动接管阻塞代码,必须手动用调度方法指定。
publishOn:只管它后面下游 的代码,下游逻辑切换到弹性线程;前面上游代码沿用原有线程。- 阻塞写在
publishOn下游 → 阻塞交给弹性线程,不卡事件线程。 - 阻塞写在
publishOn上游 → 阻塞依旧跑事件线程,会堵。
- 阻塞写在
subscribeOn:只管它前面上游源头 的代码,上游生产者切换到弹性线程;后续下游代码也跟着用这个弹性线程。- 阻塞查库这类数据源操作,放在上游,用
subscribeOn。
- 阻塞查库这类数据源操作,放在上游,用
- 核心一句话:各自管各自区间。阻塞代码在哪段,就要保证这段代码被对应的调度器包住,才能把阻塞外包给弹性线程,保护事件循环线程。
Netty 和 Tomcat 属于不同的 Web 容器;WebFlux 默认使用 Netty,但不是必须用 Netty,它也可以跑在支持 Servlet 异步的 Tomcat 这类容器上面。
- 存储位置不一样:Cookie 存在浏览器(客户端);Session 存在服务端(内存 / Redis / 数据库)。
- 容量不同:Cookie 很小,上限大概 4KB;Session 没有这个限制,可以存较多用户信息。
- 安全性:Cookie 在客户端,用户能查看、修改,容易被窃取;Session 数据放在后端,更安全。
- 工作配合:Cookie 一般用来存 sessionId;浏览器每次请求自动带上 Cookie 里的 sessionId,服务端通过这个 id 找到对应的 Session。
① 连接长时间占用 ------大量慢客户端会占住连接,要做连接数上限和空闲超时;② 断连检测延迟 ------TCP 断开可能不会立刻感知,要靠写数据失败或心跳发现;③ 中间层缓冲 ------Nginx/网关会攒住响应,必须关 buffer;④ 敏感内容已吐出无法撤回 ------模型吐了一半发现违规内容,已经推给前端了,所以关键场景要在推之前做前置审核,或者接受「边吐边审」(延迟换安全)。
Docker 容器隔离有一定防护能力,但不能完全防止恶意代码。沙箱放在容器内,恶意代码默认只会影响当前容器,销毁容器即可恢复;但容器存在逃逸风险,它是轻量级隔离,不是虚拟机那种强隔离。
沙箱程序是 SpringBoot 写的,打包成 fat jar,jar 包里自带所有依赖,如果引入 web starter,内嵌 Tomcat 也会打进去。Docker 镜像只需要 JDK,不用额外装 Tomcat、SpringBoot。
容器配置了 docker restart 策略,容器宕机后会自动重启。 对外调用沙箱的地方,做了令牌桶限流,控制请求进入沙箱的速率,防止大量请求压垮沙箱;同时加了熔断机制,沙箱连续失败就触发熔断,快速失败,避免沙箱故障拖垮主业务。
沙箱内部单独开监控线程,每 100ms 轮询检查任务状态,如果代码执行超时,直接终止执行线程,防止代码无限运行。 容器层面使用 cgroups 做资源兜底:限制最大内存、CPU,还有进程数量。Linux 里 Java 创建的线程属于轻量级进程,同样占用 pid 配额,就算恶意代码疯狂新建线程或者进程,也会被限制住,不会把宿主机资源耗尽。
风险说明:容器只是操作系统级隔离,不是绝对安全,存在容器逃逸风险。就算有这些防护,也没法完全阻挡恶意代码,但是破坏范围基本锁死在沙箱容器内部,一旦出问题,容器直接销毁重建就行。
选用常驻容器 + 子进程,是因为容器启动成本高,只需要启动一次,多个任务线程可以复用容器。每次判题新建容器没有好处,只会增加开销。缺点是容器复用会存在环境污染风险,需要配套任务超时终止、资源限制等手段防护。
沙箱镜像里面需要 JDK。编写 Dockerfile,选用自带 JDK 的基础镜像,把项目打包好的 jar 拷贝进镜像。使用docker build构建镜像,再用docker run启动容器。容器内执行java -jar运行这个 jar 包。
基础镜像可以选纯净空镜像,也可以选自带部分工具的镜像。如果需要 JDK、MySQL 等多个工具,就在 Dockerfile 里用 RUN 命令依次安装它们,最后再 COPY 项目 jar 包。把拷贝 jar 放在末尾,是因为 jar 改动比较频繁,能复用前面安装软件的镜像缓存,减少构建耗时。
主服务通过 MCP Client 远程调用沙箱,把用户代码作为参数传给沙箱。沙箱收到代码后,生成随机名称的文件,将代码写入该文件;接着编译、运行代码;执行完成后获取运行结果返回给主服务,最后删除这个随机文件。
多语言沙箱这里我采用策略模式实现。定义统一的代码沙箱接口,Java、Python 等不同语言分别写对应的实现类。服务端收到请求后,根据传入的语言类型,通过上下文类路由,拿到对应语言的 Bean。各个实现类共用同一套主体逻辑,差异只在于配套的 Docker 沙箱容器不一样。不需要额外单独抽一层 Agent,调用方直接调用沙箱实现类的方法就可以。新增语言的时候,只需要新增接口实现类,不用改动原有代码,符合开闭原则。其实不用 agent,就是最后调用沙箱的那个类这样就行不用一开始 agent 那个类就这样划分是吧就是这个接口是调用沙箱的类都实现的agent那套不管。
一次代码执行完整流程:接收请求参数,包含用户代码和测试用例。沙箱收到之后,先把用户代码写入临时文件,接着编译代码,编译成功后运行程序,把测试用例输入给程序,收集运行结果。拿到运行结果之后,和预期结果对比,把判题信息封装到响应实体里,最终返回结果。执行过程设置超时控制,超时就终止进程。任务结束后删除临时文件。补充(可选,面试官问再说):写入的是随机命名的文件,finally 块里执行文件删除。
我通过 OutputStreamWriter 获取子进程输出流,向子进程标准输入写入测试用例;读取子程序运行结果和错误信息,是在工具类里面用 InputStreamReader 读取子进程的标准输出流和标准错误流。
集到子进程运行输出和错误信息之后,我会判断程序运行是否异常,把耗时、输出、错误信息封装进 JudgeInfo 实体,组装响应对象返回上层。沙箱这里只判断运行层面有没有报错,不会拿实际输出和题目预期答案做比对,答案比对交给上层服务。判断子进程退出码是否非 0,有没有报错信息,是否超时这类运行时异常,不是判断答案对错。
多组测试用例,是循环依次执行,每一组测试用例都会新建一个独立子进程。每次循环启动进程,执行完就判断程序有没有报错,有错就记录错误信息,没有错误就保存运行输出信息。全部测试用例循环跑完之后,把所有结果封装进实体返回。
沙箱只负责编译、运行代码,收集每组用例的输出和运行异常。真正评判输出是否正确是交给上层 AI 模块:标准的预期答案提前存在数据库,请求时查出来放到 prompt 里面,交给大模型对比实际输出和标准答案,判断结果对错。
为了防止用户代码读取宿主机敏感文件,我们用 Docker 容器充当沙箱环境。用户代码不会直接在宿主机上运行,而是跑在独立容器里面,把访问范围隔离在容器内部,访问不到宿主机的敏感文件,起到安全防护的作用。
可以说 Docker 隔离用来防止恶意代码拖垮宿主机。恶意代码就算出问题,比如 JVM 崩溃,会直接抛出异常,沙箱返回繁忙;如果代码执行时间过长,我们有守护线程,超过 10 秒就杀死进程,把影响限制在容器内部,不会影响宿主机。
图编排,就是把业务流程抽象成一张图,由节点 Node、条件边、上下文 Context、状态 State 组成。每个 Node 是一个执行单元,节点执行完成更新 State 和 Context,再通过条件边选择下一个要执行的节点,框架原生支持失败重试。 if else 当然能实现相同功能,性能还更高。但是 if else 是硬编码,流程复杂之后嵌套臃肿,新增分支需要改代码上线;状态持久化、重试、断点恢复都要自己开发。图编排的优势是流程配置化,不用改代码就能调整分支,自带状态管理,支持可视化,适合复杂多变的长流程,比如 AI Agent、审批工作流。
① State:贯穿全流程共享状态对象,可以是 Map 或者自定义实体类,搭配 reducer 定义多个节点并发更新时怎么合并数据; ② Node:最小处理单元,接收 State,返回 State 的更新内容; ③ Edge:节点之间的跳转连线,普通边固定跳转;条件边读取 State,动态决定下一个节点; ④ Checkpointer:检查点器,每一步自动持久化 State 快照,依靠 thread_id 隔离会话,支持失败断点恢复、人工暂停介入; ⑤ compile:图编译。StateGraph 只是图的定义,调用 compile 校验图结构、构建执行引擎,生成可执行的图对象,之后才能 invoke 执行。
LangGraph4j 几个核心概念: ① State:贯穿全流程共享状态对象,可以是 Map 或者自定义实体类,搭配 reducer 定义多个节点并发更新时怎么合并数据; ② Node:最小处理单元,接收 State,返回 State 的更新内容; ③ Edge:节点之间的跳转连线,普通边固定跳转;条件边读取 State,动态决定下一个节点; ④ Checkpointer:检查点器,每一步自动持久化 State 快照,依靠 thread_id 隔离会话,支持失败断点恢复、人工暂停介入; ⑤ compile:图编译。StateGraph 只是图的定义,调用 compile 校验图结构、构建执行引擎,生成可执行的图对象,之后才能 invoke 执行。
LangGraph4j 几个核心概念: ① State:贯穿全流程共享状态对象,可以是 Map 或者自定义实体类,搭配 reducer 定义多个节点并发更新时怎么合并数据; ② Node:最小处理单元,接收 State,返回 State 的更新内容; ③ Edge:节点之间的跳转连线,普通边固定跳转;条件边读取 State,动态决定下一个节点; ④ Checkpointer:检查点器,每一步自动持久化 State 快照,依靠 thread_id 隔离会话,支持失败断点恢复、人工暂停介入; ⑤ compile:图编译。StateGraph 只是图的定义,调用 compile 校验图结构、构建执行引擎,生成可执行的图对象,之后才能 invoke 执行。
Checkpointer 是检查点器,用来将 State 快照持久化到内存、Redis 或者数据库。节点执行完成后返回的数据会合并更新到内存 State,我们可以在 State 中存放对话历史等业务数据。框架支持 interruptBefore、interruptAfter,可以在节点执行前或者执行后中断流程,中断时会自动保存 State 快照。compile 的作用是对图定义做合法性校验,将节点、边、中断规则、检查点器全部组装,生成可执行的 CompiledGraph 对象,之后就可以调用 invoke 执行整个流程。
interruptBefore / interruptAfter:就是节点执行之前 或者 执行完成之后打断流程,暂停下来,此时框架会先把当前 State 快照保存,等待外部输入,再继续跑这个非常适合做面试官功能
一、Spring & SpringBoot 核心区别
Spring是底层核心框架,SpringBoot是基于Spring开发的快速开发脚手架。SpringBoot帮我们省去了Spring繁琐的手动配置,不用写大量XML配置文件,还内置了Tomcat网页服务器,不用自己部署服务器。同时通过Starter启动器,自动整合依赖,简化了Maven的pom依赖编写,极大提升开发效率。
二、Bean 常用作用域
Bean最常用的两个作用域是单例和多例。singleton单例是默认作用域,整个Spring容器中只会创建一个Bean对象,全局共用。prototype多例正好相反,每次从容器中获取Bean,都会新建一个全新对象。
在Web环境下还有三个常用作用域:request作用域,一次HTTP请求生成一个Bean,只在本次请求内生效;session作用域对应一次用户会话;还有application、websocket作用域适配不同场景。
三、项目 RAG 知识库设计 & 业务逻辑
我的RAG知识库核心存储的是各类编程题型的通用知识点,包含题型易错点、踩坑场景、解题分析角度、给用户的优化建议和答题要点。比如二分查找题型,核心易错点是边界处理、区间开闭问题;链表题型易错点是断链、空指针异常。
用户传入一道具体的代码题后,系统会通过向量检索,把用户的题目和知识库中的题型易错知识点做语义匹配,召回对应题型的通用分析素材。之后结合用户的历史做题记录、代码沙箱的运行报错信息,由AI Agent拼装出贴合用户问题的个性化回复。
四、项目工具调用 & MCP 协议
MCP是模型上下文协议,专门规范大模型调用外部工具的标准。简单说就是给大模型做远程工具调用的专属协议,打破大模型只能调用本地预设工具的限制。通信方式分两种,本地工具调用走stdio标准输入输出,远程工具可以用SSE或者WebSocket通信。
我项目里配置了两个核心外部工具,分别是代码沙箱执行工具、用户历史做题记录查询工具。通过查询用户做题历史,可以判断用户是否反复踩同一个坑、是否重复做错同类题型,精准定位用户薄弱点,让AI给出的建议更贴合用户情况。
五、代码沙箱核心作用
用户提交的代码必须丢进隔离的代码沙箱强制执行,所有代码运行结果、报错信息,全部以沙箱返回数据为准。沙箱是独立隔离的真实运行环境,能有效避免恶意代码攻击、污染业务服务,只有沙箱返回的成功或报错状态,才能代表代码真实的执行情况。
六、AI 回复路由设计
项目的路由逻辑很简洁,直接在服务层根据前端传入的业务条件,做路由分发,匹配不同的AI回复逻辑。底层所有分支逻辑共用同一个AI Agent,唯一区别就是不同路由分支注册的外部工具不一样,核心执行逻辑基本一致。
七、限流熔断方案(Redis+Lua + Resilience4j)
1. 限流设计
项目接口限流采用Redis+Lua实现令牌桶限流,只针对登录、注册核心接口做限流。Key做了精细化设计,注册接口key为register:{邮箱},登录接口key为login:{用户名},精准限制单用户频繁请求。
2. 熔断设计
熔断只针对调用代码沙箱的接口配置,使用轻量级的Resilience4j实现,没有用笨重的Sentinel。核心配置:滑动窗口大小20,最小统计调用数10,请求失败占比达到50%就触发熔断。
熔断触发后打开状态持续30秒,30秒后进入半开状态,放行3个探测请求。如果3个请求全部成功,熔断器恢复正常;只要任意一个请求失败,立刻重新开启熔断保护。
补充:滑动窗口20代表最多保存最近20次调用记录,超过20条会丢弃最早记录;只要累计请求达到10次,就会按照50%失败率判断熔断,不用凑满20次。
3. 限流使用 Lua 脚本的原因
令牌桶限流逻辑只有查询令牌、扣减令牌两个简单操作,没有复杂Java业务逻辑,完全适合用Lua脚本实现。第一性能更高,Lua脚本只需一次网络往返,交给Redis一次性执行完毕,不会产生脏数据。第二相比分布式锁开销极低,分布式锁需要多次网络交互、存在锁竞争,Lua无锁执行效率更高。第三脚本逻辑简单,不会阻塞Redis主线程,且执行具备原子性,要么全成功要么全失败。
4. 舍弃 Sentinel 的原因
Sentinel适合大规模微服务集群,功能全面、自带控制台但组件笨重、复杂度高。我的项目场景简单,仅针对沙箱这一个下游依赖做熔断,不需要集群管控、流量管控等复杂功能,轻量的Resilience4j完全够用,还能减少项目组件复杂度。
八、WebFlux 响应式熔断踩坑(核心重点)
Resilience4j的@CircuitBreaker注解底层依靠AOP实现,只适用于普通SpringMVC同步代码。但我的项目是WebFlux响应式编程,方法仅返回Mono对象,不会同步执行业务逻辑。
这里是核心踩坑点:外层Java方法仅仅是组装异步Mono链路,组装完成就直接返回,方法本身不会报错。真正的沙箱调用、超时、异常报错,是后续订阅数据流时异步触发的。AOP切面只能捕获方法执行阶段的异常,根本抓不到异步数据流的异常,导致注解完全失效,熔断器统计全部失真。
所以我采用编程式写法适配WebFlux:响应式Mono链路用CircuitBreakerOperator嵌入数据流,直接监听真实异步执行结果;普通同步代码用executeSupplier包裹,两套逻辑共用同一个熔断器实例,保证统计准确。
九、多级缓存架构:Redis向量检索 + Caffeine热点缓存 + MySQL兜底
1. 整体架构逻辑
整套架构用来做题型热key识别和AI回复数据查询,冷热流量分离。热点题型走本地Caffeine缓存,直接返回数据、跳过RAG检索;非热点题型走Redis向量检索;如果RAG检索异常、失效,最后用MySQL兜底查询,保证服务可用。
2. 热key识别规则(单机滑动窗口)
我采用单机滑动窗口做热key统计,窗口时长60秒,同一题型60秒内访问次数达到10次,就判定为热key。
参数取舍:60秒窗口适配刷题短时爆发的流量特性,不会过短导致缓存频繁抖动,也不会过长让冷数据常驻内存;10次阈值可以过滤零星单次、少量访问,避免误判热key。
方案缺陷:属于单机维度统计,多实例部署时,全局热点流量会被分散,单实例达不到阈值就无法识别热key。我项目权衡后优先保证单机接口性能,接受该偏差,后续流量增大可升级为Redis全局滑动窗口。
3. 双 Caffeine 缓存核心设计(重点背诵)
我定义了两个独立的Caffeine缓存,互不干扰,都配置expireAfterWrite(60s) 写后过期,两个缓存作用完全不同:
第一个:hotCounter,存储结构是 Cache<String, AtomicInteger>,专门用来统计每个题型key的访问次数。
expireAfterWrite核心含义:只有key第一次新建、写入计数器时,才会开启60秒倒计时。后续单纯对AtomicInteger做自增操作,不会重置过期时间,严格保证60秒独立统计窗口,超时自动清理计数器,释放内存。
第二个:localCache,专门存储热点题型对应的RAG检索结果。写入数据的瞬间开启60秒倒计时,超时自动清除热点数据,实现热key自动冷却降级,不会出现一次热点、永久热点的问题。
4. 完整请求链路
用户请求进来,优先查询localCache热点缓存:如果命中,直接返回RAG结果,完全跳过RAG向量检索,提升性能;如果未命中,正常执行RAG向量检索,检索完成后回调afterQuery方法做热度统计。
afterQuery方法中,通过hotCounter.get(key, k -> new AtomicInteger(0))原子创建计数器,避免并发问题,再通过incrementAndGet做CAS无锁自增。60秒窗口内访问次数达到10次阈值,就将当前题型的RAG结果写入localCache,完成热key晋升。
5. 热key方案优缺点
优点:代码实现简单,不用手动维护复杂的时间桶;双缓存均配置过期策略,数据自动清理,不会长期占用内存;无锁CAS计数,并发安全、性能极高。
缺点:单key独立TTL窗口,不是全局对齐的固定时间窗口,存在少量流量边界毛刺;并发场景下可能出现多次重复put缓存的情况,但写入数据一致,无业务bug,刷题场景完全可以接受。
6. Caffeine 与 Redis 区别 & 冷热分离意义
核心区别是存储位置不同:Caffeine是JVM进程本地缓存,数据存在本机堆内存,速度比Redis快成百上千倍;Redis是独立中间件,存在网络IO开销。
项目冷热分离设计:高频热点题型拦截在本机Caffeine,不用频繁请求Redis,极大减轻Redis单线程压力,避免热点流量打垮Redis;低频非热点流量走Redis向量检索,兼顾性能和内容丰富度。
补充:Caffeine仅存储固定题型的一套易错点素材,性能高但内容单一;RAG向量检索是根据用户真实问题语义匹配,能匹配多维度知识点,回答更全面,所以非热key优先走RAG。
十、RAG 检索增强生成 完整原理
RAG全称检索增强生成,核心是解决大模型幻觉、知识滞后、无业务私有知识的问题。直接提问大模型,只会依赖训练参数作答,容易编造内容,且无法学习我们项目专属的题型知识点。
完整执行链路:先将用户问题向量化,在向量知识库粗召回Top-K相似文档;再通过交叉编码器+Rerank重排序,过滤低相关、碎片化内容,精准筛选有效上下文;最后将筛选后的参考文档拼接进Prompt,交给大模型结合私有知识库作答,大幅减少幻觉、提升答案准确性。
十一、向量数据库选型:Redis Stack 而非 Milvus/Faiss
我项目选用Redis Stack的RediSearch模块做向量检索,没有用专业向量数据库。原因是项目业务体量小,仅几万条题型知识库,Redis完全可以承载;且项目本身已大量使用Redis做限流、缓存,技术栈统一,无需引入新组件、降低运维成本。
技术对比:Milvus适合百万级海量向量、复杂结构化检索;Faiss是单机向量库,不适合分布式线上场景;Redis足够满足我的中小型业务场景。后续数据量暴涨、需要复杂检索能力,再升级Milvus即可。
十二、Redis 向量检索 离线建库 + 在线检索流程
1. 离线建库
首先整理题型易错点原始文本,通过LangChain4j的递归文本分割器做切片,配置chunk大小512token,重叠100token。按段落、句号、换行递归切割,保证语义完整,重叠token避免边界知识点丢失。切片完成后,调用Embedding模型将文本转为向量,将chunk原文+向量存入Redis,创建以余弦距离为度量的向量索引。
2. 在线检索
用户提问后,将用户问题向量化,向Redis发起KNN向量检索,粗召回Top5候选文档。再通过Rerank重排序打分,设置分数阈值过滤劣质内容,最终保留Top3最优上下文,拼接Prompt送入大模型。
3. 切片参数取舍
512token适配题型知识点的文本长度,足够容纳完整易错点内容;100token重叠是行业通用比例,占chunk大小10%-20%,既避免边界信息丢失,又不会造成文本冗余、增大模型负担。
十三、缓存三大问题处理(雪崩/击穿/穿透)
-
缓存雪崩:我的Redis题型数据无统一TTL,不会批量过期;上层有Caffeine本地缓存兜底,彻底杜绝大量key同时失效导致的雪崩问题。
-
缓存击穿:热点数据同时存在Caffeine和Redis两层缓存,Redis数据永不过期,不存在热点key过期、流量打穿数据库的问题,击穿风险极低。
-
缓存穿透:前端查询的题型key都是系统预定义的固定值,几乎不会查询不存在的key,无需引入布隆过滤器,避免代码冗余。
-
脏数据处理:项目为离线更新场景,无实时在线修改。更新流程为先更新MySQL、再更新Redis,通过滚动重启服务刷新Caffeine本地缓存,手动清理脏数据,不依赖自动过期清理。
十四、RAG 效果评估 & 调优
1. 评估方式
我手动构造50条测试Query,每条Query对应明确的标准答案文档,通过统计Recall@1、Recall@2、Recall@3命中率,评估检索效果,判断目标文档是否能被精准召回。
2. 阈值调优方案
循环测试0.7、0.75、0.8、0.85等不同相似度阈值,观察召回率变化,找到召回率断崖下跌的临界点。最终阈值选择临界点略低的值,既保证高召回率,又能最大化过滤噪声、劣质内容。
3. RAG 风险优化
RAG会存在检索碎片化、无关内容、上下文截断导致的模型输出错误。我通过合理文本切片、相似度阈值过滤、限制输入文档数量、Rerank精排、Prompt语句约束多重手段,精简上下文、保留核心知识点,缓解模型幻觉和错误输出问题。