EduAgentX 项目面试话术指南
一、项目整体介绍(30秒版本)
“我做的是一个智能教育管理系统 EduAgentX,核心功能包括 AI 智能题目生成、高并发抢课系统 和 RAG 知识库检索。技术栈是 Spring Boot 3 + Spring AI + Redis + RocketMQ,同时我还完成了从单体到微服务的架构演进,使用 Spring Cloud Alibaba + Dubbo + Nacos + Higress 网关。项目中我重点解决了三个技术难题:抢课系统的高并发防超卖、AI 工作流的流式输出、以及 热点数据的缓存优化。”
零、八股文融合速查表
| 八股知识点 |
项目中的应用场景 |
引出话术 |
| Redis 单线程模型 |
Lua 脚本原子性 |
“Lua 能保证原子性是因为 Redis 单线程…” |
| Redis 数据结构 |
Hash 存储库存/抢课状态 |
“我用 Hash 而不是 String,因为…” |
| Redis 持久化 |
抢课数据可靠性 |
“我配置了 AOF 持久化,防止宕机丢数据…” |
| 线程池参数 |
异步工作流执行 |
“我自定义了线程池,核心线程数设置为…” |
| CAS 与原子类 |
HeavyKeeper 计数器 |
“我用 AtomicInteger 保证计数的线程安全…” |
| ConcurrentHashMap |
热点 Key 存储 |
“我用 ConcurrentHashMap 而不是 HashMap…” |
| 阻塞队列 |
消息缓冲区 |
“我用 ConcurrentLinkedQueue 无锁队列…” |
| AOP 原理 |
限流切面、性能监控 |
“我用 AOP 实现了限流,原理是动态代理…” |
| Spring 事务 |
批量写入数据库 |
“我用 TransactionTemplate 编程式事务…” |
| MySQL 索引 |
抢课记录查询优化 |
“我给 student_id + subject_id 建了联合索引…” |
| MySQL 锁 |
纯 MySQL 抢课方案 |
“我还实现了一版用悲观锁的方案…” |
| 消息队列 |
异步削峰 |
“我用 RocketMQ 实现异步,选它是因为…” |
| 设计模式 |
题目生成模块 |
“我用了策略模式、模板方法、工厂模式…” |
| JVM 内存模型 |
ThreadLocal 存储 SSE |
“我用 ThreadLocal 存储 Emitter,避免…” |
| 序列化 |
Redis 序列化配置 |
“我配置了 Jackson 序列化,解决了…” |
| 分布式锁 |
防止重复抢课 |
“除了 Lua,我还可以用 Redisson 分布式锁…” |
| CAP 理论 |
缓存一致性策略 |
“我选择了 AP,保证可用性,最终一致…” |
| 限流算法 |
滑动窗口限流 |
“我实现了滑动窗口限流,用 Redis ZSet…” |
遇到的问题与解决方式
大纲生成
1. 背景 (Situation) “这个故事发生在我们的比赛决赛夏令营期间。当时我们有机会面对面和几位行业内的专家评委交流。
专家们对我们的‘课程大纲自动生成’功能很感兴趣,但在实测后,一位专家犀利地指出了一个问题:‘你们生成的每一个字我都认识,但感觉跟上传的这个课件关系不大,像是在一本正经地胡说八道。’ 这就是典型的 RAG 幻觉问题。生成的每一章都看似专业,但其实是大模型用自己的训练数据编的,丢失了用户上传文档的特异性。”
2. 归因分析 (Analysis) “我当时非常焦虑,回去立刻排查。
当时受限于学生服务器的算力(可能只有几核CPU,没有显卡),我们没法部署重型的 Re-rank(重排序)模型,也没法搭建 Elasticsearch 做混合检索(当时还没想到利用 Redis 的全文检索或是云端知识库)。
我们只用了最基础的向量检索(Vector Search)。
问题就出在切片上。把一份逻辑严密的教材切成碎片后,向量检索只能搜到‘关键词相似’的片段,丢失了整本书的目录结构和章节逻辑。大模型拿不到宏观结构,只能瞎编。”
3. 解决方案 (Action) —— 专家的建议与落地 “当时专家给了一个非常巧妙的思路,我们称之为LLM 导读模式(或者叫 Map-Reduce 思想),完全避开了复杂的检索算法:
-
第一步:全库浏览,提取目录(Map)。 对于几十页的文档,我们不再切片,而是让长窗口模型(Long-Context LLM)先快速通读全文,让它只做一件事:生成一份带页码的详细目录。
-
第二步:让模型自主决策(Agentic Decision)。 当需要生成大纲时,我们把这份‘目录’扔给大模型,问它:‘你要写大纲,需要参考哪几部分内容?’
大模型会根据目录判断:‘我需要第 1-5 页的简介,和第 20-25 页的核心算法。’
-
第三步:精准投喂(Fetch & Generate)。 程序根据大模型返回的页码,直接去原始 PDF 里把这几页的完整文本提取出来,原封不动地喂给模型。
这样,模型看到的是完整的上下文,而不是破碎的片段。”
4. 结果 (Result) “改用这个方案后,虽然我们没有增加任何硬件成本,但生成效果有了质的飞跃。大纲的每一级标题都能精确对应到文档的具体章节,幻觉率几乎降到了零。这次经历也让我明白,解决 RAG 问题不一定非要靠堆算力,**数据处理的逻辑(Workflow)**往往比模型本身更重要。”
题目生成
背景与挑战(起头:先说遇到的尴尬) “我想讲讲我在做那个 AI 出题项目时遇到的一个的挑战。
当时项目差不多写完了,离比赛交稿还有一周左右,我们找了自己的高中老师来试用我们的项目。结果老师们的反馈让我们挺意外的。
他们说:‘你们的 AI 很严谨,生成的题目完全没有科学性错误,也没有歧义,但是太水了。’
简单说就是,题目太简单,干扰项(错误选项)设计得太假,学生根本不用动脑子,一看就知道哪个是错的,用排除法直接就选对了。这对考察学生能力根本没用。我们当时问他们有没有切换一下难度选项,当时我们设置了3个难度等级,简单,中等,困难,他们切换了一下,结果发现结果没啥变化,还是比较简单”
2. 归因分析(过渡:我是怎么排查的) “我当时就去扒我们的 Prompt 和工作流,发现主要有两个坑:
第一,干扰项太随意。AI 纯粹是为了凑四个选项,正确答案可能是知识库文档的内容,错误选项就是随便更改了知识库文档的一两个词语,根本没有设计什么‘思维陷阱’,甚至AI会出一些显而易见的错误来。
第二,我们给的例子(Example)不好。给大模型的参考案例太简单了,三个难度用的是同一套案例,导致AI认为案例就是对应的难度了,AI 就照葫芦画瓢,出的题也简单。
3. 解决方案(高光:我是怎么修的——重点记这三招) “为了解决这个,我主要做了三件事:
-
第一,改干扰项的逻辑。 我改了 Prompt,中等和困难题目强制要求 AI ‘站在学生的易错点上去出题’。比如故意模拟一些计算错误或者逻辑漏洞作为选项。这样学生想做对,就必须真的懂,光靠蒙是不行的。
-
第二,把‘样题’升级了。 我把提示词里的参考案例全换成了那种需要深度思考的应用题,告诉大模型:‘这种有难度的题,才是我想要的’。
-
第三,这也是最关键的一步,我加了个AI 质检员 先让普通模型出题,然后引入一个更厉害的模型Gemini2.5Pro当‘审核员’,以前的质检节点只是检查题目有没有问题,如果没有问题就去生成下一道或者结束任务了,现在AI会把生成题目的问题放入上下文中,然后让AI根据问题进行重新生成。直到审核通过,才会发给用户。并且还加了个兜底机制,既然普通模型(学生)教了 3 遍还不会,那就让高级模型(Gemini 2.5 Pro,老师)直接亲自上手改写,而不是只给意见了。”
4. 结果(收尾:最后效果咋样) “改完后,老师们的态度立马变了。他们说现在的题目有嚼头了,能真正考察学生会不会用知识,而不是死记硬背。后来这种生成-审核-修改的闭环模式,也成了我们项目的一个标准做法。”
拆分为服务
一开始我们考虑到一些问题,项目里有两块业务极其特殊:一个是抢课,它是典型的高并发、短连接;另一个是题目生成和大纲设计这些 AI 业务,它们是典型的长连接。
因为 AI 生成内容比较慢,我用的是 SSE(Server-Sent Events)流式输出,一个请求经常要维持 1 到 2 分钟。
在单体架构下,这就出大事了: 因为操作系统对一个进程打开的句柄数量是有限制的(比如默认 1024)。当大量用户在用 AI 功能时,成百上千个 SSE 长连接就像‘占着茅坑不拉屎’一样,死死占住了操作系统的文件句柄不释放。结果就是,导致同一台服务器上的**‘抢课服务’和‘用户登录’**这些本来只需要几毫秒的快请求,因为拿不到句柄。
“关于架构设计,其实我也考虑过,是不是直接升级到 Java 21 用虚拟线程就能解决并发问题,从而不需要拆微服务了?
但我深入分析后发现,虚拟线程只是解决了‘线程耗尽’的问题,并没有解决‘句柄耗尽’的问题。
因为在 Linux 底层,每一个 SSE 长连接依然要占用一个文件句柄。单机即使能开 100 万个虚拟线程,操作系统的句柄数、TCP 缓冲区内存、甚至是网卡带宽,依然是物理瓶颈。
特别是 AI 这种流式输出场景,非常吃带宽。如果单机硬抗,很可能出现 CPU 很闲,但网卡被打满的情况。
所以,我坚持使用 Spring Cloud Alibaba + Nacos 做集群部署:
-
分摊句柄压力:把 5 万个连接分散到 5 台机器,每台只抗 1 万,安全系数高得多。
-
网络 I/O 扩展:利用多台服务器的网卡带宽并行传输数据。
-
微服务解耦:利用 Dubbo 的高性能 RPC,把吃资源的‘题目生成服务’和轻量级的‘用户服务’隔离开,如果是长链路的话,网络带宽带来的耗时会一直累加,我们考虑可以通过RPC来解决这个问题。
这样,无论 Java 版本怎么升,架构层面的**水平扩展能力(Scale Out)**才是高并发系统的底气。”
抢课系统
面试官你好,关于这个抢课系统,我在设计时主要面临的挑战是:如何在巨大的流量冲击下,既保证库存不超卖,又要保证页面加载的高性能。
为了解决这个问题,我没有采用“一刀切”的缓存策略,而是根据数据的业务特性,把数据分成了两类,分别做了针对性的设计:
第一部分:针对“高频变化的抢课库存” (解决写压力 & 强一致性)
“首先是核心的库存数据。因为抢课对一致性要求极高,绝对不能超卖,而且写并发很大。
所以对于这部分数据,我放弃了本地缓存,因为分布式环境下本地缓存很难保证实时强一致。
我采用了 Redis + Lua 脚本 的方案。
当抢课请求进来时,直接在 Redis 里通过 Lua 脚本原子性地扣减库存,这就保证了不会超卖。
然后,为了保护数据库不被瞬时流量打挂,我用 RocketMQ 做了一个异步削峰。抢课成功后,只发一个消息到 MQ,后端通过消费者批量拉取消息,比如每 10 秒或凑够 1000 条,再批量写入 MySQL。这样就把数据库的写压力降低了 90% 以上。”
第二部分:针对“低频变化的课程详情” (解决读压力 & 最终一致性)
“第二类是课程详情,比如课程名称、老师介绍。这类数据读多写少,对实时性要求没那么苛刻,秒级延迟是可以接受的。
所以为了极致的读性能,我设计了 Caffeine + Redis + MySQL 的三级缓存架构。
大部分请求直接打在 Caffeine 本地内存里就返回了,根本不用走网络 IO,性能非常高。”
【这里要重点讲难点:分布式缓存一致性】 “但引入本地缓存带来了一个最大的难点:就是多节点间的数据一致性。如果我在节点 A 修改了课程描述,节点 B 的本地缓存还是旧的,怎么办?
针对这个问题,我利用了 RocketMQ 的广播模式(Broadcasting)。
一旦后台修改了课程信息,除了更新库和 Redis,还会发一条‘失效消息’。所有应用节点订阅这个 Topic,收到消息后,只做一个动作:清除本地缓存。
这样下一次请求进来时,自然会去 Redis 拉取最新的数据,实现了集群下的缓存最终一致性。”
第三部分:细节优化 (展示技术深度)
“在细节实现上,我还做了两个优化:
-
Caffeine 的过期策略:我选用了 expireAfterAccess 而不是 expireAfterWrite。因为热门课程会被频繁查看,只要有人看,它就应该留在内存里,这样能最大程度减少 Redis 的压力。
-
防缓存击穿:在本地缓存失效回源查 Redis 时,我利用了 Caffeine 自带的 get(key, loader) 机制。它底层会自动合并请求,保证同一个 Key 在同一瞬间只有一个线程去查 Redis,避免了大量请求同时打穿到后端,这比自己写 DCL(双重检查锁)要优雅且高效得多。”
💡 预判面试官的追问 (Prepare for Defense)
讲完上面的话术后,面试官大概率会问以下问题,你可以提前准备:
Q1: 为什么收到 MQ 广播后是“删除缓存”而不是“更新缓存”?
- 回答: 这是一个经典的缓存模式(Cache-Aside Pattern)。如果直接更新,可能会出现并发写导致的脏数据问题(比如两个消息先后到达,但处理顺序反了)。而且如果该课程暂时没人看,主动更新缓存是浪费资源。“删除”也就是延迟加载(Lazy Load),等下次有人查的时候再加载,既安全又节省资源。
Q2: 既然用了 Redis,为什么库存还要异步写入 MySQL?直接写不行吗?
- 回答: 抢课瞬间流量可能是数据库 TPS 的几十倍。如果每扣一次 Redis 就去写一次 MySQL,数据库连接池瞬间就会爆掉。异步写入是为了保护数据库,将随机写转换为批量顺序写,这是高并发下保护持久层的标准做法。
Q3: 如果 RocketMQ 发送失败了,本地缓存不就不一致了吗?
- 回答: 是的,所以我们在 Caffeine 上还加了一个兜底的过期时间(比如 5 分钟)。MQ 保证的是准实时的一致性(秒级),而过期时间保证了最终的一致性。就算 MQ 挂了,5 分钟后缓存自动过期,数据也会修正过来。
Q4: Caffeine 这种堆内缓存,会不会导致 OOM (内存溢出)?
- 回答: 不会,因为我严格限制了
maximumSize(5000)。Caffeine 底层使用 Window TinyLfu 算法,会自动淘汰访问频率低的数据,确保存储的都是热点数据,且内存占用可控。
Q: 抢课数据为什么不走本地缓存?
“抢课涉及库存扣减,必须保证强一致性。如果用本地缓存:
- 节点A扣减本地缓存,节点B不知道
- 可能导致超卖
所以抢课直接操作 Redis,用 Lua 脚本保证原子性。本地缓存只用于课程详情这种低频变化的数据。”
Q: 课程详情为什么用续期策略?
“课程详情(名称、描述、教师)很少变化,用 expireAfterAccess 续期策略:
- 热门课程被频繁查看,可以一直命中本地缓存
- 减少 Redis 访问,提升性能
- 续期只是更新一个时间戳,开销 < 1 纳秒
如果是库存这种频繁变化的数据,就不能用续期,否则数据会严重过时。”
Q: 多节点本地缓存怎么同步?
“用 RocketMQ 广播模式:
- 数据变更时,发送缓存失效消息
- 所有节点都订阅这个 Topic(广播模式)
- 收到消息后,清除对应的本地缓存
- 下次请求时,从 Redis 重新加载
会有几十到几百毫秒的不一致窗口,但对于课程详情这种数据完全可接受。”
Q: 缓存失效后大量请求同时查 Redis 怎么办?
“这是缓存击穿问题。我用 Caffeine 的 get(key, loader) 方法:
- 它内部用
CompletableFuture 实现
- 第一个线程执行 loader 查 Redis
- 其他线程等待同一个 Future 的结果
- 无需手动加锁,只有 1 次 Redis 查询
也可以用 DCL(Double-Check Locking)或 CAS 自旋实现类似效果。”
Q: RocketMQ 在你的系统中做了什么?
“RocketMQ 在我的系统中有两个作用:
1. 抢课数据异步落库:
- 抢课成功后发送消息
- Consumer 批量消费(1000条/10秒)
- 批量写入 MySQL,数据库操作减少 90%+
2. 分布式缓存同步:
- 课程信息修改后发送广播消息
- 所有节点清除本地缓存
- 保证多节点缓存一致性”
EduAgentX 项目面试问答文档
本文档针对EduAgentX智能教育平台项目,整理了面试中可能被深入追问的技术细节,帮助你应对二面技术拷问。
目录
一、Redis技术细节
1.1 Redis序列化方式
Q: 你的项目中Redis使用了什么序列化方式?为什么这样选择?
A: 项目中配置了两个RedisTemplate,使用不同的序列化策略:
- 通用RedisTemplate - 使用
Jackson2JsonRedisSerializer序列化Value
- Lua脚本专用RedisTemplate - 使用
StringRedisSerializer全字符串序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用 Jackson2JsonRedisSerializer 序列化值
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
// Key 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
return template;
}
@Bean
public RedisTemplate<String, String> luaRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 全部使用 String 序列化,用于Lua脚本
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
return template;
}
}
|
选择原因:
- Jackson2JsonRedisSerializer: 支持复杂对象序列化,可读性好,便于调试
- StringRedisSerializer: Lua脚本中需要纯字符串操作,避免JSON前缀导致的类型转换问题
- Key统一用String: 避免Key带有序列化前缀,便于在Redis客户端直接查看
深入追问:
Q: 为什么不用JDK序列化? A: JDK序列化存在以下问题:
- 序列化后的数据包含类信息,体积大
- 可读性差,无法在Redis客户端直接查看
- 存在安全漏洞(反序列化攻击)
- 跨语言兼容性差
Q: activateDefaultTyping的作用是什么? A: 启用类型信息记录,序列化时会在JSON中包含@class字段,反序列化时能正确还原对象类型。这对于存储多态对象很重要。
1.2 Lua脚本原子操作
Q: 抢课系统中的Lua脚本是怎么设计的?如何保证原子性?
A: 抢课使用Lua脚本实现原子性操作,一次网络往返完成:检查→扣减→记录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public static final RedisScript<Long> SNATCH_SCRIPT = new DefaultRedisScript<>("""
local tempSnatchKey = KEYS[1]
local studentSnatchKey = KEYS[2]
local subjectCapacityKey = KEYS[3]
local studentId = ARGV[1]
local subjectId = ARGV[2]
-- 1. 检查是否已抢课
local hasSnatch = redis.call('HEXISTS', studentSnatchKey, subjectId)
if hasSnatch == 1 then
return -1 -- 已抢课
end
-- 2. 原子性扣减库存
local newCapacity = redis.call('HINCRBY', subjectCapacityKey, subjectId, -1)
if newCapacity < 0 then
redis.call('HINCRBY', subjectCapacityKey, subjectId, 1) -- 回滚
return -2 -- 库存不足
end
-- 3. 批量写入
local hashKey = studentId .. ':' .. subjectId
redis.call('HINCRBY', tempSnatchKey, hashKey, 1)
redis.call('HSET', studentSnatchKey, subjectId, 1)
return 1 -- 成功
""", Long.class);
|
原子性保证机制:
- Redis单线程执行: Lua脚本在Redis中原子执行,不会被其他命令打断
- HINCRBY原子操作: 扣减库存使用原子递减,避免超卖
- 失败回滚: 库存不足时立即回滚,保证数据一致性
返回值设计:
-1: 已抢课,不能重复抢
-2: 课程容量不足
1: 抢课成功
深入追问:
Q: 为什么用HINCRBY而不是先HGET再HSET? A: 在Lua脚本内部,两者的原子性是等价的(因为整个Lua脚本本身就是原子执行的)。选择HINCRBY的原因是:
- 代码简洁:一条命令完成读取+计算+写入
- 避免类型转换:HGET返回字符串,需要tonumber()转换后再计算,HINCRBY直接操作数值
- 直接返回新值:HINCRBY返回操作后的新值,方便判断库存是否为负
Q: 那HINCRBY的原子性优势体现在哪里? A: 如果不用Lua脚本,而是在Java代码中分别调用HGET和HSET,那就会有并发问题。HINCRBY的原子性优势体现在单独使用时,而不是在Lua脚本内部。
Q: Lua脚本有什么限制? A:
- 不能使用随机函数(影响主从复制)
- 执行时间不宜过长(阻塞其他请求)
- 所有Key必须在同一个Redis节点(集群模式需要用Hash Tag)
1.3 多级缓存架构
Q: 项目中的多级缓存是怎么设计的?
A: 采用 Caffeine本地缓存 + Redis分布式缓存 的两级架构:
1
2
3
4
5
6
7
8
9
10
11
|
@Component
public class CacheManager {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存10000条
.expireAfterWrite(60, TimeUnit.SECONDS) // 60秒过期
.recordStats() // 启用统计
.build();
}
}
|
缓存查询流程:
1
2
|
请求 → 本地缓存(Caffeine) → Redis → 数据库
↑ 命中返回 ↑ 命中返回 ↑ 查询并缓存
|
实际使用代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
public Integer getSubjectCapacityWithCache(Long subjectId) {
String cacheKey = "capacity:" + subjectId;
// 1. 先查本地缓存
Object cached = localCache.getIfPresent(cacheKey);
if (cached != null) {
return (Integer) cached;
}
// 2. 查询Redis
Object capacityObj = luaRedisTemplate.opsForHash()
.get(capacityKey, subjectId.toString());
// 3. 热Key检测,决定是否缓存到本地
AddResult addResult = hotKeyDetector.add(buildCacheKey(subjectId), 1);
if (addResult.isHotKey()) {
localCache.put(cacheKey, capacityValue);
}
return capacityValue;
}
|
深入追问:
Q: 本地缓存和Redis缓存的数据一致性怎么保证? A:
- 本地缓存设置较短过期时间(60秒)
- 写操作时主动失效本地缓存
- 对于强一致性场景,直接查Redis
Q: 为什么选择Caffeine而不是Guava Cache? A: Caffeine是Guava Cache的升级版,性能更好:
- 使用Window TinyLFU淘汰算法,命中率更高
- 异步加载支持更好
- 统计功能更完善
1.4 HeavyKeeper热Key检测
Q: 热Key检测是怎么实现的?
A: 使用HeavyKeeper算法实现热Key检测,这是一种概率数据结构,能在有限内存下高效识别高频访问的Key。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
public class HeavyKeeper implements TopK {
private final int k; // Top K
private final int width; // 桶宽度
private final int depth; // 桶深度
private final double[] lookupTable; // 衰减查找表
private final Bucket[][] buckets; // 计数桶
private final PriorityQueue<Node> minHeap; // 最小堆维护TopK
public HeavyKeeper(int k, int width, int depth, double decay, int minCount) {
this.k = k; // 监控Top 100
this.width = width; // 100000
this.depth = depth; // 5
this.minCount = minCount; // 最小出现3次
// 预计算衰减表
for (int i = 0; i < 256; i++) {
lookupTable[i] = Math.pow(decay, i);
}
}
@Override
public AddResult add(String key, int increment) {
// 1. 计算指纹
long itemFingerprint = hash(key.getBytes());
int maxCount = 0;
// 2. 更新多层桶
for (int i = 0; i < depth; i++) {
int bucketNumber = Math.abs(hash(key.getBytes())) % width;
Bucket bucket = buckets[i][bucketNumber];
synchronized (bucket) {
if (bucket.fingerprint == itemFingerprint) {
bucket.count += increment;
maxCount = Math.max(maxCount, bucket.count);
} else {
// 概率衰减
double decay = lookupTable[Math.min(bucket.count, 255)];
if (random.nextDouble() < decay) {
bucket.count--;
if (bucket.count == 0) {
bucket.fingerprint = itemFingerprint;
bucket.count = increment;
}
}
}
}
}
// 3. 更新TopK堆
// ...
return new AddResult(expelled, isHot, key);
}
}
|
配置参数:
1
2
3
4
5
6
7
|
hotKeyDetector = new HeavyKeeper(
100, // 监控Top 100 Key
100000, // 宽度
5, // 深度
0.92, // 衰减系数
3 // 最小出现3次才记录
);
|
深入追问:
Q: HeavyKeeper相比Count-Min Sketch有什么优势? A:
- Count-Min Sketch只能估计频率,不能直接获取TopK
- HeavyKeeper结合了Count-Min Sketch和Space-Saving算法
- 内存效率更高,误差更小
Q: 为什么需要定时fading? A: 防止历史热Key长期占据TopK位置,通过定期衰减让新的热Key有机会进入。
二、微服务架构
2.1 Dubbo配置与调用
Q: 项目中Dubbo是怎么配置的?用的什么协议?
A: 使用Apache Dubbo 3.x,配置Triple协议 + Nacos注册中心:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# application.yml
dubbo:
registry:
address: nacos://127.0.0.1:8848?username=nacos&password=nacos
register-mode: instance
protocol:
name: tri # Triple协议(基于gRPC)
port: 50052
consumer:
timeout: 60000 # 60秒超时
check: false # 启动时不检查服务提供者
retries: 2 # 失败重试2次
provider:
timeout: 60000
application:
qos-port: 22222
metadata-type: remote
metadata-report:
address: nacos://127.0.0.1:8848?username=nacos&password=nacos
|
Triple协议选择原因:
- 基于HTTP/2,支持流式传输
- 兼容gRPC,跨语言调用更方便
- 性能优于传统Dubbo协议
- 支持应用级服务发现
深入追问:
Q: check: false是什么意思? A: 启动时不检查服务提供者是否存在。在微服务场景下,服务可能还没启动完成,设置false避免启动失败。
Q: retries: 2会不会导致重复请求? A: 会的,所以对于非幂等接口(如抢课),需要在业务层做幂等处理,比如先检查是否已抢课,或者查询信息(幂等性的信息适合使用重复请求)。
在代码中通过注解对“查询接口”单独开启:
1
2
3
|
// 只有查询接口,才手动指定重试次数
@DubboReference(retries = 2, timeout = 3000)
private UserQueryService userQueryService;
|
2.2 服务拆分策略
Q: 项目拆分成了哪些微服务?拆分原则是什么?
A: 项目按业务领域拆分为以下微服务:
1
2
3
4
5
6
7
8
9
10
11
|
EduAgentX-MicroService/
├── EduAgentX-User # 用户服务 (端口8124)
├── EduAgentX-Course # 课程服务 (端口8125)
├── EduAgentX-Question # 题目服务 (端口8126)
├── EduAgentX-Snatch # 抢课服务 (端口8127)
├── EduAgentX-File # 文件服务 (端口8128)
├── EduAgentX-RagService # RAG服务 (端口8129)
├── EduAgentX-AI-Workflow # AI工作流服务 (端口8130)
├── EduAgentX-Client # 内部调用接口定义
├── EduAgentX-Common # 公共模块
└── EduAgentX-Model # 数据模型
|
拆分原则:
- 单一职责: 每个服务只负责一个业务领域
- 高内聚低耦合: 服务内部高度内聚,服务间通过接口通信
- 独立部署: 每个服务可以独立部署、扩容
- 数据隔离: 每个服务有自己的数据库表(逻辑隔离)
深入追问:
Q: 为什么抢课服务要单独拆出来? A: 抢课是高并发场景,需要独立扩容。拆分后可以:
- 单独优化Redis连接池
- 独立限流配置
- 不影响其他服务稳定性
2.3 跨服务调用设计
Q: 微服务之间是怎么调用的?
A: 通过定义InnerService接口 + @DubboService实现:
接口定义(EduAgentX-Client模块):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public interface InnerUserService {
List<User> listByIds(Collection<? extends Serializable> ids);
User getById(Serializable id);
UserVO getUserVO(User user);
// 静态方法,避免跨服务调用
static User getLoginUser(HttpServletRequest request) {
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return currentUser;
}
}
|
服务实现(EduAgentX-Snatch模块):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
@DubboService
public class InnerSnatchSubjectServiceImpl implements InnerSnatchSubjectService {
@Resource
private SnatchSubjectService snatchSubjectService;
@Override
public void initSubjectCapacityToRedis(Long subjectId) {
snatchSubjectService.initSubjectCapacityToRedis(subjectId);
}
@Override
public SnatchSubject getBySubjectId(Long subjectId) {
return snatchSubjectService.getBySubjectId(subjectId);
}
}
|
调用方使用:
1
2
3
4
5
6
|
@DubboReference
private InnerUserService innerUserService;
public void someMethod() {
User user = innerUserService.getById(userId);
}
|
深入追问:
Q: 为什么getLoginUser用静态方法? A: 获取登录用户需要HttpServletRequest,这个对象无法跨服务传递。使用静态方法在本地从Session获取,避免RPC调用。
2.4 分布式Session
Q: 微服务之间Session是怎么共享的?
A: 使用Spring Session + Redis实现分布式Session:
1
2
3
4
5
6
|
spring:
session:
store-type: redis
timeout: 2880m # 48小时
redis:
namespace: spring:session
|
工作原理:
- 用户登录后,Session存储到Redis
- 所有微服务连接同一个Redis
- 请求携带JSESSIONID,从Redis获取Session
Session存储结构:
1
2
3
4
5
|
spring:session:sessions:{sessionId}
├── creationTime
├── lastAccessedTime
├── maxInactiveInterval
└── sessionAttr:USER_LOGIN_STATE # 用户登录信息
|
深入追问:
Q: Session过期时间为什么设置48小时? A: 教育场景下用户可能长时间使用,设置较长过期时间提升体验。同时Redis会自动清理过期Session。
Q: 如果Redis挂了怎么办? A:
- 短期:用户需要重新登录
- 长期:可以考虑Redis集群或哨兵模式保证高可用
2.5 Dubbo底层原理深入
Q: Dubbo的服务调用过程是怎样的?从发起调用到收到响应经历了哪些步骤?
A: 完整的调用链路如下:
1
2
3
4
5
|
Consumer端:
1. Proxy代理 → 2. Filter链 → 3. Invoker → 4. Directory → 5. Router → 6. LoadBalance → 7. Invoker → 8. Protocol → 9. Exchanger → 10. Transporter(Netty)
Provider端:
1. Transporter(Netty) → 2. Exchanger → 3. Protocol → 4. Filter链 → 5. Invoker → 6. 实际服务实现
|
详细流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 1. 代理层:生成代理对象
UserService proxy = Proxy.getProxy(UserService.class);
// 2. 调用时,代理对象调用InvokerInvocationHandler
public Object invoke(Object proxy, Method method, Object[] args) {
// 3. 构建RpcInvocation
RpcInvocation invocation = new RpcInvocation(method, args);
// 4. 通过Directory获取所有可用Invoker
List<Invoker> invokers = directory.list(invocation);
// 5. Router过滤(标签路由、条件路由等)
invokers = router.route(invokers, invocation);
// 6. LoadBalance选择一个Invoker
Invoker invoker = loadBalance.select(invokers, invocation);
// 7. 发起远程调用
Result result = invoker.invoke(invocation);
return result.getValue();
}
|
Q: Dubbo的Invoker是什么?为什么说它是核心模型?
A: Invoker是Dubbo的核心抽象,代表一个可执行体:
1
2
3
4
5
|
public interface Invoker<T> {
Class<T> getInterface(); // 服务接口
URL getUrl(); // 服务地址
Result invoke(Invocation invocation); // 执行调用
}
|
Invoker的类型:
- 本地Invoker:直接调用本地实现
- 远程Invoker:封装了网络通信逻辑
- 集群Invoker:封装了负载均衡、容错逻辑
为什么是核心:
- 统一了本地调用和远程调用的抽象
- 所有的Filter、Router、LoadBalance都围绕Invoker工作
- 服务暴露和引用的最终产物都是Invoker
Q: Dubbo的Filter机制是怎样的?如何自定义Filter?
A: Filter是Dubbo的拦截器机制,类似于Servlet Filter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// 自定义Filter示例
@Activate(group = {CommonConstants.CONSUMER, CommonConstants.PROVIDER})
public class LogFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
long startTime = System.currentTimeMillis();
String methodName = invocation.getMethodName();
try {
// 前置处理
log.info("调用方法: {}", methodName);
// 执行调用
Result result = invoker.invoke(invocation);
// 后置处理
long cost = System.currentTimeMillis() - startTime;
log.info("方法 {} 耗时: {}ms", methodName, cost);
return result;
} catch (Exception e) {
log.error("调用异常: {}", e.getMessage());
throw e;
}
}
}
|
配置文件(META-INF/dubbo/org.apache.dubbo.rpc.Filter):
1
|
logFilter=com.lucius.eduAgentX.filter.LogFilter
|
内置Filter:
- ConsumerContextFilter:设置调用上下文
- FutureFilter:处理异步调用
- MonitorFilter:监控统计
- TimeoutFilter:超时处理
- ExceptionFilter:异常处理
Q: Dubbo的Directory和Router有什么区别?
A:
Directory(服务目录):
- 存储所有可用的服务提供者列表
- 监听注册中心变化,动态更新列表
- 类似于"电话簿"
1
2
3
|
public interface Directory<T> {
List<Invoker<T>> list(Invocation invocation); // 获取所有Invoker
}
|
Router(路由器):
- 对Directory返回的列表进行过滤
- 实现条件路由、标签路由等
- 类似于"筛选器"
1
2
3
|
public interface Router {
List<Invoker<?>> route(List<Invoker<?>> invokers, Invocation invocation);
}
|
路由规则示例:
1
2
3
4
5
6
7
8
|
# 条件路由:北京机房的消费者只调用北京机房的提供者
conditions:
- "host = 192.168.1.* => host = 192.168.1.*"
# 标签路由:灰度发布
tags:
- name: gray
addresses: [192.168.1.100, 192.168.1.101]
|
Q: Dubbo的服务暴露过程是怎样的?
A: 服务暴露的核心流程:
1
2
|
@DubboService注解 → ServiceBean → ServiceConfig.export() →
Protocol.export() → 启动Server → 注册到注册中心
|
详细步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// 1. Spring扫描@DubboService注解,创建ServiceBean
@DubboService
public class UserServiceImpl implements UserService { }
// 2. ServiceConfig.export()
public void export() {
// 2.1 检查配置
checkAndUpdateSubConfigs();
// 2.2 构建URL
URL url = new URL(protocol, host, port, path, parameters);
// 2.3 通过Protocol暴露服务
Exporter<?> exporter = protocol.export(invoker);
// 2.4 注册到注册中心
registry.register(url);
}
// 3. Protocol.export() - 以Triple协议为例
public <T> Exporter<T> export(Invoker<T> invoker) {
// 启动HTTP/2 Server
openServer(url);
return new TripleExporter<>(invoker);
}
|
Q: Dubbo的服务引用过程是怎样的?
A: 服务引用的核心流程:
1
2
|
@DubboReference注解 → ReferenceBean → ReferenceConfig.get() →
Protocol.refer() → 创建代理对象
|
详细步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 1. Spring扫描@DubboReference注解
@DubboReference
private UserService userService;
// 2. ReferenceConfig.get()
public T get() {
// 2.1 从注册中心订阅服务
List<URL> urls = registry.lookup(url);
// 2.2 创建Invoker
Invoker<T> invoker = protocol.refer(type, url);
// 2.3 创建代理对象
return proxyFactory.getProxy(invoker);
}
|
2.6 Dubbo高级特性
Q: Dubbo如何实现服务分组和多版本?
A: 通过group和version参数实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// 服务提供者 - 不同分组
@DubboService(group = "primary")
public class PrimaryUserServiceImpl implements UserService { }
@DubboService(group = "backup")
public class BackupUserServiceImpl implements UserService { }
// 服务消费者 - 指定分组
@DubboReference(group = "primary")
private UserService primaryUserService;
// 多版本共存
@DubboService(version = "1.0.0")
public class UserServiceV1Impl implements UserService { }
@DubboService(version = "2.0.0")
public class UserServiceV2Impl implements UserService { }
// 消费者指定版本
@DubboReference(version = "2.0.0")
private UserService userService;
// 消费者随机调用任意版本
@DubboReference(version = "*")
private UserService userService;
|
使用场景:
- 分组:同一接口的不同实现(如主备、读写分离)
- 版本:接口升级时的灰度发布
Q: Dubbo的隐式参数传递是怎么实现的?
A: 通过RpcContext传递隐式参数:
1
2
3
4
5
6
7
8
9
10
|
// Consumer端设置
RpcContext.getClientAttachment().setAttachment("traceId", "123456");
RpcContext.getClientAttachment().setAttachment("userId", "1001");
// 调用服务
userService.getById(1L);
// Provider端获取
String traceId = RpcContext.getServerAttachment().getAttachment("traceId");
String userId = RpcContext.getServerAttachment().getAttachment("userId");
|
应用场景:
- 分布式链路追踪(TraceId传递)
- 用户身份信息传递
- 灰度标签传递
Q: Dubbo如何实现本地存根(Stub)和本地伪装(Mock)?
A:
本地存根(Stub)- 在Consumer端做预处理:
如果校验不通过,直接返回,而不是去调用rpc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 存根实现
public class UserServiceStub implements UserService {
private final UserService userService;
public UserServiceStub(UserService userService) {
this.userService = userService;
}
@Override
public User getById(Long id) {
// 前置校验
if (id == null || id <= 0) {
return null;
}
// 调用远程服务
return userService.getById(id);
}
}
// 配置
@DubboReference(stub = "com.lucius.eduAgentX.stub.UserServiceStub")
private UserService userService;
|
本地伪装(Mock)- 服务降级:
如果服务炸了,返回默认字符串,如”服务繁忙“,或者可以不管服务器好坏,直接降级返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Mock实现
public class UserServiceMock implements UserService {
@Override
public User getById(Long id) {
// 返回默认值或从缓存获取
User user = new User();
user.setId(id);
user.setUserName("默认用户");
return user;
}
}
// 配置 - 失败时Mock
@DubboReference(mock = "com.lucius.eduAgentX.mock.UserServiceMock")
private UserService userService;
// 配置 - 强制Mock(不调用远程)
@DubboReference(mock = "force:com.lucius.eduAgentX.mock.UserServiceMock")
private UserService userService;
|
Q: Dubbo的连接控制是怎样的?
A: Dubbo支持多种连接控制策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
dubbo:
#作为服务端的时候
provider:
# 服务端最大连接数
accepts: 1000
# 每个服务的线程数
threads: 200
# 线程池类型
threadpool: fixed
#作为客户端的时候,和服务端建立的连接数量取决于端口数
consumer:
# 每个服务的连接数,这里指的是一个服务和一个客户端之间最大连接数
connections: 1
# 是否共享连接
shareconnections: true
|
连接模式:
- 单一长连接(默认):所有请求复用一个连接
- 多连接:每个服务建立多个连接,提高并发
1
2
3
|
// 为特定服务配置多连接
@DubboReference(connections = 10)
private UserService userService;
|
Q: Dubbo如何处理大数据量传输?
A:
1. 分页查询:
1
2
3
4
|
// 避免一次返回大量数据
public interface UserService {
Page<User> listByPage(int pageNum, int pageSize);
}
|
2. 流式传输(Triple协议支持):
1
2
3
4
|
// 服务端流
public interface UserService {
StreamObserver<User> listAllUsers(StreamObserver<User> responseObserver);
}
|
3. 调整payload限制:
1
2
3
|
dubbo:
protocol:
payload: 8388608 # 8MB,默认是8MB
|
4. 压缩传输:
1
2
3
4
5
6
7
|
dubbo:
provider:
payload: 8388608
protocol:
# 启用压缩
parameters:
compressor: gzip
|
Q: Dubbo的服务治理有哪些功能?
A:
1. 动态配置:
1
2
3
4
|
// 通过Admin或API动态修改配置
ConfigCenterConfig config = new ConfigCenterConfig();
config.setAddress("nacos://127.0.0.1:8848");
// 动态修改超时时间、权重等
|
2. 服务降级:
1
2
3
4
5
|
# 通过规则配置降级
override:
- service: com.lucius.eduAgentX.service.UserService
parameters:
mock: force:return null
|
3. 访问控制:
1
2
3
4
5
|
# 黑白名单
accesslog:
- service: com.lucius.eduAgentX.service.UserService
whitelist: [192.168.1.*]
blacklist: [192.168.2.*]
|
4. 权重调整:
1
2
3
4
5
6
|
# 动态调整权重
override:
- service: com.lucius.eduAgentX.service.UserService
address: 192.168.1.100
parameters:
weight: 200
|
Q: Dubbo 3.x相比2.x有哪些重要变化?
A:
| 特性 |
Dubbo 2.x |
Dubbo 3.x |
| 服务发现 |
接口级 |
应用级(默认) |
| 协议 |
Dubbo协议 |
Triple协议(默认) |
| 注册中心 |
Zookeeper为主 |
Nacos/Zookeeper |
| 云原生 |
不支持 |
支持K8s、Service Mesh |
| 性能 |
高 |
更高 |
应用级服务发现的优势:
1
2
3
4
5
6
7
|
接口级:每个接口都注册一条记录
- UserService → provider1, provider2
- OrderService → provider1, provider2
应用级:每个应用只注册一条记录
- user-service → provider1, provider2
- 元数据单独存储
|
- 减少注册中心压力
- 更适合大规模微服务
- 与K8s服务发现模型一致
Q: 如何排查Dubbo调用问题?
A:
1. 开启访问日志:
1
2
3
|
dubbo:
provider:
accesslog: true # 或指定文件路径
|
2. 查看调用统计:
1
2
3
4
|
// 通过QoS命令
telnet localhost 22222
> ls // 列出服务
> count UserService // 查看调用统计
|
3. 链路追踪:
1
2
|
// 集成SkyWalking或Zipkin
// 通过Filter传递TraceId
|
4. 常见问题排查:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
问题:No provider available
排查:
1. 检查服务是否启动
2. 检查注册中心连接
3. 检查group/version是否匹配
4. 检查网络是否通畅
问题:Timeout
排查:
1. 检查Provider处理时间
2. 检查网络延迟
3. 调整timeout配置
4. 检查线程池是否满了
|
三、高并发抢课系统
3.1 抢课核心流程
Q: 抢课系统的核心流程是怎样的?
A: 采用 Redis预扣库存 + 异步落库 的架构:
1
2
3
4
5
|
用户请求 → 限流检查 → Lua脚本原子操作 → 返回结果
↓
临时数据写入Redis
↓
定时任务批量同步到MySQL
|
核心代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
@Override
public BaseResponse<Boolean> snatchTheSubject(SnatchRequest request, HttpServletRequest httpRequest) {
User loginUser = userService.getLoginUser(httpRequest);
Long studentId = loginUser.getId();
Long subjectId = request.getSubjectId();
// 1. 检查Redis中是否有课程容量数据
Integer capacity = snatchCacheService.getSubjectCapacityWithCache(subjectId);
if (capacity == null) {
snatchSubjectService.initSubjectCapacityToRedis(subjectId);
}
// 2. 执行Lua脚本抢课
Long result = snatchCacheService.snatchWithCache(studentId, subjectId);
// 3. 处理返回结果
if (result == -1) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "已抢课,不能重复抢课");
}
if (result == -2) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "课程余量不足");
}
if (result == 1) {
return ResultUtils.success(true);
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "抢课失败");
}
|
Redis数据结构设计:
1
2
3
4
5
6
7
8
9
10
11
|
subject:capacity # Hash: 课程容量
├── 1 → 100
└── 2 → 50
snatch:{studentId} # Hash: 学生已抢课程
├── 1 → 1
└── 3 → 1
snatch:temp:{timeSlice} # Hash: 临时抢课数据
├── 1001:1 → 1 # 学生1001抢了课程1
└── 1002:1 → -1 # 学生1002退了课程1
|
深入追问:
Q: 为什么不直接写MySQL? A:
- MySQL单机QPS约1000,无法支撑高并发
- Redis单机QPS可达10万+
- 异步落库减少数据库压力
3.2 数据一致性保证
Q: Redis和MySQL的数据一致性怎么保证?
A: 通过 定时任务 + 补偿机制 保证最终一致性:
定时同步任务(每10秒执行):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
@Scheduled(fixedRate = 10000)
@Transactional(rollbackFor = Exception.class)
public void run() {
String lastTimeSlice = RedisKeyUtil.getLastTimeSlice();
syncSnatch2DBByTimeSlice(lastTimeSlice);
}
public void syncSnatch2DBByTimeSlice(String timeSlice) {
String tempSnatchKey = RedisKeyUtil.getTempSnatchKey(timeSlice);
Map<Object, Object> allTempSnatchMap = redisTemplate.opsForHash().entries(tempSnatchKey);
List<Snatch> snatchListToInsert = new ArrayList<>();
List<Snatch> snatchListToDelete = new ArrayList<>();
for (Object key : allTempSnatchMap.keySet()) {
String[] parts = key.toString().split(":");
Long studentId = Long.valueOf(parts[0]);
Long subjectId = Long.valueOf(parts[1]);
Integer snatchType = Integer.valueOf(allTempSnatchMap.get(key).toString());
if (snatchType == 1) { // 抢课
snatchListToInsert.add(new Snatch(studentId, subjectId));
} else if (snatchType == -1) { // 退课
snatchListToDelete.add(new Snatch(studentId, subjectId));
}
}
// 批量操作
if (!snatchListToInsert.isEmpty()) {
snatchService.saveBatch(snatchListToInsert);
}
// ...
}
|
补偿任务(每天凌晨2点):
1
2
3
4
5
6
7
|
@Scheduled(cron = "0 0 2 * * *")
public void run() {
Set<String> snatchKeys = redisTemplate.keys("snatch:temp:*");
for (String timeSlice : extractTimeSlices(snatchKeys)) {
syncSnatch2DBJob.syncSnatch2DBByTimeSlice(timeSlice);
}
}
|
时间片设计:
1
2
3
4
5
6
7
|
public static String getCurrentTimeSlice() {
Date now = new Date();
int second = DateUtil.second(now);
int alignedSecond = (second / 10) * 10; // 向下取整到10的倍数
return DateUtil.format(now, "HH:mm:") + String.format("%02d", alignedSecond);
}
// 例如:14:30:00, 14:30:10, 14:30:20...
|
3.3 限流实现
Q: 接口限流是怎么实现的?
A: 使用 Redis + Lua脚本 实现滑动窗口限流:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
@Aspect
@Component
public class RateLimitAspect {
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT = new DefaultRedisScript<>("""
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local current = tonumber(ARGV[3])
-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, current - window * 1000)
-- 统计当前窗口内的请求数
local currentCount = redis.call('ZCARD', key)
if currentCount < limit then
-- 未超限,记录本次请求
redis.call('ZADD', key, current, current)
redis.call('EXPIRE', key, window)
return limit - currentCount - 1 -- 返回剩余次数
else
return -1 -- 超限
end
""", Long.class);
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable {
String limitKey = buildLimitKey(rateLimit);
Long remaining = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
Collections.singletonList(limitKey),
rateLimit.window(),
rateLimit.limit(),
System.currentTimeMillis()
);
if (remaining != null && remaining < 0) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "操作过于频繁,请稍后重试");
}
return point.proceed();
}
}
|
使用方式:
1
2
3
4
5
|
@RateLimit(key = "snatch", window = 1, limit = 5, type = "user")
@PostMapping("/save")
public BaseResponse<Boolean> snatchTheSubject(@RequestBody SnatchRequest request) {
// ...
}
|
深入追问:
Q: 为什么用滑动窗口而不是固定窗口? A: 固定窗口在窗口边界会有突刺问题。比如限制每秒5次,在0.9秒请求5次,1.1秒又请求5次,实际0.2秒内请求了10次。滑动窗口能更平滑地限流。
3.4 性能优化措施
Q: 抢课系统做了哪些性能优化?
A: 主要优化措施:
1. 连接池优化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
spring:
data:
redis:
lettuce:
pool:
max-active: 100 # 最大连接数
max-idle: 50
min-idle: 10
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
server:
tomcat:
threads:
max: 500
min-spare: 50
max-connections: 10000
|
2. Lua脚本优化:
- 减少Redis操作次数(原来5次→现在3次)
- 使用HINCRBY原子操作
3. 批量SQL优化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 使用CASE WHEN批量更新
@Update("""
UPDATE snatch_subject
SET capacity = CASE subject_id
<foreach collection="list" item="item">
WHEN #{item.subjectId} THEN capacity + #{item.change}
</foreach>
END
WHERE subject_id IN
<foreach collection="list" item="item" open="(" separator="," close=")">
#{item.subjectId}
</foreach>
""")
void batchUpdateSubjectCapacity(@Param("list") List<SubjectCapacityChangeDTO> list);
|
4. 异步热Key检测:
- HeavyKeeper的add()操作异步执行
- 不阻塞主流程
性能对比:
| 指标 |
优化前 |
优化后 |
| QPS |
500 |
2000+ |
| 响应时间 |
100ms |
<50ms |
| Redis连接数 |
10 |
100 |
四、AI工作流系统
4.1 LangGraph4j工作流
Q: AI出题的工作流是怎么设计的?
A: 使用LangGraph4j构建有向图工作流,支持条件路由和循环:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
private CompiledGraph<MessagesState<String>> createQuestionWorkflow() {
return new MessagesStateGraph<String>()
// 添加节点
.addNode("ques_knowledge_result_node", QuesKnowledgeResultNode.create())
.addNode("ques_task_list_node", QuesTaskListNode.create())
.addNode("ques_generate_node", QuesGenerateNode.create())
.addNode("ques_parse_check_node", QuesParseCheckNode.create())
// 添加边
.addEdge(START, "ques_knowledge_result_node")
.addEdge("ques_knowledge_result_node", "ques_task_list_node")
.addEdge("ques_task_list_node", "ques_generate_node")
.addEdge("ques_generate_node", "ques_parse_check_node")
// 条件路由
.addConditionalEdges("ques_parse_check_node",
edge_async(this::routeAfterCheck),
Map.of(
"continue_generate", "ques_generate_node",
"retry_generate", "ques_generate_node",
"finish", END
))
.compile();
}
private String routeAfterCheck(MessagesState<String> state) {
QuestionGenerateContext context = QuestionGenerateContext.getContext(state);
Boolean checkResult = context.getCheckResult();
if (checkResult == null || !checkResult) {
return "retry_generate"; // 质检未通过,重新生成
}
context.setCurrentQuestionIndex(context.getCurrentQuestionIndex() + 1);
if (context.getQuestionNum() > 0) {
return "continue_generate"; // 继续生成下一题
}
return "finish"; // 完成
}
|
工作流图:
1
2
3
4
5
6
7
8
|
graph LR
START --> 知识点检索
知识点检索 --> 任务列表生成
任务列表生成 --> 题目生成
题目生成 --> 质检解析
质检解析 -->|通过且有剩余| 题目生成
质检解析 -->|未通过| 题目生成
质检解析 -->|完成| END
|
4.2 设计模式应用
Q: AI出题系统用了哪些设计模式?
A: 主要使用了三种设计模式:
1. 外观模式(Facade):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
@Service
public class AIQuestionFacade {
@Resource
private QuestionMakerService questionMakerService;
@Resource
private ExplanationExecutor explanationExecutor;
@Resource
private ScoreStandardExecutor scoreStandardExecutor;
public QuestionGenerateContext generateAndParseQuestion(
SseEmitter emitter, QuestionGenerateContext context,
String prompt, QuestionTypeEnum questionTypeEnum) {
return switch (questionTypeEnum) {
case MULTIPLE_CHOICE_QUESTION -> {
var result = questionMakerService.multipleChoiceQuestion(prompt, subjectId, emitter);
yield processQuestionStream(context, result, questionTypeEnum, emitter);
}
case FILL_BLANK_QUESTION -> {
var result = questionMakerService.fillBlankQuestion(subjectId, prompt, emitter);
yield processQuestionStream(context, result, questionTypeEnum, emitter);
}
case BIG_QUESTION -> {
var result = questionMakerService.bigQuestion(prompt, emitter, subjectId);
yield processQuestionStream(context, result, questionTypeEnum, emitter);
}
};
}
}
|
2. 模板方法模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
@Component
public class ParseCheckStorageExecutor {
@Resource
private MultipleChoiceStorageTemplate multipleChoiceStorageTemplate;
@Resource
private FillBlankStorageTemplate fillBlankStorageTemplate;
@Resource
private BigStorageTemplate bigStorageTemplate;
public QuestionGenerateContext executeParseCheckAndStorage(
Object questionResult, QuestionGenerateContext context,
QuestionTypeEnum questionTypeEnum) {
return switch (questionTypeEnum) {
case MULTIPLE_CHOICE_QUESTION ->
multipleChoiceStorageTemplate.saveQuestion((MultipleChoiceQuestionBO) questionResult, context);
case FILL_BLANK_QUESTION ->
fillBlankStorageTemplate.saveQuestion((FillBlankQuestionBO) questionResult, context);
case BIG_QUESTION ->
bigStorageTemplate.saveQuestion((BigQuestionBO) questionResult, context);
};
}
}
|
3. 策略模式:
- 不同题型使用不同的生成策略
- 不同题型使用不同的解析策略
- 不同题型使用不同的评分标准生成策略
4.3 SSE实时推送
Q: AI生成过程中的实时推送是怎么实现的?
A: 使用SSE(Server-Sent Events)+ ThreadLocal存储Emitter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
@Service
public class WorkFlowServiceImpl implements WorkFlowService {
public static final ThreadLocal<SseEmitter> SSE_EMITTER_HOLDER = new ThreadLocal<>();
@Override
public Boolean generateQuestionWorkFlow(..., SseEmitter emitter) {
SSE_EMITTER_HOLDER.set(emitter);
try {
// 发送初始消息
JSONObject jsonObject = new JSONObject();
jsonObject.put(WorkFlowTagEnum.START.getValue(), "工作流开始执行...");
emitter.send(SseEmitter.event()
.name(WorkFlowTagEnum.START.getValue())
.data(jsonObject.toString()));
CompiledGraph<MessagesState<String>> workflow = createQuestionWorkflow();
// 异步执行工作流
CompletableFuture.runAsync(() -> {
SSE_EMITTER_HOLDER.set(emitter); // 异步线程重新设置
try {
for (NodeOutput<MessagesState<String>> step : workflow.stream(initialState)) {
// 节点内部实时发送SSE消息
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
} finally {
SSE_EMITTER_HOLDER.remove();
}
});
} finally {
// 主线程不清理,异步任务会重新设置
}
return true;
}
}
|
节点中发送消息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class QuesGenerateNode {
public static AsyncNodeAction<MessagesState<String>> create() {
return node_async(state -> {
SseEmitter emitter = SSE_EMITTER_HOLDER.get();
JSONObject jsonObject = new JSONObject();
jsonObject.put(WorkFlowTagEnum.ALERT.getValue(), "题目生成节点");
emitter.send(SseEmitter.event()
.name(WorkFlowTagEnum.ALERT.getValue())
.data(jsonObject.toString()));
// 生成题目...
return QuestionGenerateContext.saveContext(context);
});
}
}
|
深入追问:
Q: 为什么用ThreadLocal存储SseEmitter? A:
- SseEmitter不能序列化,无法放入工作流状态
- ThreadLocal可以在同一线程的不同节点间共享
- 异步线程需要重新设置
4.4 RAG检索增强
Q: RAG是怎么实现的?
A: 使用Spring AI + Redis向量存储:
1
2
3
4
5
6
7
8
9
|
spring:
ai:
openai:
base-url: ${spring.ai.openai.base-url}
api-key: ${spring.ai.openai.api-key}
embedding:
options:
model: text-embedding-v3
dimensions: 1024
|
文档处理流程:
1
2
3
4
5
6
7
8
9
10
11
|
public interface RagService {
// 1. 文档上传并向量化
Long documentPush(MultipartFile file, String category, String subjectId,
HttpServletRequest request) throws IOException;
// 2. 检索相关内容
RagResultVO getRagResult(RagPromptRequest request);
// 3. 删除文档
boolean deleteDocumentByFileId(Long id);
}
|
支持的文档格式:
- PDF(使用spring-ai-pdf-document-reader)
- DOCX(使用Apache POI)
五、数据库与ORM
5.1 MyBatis-Flex使用
Q: 为什么选择MyBatis-Flex而不是MyBatis-Plus?
A: MyBatis-Flex是MyBatis-Plus的轻量级替代:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// QueryWrapper使用
public QueryWrapper getQueryWrapper(UserQueryRequest request) {
return QueryWrapper.create()
.eq("id", request.getId())
.eq("userRole", request.getUserRole())
.like("userAccount", request.getUserAccount())
.like("userName", request.getUserName())
.orderBy(request.getSortField(), "ascend".equals(request.getSortOrder()));
}
// 条件更新
QueryWrapper query = QueryWrapper.create()
.where(User::getId).eq(loginUser.getId());
userMapper.updateByQuery(updateUser, query);
|
对比MyBatis-Plus: | 特性 | MyBatis-Flex | MyBatis-Plus |
|——|————-|————–|
| 依赖大小 | 更小 | 较大 |
| Lambda支持 | 支持 | 支持 |
| 多表关联 | 更好 | 一般 |
| 学习成本 | 低 | 低 |
5.2 批量操作优化
Q: 批量更新是怎么优化的?
A: 使用CASE WHEN语句一次SQL更新多条记录:
1
2
3
4
5
6
7
8
9
10
11
12
|
<update id="batchUpdateSubjectCapacity">
UPDATE snatch_subject
SET capacity = CASE subject_id
<foreach collection="list" item="item">
WHEN #{item.subjectId} THEN capacity + #{item.change}
</foreach>
END
WHERE subject_id IN
<foreach collection="list" item="item" open="(" separator="," close=")">
#{item.subjectId}
</foreach>
</update>
|
对比逐条更新:
- 逐条更新:N次网络往返 + N次SQL解析
- 批量更新:1次网络往返 + 1次SQL解析
5.3 连接池配置
Q: HikariCP是怎么配置的?
A:
1
2
3
4
5
6
7
8
|
spring:
datasource:
hikari:
maximum-pool-size: 50 # 最大连接数
minimum-idle: 10 # 最小空闲连接
connection-timeout: 30000 # 连接超时30秒
idle-timeout: 600000 # 空闲超时10分钟
max-lifetime: 1800000 # 最大生命周期30分钟
|
参数调优原则:
maximum-pool-size: 根据并发量和数据库承载能力设置
minimum-idle: 保持一定空闲连接,减少创建开销
max-lifetime: 小于数据库的wait_timeout
5.4 事务管理
Q: 事务是怎么管理的?
A:
1
2
3
4
5
6
|
@Scheduled(fixedRate = 10000)
@Transactional(rollbackFor = Exception.class)
public void run() {
// 批量插入、删除、更新
// 任何异常都会回滚
}
|
rollbackFor = Exception.class的作用:
- 默认只对RuntimeException回滚
- 设置后对所有Exception都回滚
六、安全与认证
6.1 密码加密
Q: 密码是怎么加密的?
A: 使用MD5 + 盐值加密:
1
2
3
4
5
6
7
|
@Override
public String getEncryptPassword(String userPassword) {
final String SALT = "lucius";
return DigestUtils.md5DigestAsHex(
(userPassword + SALT).getBytes(StandardCharsets.UTF_8)
);
}
|
深入追问:
Q: MD5安全吗?有什么改进方案? A: MD5已不够安全,可以改进为:
6.2 权限控制
Q: 权限控制是怎么实现的?
A: 使用AOP切面 + 自定义注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@Aspect
@Component
public class AuthInterceptor {
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
String mustRole = authCheck.mustRole();
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
User loginUser = userService.getLoginUser(request);
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
if (mustRoleEnum == null) {
return joinPoint.proceed(); // 不需要权限
}
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());
if (!mustRoleEnum.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
return joinPoint.proceed();
}
}
|
使用方式:
1
2
3
4
5
|
@AuthCheck(mustRole = "admin")
@GetMapping("/list")
public BaseResponse<List<User>> listUsers() {
// 只有admin角色可以访问
}
|
七、RocketMQ消息队列
7.1 MQ选型对比
Q: 为什么选择RocketMQ?
A: 对比三种主流MQ:
| 特性 |
RocketMQ |
Kafka |
RabbitMQ |
| 吞吐量 |
高 |
最高 |
中等 |
| 延迟 |
毫秒级 |
毫秒级 |
微秒级 |
| 可靠性 |
高 |
高 |
高 |
| 事务消息 |
支持 |
不支持 |
不支持 |
| 顺序消息 |
支持 |
支持 |
支持 |
| 运维复杂度 |
中等 |
高 |
低 |
选择RocketMQ原因:
- 阿里开源,国内社区活跃
- 支持事务消息(抢课场景可能需要)
- 性能和可靠性平衡好
7.2 生产者/消费者配置
Q: RocketMQ是怎么配置的?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
rocketmq:
name-server: 192.168.5.4:8081
topic: snatch-topic
producer:
group: snatch-producer-group
send-message-timeout: 3000
retry-times-when-send-failed: 2
consumer:
group: snatch-consumer-group
batch-size: 1000 # 批量消费
batch-timeout: 10 # 批次超时
pull-batch-size: 100
consume-thread-min: 5
consume-thread-max: 20
|
八、阿里云OSS文件存储
8.1 OSS配置
Q: OSS是怎么配置的?
A:
1
2
3
4
5
6
|
edu:
alioss:
endpoint: ${edu.alioss.endpoint}
access-key-id: ${edu.alioss.access-key-id}
access-key-secret: ${edu.alioss.access-key-secret}
bucket-name: ${edu.alioss.bucket-name}
|
敏感信息管理:
8.2 文件上传流程
Q: 文件上传是怎么实现的?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
@Override
public Boolean updateUserAvatar(MultipartFile file, HttpServletRequest request) {
User loginUser = getLoginUser(request);
// 1. 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
String snowId = IdUtil.getSnowflake().nextIdStr();
String objectName = "user-avatar/" + snowId + extension;
// 2. 上传到OSS
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
// 3. 更新数据库
User updateUser = new User();
updateUser.setUserAvatar(filePath);
userMapper.updateByQuery(updateUser,
QueryWrapper.create().where(User::getId).eq(loginUser.getId()));
return true;
}
|
雪花ID的作用:
九、上线运维
9.1 多环境配置
Q: 多环境是怎么管理的?
A: 使用Spring Profiles:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# application.yml
spring:
profiles:
active: local # 或 prod
# application-local.yml (开发环境)
spring:
datasource:
url: jdbc:mysql://localhost:3306/eduagentxnew
username: root
password: 123456
# application-prod.yml (生产环境)
spring:
datasource:
url: jdbc:mysql://localhost:3306/eduagentxnew
username: eduagentxnew
password: ${DB_PASSWORD} # 从环境变量读取
|
9.2 健康检查
Q: 健康检查接口是怎么设计的?
A:
1
2
3
4
5
6
7
8
|
@RestController
@RequestMapping("/health")
public class HealthController {
@GetMapping("/")
public BaseResponse<String> healthCheck() {
return ResultUtils.success("ok");
}
}
|
用途:
9.3 缓存监控
Q: 缓存监控是怎么实现的?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@RestController
@RequestMapping("/admin/cache")
public class AdminCacheMonitorController {
@GetMapping("/stats")
public BaseResponse<CacheStatsVO> getCacheStats() {
CacheStats stats = localCache.stats();
CacheStatsVO vo = new CacheStatsVO();
vo.setHitCount(stats.hitCount());
vo.setMissCount(stats.missCount());
vo.setHitRate(stats.hitRate());
vo.setEvictionCount(stats.evictionCount());
vo.setCacheSize(localCache.estimatedSize());
return ResultUtils.success(vo);
}
@GetMapping("/hot-subjects")
public BaseResponse<List<Item>> getHotSubjects() {
return ResultUtils.success(snatchCacheService.getHotSubjects());
}
}
|
9.4 系统初始化
Q: 应用启动时做了什么初始化?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@Component
public class SnatchInitializer {
@Resource
private SnatchSubjectService snatchSubjectService;
@PostConstruct
public void init() {
try {
log.info("开始初始化抢课系统...");
snatchSubjectService.initAllSubjectCapacityToRedis();
log.info("抢课系统初始化完成");
} catch (Exception e) {
log.error("抢课系统初始化失败", e);
}
}
}
|
初始化内容:
十、异常处理与全局响应
10.1 统一响应格式
Q: 接口响应格式是怎么统一的?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@Data
public class BaseResponse<T> implements Serializable {
private int code;
private T data;
private String message;
}
public class ResultUtils {
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}
public static BaseResponse<?> error(ErrorCode errorCode, String message) {
return new BaseResponse<>(errorCode.getCode(), null, message);
}
}
|
10.2 全局异常处理
Q: 全局异常是怎么处理的?
A:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("BusinessException", e);
return ResultUtils.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
}
}
|
十一、技术栈选型
11.1 核心版本
| 技术 |
版本 |
| Spring Boot |
3.5.4 |
| Java |
17/21 |
| MyBatis-Flex |
1.11.1 |
| Spring AI |
1.0.0-M7 |
| LangGraph4j |
1.6.0-rc2 |
| Caffeine |
3.1.8 |
| Jedis |
5.2.0 |
11.2 依赖选型原因
| 依赖 |
选择原因 |
| Hutool |
全能工具库,减少重复代码 |
| Knife4j |
美观的API文档,支持调试 |
| Caffeine |
高性能本地缓存 |
| LangGraph4j |
Java版LangGraph,构建AI工作流 |
| HikariCP |
最快的JDBC连接池 |
总结
本文档覆盖了EduAgentX项目的核心技术细节,建议面试前:
- 重点掌握:Redis Lua脚本、多级缓存、Dubbo配置
- 理解原理:HeavyKeeper算法、滑动窗口限流、异步落库
- 熟悉代码:能够手写关键代码片段
- 准备追问:每个技术点都要想好深入追问的答案
祝面试顺利!🎉