小红书 PE 一面面经:检索链路与并发基础
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 自我介绍。
- 介绍一下实习项目。
- MQ 消息积压怎么处理?
- 从向量检索转为混合检索后,召回准确率提升多少?
- 如果某一次请求召回准确率暴跌,你们有做排查的链路吗?可能是哪一块的问题?
- 如果向量数据库数据量很大的话,怎么提高检索效率?
- HashMap 中遇到过把 id 加一个对象做成自定义 key 吗?讲一下 HashMap 吧。
- 如果两个线程访问同一个没有加任何限制的变量,会出现什么问题?
- 如何解决?除了加 synchronized 锁之外还有什么别的方法吗?
- Atomic 是不是可以解决?能用 CAS 或者 volatile 关键字解决吗?
- Java 中的垃圾回收讲一下。
- 如果一个服务的接口响应时间特别久,你怎么排查是哪里出了问题?
- 你在实习中实际遇到过这种情况吗?可以从一个服务的执行链路上讲讲。
- 之后是打算直接就业还是读博?
- 手撕:实现 LRU。
- 反问:针对面试以及岗位要求,我需要提升的地方有哪些?
- 反问:岗位是 PE,那么职责和部门的业务方向是什么?
《参考解析》
MQ 消息积压
先看是「消费能力不足」还是「消费卡住」,两者的处理方式完全不同。如果监控显示消费者还在推进、只是速率跟不上,说明是短时流量高峰,先扩容消费者实例(注意实例数不能超过分区或队列数,否则多出来的实例分不到消息),再临时提高单批拉取条数和并发线程数。如果消费位点长时间不动,多半是某条消息一直处理失败在重试:定位到那条消息,把它投到死信队列或单独的错误 topic,先让主链路恢复。长期方案要回到根因——生产者侧限流或合并消息、消费逻辑里把同步调用的外部接口改成批量或异步、分区键重新设计避免热点分区。还要注意积压恢复期的顺序问题,如果业务依赖顺序,扩容消费实例会破坏同一分区内的顺序。
召回准确率暴涨暴跌怎么排查
要有一条可观测的链路,而不是靠感觉。第一层看数据:离线评测集上的召回率、MRR 有没有同步变化,如果离线也掉了就是检索侧的问题,离线正常而线上掉就要怀疑流量分布变了。第二层拆链路:查询改写有没有改写错(把关键实体改没了)、embedding 服务有没有换模型或超时降级、向量库有没有发生分片迁移或索引重建、过滤条件(时间、类目、权限)有没有把结果筛掉、融合排序的权重有没有被人改过。第三层看口径:准确率是怎么算的,样本量够不够,是不是分母变小导致的统计波动。把每一层的输入输出都落日志并带上 traceId,才能在出问题的那一刻定位到具体哪一段。
向量库数据量很大时提高检索效率
几个方向可以叠加。索引层面用近似最近邻(HNSW、IVF-PQ)替代暴力检索,把复杂度从线性降到近似对数,代价是牺牲一点召回;HNSW 调大 efSearch 提召回、调小降延迟,IVF 通过 nprobe 控制扫描的倒排桶数量。分片层面把向量按业务维度(类目、时间、租户)水平切分,查询时只打相关的分片,避免全库扫描。存储层面做量化压缩(PQ、SQ)把内存占用降下来,或者在 SSD 上做磁盘索引。查询层面做粗排到精排的两级漏斗,先用便宜的向量粗筛出几百条,再用精排模型算;同时把热门查询的结果缓存起来。元数据过滤要尽量前置到向量检索阶段,而不是检索完再过滤,后者会白算大量无效候选。
HashMap 与自定义 key
HashMap 是数组加链表加红黑树:hash 值经过扰动函数(高 16 位异或低 16 位)后对数组长度取模定位桶,桶内先用链表,链表长度到 8 且数组长度到 64 时转红黑树,退化到 6 时转回链表。默认容量 16,负载因子 0.75,元素数超过阈值就扩容成两倍,扩容时会重新分配元素,JDK 8 用高低位拆分避免了 1.7 头插法导致的死循环。用自定义对象当 key 必须同时重写 equals 和 hashCode,否则两个「内容相同」的对象会落到不同桶里,get 拿不回来;如果 key 的字段可变,改完之后 hashCode 变了就再也找不到,所以 key 最好是不可变对象。另外要注意 key 很多时 hash 冲突严重会退化成链表,O(1) 变 O(n)。
无保护的共享变量与解决方案
两个线程读同一个变量、其中至少一个写,就会出现可见性和原子性两类问题:可见性上,一个线程改了值,另一个线程可能因为工作内存里的副本没刷新而读到旧值;原子性上,像 i++ 这种「读—改—写」三步操作会被交错执行导致丢更新;更极端的情况下还可能因为指令重排看到「半初始化」的对象。除了 synchronized,解法还有几种:用 volatile 解决可见性和禁止重排,但它不保证原子性;用 AtomicInteger 这类原子类,底层靠 CAS 自旋;用 LongAdder 在写竞争激烈时比 AtomicInteger 吞吐更高,代价是读的时候要汇总多个 cell;用 ReentrantLock 或读写锁做更细粒度的控制;再彻底一点,改成不可变对象或线程封闭,从设计上消灭共享状态。面试里如果问「CAS 能不能解决」,要答清它解决的是单个变量的原子更新,靠 Unsafe 的 compareAndSwap 指令,但会有 ABA 问题和自旋开销,ABA 用 AtomicStampedReference 加版本号来解。
Java 垃圾回收
判断对象是否存活用可达性分析,从 GC Roots(栈帧里的局部变量、静态变量、常量、JNI 引用)出发,不可达的对象才回收,而不是引用计数,因为引用计数解决不了循环引用。堆按分代组织,新生代用 Eden 加两块 Survivor 的复制算法,Minor GC 时存活对象在两个 Survivor 之间来回搬,年龄到阈值(默认 15,动态调整)晋升老年代;大对象直接进老年代避免频繁复制。老年代用标记整理或标记清除。常见回收器按停顿目标分:Serial 单线程、ParNew 配合 CMS、Parallel 追求吞吐、CMS 用并发标记清除换低停顿但有碎片和 concurrent mode failure、G1 把堆切成 Region 按回收收益排序、ZGC 和 Shenandoah 走染色指针与读屏障做到停顿与堆大小基本无关。实际发生频繁 Full GC 时,先看是不是内存泄漏、大对象分配过快、或者元空间被动态代理撑爆。
接口响应时间变长的排查
从外往里、从大到小逐层缩小范围。第一步确认范围和形态:是所有接口都慢还是某一个接口,是突然变慢还是缓慢劣化,是全部实例慢还是个别实例慢——个别实例慢优先怀疑那台机器的宿主机、网络或磁盘。第二步看指标分层:监控上的 CPU、内存、GC 次数和停顿、线程池活跃数与队列长度、数据库连接池等待、下游接口的 RT 和错误率、慢 SQL 日志。如果线程池队列堆积、CPU 不高,多半是卡在 IO 或锁上;如果 GC 停顿变长,就是内存问题。第三步做链路追踪:按 traceId 把一次请求的每段耗时打出来,找出占比最大的那一段,再进到那一段看具体是慢 SQL、锁竞争、还是下游超时。临时手段可以限流、降级、扩容或者重启,但根因要落到具体的变更上——常见的触发点是某次发布、某个索引失效、缓存大面积过期或者流量模型变了。