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…” |
二、核心亮点详细话术(融合八股版)
亮点一:高并发抢课系统(Redis + Lua + RocketMQ)
问题背景
“在抢课场景下,我们面临的核心问题是 高并发下的库存超卖 和 数据库压力过大。比如一门课只有100个名额,但可能有1000个学生同时点击抢课,如果处理不当就会出现超卖,或者数据库直接被打挂。”
技术方案
“我的解决方案分三层:
第一层:Redis + Lua 脚本保证原子性
- 把课程库存预热到 Redis 的 Hash 结构中
- 使用 Lua 脚本实现原子性的「检查库存 → 扣减库存 → 记录抢课状态」
- 这样即使万级并发,也不会出现超卖
第二层:RocketMQ 异步削峰
- Redis 操作成功后,不直接写数据库,而是发送消息到 RocketMQ
- 消费者批量消费(每批1000条或10秒),批量写入 MySQL
- 这样把瞬时高并发转化为平稳的数据库写入
第三层:失败回滚机制
- 如果消息发送失败,立即执行 Redis 回滚脚本
- 保证 Redis 和最终数据库的数据一致性”
代码亮点(可以主动提)
“Lua 脚本的核心逻辑是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13-- 1. 检查是否已抢课 if redis.call('HEXISTS', studentSnatchKey, subjectId) == 1 then return -1 -- 已抢课 end -- 2. 检查并扣减库存(原子操作) local capacity = redis.call('HINCRBY', capacityKey, subjectId, -1) if capacity < 0 then redis.call('HINCRBY', capacityKey, subjectId, 1) -- 回滚 return -2 -- 库存不足 end -- 3. 记录抢课状态 redis.call('HSET', studentSnatchKey, subjectId, 1) return 1 -- 成功整个过程在 Redis 单线程中执行,天然保证原子性。”
性能数据
“优化后,抢课接口的 QPS 从原来纯 MySQL 的 500 提升到 2000+,响应时间从 200ms 降到 50ms 以内。”
🎯 融合八股:Redis 单线程模型
面试官追问:为什么 Lua 脚本能保证原子性?
回答:“这要从 Redis 的线程模型说起。Redis 6.0 之前是纯单线程模型,所有命令都在一个线程中串行执行。虽然 6.0 引入了 IO 多线程(处理网络读写),但命令执行仍然是单线程的。
Lua 脚本在执行期间,Redis 不会执行其他命令,相当于把多个操作打包成一个原子操作。这和数据库事务不同——数据库是通过锁来保证隔离性,而 Redis 是通过单线程串行执行来保证原子性。
需要注意 Lua 脚本不能太长,否则会阻塞其他请求。我的脚本只有几行,执行时间在微秒级。”
🎯 融合八股:Redis 数据结构选择
面试官追问:为什么用 Hash 存储库存,而不是 String?
回答:“我用 Hash 有几个考虑:
内存效率:如果用 String,每个课程一个 Key(如
capacity:1、capacity:2),会有大量的 Key 元数据开销。用 Hash(subject:capacity→{1: 100, 2: 50}),多个字段共享一个 Key 的元数据,内存更省。原子操作:
HINCRBY可以原子性地对某个字段加减,配合 Lua 脚本很方便。批量操作:
HGETALL可以一次获取所有课程的库存,方便做缓存预热。Redis 的 Hash 底层是 ziplist(小数据量)或 hashtable(大数据量),当字段数超过
hash-max-ziplist-entries(默认512)时会转换。我的场景课程数不多,用 ziplist 更省内存。”
🎯 融合八股:消息队列对比
面试官追问:为什么选 RocketMQ 而不是 Kafka?
回答:“我对比了几个主流消息队列:
特性 RocketMQ Kafka RabbitMQ 吞吐量 10万级 百万级 万级 延迟 ms级 ms级 us级 事务消息 ✅ 支持 ❌ 不支持 ❌ 不支持 延迟消息 ✅ 支持 ❌ 不支持 ✅ 插件支持 消息回溯 ✅ 支持 ✅ 支持 ❌ 不支持 选择 RocketMQ 的原因:
- 事务消息:可以保证本地事务和消息发送的一致性
- 延迟消息:未来可以做延迟退课提醒
- 消息回溯:出问题时可以重新消费历史消息
- 阿里生态:和 Spring Cloud Alibaba 集成更好”
🎯 融合八股:MySQL 悲观锁 vs 乐观锁
面试官追问:如果不用 Redis,纯 MySQL 怎么实现?
回答:“我其实还实现了一版纯 MySQL 的方案,用的是悲观锁:
1 2 3 4-- 悲观锁:SELECT FOR UPDATE SELECT * FROM snatch_subject WHERE subject_id = ? FOR UPDATE; -- 检查库存后更新 UPDATE snatch_subject SET stock_remain = stock_remain - 1 WHERE subject_id = ?;悲观锁 vs 乐观锁的选择:
- 悲观锁:适合写多读少、冲突概率高的场景,但会阻塞其他事务
- 乐观锁:适合读多写少、冲突概率低的场景,通过版本号实现
抢课场景冲突概率高,所以用悲观锁更合适。但悲观锁的问题是性能差(QPS 只有 500),所以最终选择了 Redis 方案。”
亮点二:AI 工作流引擎(LangGraph4j + SSE 流式输出)
问题背景
“AI 生成题目是一个耗时操作,可能需要 10-30 秒。如果用传统的同步请求,用户体验很差,而且容易超时。另外,题目生成涉及多个步骤:知识检索 → 任务拆分 → 题目生成 → 质量检查,需要一个灵活的工作流来编排。”
技术方案
“我使用了 LangGraph4j 作为工作流引擎,配合 SSE(Server-Sent Events) 实现流式输出:
工作流设计:
- RAG 知识检索节点 - 从向量数据库检索相关知识点
- 任务列表节点 - 根据题目数量创建生成任务队列
- 题目生成节点 - 调用大模型生成题目
- 质量检查节点 - 验证题目格式和逻辑
- 条件路由 - 质检不通过则重新生成,通过则继续下一题
流式输出实现:
- 使用 ThreadLocal 存储 SseEmitter,避免序列化问题
- 工作流异步执行,每个节点完成后实时推送结果
- 前端通过 EventSource 监听,实时展示生成进度”
代码亮点
“条件路由的实现是这样的:
1 2 3 4 5 6 7.addConditionalEdges("ques_parse_check_node", edge_async(this::routeAfterCheck), Map.of( \"continue_generate\", \"ques_generate_node\", // 继续生成下一题 \"retry_generate\", \"ques_generate_node\", // 重新生成当前题 \"finish\", END // 完成 ))这样就实现了一个带重试机制的循环工作流。”
设计模式应用
“在题目生成模块,我还应用了多种设计模式:
- 策略模式:不同题型(选择题、填空题、大题)使用不同的生成策略
- 模板方法模式:题目存储流程统一,但具体存储逻辑由子类实现
- 工厂模式:根据题型创建对应的生成器
- 门面模式:AIQuestionFacade 统一对外接口”
🎯 融合八股:ThreadLocal 原理与内存泄漏
面试官追问:为什么用 ThreadLocal 存储 SseEmitter?
回答:“因为 SseEmitter 不能序列化,不能放到工作流上下文中传递。我用 ThreadLocal 存储,每个线程有自己的副本。
1 2 3 4 5 6 7public static final ThreadLocal<SseEmitter> SSE_EMITTER_HOLDER = new ThreadLocal<>(); // 异步执行前设置 SSE_EMITTER_HOLDER.set(emitter); // 执行完毕后清理(重要!) SSE_EMITTER_HOLDER.remove();ThreadLocal 原理:每个 Thread 内部有一个 ThreadLocalMap,Key 是 ThreadLocal 对象(弱引用),Value 是存储的值。
内存泄漏问题:如果不调用
remove(),在线程池场景下,线程会被复用,ThreadLocalMap 中的 Entry 不会被清理,导致内存泄漏。所以我在 finally 块中一定会调用remove()。”
🎯 融合八股:线程池参数配置
面试官追问:异步执行用的什么线程池?
回答:“我用的是
CompletableFuture.runAsync(),默认使用 ForkJoinPool.commonPool()。但在生产环境,我会自定义线程池:
1 2 3 4 5 6 7 8ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 = CPU核心数 8, // 最大线程数 = 2 * CPU核心数 60, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(1000), // 有界队列,防止OOM new ThreadFactoryBuilder().setNameFormat(\"ai-workflow-%d\").build(), new CallerRunsPolicy() // 拒绝策略:调用者执行 );参数设置原则:
- CPU 密集型:核心线程数 = CPU 核心数
- IO 密集型:核心线程数 = CPU 核心数 * 2(或更多)
- AI 生成是 IO 密集型(等待 API 响应),所以可以设置多一些线程
拒绝策略选择:
AbortPolicy:直接抛异常(默认)CallerRunsPolicy:调用者线程执行(我选这个,保证任务不丢失)DiscardPolicy:静默丢弃DiscardOldestPolicy:丢弃最老的任务”
🎯 融合八股:设计模式详解
面试官追问:能详细说说策略模式怎么用的吗?
回答:“以题目生成为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20// 1. 策略接口 public interface QuestionGenerator { Question generate(String knowledge, String difficulty); } // 2. 具体策略 @Component(\"multipleChoice\") public class MultipleChoiceGenerator implements QuestionGenerator { ... } @Component(\"fillBlank\") public class FillBlankGenerator implements QuestionGenerator { ... } // 3. 策略选择(配合 Spring 容器) @Resource private Map<String, QuestionGenerator> generatorMap; // Spring 自动注入所有实现 public Question generate(String type, String knowledge) { QuestionGenerator generator = generatorMap.get(type); return generator.generate(knowledge, difficulty); }策略模式的好处:
- 开闭原则:新增题型只需添加新的策略类,不修改原有代码
- 消除 if-else:避免大量的条件判断
- 易于测试:每个策略可以独立测试”
亮点三:热点数据缓存优化(HeavyKeeper + 二级缓存)
问题背景
“在抢课高峰期,某些热门课程会被大量访问,形成热点 Key。如果每次都访问 Redis,会造成 Redis 压力过大,响应时间从正常的 5ms 飙升到 200ms+。”
技术方案
“我实现了一套 热点检测 + 二级缓存 的方案:
热点检测(AsyncHeavyKeeper):
- 使用 HeavyKeeper 算法实时检测 Top K 热点 Key
- 核心优化:将耗时操作异步化,add() 方法耗时从 5ms 降到 <1ms
- 使用 ConcurrentLinkedQueue 无锁队列,后台线程批量处理
二级缓存架构:
- L1 缓存:Caffeine 本地缓存,容量 10000,过期时间 60 秒
- L2 缓存:Redis 分布式缓存
- 读操作:先查 L1 → 未命中查 L2 → 写入 L1
- 写操作:直接写 Redis(保证一致性),不走本地缓存”
关键设计决策
“这里有一个重要的设计决策:写操作不使用本地缓存。
原因是:抢课是写操作,需要强一致性。如果用本地缓存,在分布式环境下会出现数据不一致。比如 A 服务器的本地缓存显示还有库存,但实际 Redis 中已经没了。
所以我的策略是:
- 写操作(抢课/退课):直接走 Redis Lua 脚本
- 读操作(查询库存/状态):走二级缓存,提升性能”
🎯 融合八股:ConcurrentHashMap 原理
面试官追问:热点 Key 存储为什么用 ConcurrentHashMap?
回答:“因为热点检测是多线程并发访问的场景。
HashMap 的问题:
- 非线程安全,多线程 put 可能导致死循环(JDK7 的头插法)或数据丢失
ConcurrentHashMap 的优势(JDK8+):
- 使用 CAS + synchronized 保证线程安全
- 锁粒度是单个 Node,而不是整个 Map
- 读操作完全无锁(volatile 保证可见性)
1 2 3 4 5 6// 我的代码中 private final ConcurrentHashMap<String, AtomicInteger> hotKeyMap; // computeIfAbsent 是原子操作 AtomicInteger counter = hotKeyMap.computeIfAbsent(key, k -> new AtomicInteger(0)); counter.addAndGet(increment); // AtomicInteger 也是线程安全的为什么不用 Hashtable?
- Hashtable 用 synchronized 锁整个表,性能差
- ConcurrentHashMap 锁粒度更细,并发性能更好”
🎯 融合八股:CAS 与原子类
面试官追问:AtomicInteger 怎么保证线程安全的?
回答:“AtomicInteger 底层使用 CAS(Compare And Swap) 实现:
1 2 3 4 5 6 7 8 9 10 11 12 13// AtomicInteger.addAndGet 的底层实现 public final int addAndGet(int delta) { return U.getAndAddInt(this, VALUE, delta) + delta; } // Unsafe.getAndAddInt public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); // 读取当前值 } while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS 更新 return v; }CAS 原理:比较内存值和预期值,相等则更新,不相等则重试。
CAS 的问题:
- ABA 问题:值从 A→B→A,CAS 认为没变化。解决:AtomicStampedReference(带版本号)
- 自旋开销:高并发时大量线程自旋,CPU 开销大
- 只能保证单个变量:多个变量需要用锁
在我的场景中,计数器更新冲突概率不高,CAS 很合适。”
🎯 融合八股:阻塞队列选择
面试官追问:为什么用 ConcurrentLinkedQueue 而不是 LinkedBlockingQueue?
回答:“两者的区别:
特性 ConcurrentLinkedQueue LinkedBlockingQueue 实现 CAS 无锁 ReentrantLock 加锁 阻塞 非阻塞 支持阻塞 容量 无界 可设置有界 性能 高并发下更好 中等 我选择 ConcurrentLinkedQueue 的原因:
- 生产者不阻塞:add() 方法要求极快返回,不能等待
- 高并发:无锁实现,性能更好
- 消费者轮询:后台线程用 poll() 非阻塞获取,配合 sleep 避免空转
如果需要阻塞等待,比如生产者-消费者模式,我会用 LinkedBlockingQueue。”
亮点四:微服务架构演进
问题背景
“随着业务发展,单体应用出现了几个问题:
- AI 生成任务占用大量资源,影响其他业务
- 抢课高并发场景无法独立扩展
- 部署时间长,发布风险高”
技术方案
“我完成了从单体到微服务的架构演进:
服务拆分(7个微服务):
- User Service - 用户认证
- Course Service - 课程管理
- Snatch Service - 抢课服务(独立扩展)
- Question Service - 题目管理
- File Service - 文件存储
- RAG Service - 知识库检索
- AI-Workflow Service - AI 工作流(独立部署,可配 GPU)
技术选型:
- 服务注册与配置:Nacos
- 服务间调用:Dubbo(Triple 协议,性能比 HTTP 高 10 倍)
- API 网关:Higress(基于 Envoy,支持 Dubbo 协议转换)
- 消息队列:RocketMQ
- 分布式事务:Seata(Saga 模式)
- Session 共享:Spring Session + Redis”
为什么选择 Dubbo 而不是 Feign?
“主要是性能考虑。Feign 基于 HTTP,每次调用都要经过 HTTP 协议栈,序列化/反序列化开销大。Dubbo 使用 Triple 协议(兼容 gRPC),基于 HTTP/2,支持多路复用,性能是 Feign 的 10 倍以上。
在抢课这种高并发场景下,服务间调用的性能差异会被放大,所以选择 Dubbo 更合适。”
🎯 融合八股:Spring 事务传播机制
面试官追问:批量写入数据库的事务怎么处理的?
回答:“我用的是 TransactionTemplate 编程式事务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17@Resource private TransactionTemplate transactionTemplate; private void batchHandleSnatchEvents(List<SnatchEvent> events) { transactionTemplate.execute(status -> { try { // 1. 批量插入抢课记录 snatchMapper.batchInsertSnatch(snatchList); // 2. 批量更新课程容量 snatchSubjectMapper.decrementCapacityBatch(subjectId, count); return true; } catch (Exception e) { status.setRollbackOnly(); // 手动回滚 throw e; } }); }为什么用编程式事务而不是 @Transactional?
- 消费者方法不是 Spring 代理调用,@Transactional 可能失效
- 编程式事务更灵活,可以精确控制事务边界
事务传播机制(常问):
REQUIRED(默认):有事务就加入,没有就新建REQUIRES_NEW:总是新建事务,挂起当前事务NESTED:嵌套事务,可以独立回滚SUPPORTS:有事务就加入,没有就非事务执行”
🎯 融合八股:分布式事务 Seata
面试官追问:跨服务的事务怎么处理?
回答:“我用的是 Seata 的 Saga 模式:
Seata 的几种模式:
模式 原理 优点 缺点 AT 自动补偿,基于 undo_log 无侵入 需要数据库支持 TCC Try-Confirm-Cancel 性能好 侵入性强,需要写三个方法 Saga 正向操作 + 补偿操作 长事务友好 最终一致性 XA 两阶段提交 强一致 性能差 我选择 Saga 的原因:
- 抢课流程是长事务(Redis → MQ → MySQL)
- 可以接受最终一致性
- 补偿逻辑简单(回滚 Redis 操作)
Saga 的实现:
1 2 3 4 5 6 7// 正向操作 redisService.decrStock(); mqService.sendMessage(); // 补偿操作(失败时调用) redisService.incrStock(); // 回滚库存 ```"
🎯 融合八股:CAP 理论
面试官追问:你的系统是 CP 还是 AP?
回答:“我的系统选择了 AP(可用性 + 分区容错性),牺牲强一致性,保证最终一致性。
CAP 理论:
- C(Consistency):所有节点数据一致
- A(Availability):每个请求都能得到响应
- P(Partition tolerance):网络分区时系统仍能工作
分布式系统必须保证 P,所以只能在 C 和 A 之间选择。
我的选择:
- 抢课场景对可用性要求高(用户不能等太久)
- 可以接受最终一致性(Redis 和 MySQL 短暂不一致)
- 通过补偿机制保证数据最终一致
如果选择 CP:
- 每次抢课都要等 MySQL 写入成功才返回
- 性能会很差,用户体验不好”
三、常见追问及回答
Q1: Redis 和 MySQL 数据一致性怎么保证?
“我采用的是 最终一致性 方案:
- Redis 先行:抢课操作先在 Redis 完成,保证高性能
- 消息队列异步同步:成功后发送 RocketMQ 消息
- 批量写入 MySQL:消费者批量消费,写入数据库
- 失败回滚:消息发送失败时,立即回滚 Redis
- 补偿机制:定时任务对比 Redis 和 MySQL 数据,修复不一致
这样既保证了高性能,又保证了最终数据一致。”
Q2: 如果 RocketMQ 消息丢失怎么办?
“我做了多层保障:
- 生产者确认:使用同步发送,确保消息到达 Broker
- 消息持久化:RocketMQ 配置同步刷盘
- 消费者确认:处理成功后才 ACK,失败会重试
- 补偿任务:定时任务扫描 Redis 中的抢课记录,对比 MySQL,补偿漏掉的数据
即使极端情况下消息丢失,补偿任务也能保证数据最终一致。”
Q3: Lua 脚本为什么能保证原子性?
“Redis 是单线程模型,所有命令都是串行执行的。Lua 脚本在执行期间,不会被其他命令打断,相当于一个原子操作。
这和数据库的事务不同,数据库事务是通过锁来保证隔离性,而 Redis Lua 是通过单线程串行执行来保证原子性。
需要注意的是,Lua 脚本不能太长,否则会阻塞其他请求。我的脚本只有几行,执行时间在微秒级。”
Q4: 为什么用 SSE 而不是 WebSocket?
“SSE 和 WebSocket 的选择取决于场景:
- SSE:单向通信(服务器 → 客户端),基于 HTTP,实现简单,自动重连
- WebSocket:双向通信,需要额外的握手和心跳机制
AI 生成场景是典型的单向推送,用户发起请求后,服务器持续推送生成结果,不需要客户端再发消息。所以 SSE 更合适,实现也更简单。”
Q5: HeavyKeeper 算法原理是什么?
“HeavyKeeper 是一种概率数据结构,用于在有限内存下找出 Top K 热点元素。
核心思想是:
- 使用多层 Bucket 数组,每层用不同的 Hash 函数
- 每个 Bucket 存储一个 Key 的指纹和计数
- 新元素到来时,如果指纹匹配则计数+1,否则以一定概率衰减原计数
- 衰减到 0 时,用新元素替换
这样高频元素会稳定占据 Bucket,低频元素会被逐渐淘汰。
我的优化是把计数更新和清理操作异步化,主线程只做快速的 Bucket 更新,耗时操作放到后台线程。”
Q6: 微服务拆分的原则是什么?
“我遵循的原则是:
- 业务边界清晰:按领域划分,用户、课程、抢课各自独立
- 高内聚低耦合:服务内部高内聚,服务间通过接口通信
- 独立部署:每个服务可以独立部署和扩展
- 数据独立:每个服务有自己的数据库,避免跨库查询
- 渐进式拆分:先拆独立性高的服务(用户、文件),再拆有依赖的服务
比如抢课服务,它是高并发场景,需要独立扩展,所以单独拆出来。AI 服务是计算密集型,可能需要 GPU,也单独拆出来。”
四、项目难点与解决方案总结
| 难点 | 问题描述 | 解决方案 | 效果 |
|---|---|---|---|
| 高并发超卖 | 1000人同时抢100个名额 | Redis Lua 原子操作 | 零超卖 |
| 数据库压力 | 瞬时高并发写入 | RocketMQ 异步削峰 | 数据库 QPS 降低 90% |
| AI 生成超时 | 生成耗时 10-30 秒 | SSE 流式输出 + 异步工作流 | 用户实时看到进度 |
| 热点 Key | Redis 响应从 5ms 飙到 200ms | HeavyKeeper + 二级缓存 | 响应稳定在 10ms |
| 单体瓶颈 | 无法独立扩展 | 微服务拆分 | 抢课服务可独立扩容 |
五、面试加分项
1. 主动提及的技术深度
- “我还研究了Redis 的 IO 多线程优化,在 Redis 6.0+ 可以配置
io-threads提升性能” - “Lua 脚本我做了优化,把多次 Redis 操作合并,减少网络往返”
- “消息队列我对比了 RocketMQ 和 Kafka,选择 RocketMQ 是因为它支持事务消息和延迟消息”
2. 可以展示的监控意识
- “我在关键路径加了性能监控切面,记录每个方法的执行时间”
- “Redis 慢查询我配置了告警,超过 50ms 就会记录日志”
- “消息队列的消费延迟我也做了监控,防止消息堆积”
3. 可以提及的扩展思考
- “如果并发量再大 10 倍,我会考虑 Redis Cluster 分片”
- “如果要支持秒杀场景,可以加入令牌桶限流”
- “未来可以考虑用 Serverless 部署 AI 服务,按需扩缩容”
六、更多八股融合场景
🎯 AOP 原理(限流切面)
项目应用:我用 AOP 实现了接口限流
1 2 3 4 5 6 7 8 9 10 11 12 13 14@Aspect @Component public class RateLimitAspect { @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint point, RateLimit rateLimit) { // 滑动窗口限流(Redis ZSet 实现) Long remaining = redisTemplate.execute(RATE_LIMIT_SCRIPT, ...); if (remaining < 0) { throw new BusinessException("操作过于频繁"); } return point.proceed(); } }AOP 原理:
- Spring AOP 基于动态代理实现
- JDK 动态代理:目标类实现接口时使用,基于反射
- CGLIB 代理:目标类没有接口时使用,基于字节码生成子类
@Around 的执行顺序:
1 2 3 4 5 6@Around 前置逻辑 → @Before → 目标方法 → @AfterReturning / @AfterThrowing → @Around 后置逻辑 → @After
🎯 限流算法(滑动窗口)
项目应用:我实现了滑动窗口限流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17-- Redis ZSet 实现滑动窗口 local key = KEYS[1] local window = ARGV[1] -- 窗口大小(秒) local limit = ARGV[2] -- 限制次数 local now = ARGV[3] -- 当前时间戳 -- 移除窗口外的请求 redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000) -- 统计窗口内的请求数 local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, now) -- 记录本次请求 return limit - count - 1 -- 返回剩余次数 else return -1 -- 限流 end常见限流算法对比:
算法 原理 优点 缺点 固定窗口 固定时间段计数 简单 临界问题(窗口边界突发) 滑动窗口 滑动时间段计数 平滑 内存占用大 漏桶 固定速率流出 平滑流量 无法应对突发 令牌桶 固定速率生成令牌 允许突发 实现复杂 我选择滑动窗口是因为它能平滑限流,避免固定窗口的临界问题。
🎯 MySQL 索引优化
项目应用:抢课记录查询优化
1 2 3 4 5-- 查询某学生是否抢过某课程 SELECT * FROM snatch WHERE student_id = ? AND subject_id = ?; -- 我建了联合索引 CREATE INDEX idx_student_subject ON snatch(student_id, subject_id);索引原理(B+ 树):
- 非叶子节点只存索引,叶子节点存数据
- 叶子节点用链表连接,支持范围查询
- 树高一般 3-4 层,查询复杂度 O(log n)
联合索引的最左前缀原则:
(student_id, subject_id)索引可以支持:
WHERE student_id = ?✅WHERE student_id = ? AND subject_id = ?✅WHERE subject_id = ?❌(不走索引)索引失效场景:
- 对索引列使用函数:
WHERE YEAR(create_time) = 2024- 隐式类型转换:
WHERE student_id = '123'(student_id 是 int)- LIKE 左模糊:
WHERE name LIKE '%张'- OR 条件:
WHERE student_id = 1 OR name = '张三'
🎯 Redis 序列化问题
项目应用:我配置了两个 RedisTemplate
1 2 3 4 5 6 7 8 9 10 11 12 13// 1. 通用 RedisTemplate(Jackson 序列化) @Bean public RedisTemplate<String, Object> redisTemplate() { template.setValueSerializer(new Jackson2JsonRedisSerializer<>(...)); return template; } // 2. Lua 专用 RedisTemplate(String 序列化) @Bean public RedisTemplate<String, String> luaRedisTemplate() { template.setValueSerializer(new StringRedisSerializer()); return template; }为什么要两个?
- Jackson 序列化会在值前面加类型信息,Lua 脚本处理不了
- Lua 脚本需要纯字符串,所以用 StringRedisSerializer
常见序列化方式:
JdkSerializationRedisSerializer:Java 原生序列化,可读性差StringRedisSerializer:字符串,简单但只能存字符串Jackson2JsonRedisSerializer:JSON,可读性好,需要配置类型信息GenericJackson2JsonRedisSerializer:JSON + 类型信息,通用性好
🎯 Spring Bean 生命周期
项目应用:消费者启动和关闭
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16@Component public class SnatchEventConsumer { @PostConstruct // Bean 初始化后执行 public void start() { // 启动消费线程 new Thread(this::consumeLoop).start(); } @PreDestroy // Bean 销毁前执行 public void shutdown() { // 优雅关闭:处理完剩余消息 flushBatch(); scheduler.shutdown(); } }Bean 生命周期:
- 实例化(new)
- 属性注入(@Autowired)
- Aware 接口回调(BeanNameAware、ApplicationContextAware)
- BeanPostProcessor.postProcessBeforeInitialization
- @PostConstruct
- InitializingBean.afterPropertiesSet
- init-method
- BeanPostProcessor.postProcessAfterInitialization
- 使用中…
- @PreDestroy
- DisposableBean.destroy
- destroy-method
🎯 JVM 内存模型与可见性
项目应用:后台线程的停止标志
1 2 3 4 5 6 7 8 9 10 11 12 13private final AtomicBoolean running = new AtomicBoolean(true); // 后台线程 private void processQueue() { while (running.get()) { // 需要保证可见性 // 处理逻辑 } } // 关闭方法 public void shutdown() { running.set(false); // 其他线程能立即看到 }为什么用 AtomicBoolean 而不是普通 boolean?
- 普通 boolean 没有可见性保证,其他线程可能看不到修改
- AtomicBoolean 底层用 volatile,保证可见性
volatile 的作用:
- 可见性:一个线程修改后,其他线程立即可见
- 禁止指令重排序:防止编译器和 CPU 优化导致的乱序
volatile 不能保证原子性:
count++不是原子操作(读-改-写)- 需要原子性用 AtomicInteger 或 synchronized
七、一句话总结
“这个项目让我深入理解了 高并发系统设计(Redis + Lua + MQ)、AI 应用开发(LangGraph4j + RAG + SSE)、以及 微服务架构演进(Spring Cloud Alibaba + Dubbo)。最大的收获是学会了如何在 性能、一致性、可用性 之间做权衡。”
八、面试话术模板
开场白
“我做的是一个智能教育管理系统,主要有三个技术亮点:高并发抢课、AI 工作流、微服务架构。您想先听哪个?”
引出八股的过渡句
- “说到这个,其实涉及到 Redis 的单线程模型…”
- “这里我用了 ConcurrentHashMap,它的原理是…”
- “为了保证线程安全,我用了 AtomicInteger,它底层是 CAS…”
- “事务这块我用的是编程式事务,因为 @Transactional 有个坑…”
展示深度的句式
- “我还对比了几种方案…”
- “这里有个细节需要注意…”
- “我踩过一个坑是…”
- “如果并发量再大 10 倍,我会考虑…”
结束语
“这个项目让我对高并发和分布式有了更深的理解,也让我养成了从性能、一致性、可用性多角度思考问题的习惯。”
九、口语化面试话术(完整版)
公式:业务背景 → 技术选型原因 → 核心难点实现(手撕逻辑) → 遇到的坑与解决 → 未来优化方向
【话术一】高并发抢课系统
1️⃣ 业务背景(30秒)
“我们这个系统有一个抢课功能,就是学生选课的时候,热门课程可能几百上千人同时抢。
核心问题有两个:
- 第一是超卖,比如课程只有100个名额,结果抢了120个人,这肯定不行
- 第二是数据库扛不住,如果每个请求都直接打到MySQL,瞬时1000个并发,数据库直接就挂了
所以我需要设计一个既能防超卖、又能扛住高并发的方案。”
2️⃣ 技术选型原因(1分钟)
“我调研了几种方案:
方案一:纯MySQL + 悲观锁
- 用
SELECT FOR UPDATE锁住库存记录 - 问题是性能太差,测下来只有500 QPS,而且锁竞争严重
方案二:MySQL + 乐观锁
- 用版本号控制,
UPDATE ... WHERE version = ? - 问题是高并发下大量重试,成功率低
方案三:Redis + Lua + 消息队列(我最终选的)
- Redis 单线程,天然防并发问题
- Lua 脚本保证原子性,不会超卖
- 消息队列异步写数据库,削峰填谷
选 Redis 的核心原因是:它的单线程模型天然保证了操作的原子性,不需要加锁就能防止并发问题。”
3️⃣ 核心难点实现(2分钟,可手撕)
“核心就是这个 Lua 脚本,我给您讲一下逻辑:
|
|
为什么能防超卖?
关键在于 Redis 是单线程执行命令的。这个 Lua 脚本在执行期间,不会有其他命令插进来。所以「检查库存 → 扣减库存 → 记录状态」这三步是一个原子操作,不可能出现两个人同时扣减最后一个库存的情况。
数据怎么落库?
Redis 操作成功后,我不是直接写 MySQL,而是发一条消息到 RocketMQ。消费者那边批量消费,比如攒够1000条或者等10秒,然后批量 INSERT。这样数据库的压力就从瞬时1000 QPS 变成了平稳的每秒几十次批量写入。”
4️⃣ 遇到的坑与解决(1分钟)
“我踩过几个坑:
坑一:Redis 序列化问题
一开始我用 Jackson 序列化,结果 Lua 脚本执行报错。因为 Jackson 会在值前面加类型信息,Lua 处理不了。
解决:我配了两个 RedisTemplate,一个用 Jackson 给业务用,一个用纯 String 序列化专门给 Lua 脚本用。
坑二:消息发送失败数据不一致
Redis 扣减成功了,但是消息发送失败了,这时候 Redis 和 MySQL 数据就不一致了。
解决:我加了回滚机制。消息发送失败时,立即执行一个回滚的 Lua 脚本,把库存加回去,把抢课状态删掉。
坑三:热点 Key 导致 Redis 响应变慢
压测的时候发现,热门课程的 Key 被大量访问,Redis 响应时间从5ms飙到200ms。
解决:我实现了一个热点检测 + 本地缓存的方案。用 HeavyKeeper 算法检测热点 Key,检测到之后把数据缓存到本地 Caffeine,减少 Redis 访问。”
5️⃣ 未来优化方向(30秒)
“如果并发量再大10倍,我会考虑:
- Redis Cluster 分片:把不同课程的库存分散到不同节点,避免单点瓶颈
- 令牌桶预热:抢课开始前,先发放令牌,没有令牌的请求直接拒绝,减少无效请求
- 本地预扣减:在应用层先做一次本地库存预扣减,过滤掉大部分请求,只有预扣减成功的才去访问 Redis”
【话术二】AI 智能题目生成
1️⃣ 业务背景(30秒)
“我们系统有一个 AI 出题功能,老师输入知识点和题目数量,系统自动生成选择题、填空题、大题。
核心问题:
- AI 生成很慢,一道题可能要5-10秒,生成10道题就要1-2分钟
- 如果用传统的同步请求,用户要等很久,体验很差,而且容易超时
- 生成的题目质量参差不齐,需要有质检和重试机制”
2️⃣ 技术选型原因(1分钟)
“我选了 LangGraph4j + SSE 流式输出 的方案:
为什么用 LangGraph4j?
- 它是一个工作流引擎,可以把复杂的 AI 任务拆成多个节点
- 支持条件路由,比如质检不通过可以自动重试
- 节点之间可以传递上下文,方便管理状态
为什么用 SSE 而不是 WebSocket?
- SSE 是单向通信,服务器推送给客户端,正好符合我的场景
- 基于 HTTP,实现简单,不需要额外的握手和心跳
- 自动重连,断了会自己连回来
为什么不用轮询?
- 轮询会产生大量无效请求
- 实时性差,用户体验不好”
3️⃣ 核心难点实现(2分钟)
“我设计了一个四节点的工作流:
|
|
节点一:RAG 知识检索
- 根据老师输入的知识点,从向量数据库检索相关的教学文档
- 这样生成的题目更贴合教材内容
节点二:任务拆分
- 把「生成10道选择题」拆成10个独立的任务
- 每个任务生成一道题
节点三:题目生成
- 调用大模型,传入知识点和检索到的文档
- 生成题目、选项、答案、解析
节点四:质量检查
- 检查 JSON 格式是否正确
- 检查选项数量是否符合要求
- 检查答案是否在选项中
条件路由的实现:
|
|
SSE 流式输出:
每生成一道题,就立即推送给前端,用户可以实时看到进度。我用 ThreadLocal 存储 SseEmitter,避免序列化问题。”
4️⃣ 遇到的坑与解决(1分钟)
“坑一:ThreadLocal 内存泄漏
一开始我忘了清理 ThreadLocal,结果在线程池场景下,线程被复用,ThreadLocal 里的对象一直不释放,内存越来越大。
解决:在 finally 块里一定要调用 remove()。
坑二:异步线程拿不到 ThreadLocal
工作流是异步执行的,但是异步线程和主线程不是同一个,拿不到主线程的 ThreadLocal。
解决:在异步任务开始时,重新 set 一次 SseEmitter。
坑三:大模型返回格式不稳定
有时候大模型返回的 JSON 格式不对,解析失败。
解决:我在 prompt 里加了严格的格式要求,并且在质检节点做了格式校验,不通过就重试,最多重试3次。”
5️⃣ 未来优化方向(30秒)
“1. 并行生成:现在是串行生成10道题,可以改成并行,开10个线程同时生成 2. 缓存相似题目:如果知识点相似,可以从缓存里取,不用每次都调大模型 3. 模型微调:用我们自己的题库数据微调模型,提高生成质量”
【话术三】微服务架构演进
1️⃣ 业务背景(30秒)
“项目一开始是单体架构,后来遇到了几个问题:
- 资源竞争:AI 生成任务很吃 CPU 和内存,一跑起来其他接口都变慢了
- 无法独立扩展:抢课高峰期,只有抢课模块需要扩容,但单体架构只能整体扩容,浪费资源
- 发布风险高:改一行代码要重新部署整个应用,万一出问题影响所有功能
所以我决定做微服务拆分。”
2️⃣ 技术选型原因(1分钟)
“我选的是 Spring Cloud Alibaba + Dubbo + Nacos + Higress 这套:
为什么选 Dubbo 而不是 Feign?
- Feign 基于 HTTP,每次调用都要经过完整的 HTTP 协议栈,开销大
- Dubbo 用的是 Triple 协议,基于 HTTP/2,支持多路复用,性能是 Feign 的10倍
- 在抢课这种高并发场景,服务间调用的性能差异会被放大
为什么选 Nacos?
- 同时支持服务注册和配置中心,不用部署两套
- 和 Spring Cloud Alibaba 生态集成好
- 性能比 Eureka 和 Consul 都好
为什么选 Higress 网关?
- 基于 Envoy,性能很高
- 原生支持 Dubbo 协议转换,外部 HTTP 请求可以直接转成 Dubbo 调用
- 和 Nacos 无缝集成”
3️⃣ 核心难点实现(1分钟)
“我拆成了7个服务:
| 服务 | 职责 | 为什么单独拆 |
|---|---|---|
| User | 用户认证 | 基础服务,被所有服务依赖 |
| Course | 课程管理 | 业务独立 |
| Snatch | 抢课 | 高并发,需要独立扩展 |
| Question | 题目管理 | 业务独立 |
| File | 文件存储 | IO 密集,独立部署 |
| RAG | 知识库检索 | 向量计算,资源隔离 |
| AI-Workflow | AI 工作流 | CPU 密集,可能要 GPU |
服务间调用:用 Dubbo RPC,定义了统一的接口模块 EduAgentX-Client,所有服务都依赖它。
Session 共享:用 Spring Session + Redis,所有服务共享同一个 Session 存储。”
4️⃣ 遇到的坑与解决(1分钟)
“坑一:循环依赖
Course 服务要调用 User 服务获取教师信息,User 服务又要调用 Course 服务获取用户的课程列表,形成了循环依赖。
解决:重新梳理服务边界,把「用户的课程列表」这个功能放到 Course 服务,User 服务只负责用户基本信息。
坑二:分布式事务
抢课成功后要同时更新 Redis、发消息、写数据库,跨了多个服务。
解决:用 Seata 的 Saga 模式,定义正向操作和补偿操作。失败时自动执行补偿,保证最终一致性。
坑三:服务调用超时
AI 服务生成题目很慢,默认的 Dubbo 超时时间是3秒,经常超时。
解决:针对 AI 服务单独配置超时时间为60秒,其他服务保持3秒。”
5️⃣ 未来优化方向(30秒)
“1. 服务网格:引入 Istio,把服务治理下沉到基础设施层 2. 容器化:用 Kubernetes 部署,实现自动扩缩容 3. Serverless:AI 服务可以用 Serverless 部署,按调用量付费,降低成本”
【话术四】热点缓存优化
1️⃣ 业务背景(30秒)
“压测的时候发现一个问题:热门课程被大量访问,Redis 响应时间从正常的5ms飙升到200ms,严重影响了抢课接口的性能。
分析原因是:所有请求都打到 Redis 的同一个 Key 上,形成了热点 Key,Redis 单线程处理不过来。”
2️⃣ 技术选型原因(30秒)
“我的方案是 热点检测 + 本地缓存:
- 用 HeavyKeeper 算法 实时检测哪些 Key 是热点
- 检测到热点后,把数据缓存到本地 Caffeine
- 后续请求先查本地缓存,命中就不用访问 Redis 了
为什么用 HeavyKeeper?因为它是概率数据结构,内存占用小,可以在有限内存下找出 Top K 热点元素。”
3️⃣ 核心难点实现(1分钟)
“HeavyKeeper 的原理是:
- 维护一个多层的 Bucket 数组
- 每个 Bucket 存一个 Key 的指纹和计数
- 新请求来了,如果指纹匹配就计数+1
- 如果不匹配,以一定概率衰减原来的计数
- 衰减到0就用新 Key 替换
我做的优化:
原始实现的 add() 方法要5ms,太慢了。我改成了异步版本:
|
|
耗时操作(清理过期数据、统计 Top K)都放到后台线程异步处理,主线程只做快速的计数更新。优化后 add() 耗时从5ms降到了0.1ms。”
4️⃣ 遇到的坑与解决(30秒)
“坑:本地缓存数据不一致
本地缓存和 Redis 数据可能不一致,比如 A 服务器的本地缓存显示还有库存,但 Redis 里已经没了。
解决:我的策略是写操作不走本地缓存。抢课、退课这种写操作,直接走 Redis Lua 脚本。本地缓存只用于读操作(查询库存、查询状态),而且设置了60秒过期时间,保证最终一致性。”
5️⃣ 未来优化方向(30秒)
“1. 多级缓存:可以再加一层进程内缓存,形成 L1(进程内)→ L2(本地 Caffeine)→ L3(Redis)的三级缓存 2. 缓存预热:抢课开始前,提前把热门课程的数据加载到本地缓存 3. Redis Cluster:如果热点 Key 太多,可以用 Redis Cluster 分散到多个节点”
十、万能应对话术
当面试官问「还有什么要补充的吗」
“我想补充一点,这个项目让我最大的收获不是学会了某个技术,而是学会了怎么做技术选型。
比如抢课系统,我一开始想用分布式锁,后来发现 Lua 脚本更简单高效;消息队列我对比了 Kafka 和 RocketMQ,最后选了 RocketMQ 因为它支持事务消息。
我觉得做技术选型最重要的是理解每个方案的优缺点和适用场景,而不是盲目追求新技术。”
当面试官问「这个项目有什么不足」
“有几个地方我觉得可以做得更好:
- 监控不够完善:目前只有基础的日志,缺少完整的链路追踪和性能监控大盘
- 测试覆盖率不够:单元测试写得比较少,主要靠手工测试
- 文档不够完善:接口文档有,但是架构设计文档和运维文档比较欠缺
如果有机会重新做,我会在项目初期就把这些基础设施搭建好。”
当面试官问「你在团队中的角色」
“我是这个项目的主要开发者,负责核心模块的设计和实现:
- 抢课系统的高并发方案是我设计的
- AI 工作流引擎是我从零搭建的
- 微服务拆分的架构设计也是我主导的
遇到技术难题时,我会先自己调研,然后和团队讨论,最后形成方案文档,评审通过后再实施。”
当面试官深挖某个技术细节你不太确定时
“这个细节我不太确定,但是我的理解是…(说你的理解)。回去之后我会再深入研究一下,确认一下是不是这样。”
千万不要:
- 瞎编一个答案
- 说「我不知道」然后沉默
十一、面试前 Checklist
必须能脱口而出的
- 项目30秒介绍
- 三个核心亮点的业务背景
- Lua 脚本的核心逻辑(能手写)
- 为什么选 Redis 不选 MySQL
- 为什么选 RocketMQ 不选 Kafka
- 为什么选 Dubbo 不选 Feign
- ThreadLocal 内存泄漏怎么解决
- 分布式事务用的什么方案
最好能说清楚的
- Redis 单线程模型
- ConcurrentHashMap 原理
- 线程池参数怎么配置
- AOP 的实现原理
- Spring 事务传播机制
- CAP 理论,你的系统是 CP 还是 AP
加分项
- 性能优化的具体数据(QPS 从多少到多少)
- 踩过的坑和解决方案
- 未来的优化方向
- 对比过哪些技术方案