携程 Java 后端 AI 面:InnoDB 锁、ConcurrentHashMap 扩容与 RAG 评测 12 题
- 轮次
- AI面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 自我介绍,重点聊聊你解决过的复杂问题、实习时候承担的工作
- MySQL 在 RR 隔离级别下,一条范围 UPDATE 语句为什么可能锁住并不存在的记录?如果该 SQL 使用了非唯一索引,锁范围会如何变化?
- ConcurrentHashMap 在扩容过程中允许多个线程同时迁移数据,它是怎样避免节点重复迁移、数据丢失和新旧数组访问冲突的?
- 在跨境电商库存预占与履约编排系统中,如果库存预占成功,但订单服务在记录预占结果之前发生重启,如何避免库存长期占用以及补偿过程中产生重复释放?
- CAS 操作即使不存在 ABA 问题,也不意味着业务更新一定正确。请解释原因,并说明在复杂状态更新中如何设计版本控制。
- MySQL 一条 SQL 的执行计划显示使用了索引,但实际执行时间仍然很长,如何区分是回表成本、索引扫描成本还是优化器统计信息失真?
- 一个 RAG 检索系统离线评测结果很高,但上线后经常返回与问题相关却无法支撑答案的文档,应该如何重新设计评测与召回链路?
- Java 线程池在高并发下出现任务堆积,但 CPU 使用率很低,线程数量也没有达到 maximumPoolSize,可能是什么原因?
- Redis Cluster 发生主从切换时,某个客户端已经收到写入成功的响应,但切换完成后数据却不存在,为什么会发生这种情况?
- 为什么 ConcurrentHashMap 在高并发环境下调用 size() 不能简单理解为某一个绝对时刻的精确快照?CounterCell 又是怎样降低计数竞争的?
- 设备故障根因检索系统中,知识库包含数百万条维修记录,如果新增数据与历史数据存在大量语义重复,如何避免向量索引膨胀和召回结果同质化?
- HTTP/2 已经支持多路复用,为什么在弱网环境下仍然可能出现多个请求同时变慢的问题?HTTP/3 是如何改变这一情况的?
《参考解析》
范围 UPDATE 锁住不存在的记录,本质是 InnoDB 用间隙锁防幻读。 RR 隔离级别下,当前读(UPDATE/DELETE/SELECT … FOR UPDATE)为了防止其他事务在查询范围内插入新行,会使用 Next-Key Lock——记录锁加间隙锁的组合,锁的是左开右闭区间。所以 WHERE score BETWEEN 15 AND 25 即使表里没有 score=18 这一行,18 所在的间隙仍在锁内,别的事务插不进去。换成非唯一索引后锁范围会明显扩大:唯一索引做等值查询且命中记录时可以退化成记录锁,而普通索引存在重复键值,等值查询也往往要带上间隙锁;范围查询则会把扫描路径上经过的所有索引记录及其间隙全部锁住。更关键的一点是锁范围由实际使用的索引和执行计划决定,不是由 WHERE 条件决定的——如果没走索引、走了全表扫描,等于锁住整张表。线上遇到异常锁等待,用 performance_schema.data_locks / data_waits 看实际锁了什么。
ConcurrentHashMap 的协作式扩容,靠三件事保证正确。 一是桶的归属明确:扩容时创建 nextTable,用 transferIndex 把待迁移的桶区间按 CAS 分配给不同线程,每个线程只处理自己领到的区间,再加上迁移某个桶时对该桶头节点加锁,同一个桶不会被重复迁移。二是先组织好新桶再发布标记:线程必须先把数据拆分到新数组的新桶里,再把旧桶替换成 ForwardingNode(哈希值为 MOVED 的特殊节点),顺序反了就会出现读写丢失。三是读写路径能识别迁移中状态:读到 ForwardingNode 就转到新数组继续查,写操作遇到 MOVED 会参与协助扩容,之后再重新执行自己的操作。JDK 8 里链表节点的 next 是 volatile,配合数组元素的可见性语义支撑了这套无锁读。扩容期间仍能读写,但不代表没有成本——高并发扩容会带来 CPU 竞争和额外的内存占用。
库存预占后订单服务重启,解法是「幂等状态机 + 超时补偿 + 对账」。 关键是每次预占请求都带全局唯一的 reservationId,库存服务持久化预占记录并维护状态(INIT → RESERVED → CONFIRMED 或 INIT → RESERVED → RELEASED,两个终态互斥)。库存服务处理请求时先按 reservationId 查重:已预占成功的重复请求直接返回既有结果,而不是再扣一次库存。订单服务重启恢复后,应该先拿 reservationId 去查预占状态,而不是直接发起新的预占。防长期占用靠 expire_at 加定时扫描,把超时仍为 RESERVED 的记录释放;防重复释放靠条件状态迁移(UPDATE ... SET status='RELEASED' WHERE reservation_id=? AND status='RESERVED',影响行数为 0 就说明已被处理过)。订单确认和超时释放必须竞争同一条记录的状态转换,否则会出现「库存刚被释放、订单又确认成功」的竞态。最终一致性靠持久化预占 + 唯一业务标识 + 幂等状态迁移 + 超时补偿 + 定期对账,而不是指望一次 RPC 必然成功。
CAS 保证的是单点原子性,不是业务不变量。 典型反例是一个账户有 balance 和 frozenAmount 两个字段,业务要求 balance >= frozenAmount;两个线程各自用 CAS 更新其中一个字段,每个 CAS 都成功,但中间任意时刻这个不变量可能已被破坏——因为两个独立的 CAS 无法组成一个原子事务。另外 CAS 只比较某个变量的值,判断不了「这个值背后的业务前提是否还成立」。正确做法是把相关状态封装成一个不可变对象,对整个对象做 CAS(用版本号或对象引用),让一次比较交换覆盖全部相关状态;如果状态落在数据库或多服务共享,就要靠条件更新、乐观锁版本号(UPDATE ... SET balance=?, version=version+1 WHERE id=? AND version=?)或分布式事务协议。要点是版本号必须绑定完整的业务状态,不能只给一个字段加版本控制而让其他字段绕过它。
「走了索引还是慢」要按三层排查。 先看索引扫描量:EXPLAIN ANALYZE 对比估算行数与实际行数,差距大就说明优化器统计信息失真(可能是没及时 ANALYZE TABLE、数据倾斜、列之间存在相关性,或存在隐式类型转换/字符集不一致导致索引失效)。再看回表成本:索引扫描本身很快,但要取出 SELECT 里未覆盖的列就得回表做随机 I/O,行数一多就是瓶颈,解法是加覆盖索引或调整查询只取需要的列。最后看索引选择性:低选择性索引即使 type 显示 range/ref,也可能扫掉大量索引记录。区分方法很直接:先看扫描行数(判断选择性)、再看是否回表(看 Extra 有没有 Using index)、最后看估算与实际的偏差(判断统计信息)。FORCE INDEX 只适合做对照实验,不要在没验证数据分布稳定性的情况下固化到代码里。
RAG 离线高分上线没用,根因是把「语义相关」当成了「能支撑答案」。 用户问「某设备故障码在固件 V3.2 中的处理方案」,检索返回同设备 V2.8 的文档——Embedding 相似度很高,但旧版本文档根本支撑不了答案。重新设计要分两层:检索层除了 Recall@K、MRR、nDCG@K,必须增加「答案证据覆盖率」和「版本/型号等硬约束的匹配率」;生成层要评答案正确率、引用准确率、忠实度和拒答准确率。召回链路上把硬约束(版本、型号、时间有效期)从向量相似度里拿出来做元数据过滤,用 BM25 与向量混合召回 + RRF 融合,再上 cross-encoder 重排,最后做证据片段筛选并控制冗余(避免 Top-K 全来自同一文档的相邻 chunk)。测试集也要覆盖版本冲突、多文档联合推理、无答案、相似故障码干扰、过期资料这些困难样本,否则离线分永远是虚高的。
线程池任务堆积但 CPU 低、线程数上不去,最典型的原因是无界队列。 ThreadPoolExecutor 的扩容顺序是「核心线程未满先建线程 → 核心满了先入队 → 队列满了才扩到 maximumPoolSize」,所以用 LinkedBlockingQueue(默认容量接近无穷)时队列几乎永远不满,线程数就停在 corePoolSize,任务排着队等——此时 CPU 自然很低。另一类原因是工作线程长期卡在等待上(下游接口慢、数据库连接池耗尽、锁竞争、同步阻塞调用),线程看着「在忙」其实在阻塞。排查要一起看 getActiveCount()、getPoolSize()、getQueue().size() 和 jstack 的线程栈。设计上不能只看吞吐,还要结合任务耗时分布、外部资源容量、队列等待时间和拒绝策略——有界队列加合理的拒绝策略通常比无界队列安全。
Redis Cluster 主从切换丢写,根因是复制是异步的。 客户端写主节点,主节点本地执行完就返回成功,这次写入可能还没同步到副本;主节点此时故障,新主节点没有这部分数据,于是「写入成功」的响应和「数据不存在」同时为真。WAIT numreplicas timeout 可以要求至少 N 个副本确认复制进度,但它只提高确认程度、不把 Redis 变成强一致数据库:副本确认了也不等于落盘,故障切换仍可能丢;WAITAOF 解决的是持久化确认,同样替代不了共识协议。工程结论是按数据性质分层:订单支付这类权威状态必须落在 MySQL 等持久化存储,Redis 只承担缓存/加速;同时要在业务侧分清「缓存确认、复制确认、持久化确认、业务提交确认」是四件不同的事。
size() 不是快照,是因为它需要读多个计数单元,而读取过程本身不是原子的。 JDK 8 的 ConcurrentHashMap 用 baseCount 加 CounterCell[] 计数:竞争不激烈时 CAS 更新 baseCount,竞争激烈时按线程探针值把计数分散到不同的 CounterCell,避免所有线程抢同一个变量(和 LongAdder 的分段思想一致)。求和时要把 baseCount 和各 CounterCell 相加,但在遍历期间其他线程还在插入删除——即使每个单元的读取都线程安全,也无法保证所有单元对应同一个逻辑时刻,所以 size() 只能是近似值(弱一致)。另外 size() 返回 int,超过 Integer.MAX_VALUE 会截断,需要大容量时用 mappingCount()。由此引出的实践结论:不能用 if (map.size() < 1000) map.put(...) 做容量控制,因为检查与插入不是原子操作,要用外部同步、原子配额或专门的限流机制。
向量索引膨胀与召回同质化,要拆成「入库去重」和「结果多样性」两段。 入库阶段先做标准化(去掉模板字段、统一设备型号与故障码格式、规范化时间),完全相同的内容用文本 Hash 精确去重;语义近似的用 Embedding 相似度加元数据做候选判重——但不能仅凭向量相似就删,两条相似记录可能对应不同设备版本、不同根因或不同时间条件,更合理的是把它们组织成「故障案例簇」并保留原始数据的可追溯性。检索阶段如果直接按相似度取 Top-K,可能出现十条结果全来自同一簇,需要用 MMR 之类的重排(在相关性和已选结果的新颖度之间权衡)提升多样性,避免重复证据占满上下文窗口。还要设计增量索引更新机制来处理删除、版本替换和历史文档失效,并在索引规模、查询延迟、召回覆盖率、更新延迟之间做权衡。
HTTP/2 的多路复用解决的是应用层队头阻塞,不是传输层的。 HTTP/2 让多个逻辑 Stream 复用一条 TCP 连接,绕开了 HTTP/1.1 的请求排队;但 TCP 向上交付的是可靠且有序的字节流,一个报文段丢失时,后续已到达的字节也不能越过缺口交给应用层——所以同一条 TCP 连接上的所有 Stream 会一起被卡住,这就是传输层队头阻塞,弱网下尤其明显。HTTP/3 基于 QUIC(跑在 UDP 上),把可靠传输、拥塞控制和按 Stream 独立的数据交付做进了协议内部:某个 Stream 丢包不会阻塞其他已完整接收的 Stream 向应用层交付,同时还把 TLS 握手合并进连接建立,减少往返。但 HTTP/3 也不是万能的——多个 Stream 仍共享同一条连接的拥塞控制和带宽,严重丢包时整体吞吐依然会下降,只是不再互相拖死。