面试话术

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 做集群部署:

  1. 分摊句柄压力:把 5 万个连接分散到 5 台机器,每台只抗 1 万,安全系数高得多。

  2. 网络 I/O 扩展:利用多台服务器的网卡带宽并行传输数据。

  3. 微服务解耦:利用 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 拉取最新的数据,实现了集群下的缓存最终一致性。”

第三部分:细节优化 (展示技术深度)

“在细节实现上,我还做了两个优化:

  1. Caffeine 的过期策略:我选用了 expireAfterAccess 而不是 expireAfterWrite。因为热门课程会被频繁查看,只要有人看,它就应该留在内存里,这样能最大程度减少 Redis 的压力。

  2. 防缓存击穿:在本地缓存失效回源查 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: 抢课数据为什么不走本地缓存?

“抢课涉及库存扣减,必须保证强一致性。如果用本地缓存:

  1. 节点A扣减本地缓存,节点B不知道
  2. 可能导致超卖

所以抢课直接操作 Redis,用 Lua 脚本保证原子性。本地缓存只用于课程详情这种低频变化的数据。”

Q: 课程详情为什么用续期策略?

“课程详情(名称、描述、教师)很少变化,用 expireAfterAccess 续期策略:

  • 热门课程被频繁查看,可以一直命中本地缓存
  • 减少 Redis 访问,提升性能
  • 续期只是更新一个时间戳,开销 < 1 纳秒

如果是库存这种频繁变化的数据,就不能用续期,否则数据会严重过时。”

Q: 多节点本地缓存怎么同步?

“用 RocketMQ 广播模式:

  1. 数据变更时,发送缓存失效消息
  2. 所有节点都订阅这个 Topic(广播模式)
  3. 收到消息后,清除对应的本地缓存
  4. 下次请求时,从 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,使用不同的序列化策略:

  1. 通用RedisTemplate - 使用Jackson2JsonRedisSerializer序列化Value
  2. 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);

原子性保证机制:

  1. Redis单线程执行: Lua脚本在Redis中原子执行,不会被其他命令打断
  2. HINCRBY原子操作: 扣减库存使用原子递减,避免超卖
  3. 失败回滚: 库存不足时立即回滚,保证数据一致性

返回值设计:

  • -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       # 数据模型

拆分原则:

  1. 单一职责: 每个服务只负责一个业务领域
  2. 高内聚低耦合: 服务内部高度内聚,服务间通过接口通信
  3. 独立部署: 每个服务可以独立部署、扩容
  4. 数据隔离: 每个服务有自己的数据库表(逻辑隔离)

深入追问:

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

工作原理:

  1. 用户登录后,Session存储到Redis
  2. 所有微服务连接同一个Redis
  3. 请求携带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已不够安全,可以改进为:

  • BCrypt(推荐)
  • PBKDF2
  • Argon2

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");
    }
}

用途:

  • 负载均衡健康探测
  • K8s存活探针
  • 监控系统检测

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);
        }
    }
}

初始化内容:

  • 将所有课程容量加载到Redis
  • 预热本地缓存

十、异常处理与全局响应

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项目的核心技术细节,建议面试前:

  1. 重点掌握:Redis Lua脚本、多级缓存、Dubbo配置
  2. 理解原理:HeavyKeeper算法、滑动窗口限流、异步落库
  3. 熟悉代码:能够手写关键代码片段
  4. 准备追问:每个技术点都要想好深入追问的答案

祝面试顺利!🎉

Licensed under CC BY-NC-SA 4.0