蚂蚁集团 Java 后端面经合集 04:AI 工程与系统底层
- 轮次
- 多轮面试合集
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
面经 01 · Java 后端开发(2025-09-25)
- Spring AI 对于 Agent 开发提供的常见模式有了解吗?
- MCP 有哪些通讯协议?
- SSE 的断点续传怎么实现?
- 阻塞通信和非阻塞通信的区别和原理是什么?
- Linux 中用的非阻塞通信模块是哪个模块?
- 数据库的四个特性分别是怎么实现的?
- 怎么用 MySQL 实现分布式锁?
面经 02 · C++ 后端开发(2025-09-25)
- C++ 除了互斥锁还有什么锁?
- 自旋锁的 while 等待怎么判断的?
- 原子操作底层是怎么实现的?
- 线程创建过程中操作系统是什么样的状态?
- 缓存淘汰策略有哪些?区别和优缺点是什么?
- 操作系统的内存分配是怎么做的?
- 数据库的隔离级别有哪些?
- 四种隔离级别分别是怎么实现的?
- TCP 四次挥手的过程?
- 四次挥手如果没有得到确认报文会怎么办?比如另一方坏掉了。
- 手撕:实现 LRU。
- 手撕:实现 LFU。
面经 03 · Java 后端开发(2025-09-23)
- 主机突然断电了,日志会丢失吗?
- 如何保证日志按时被清理?
- 你在重构时,如何考虑和原有的代码兼容?
- 什么是线程安全?Java 如何实现线程安全?
- 数据库中如果对一个数据有读有写的话,要加锁吗?
- 事务中的一致性和分布式系统中的一致性是一样的吗?
- 手撕:非严格递增序列原地去重,返回不同数字的个数([1,1,2,3,3,3,4,4] 返回 k=4,原数组前 4 位变为 [1,2,3,4])。
- 手撕:顺时针螺旋输出矩阵。
面经 04 · Java 后端开发(2025-09-18)
- 如何保证 MQ 消息的有序性?
- Redis 集群有多少实例?集群有一个实例挂了,之后的数据是怎么流转的?
- 实习中怎么实现的 prompt 调优?
- 进程和线程的区别是什么?
- 死锁是什么?怎么避免?
- 数据库索引结构是什么?
- 怎么设计索引?
- 什么是桶排序?
- TCP 拥塞控制算法有哪些?
- 什么是贪心算法?举个例子说说。
- 什么是图?
- 最短路径算法口述。
- 什么是红黑树?
面经 05 · Java 后端开发(2025-09-17)
- 你对 Spring 框架了解吗?
- 介绍一下 Spring Bean 的生命周期。
- Spring 的 IOC 你了解吗?
- Spring 的动态代理是怎么实现的?
- 什么情况下会用 AOP 来进行代理?
- 手撕:判断两棵相同的二叉树。
- 手撕:旋转链表,将每个节点向右移动 k 个位置。
面经 06 · 后端开发岗(2025-09-15)
- 你在第一个项目中提到使用内存数据库与时序数据库,能否介绍具体用法?
- 时序数据库在查询层面为何会有性能优势?
- 为何选择时序数据库而不是普通列存分析型数据库?
- 你还了解内存数据库的哪些模块或特性?
- 内存数据库在内存管理或资源管理方面有哪些先进设计?
- 各数据类型在实现上有哪些结构优势使其性能优秀?
- 你对 OLTP/OLAP 数据库在存储、优化器或执行层有哪些了解?
《参考解析》
1. Spring AI 给 Agent 开发提供的模式:它把 Agent 拆成可替换的几块积木:ChatClient 封装对话调用;Advisor 链在请求前后做拦截改写,QuestionAnswerAdvisor 负责 RAG 检索注入、ChatMemoryAdvisor 负责拼接历史、SafeGuardAdvisor 做内容过滤;Tool Calling 用 @Tool 声明工具,框架自动生成 JSON Schema、解析参数并回调方法;结构化输出用 BeanOutputConverter 映射成 Java 对象;ChatMemory 提供内存、Redis 等会话存储;VectorStore 与 EmbeddingModel 承担向量检索;再通过 MCP 客户端把外部服务当工具挂进来。整体思路与 LangChain 一致,卖点是能与 Spring 容器、AOP、配置体系无缝衔接。
2. MCP 的通讯协议与 SSE 断点续传:MCP 分传输层与消息层。传输层有两种标准:stdio 面向本地子进程,通过标准输入输出交换 JSON-RPC;Streamable HTTP 面向远程服务,客户端 POST 发送消息、服务端用 SSE 流式回推(它取代了早期「一个端点发消息、一个端点收 SSE」的双端点方案)。消息层统一是 JSON-RPC 2.0,方法包括 initialize 握手与能力协商、tools/list 与 tools/call、resources/list 与 resources/read、prompts/list 与 prompts/get 以及各类通知。SSE 断点续传靠事件 ID:服务端为每个事件带 id,客户端重连时在 Last-Event-ID 头里带上最后收到的 ID,服务端从该 ID 之后重放;工程上要维护连接标识与事件环形缓冲,并限制重放窗口。
3. 阻塞与非阻塞通信、Linux 的 epoll:阻塞 IO 调用后线程挂在系统调用里,直到数据就绪且拷贝完成才返回,一个线程只能盯一个连接;非阻塞 IO 立即返回 EAGAIN,需要用户态反复轮询,空转烧 CPU;IO 多路复用把「等数据」交给内核,一个线程管成千上万个连接。Linux 上 select/poll 每次调用都要把 fd 集合全量拷进内核并线性扫描,复杂度 O(n);epoll 用红黑树管理注册的 fd、用就绪链表返回事件,每次只拷就绪的 fd,复杂度降到与就绪数相关,还支持边缘触发——只通知一次,要求 fd 非阻塞并一次读到 EAGAIN。更彻底的异步方案是 io_uring,用共享内存的提交/完成队列把系统调用开销也省掉。理解「等待数据」与「拷贝数据」两个阶段,是分清阻塞/非阻塞、同步/异步的标准口径。
4. 用 MySQL 实现分布式锁:建一张锁表,锁名做唯一索引,加锁就是插入一行,插入成功即持锁,冲突则失败重试或阻塞等待;解锁删除该行。必须补两件事:加过期时间字段并起清理任务扫掉超时行,避免进程崩溃后锁永久不释放;记录持有者标识(机器 + 线程 UUID),解锁时校验是自己的锁才能删,防止误删别人的。也可以用 SELECT … FOR UPDATE 做阻塞式行锁,但锁随事务提交才释放,业务必须在同一事务里完成并尽快提交。它的可靠性依赖数据库本身,主从切换可能丢锁、单点风险高、写入竞争也不算快,因此适合低频强一致的场景(例如定时任务选主),高并发互斥还是 Redis 的 SET NX PX 加 Lua 解锁与看门狗续期,或 ZooKeeper 的临时顺序节点更合适。
5. ACID 的实现与四种隔离级别:原子性靠 undo log 回滚,持久性靠 redo log 的 WAL(提交时把日志刷盘,脏页慢慢刷),隔离性靠锁与 MVCC,一致性是目的,由前三者加业务约束共同保证。读未提交能读到别人未提交的数据,基本不用;读已提交每条 SELECT 都生成新 ReadView,解决脏读;可重复读在事务首次读时生成 ReadView 并复用,配合 next-key lock 把当前读的间隙也锁住,是 InnoDB 的默认级别;串行化对所有读加共享锁、范围条件加间隙锁,最安全但并发最差。实现细节上,读已提交与可重复读的差别就在 ReadView 的生成时机,间隙锁在读已提交下基本不生效,所以它的幻读更容易复现。
6. C++ 的锁家族与自旋锁:互斥量 std::mutex 不可重入,另有 recursive_mutex、timed_mutex、C++17 的 shared_mutex(读写锁)、C++20 的 counting_semaphore(信号量)、condition_variable(条件等待),以及用原子操作实现的自旋锁与完全无锁的数据结构。自旋锁的 while 等待本质是原子 compare_exchange_weak 循环(test-and-set):拿不到就继续尝试,可以在循环里插 _mm_pause 或 std::this_thread::yield 降低总线压力;单核上自旋必须让出 CPU,否则永远等不到释放。选型看临界区长度与竞争强度:临界区极短、核数充足时自旋省掉内核调度开销更划算;临界区长或可能睡眠时用 mutex;读多写少用 shared_mutex。
7. 原子操作的底层实现:靠 CPU 提供的原子指令加编译器屏障共同实现。x86 上用 lock 前缀指令(lock cmpxchg、lock xadd)锁缓存行或总线;ARM 用 LL/SC(ldrex/strex)或 LSE 原子指令。C++ 的 std::atomic 把这些封装成带内存序的接口,memory_order 决定编译器与 CPU 能重排到什么程度:relaxed 只保证原子性,acquire/release 成对建立同步关系、是无锁队列的标配,seq_cst 提供全局全序但代价最高。写无锁结构时有三个典型坑:CAS 循环失败重试导致 CPU 飙升、伪共享(相邻变量落在同一缓存行,用 alignas 填充隔离)、以及 ABA 问题(用版本号或 tagged pointer 解决)。
8. 线程创建的操作系统视角,以及进程与线程的区别:调用 pthread_create 时内核会 clone 出 task_struct 与内核栈,并按标志位决定共享哪些资源(CLONE_VM 共享地址空间、CLONE_FILES 共享文件描述符表等),随后新任务进入就绪队列,被调度到某个 CPU 时先在内核态做上下文切换(保存寄存器、切换页表),再返回用户态执行入口函数,所以它「创建」完成时已经是就绪态,第一次执行仍需等待调度。进程与线程的差别在资源与调度两个维度:进程有独立地址空间,是资源分配单位;线程共享地址空间、只独占栈与寄存器,是调度单位。因此线程切换更便宜(不必换页表、TLB 不失效),但一个线程崩溃会带走整个进程。协程把调度搬到用户态,进一步省掉内核切换。
9. 缓存淘汰策略与 LRU/LFU 的手撕要点:FIFO 简单但会误伤热点;LRU 按最近访问时间淘汰,实现直白,但一次全表扫描式的突发访问就会把热点全冲掉;LFU 按访问频率淘汰,能留住长期热点,却会让新键饿死、老热点频率累积后赖着不走,通常要配频率衰减或分段 LRU;CLOCK(二次机会)用一个引用位加环形指针近似 LRU,不需要维护链表;Redis 用的是采样式的近似 LRU/LFU,用少量内存换掉精确性。手撕 LRU 的标配是哈希表加双向链表:哈希表 O(1) 定位节点,链表维护顺序,哨兵头尾简化边界,get 和 put 都要把节点挪到头部,超容量删尾节点。LFU 要再进一步:用「频率到双向链表的桶」加一个 key 到节点的哈希表,淘汰时取最小频率桶的尾节点,这样才能 O(1) 处理「频率相同」——按 LRU 顺序淘汰同频率里最久未用的那个。
10. TCP 四次挥手,以及最后确认丢失怎么办:主动方发 FIN 进入 FIN_WAIT_1;被动方回 ACK 进入 CLOSE_WAIT,此时是半关闭状态,主动方仍能接收数据;被动方把剩下数据发完再发自己的 FIN;主动方回 ACK 并进入 TIME_WAIT,等 2MSL 后彻底关闭。需要四次而不是三次,是因为被动方的 ACK 与应用层关闭触发的 FIN 不能合并。若最后一个 ACK 丢失(对方进程已退出或崩溃),被动方会超时重传 FIN,主动方在 TIME_WAIT 内收到重传就再补一个 ACK,这正是 2MSL 的意义:覆盖对方的重传窗口,同时让本次连接的旧报文在网络中消散,避免被新连接误收。若对方崩溃且根本没发 FIN,则属于半开连接,只能靠 keepalive 或应用层心跳发现。
11. 断电时日志会不会丢,以及日志怎么按时清理:是否丢取决于写路径和刷盘策略。只写进用户态缓冲区(日志框架 immediateFlush=false)必丢;write 到 page cache 后断电也丢,因为数据还在内核内存里;只有 fsync/fdatasync 返回成功才算落盘。所以关键日志要么同步刷盘,要么走 WAL 的思路先顺序写日志再改数据,分布式场景再叠加多副本才敢说可靠。定时清理一般交给 logrotate:按时间或大小切分,postrotate 里压缩归档,用 copytruncate 或发信号让进程重开文件句柄;应用侧用滚动策略加 maxHistory、totalSizeCap 限制保留量。最容易踩的坑是直接删正在写的文件——fd 仍被占用、空间不会释放。
12. MQ 消息的有序性:全局有序只能把并发度降到 1(单分区单消费者),吞吐代价太大,所以实践里做的是局部有序:同一业务键(订单号、用户 ID)路由到同一分区或队列,Kafka 用 key 哈希、RocketMQ 用 MessageQueueSelector、RabbitMQ 用一致性哈希交换机;消费端要么按分区单线程消费,要么按 key 分发到内存队列并行处理、保证同 key 串行。重试是破坏有序的最大来源:失败消息直接投回原队列尾部会插到新消息后面,正确做法是本地按序重试、或投到重试队列并阻塞同 key 的后续消息,严重失败时宁可暂停该分区。生产端开启幂等(Kafka 的 enable.idempotence)并禁用会乱序的重试策略,端到端才谈得上有序。
13. Redis Cluster 的槽与实例挂掉后的流转:集群有 16384 个哈希槽,key 经 CRC16 取模落到某个槽,槽分配给不同主节点;客户端缓存槽映射直连对应节点,收到 MOVED 说明槽已迁走、收到 ASK 说明正在迁移需临时转向。节点之间用 gossip 交换状态,生产环境至少三主三从。某个实例挂掉时:如果挂的是从节点,不影响服务;如果挂的是主节点,它的从节点在主观下线并收集到多数主节点认同的客观下线后发起选举,拿到多数主节点投票即晋升为主并接管原槽。因为复制是异步的,切换瞬间可能丢掉最后几条写入,所以强要求的数据要配 WAIT 命令或业务侧补偿。槽迁移期间集群整体仍可用,客户端必须正确处理 MOVED/ASK 并刷新拓扑缓存。
14. Spring Bean 生命周期、IOC 与 AOP 动态代理:主干是:实例化 → 属性填充 → Aware 回调 → BeanPostProcessor 前置 → @PostConstruct / InitializingBean / init-method → BeanPostProcessor 后置(AOP 代理在这一步生成)→ @PreDestroy / DisposableBean.destroy。IOC 容器用 BeanDefinition 加反射完成实例化与依赖装配,把对象控制权收归容器。AOP 动态代理有两条路:JDK 动态代理基于接口,用 Proxy 与 InvocationHandler 生成实现同一接口的代理类,只能代理接口方法;CGLIB 用 ASM 生成目标类的子类并覆写方法,final 类和方法代理不了。Spring Boot 2.x 起默认走 CGLIB。
15. 时序数据库、内存数据库,以及 OLTP 与 OLAP 的差别:时序库(InfluxDB、TDengine、TimescaleDB)快在按时间分区、列式压缩(相邻时间戳做差分、值用 Gorilla 编码,压缩比常达十倍以上)、时间戳天然有序,所以范围扫描与按窗口降采样聚合只需顺序读少量数据块;普通列存分析型数据库通用性强,但缺少针对时序的编码与预聚合。内存库(Redis、VoltDB)快在数据常驻内存、没有磁盘 IO 与页缓存管理,再配单线程或分段无锁模型与定制数据结构;先进设计包括分段内存池与 jemalloc 减碎片、渐进式 rehash、惰性删除加定期删除。OLTP 面向高并发点查与短事务;OLAP 面向大范围扫描与聚合,靠列存、向量化执行与谓词下推。