小红书中间件研发一面面经:Full GC 定位、RocketMQ 延时消息与线程池
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
临时约面,40 分钟飞速结束。
- 先简单做个自我介绍吧,几句话就行。
- 电商优惠券平台是你自己做的吗?再介绍一下。
- 你实习也是做中台支持服务吗?
- 你提到的 Full GC 问题,你是怎么定位的?
- CSV reader 的双重加载是什么意思?
- 我看是 800 多兆,你们那个实例有 1G 多吗?
- 是不是加点内存就好了?如果服务本来就需要很多内存,是不是就没什么优化空间了?
- 电商优惠券平台里用到了 RocketMQ?你对 RocketMQ 了解多少?大概说一下。
- 你对消息队列的理解是什么?
- 你写的延时消息是这里面的延时标记吗?延时消息的功能是什么?又是怎么实现的?
- 延时消息底层的数据结构你了解吗?是不是时间轮?
- 围绕分片键选择和数据倾斜问题,你做了哪些针对性设计?简单说一下。
- 数据倾斜你是怎么发现的?
- 那你是怎么解决的?
- Java 线程池也可以简单说一下它的原理。
- 你刚才提到核心线程数和最大线程数,提交一个任务时,这两个数字分别是什么时候起作用?
- 线程池里还有一种 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)。