面灵AI→

小红书中间件研发一面面经:Full GC 定位、RocketMQ 延时消息与线程池

轮次
一面
时间
2026-10
来源
牛客网

《面试题目》

临时约面,40 分钟飞速结束。

  1. 先简单做个自我介绍吧,几句话就行。
  2. 电商优惠券平台是你自己做的吗?再介绍一下。
  3. 你实习也是做中台支持服务吗?
  4. 你提到的 Full GC 问题,你是怎么定位的?
  5. CSV reader 的双重加载是什么意思?
  6. 我看是 800 多兆,你们那个实例有 1G 多吗?
  7. 是不是加点内存就好了?如果服务本来就需要很多内存,是不是就没什么优化空间了?
  8. 电商优惠券平台里用到了 RocketMQ?你对 RocketMQ 了解多少?大概说一下。
  9. 你对消息队列的理解是什么?
  10. 你写的延时消息是这里面的延时标记吗?延时消息的功能是什么?又是怎么实现的?
  11. 延时消息底层的数据结构你了解吗?是不是时间轮?
  12. 围绕分片键选择和数据倾斜问题,你做了哪些针对性设计?简单说一下。
  13. 数据倾斜你是怎么发现的?
  14. 那你是怎么解决的?
  15. Java 线程池也可以简单说一下它的原理。
  16. 你刚才提到核心线程数和最大线程数,提交一个任务时,这两个数字分别是什么时候起作用?
  17. 线程池里还有一种 ScheduledThreadPoolExecutor,也就是周期性线程池,你了解吗?

手撕:滑动窗口最大值

《参考解析》

Full GC 定位与「加内存能不能解决」。定位的顺序是先现象后根因:jstat -gcutil 看 Full GC 的频率与单次停顿,同时看每次回收之后老年代的底水位——底水位持续抬升说明有泄漏或缓存无上限,底水位能降下来但很快又涨满,说明是分配速率过高或大对象批量进入老年代。然后用 jmap 拿堆转储,在 MAT 里按支配树找真正的占用者。CSV 场景的「双重加载」通常有两类原因:一是把文件整体读进内存后又建了一份索引或映射结构,同一份数据在堆里存了两遍;二是框架的预加载与懒加载叠加,或者同一个 reader 被重复初始化。这类放大倍数才是要治的东西——治的方向是流式解析(按行或按块处理,不保留全量)、用基本类型数组代替包装类型集合、按需建索引、给缓存设容量与过期。至于「是不是加点内存就好了」,答案是否定的:加内存只是把 OOM 推迟,先要判断这是不是服务的合理占用。判断依据是把峰值拆成「必需的数据结构」与「可以避免的放大」,只要存在放大就还有优化空间;如果确实是流式处理大文件这类固有需求,那就该改架构(外存化、分片处理、独立进程或离线任务),而不是不断上调堆。

RocketMQ 与消息队列的价值。讲 RocketMQ 可以从「解决什么问题」切入:异步解耦(下游故障不阻塞主链路)、削峰填谷(把突发流量放进队列按能力消费)、最终一致性(事务消息把本地事务与消息发送绑定,失败可回查补偿)、以及顺序与延时这类业务语义。组件层面要点出 NameServer(路由注册与发现,无状态)、Broker(存储与投递,主从复制支持同步双写不丢)、Producer(发送,支持同步/异步/单向,事务消息)、Consumer(推拉两种模式、集群与广播、消费位点与重试)。可靠性要能讲清三个方向:生产者用同步发送加确认与重试,Broker 用同步刷盘与主从同步复制,消费者用消费成功后再提交位点;重复消费由业务幂等兜住(唯一键或状态机)。消息积压的处理思路也要有:先看是消费慢还是生产突增,临时扩消费者(注意队列数是并行度上限)、把非核心逻辑异步化、必要时把整批消息转存到新 topic 再慢慢消费。

延时消息的实现与时间轮。延时消息的功能是「消息投递后不立即被消费,而是在指定延迟之后可见」,典型用途是订单超时关闭、支付结果补偿、定时提醒。实现上要区分厂商版本:开源早期版本只支持固定档位的延时级别(默认 18 级:1s 到 2h),做法是把消息先写进对应级别的内部延时 topic,由定时任务扫描到期后转投到真实 topic;要做到任意时刻的延时,需要时间轮或定时器组件(新版与商业版才提供)。时间轮的原理是把时间轴切成若干槽位,指针按固定的 tick 推进,消息按到期时间放到对应槽位并挂成链表,指针走到哪个槽就把该槽的消息取出执行。单层时间轮在延时跨度大时会很占内存,所以实践中用分层(多级)时间轮:上层走一圈代表下层一大格,到期后逐级下沉,既能支持长延时又保证精度与内存。答题时的加分点是讲清工程约束:精度取决于 tick 大小与最大承诺误差、需要在内存中重建(重启后要能恢复,靠持久化或位点)、时间轮只是投递触发机制,业务侧仍要幂等,因为触发可能重复。

分片键选择与数据倾斜。分片键决定数据与流量怎么分布,选错的典型症状就是倾斜:某个分片队列堆积、某个库表的写入量是别人的几倍,整体吞吐被最慢的那一片拖住。选键的原则是「高基数、分布均匀、且查询能命中」——订单号、用户 ID 这类天然离散的键通常合适,用时间或地域做键要谨慎(热点集中)。倾斜的发现要靠数据说话:看各分片/队列的 TPS、堆积量、磁盘与 CPU 水位,直方图比平均值有用;再做热点 key 统计,看 topN 的 key 占比。解决的层次从便宜到贵:先是复合分片键或加盐(比如用户 ID 拼上 hash 后的小段,把热点人为打散,代价是查询要打多片),再是预分区并让生产者侧带路由(避免依赖中间件的自动分配),业务层可做热点 key 的本地缓存与异步合并写,最后才是扩容重分布。

线程池两个线程数的生效时机与 ScheduledThreadPoolExecutor。提交任务时的顺序是:当前线程数小于核心线程数就直接新建线程;否则尝试入队;队列满了且线程数小于最大线程数才继续新建;再满就走拒绝策略。所以核心线程数是「平时常驻的并发度」,最大线程数只有在队列被填满之后才起作用——用了无界队列它就永远不会生效。ScheduledThreadPoolExecutor 是定时与周期任务的实现:它用 DelayedWorkQueue(基于小顶堆的延时队列)保证队首最早到期,schedule 只执行一次,scheduleAtFixedRate 按固定频率(以开始时间为基准,任务执行超时会压缩间隔),scheduleWithFixedDelay 按固定间隔(以上次结束为基准)。两个高频坑必须记住:一是任务抛出的异常会终止后续周期执行且默认不打印堆栈,必须自己在任务体内 try/catch;二是它的最大线程数对定时任务基本无效(核心线程数才是关键),并且 shutdown 默认不取消已在等待的周期任务。

手撕滑动窗口最大值。暴力解法每次窗口扫 k 个元素是 O(nk),标准解法是单调队列 O(n):维护一个下标双端队列,遍历时先把队尾所有小于当前元素的下标弹出(它们不可能再成为后续窗口的最大值),再把当前下标入队,然后检查队首下标是否已经滑出窗口(小于 i-k+1 就弹出),从 i≥k-1 开始每次输出队首对应的值。要点是队列里存下标而不是值(否则无法判断过期),以及「先弹尾、再入队、再弹头、再取值」的顺序——顺序写反会出现窗口外的旧值或漏掉新最大值。面试写完后要能主动说清复杂度:每个下标最多进出队一次,时间 O(n),空间 O(k)。