1. 自我介绍
您好,面试官好,我目前是一名大三学生,主方向是 Java 后端和 AI 应用工程。
平时主要使用 Spring Boot、Spring Cloud、Spring AI Alibaba、Redis、RabbitMQ、Elasticsearch、MySQL 等技术做 AI 相关系统开发,也比较关注 AI Agent、RAG、Workflow 以及 AI 工程化相关方向。
目前做的核心项目是企业级 RAG + Agent 推理系统,主要负责检索链路和 Agent 工作流相关功能,包括 Hybrid Recall、Rerank、多轮 Memory、Workflow Agent 等,同时也比较关注系统工程能力,比如缓存、异步、日志链路、限流、可观测性以及 AI 系统稳定性这些问题。
另外平时也会结合 Codex、Cursor 等 AI 工具辅助开发,提高开发效率和代码质量。目前也在持续学习 JVM、并发、Redis 和分布式系统设计等后端相关内容,希望后续能够继续往 AI 工程化和 Java 后端结合的方向发展。
2. 使用 AI 工具遇到的问题
第一个是上下文缺失。比如项目比较复杂时,如果不给 AI 足够上下文,它很容易生成和当前架构不一致的代码,比如包结构不对、调用不存在的方法、或者不符合现有设计规范。
第二个是"看起来对,但实际上有问题"。比如复杂业务逻辑、并发逻辑、缓存一致性这些场景,AI 很容易生成逻辑不完整的代码,需要自己再检查和调整。
第三个是长链路问题。像 Agent、Workflow、RAG 这种链路比较长的系统,如果一次性让 AI 生成完整功能,通常效果不好,所以我后来会把任务拆小,比如先定义状态流转、输入输出,再逐步生成。
3. OSI 七层模型
OSI 七层模型从下到上分别是:
物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。
物理层主要负责比特流传输,比如网线、电信号。
数据链路层负责帧传输和 MAC 地址
网络层负责IP 路由。
传输层负责TCP、UDP 这种端到端通信。
应用层就是我们开发最常接触的,比如 HTTP、HTTPS、DNS(讲域名进行转化)
4. 数组和链表区别
数组底层是连续内存空间,支持随机访问,所以查询效率高
但中间插入删除效率低,因为需要移动元素。
链表内存不连续,插入删除效率更高,只需要修改指针,但随机访问效率低,需要遍历。
5. 追问一下,在实际开发中如果你需要处理一个动态变化的数据集合,比如频繁的插入和删除操作,同时还需要偶尔进行随机访问,你会选择数组还是链表?为什么?
单纯数组和链表其实都不完全合适
因为:
- 数组随机访问快,但插入删除成本高。
- 链表插入删除快,但随机访问慢。
如果让我选择的话,我会去选择跳表作为数据集合
跳表(SkipList)本质上是一种:
"基于链表实现的多层有序索引结构"。
它的核心目标是:
在保持链表插入删除简单的前提下,提高查询效率。
比如底层:
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
这是普通链表
跳表会额外建立:
1 ------> 4 ------> 7
甚至:
1 -------------> 7
形成:
- 第0层(完整数据层)
- 第1层(稀疏索引)
- 第2层(更稀疏索引)
查询时:
先走高层,找不到再下沉
6.请你解释一下垃圾回收机制 GC 的工作原理以及它在 Java 中的作用?
GC 的核心作用是自动回收不再使用的对象,避免内存泄漏
Java 中对象主要分配在堆中,GC 会通过可达性分析算法,从 GC Roots 出发判断对象是否还能被引用。
GC Roots 是什么
GC Roots 是 Java 垃圾回收中的:"根对象集合"
如果对象不可达,就会被回收。
对象一般会经历:
Eden → Survivor → Old
新生代主要是 Minor GC,(指新生代垃圾回收)
老年代空间不足时会触发 Full GC(整个堆的大范围垃圾回收)
7.你提到了垃圾回收机制的核心原理和分代回收策略,整体思路是对的。接下来我想深入问一下:在 Java 的垃圾回收机制中,新生代和老年代的回收算法分别适用于哪些场景?为什么会选择这些算法?
新生代一般使用:复制算法
因为新生代对象:
- 存活率低
- 回收频率高
- 大部分对象 GC 一次就死掉
老年代一般使用:标记整理
因为老年代对象特点是:
- 存活率高
- 对象数量大
- 生命周期长
8.如果频繁出现 Full GC,如何排查和优化?
繁出现 Full GC,一般说明 JVM 内存压力比较大,常见原因包括老年代空间不足 、对象晋升过快、大对象频繁创建、内存泄漏
排查时我一般会先看 GC 日志,重点关注 Full GC 的频率、停顿时间以及 GC 前后老年代内存变化 。如果 Full GC 后内存下降不明显,通常会怀疑存在内存泄漏
然后会通过 jstat 观察各区域内存变化,再导出堆 dump(JVM 某一时刻的"内存快照),结合 MAT 分析大对象和引用链,重点排查缓存、大集合、ThreadLocal、MQ 消息堆积等问题
优化上会根据具体原因处理,比如:
- 内存泄漏就修复引用问题
- 缓存增加淘汰和过期策略
- 减少大对象和长生命周期对象
- 调整堆大小和新生代比例
9.谈谈 SQL 查询的优化方法以及如何排查慢查询问题?
老生常谈的问题,不再赘述
10.你提到了慢查询 explain 分析以及索引优化等方法,这些都是常见的手段。那么我想追问一下,当你通过 explain 发现某个查询的 type=ALL,并且扫描行数非常多时,你会如何具体优化这个查询?能否结合一个场景来说明?
如果 explain 发现 type=ALL,说明 SQL 走了全表扫描,而且扫描行数很多,这种情况下我会先看 where 条件、order by、group by 字段是否有合适索引,再判断是不是索引失效
1.如果没有建立对应的索引,则补充创建索引
2.检查是否索引失效
3.是否数据区分度太低/数据返回量太大导致
4.order by / group by 无法利用索引(没有遵循最左原则)
比如有一张订单表 order_info,数据量几百万,现在有这样一个查询:
select *
from order_info
where user_city = '北京'
and create_time >= '2026-01-01'
order by create_time desc;
如果 explain 发现 type=ALL,说明它没有走索引,而是扫全表。这个时候我会考虑建立联合索引:
create index idx_city_time
on order_info(user_city, create_time);
这样数据库可以先根据 user_city 缩小范围,再根据 create_time 做范围查询和排序,扫描行数会明显减少。
11.在你提到的联合索引中,字段的顺序是 user_city、create_time你是如何确定这个顺序的?为什么不是排列方式?能否详细说明一下背后的逻辑?
我会把 user_city 放前面,是因为这个查询通常会先按城市过滤:
where user_city = '北京'
and create_time >= '2026-01-01'
这里 user_city 是等值查询,放在联合索引最左边,可以先快速缩小数据范围;然后再利用 create_time 做范围查询。
如果反过来建成:
(create_time, user_city)
当 create_time 是范围查询时,范围查询后面的字段通常就很难继续充分利用索引过滤了,user_city 的过滤效果可能会变弱。
因为联合索引在 B+ 树里是按字段顺序排序的。
比如索引是:
(create_time, user_city)
它的排序方式相当于:
先按 create_time 排序
create_time 相同的情况下,再按 user_city 排序
所以如果你写:
where create_time >= '2026-01-01'
and user_city = '北京'
create_time 是范围查询,数据库只能先定位到一大片时间范围:
2026-01-01 之后的所有数据
这时候后面的 user_city 已经不是一个连续、有序的小范围了,MySQL 很难再继续利用索引精准定位,只能在这个时间范围内再过滤 user_city。
所以一般经验是:
等值查询字段优先,范围查询字段靠后。
12.你需要设计一个简单的缓存系统来提高数据库查询效率,请描述你会如何实现,并考虑缓存更新和失效的策略
我会采用 Redis + MySQL 的缓存架构,核心思路是 Cache Aside 旁路缓存模式。
查询时,先查 Redis,如果命中就直接返回;如果没命中,再查 MySQL,然后把查询结果写入 Redis,并设置过期时间,后续相同请求就可以直接走缓存,减少数据库压力。
更新时,我一般不会直接更新缓存,而是采用:
先更新数据库,再删除缓存。
因为数据库才是最终数据源,缓存只是加速层。更新数据库成功后删除缓存,下一次查询发现缓存不存在,就会重新从数据库加载最新数据。
同时为了防止缓存问题,我会做几个保护:
第一,给缓存设置合理 TTL,避免长期脏数据
第二,为了防止缓存穿透,对于不存在的数据,可以缓存空值,或者使用布隆过滤器。
第三,为了防止缓存击穿,对于热点 key 失效,可以加互斥锁,避免大量请求同时打到数据库。
第四,为了防止缓存雪崩,可以给过期时间加随机值,避免大量 key 同一时间失效。
13.我继续问一个细节问题,你提到的缓存更新时采用更新数据库后删除缓存的策略,这种方式在高并发场景下可能会出现什么潜在问题?你会如何优化?
线程 A 读数据,发现缓存不存在,于是去查数据库,查到了旧值。
这时线程 B 更新数据库成功,并删除缓存。
随后线程 A 把刚才查到的旧值写回 Redis
这样缓存里又变成了旧数据,后续请求就会读到脏缓存
第一,可以采用延迟双删。也就是更新数据库后先删除一次缓存,隔一小段时间再删一次,尽量把并发读写过程中回写的旧缓存清掉。
第二,可以通过 MQ异步删除缓存。数据库变更后发送消息,保证缓存最终被删除,适合一致性要求更高的场景。
14.你提到使用分布式锁来解决并发问题,具体来说,你会选择什么工具或技术来实现分布式锁?为什么?
我一般会优先选择 Redis+lua脚本 实现分布式锁
- Redis 性能高、实现简单、延迟低,适合大多数业务并发控制场景
- lua脚本可以实现一些原子操作,比如判断删除等可能导致并发问题的操作
15.你要提供一个文本生成 HTTP 接口给业务方调用,请你设计请求与返回的关键字段,至少包含上下文、模型、参数、输出结构、错误码以及用于追踪的一次调用 ID。你会如何支持流式返回?
我会把这个接口设计成一个统一的文本生成接口,比如:
POST /api/ai/text/generate
请求里至少包含这些字段:
{
"requestId": "req-xxx",
"model": "qwen-plus",
"stream": true,
"messages": [
{
"role": "system",
"content": "你是一个文本生成助手"
},
{
"role": "user",
"content": "帮我生成一段营销文案"
}
],
"parameters": {
"temperature": 0.7,
"maxTokens": 1024,
"topP": 0.8
},
"outputFormat": {
"type": "text",
"language": "zh-CN"
},
"context": {
"bizType": "marketing",
"userId": "10001",
"conversationId": "conv-xxx"
}
}
这里**requestId 用于一次调用的链路追踪** ;model 指定模型;messages 表示上下文 ;parameters****控制生成效果;outputFormat 约束返回结构 ;context 用于业务方传入业务上下文。
流式返回我会用 SSE 实现,也就是接口返回:
Content-Type: text/event-stream
服务端逐步推送 token 或片段:
event: message
data: {"requestId":"req-xxx","delta":"你好"}
event: message
data: {"requestId":"req-xxx","delta":",这是生成内容"}
event: done
data: {"requestId":"req-xxx","finishReason":"stop"}
同时我会在流式接口里处理超时、客户端断开、异常关闭和日志记录。每个 chunk 都带上 requestId,方便排查问题。
