美团AI面试 实习一面面经
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,方便排查问题。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)