Doris FE OOM Heap Dump 分析报告
| 项目 | 内容 |
|---|---|
| 集群节点 | FE 172.4.8.10(MASTER) |
| Doris 版本 | doris-3.1.4-rc02 |
| JDK | 17.0.12(崩溃时)/ 17.0.17(现网) |
| 事故时间 | 2026-08-17 10:55:29 (+08:00) OOM,进程被 OnOutOfMemoryError=kill -9 杀死 |
| 堆配置 | 崩溃时 -Xmx32768m,重启后调整为 -Xmx49152m |
| Dump 文件 | /data01/doris/fe/log/java_pid72774.hprof(30,463,119,875 字节,545,178,838 个对象,写入耗时 98.7s) |
| 分析工具 | Eclipse MAT 1.16.1(服务器 /tmp/mat116,解析堆 80GB) |
一、结论速览
OOM 根因是 Env.aliveSessionSet(存活会话 ID 集合)泄漏:该集合堆积了 ~1.8 亿条会话 UUID 字符串,占满 32GB 堆的 ~98.5%。
- 触发线程:
all-fe-session-mgr-pool-0 - 触发点:
Env.getAllAliveSessionIds()→new ArrayList<>(aliveSessionSet)→Arrays.copyOf申请Object[180,221,098](1.44 GB 连续数组)失败 →java.lang.OutOfMemoryError: Java heap space - 泄漏主体:FE 内部高频创建 ConnectContext 的组件(统计任务、Routine Load、Group Commit、HTTP 接口、PLSQL、MV 等 40+ 处),以及
FrontendServiceImpl.forward()三参构造导致的设计性泄漏 - 与业务客户端基本无关:三个高流量用户(prod_cd_option_w / test_cd_option_w / prod_cd_zquity_track_w)即使所有连接全部泄漏,14 天也仅 ~11.6 万条,占泄漏总量 0.06%
- 增大堆至 48GB 只能延缓,不能根治;清理逻辑自身存在"整体拷贝"缺陷,集合增大后清理必然自爆,须代码级修复或升级
二、崩溃时间线(fe.out / fe.log / fe.gc.log 佐证)
| 时间 | 事件 |
|---|---|
| 2026-08-03 16:05:50 | FE 以 -Xmx32768m 启动(PID 72774) |
| 2026-08-17 02:10 | 日志尚正常(report-thread 常规 WARN) |
| 2026-08-17 ~09:18 | 开始频繁 Full GC;Full GC 后堆底 ~25GB(活对象不可回收,泄漏已达 ~25GB) |
| 2026-08-17 09:18–10:54 | Full GC 每 ~2 分钟一次,堆反复顶到 32GB;业务侧开始报错 |
| 2026-08-17 10:50–10:53 | thrift/mysql 大量异常:TThreadPoolServer Thrift Error、Null packet received from network、Socket is closed by peer;edit log insert 写 bdb 耗 19.3s、锁持有 18.4s(GC 停顿所致) |
| 2026-08-17 10:55:29 | java.lang.OutOfMemoryError: Java heap space,写 dump 98.7s 后 kill -9 |
| 2026-08-17 11:04:35 | FE 重启,-Xmx49152m |
辅助信号:fe.audit.log 中 Null packet received 由 8/16 全天 84 次激增至 8/17 的 1153 次(客户端 172.24.16.108 异常断连增多,系内存吃紧的并发症而非根因)。
三、MAT 分析结果
总使用堆 28GB / 5.45 亿对象。两个 Problem Suspect 指向同一个对象(Env.aliveSessionSet):
Problem Suspect 1 — 占堆 57.64%
- 180,268,901 个
java.lang.String= 17,305,670,752 字节(17.3 GB) - 内容清一色为 UUID 格式会话 ID(如
4041343b-f038-427d-a967-76570abb5b2e) - 其下挂 180,268,875 个
byte[](11.5 GB)——字符串底层数组 - 被一个
java.lang.Object[180,221,098] @ 0x14e4f8000000(1.44 GB)引用 —— 正是 OOM 时正在分配的那个数组
Problem Suspect 2 — 占堆 35.97%
- 一个
java.util.concurrent.ConcurrentHashMap$Node[268,435,456] @ 0x14e428000000= 10,800,711,176 字节(10.8 GB) - 即
aliveSessionSet的底层哈希表,内含 180,239,068 个ConcurrentHashMap$Node(8.65 GB) - 经
all-fe-session-mgr-pool-0线程栈上的ConcurrentHashMap$KeyIterator.toArray()关联
OOM 调用栈(MAT 还原)
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Object[], int) (Arrays.java:3481)
at java.util.concurrent.ConcurrentHashMap$CollectionView.toArray (ConcurrentHashMap.java:4471)
at java.util.ArrayList.<init>(Collection) (ArrayList.java:181)
at org.apache.doris.catalog.Env.getAllAliveSessionIds() (Env.java:7031)
at org.apache.doris.catalog.FESessionMgr$FEAliveSessionHandler.run() (FESessionMgr.java:94)
...
内存构成(泄漏占堆 ~98.5%)
| 组件 | 大小 | 占比 |
|---|---|---|
| 会话 UUID 字符串(String + byte[]) | 17.3 GB | 57.6% |
| aliveSessionSet 底层 CHM Node[] + Node | 10.8 GB | 36.0% |
| OOM 时分配的 Object[180M] 拷贝数组 | 1.44 GB | 4.8% |
| FE 正常工作对象 | ~0.4 GB | ~1.5% |
四、根因定位(反编译 doris-fe.jar 核实)
4.1 注册/注销机制
// Env.java:154 附近 – Guava 并发 Set(底层 ConcurrentHashMap)
private Set<String> aliveSessionSet = Sets.newConcurrentHashSet();
public void registerSessionInfo(String id) { aliveSessionSet.add(id); } // 唯一注册点
public void unregisterSessionInfo(String id) { aliveSessionSet.remove(id); } // 唯一注销点
public List<String> getAllAliveSessionIds() { return new ArrayList<>(aliveSessionSet); } // ★OOM 点
// ConnectContext.java
public ConnectContext(StreamConnection, boolean) {
...
init(); // 324: invokevirtual init() —— 构造函数即注册!
}
public void init() {
...
this.sessionId = UUID.randomUUID().toString();
if (!runningUnitTest) Env.getCurrentEnv().registerSessionInfo(sessionId); // 注册
}
public void cleanup() { ... unregisterSessionInfo(sessionId); }
protected void killConnection() { ... unregisterSessionInfo(sessionId); }
关键结论:new ConnectContext(...) 一出生就会在 aliveSessionSet 注册一条随机 UUID;只有走到 cleanup()/killConnection() 才注销。
4.2 注销路径核对(JDBC 连接的清理路径是完整的)
| 路径 | 是否调用 cleanup/注销 | 依据 |
|---|---|---|
| MySQL 客户端正常退出(COM_QUIT) | ✅ | ReadListener 正常分支:isKilled→stopAcceptQuery→cleanup→ConnectContext.remove |
| 客户端异常断连(peer 关 socket / null packet) | ✅ | ReadListener 异常分支:记录 Exception happened in one session(...) 后 setKilled→cleanup(8/17 的 1157 次异断均被清理) |
| 会话超时被 TimeoutChecker 杀 | ✅ | ConnectScheduler$TimeoutChecker→killConnection |
| KILL connection / 查询取消 | ✅ | ConnectProcessor→killConnection |
三参构造 ConnectContext(stream, bool, String) |
❌ 构造即泄漏 | init() 已注册随机 UUID,随后 sessionId 被覆盖;cleanup 只移除"覆盖后的 id",随机 UUID 成为永远清不掉的孤儿 |
FE 内部任务 new ConnectContext() 不做 cleanup |
❌ 视调用方而定 | 多个内部组件(见 4.3)未保证 cleanup |
4.3 三参构造漏洞(设计性泄漏)
唯一调用方:org.apache.doris.service.FrontendServiceImpl.forward()(FE→FE 转发,follower 把 master-only 操作转发给 MASTER):
2043: invokespecial ConnectContext."<init>":(Lorg/xnio/StreamConnection;ZLjava/lang/String;)V
// init() 注册 UUID_A
// 构造器尾部 putfield sessionId = 传入id
// cleanup() 时移除的是“传入id”,UUID_A 永久泄漏
4.4 内部 ConnectContext 创建点(jar 全量扫描,40+ 处)
调用频次较高/可疑的代表:
| 类 | 处数 | 说明 |
|---|---|---|
org.apache.doris.statistics.util.StatisticsUtil |
3 | 自动统计收集(ANALYZE 任务) |
org.apache.doris.service.FrontendServiceImpl |
3 | thrift 接口(含 forward 漏洞路径) |
org.apache.doris.load.routineload.RoutineLoadJob |
3 | Routine Load 调度 |
org.apache.doris.httpv2.rest.LoadAction |
2 | FE 侧 HTTP stream load 入口,类内无 cleanup |
org.apache.doris.httpv2.controller.BaseController |
2 | REST API 基类 |
org.apache.doris.load.StreamLoadHandler / GroupCommitManager / GroupCommitPlanner |
1/1/1 | Stream load / 分组提交 |
org.apache.doris.plsql.executor.* |
4 | PL/SQL 执行器 |
org.apache.doris.mtmv.MTMVPlanUtil |
1 | 物化视图 |
org.apache.doris.nereids.minidump.MinidumpUtils |
2 | Minidump |
org.apache.doris.qe.ConnectContextUtil、org.apache.doris.job.extensions.insert.InsertTask、org.apache.doris.load.ExportTaskExecutor 等 |
1–3 | 内部任务 |
4.5 清理逻辑的致命缺陷(放大器)
FESessionMgr.FEAliveSessionHandler(周期 alive_session_update_interval_second)在清理本节点会话前,必须先执行 getAllAliveSessionIds() —— 把整个 Set 整体拷贝成 ArrayList(O(n) 连续大数组)。
集合涨到上亿后:拷贝动作本身先 OOM → 清理线程永远无法执行 → 集合只增不减 → 必然崩溃。形成不可恢复的正反馈。
相关配置(fe.conf):
alive_session_update_interval_second # 同步/清理周期
fe_session_mgr_threads_num
fe_session_mgr_blocking_queue_size
4.6 压缩指针关闭(次要放大因素)
Dump 元信息 Compressed object pointers = false(32GB 堆边界触发 HotSpot 关闭压缩指针)→ 每个对象引用按 8 字节计,Object[180M] 拷贝体积翻倍(1.44GB vs 720MB),OOM 提前到来。
五、泄漏主体排查(为什么是内部组件而不是业务客户端)
5.1 数量级对不上(决定性)
8/3 16:05 重启 → 8/17 10:55 OOM(13.6 天),aliceSessionSet 累积 ~1.8 亿条 ≈ 153 条/秒:
| 指标(14 天合计) | 数量 | 折算速率 | 与泄漏量比 |
|---|---|---|---|
aliveSessionSet 累积 |
~180,000,000 | ~153/s | 100% |
| 审计 SQL(全部用户/全部语句) | ~3,400,000 | 2.8/s | <2% |
新建 JDBC 连接(握手查询 SELECT @@session...) |
~116,000 | 0.096/s | 0.06% |
| Stream load 事务(BE 端 begin/commit 指标) | ~60,000/BE | 0.05/s | 0.03% |
三个高流量用户(prod_cd_option_w / test_cd_option_w / prod_cd_zquity_track_w)占业务流量 ~95%,但即便其所有连接 100% 泄漏,也只占泄漏总量的 ~0.06%。
5.2 JDBC 连接的注销路径完整(见 4.2)
正常断开、异常断开、超时杀掉、KILL 均会 unregisterSessionInfo。8/17 激增的异常断连(Null packet ↑84→1153)有日志佐证均走了 cleanup。
5.3 Stream load 不产生 FE MySQL 会话
Stream load 走 HTTP 直达 BE(8040,FE 仅 307 重定向),不创建 9030 MySQL 协议会话,与本泄漏无关。BE 指标 14 天仅 ~6 万次。
5.4 内部组件是唯一可达 153/s 量级的来源
内部任务(统计、Routine Load、MV、PLSQL、REST/HTTP 等)创建 ConnectContext 不产生审计行,且部分路径(构造器即注册的默认行为 + 缺 cleanup + forward 覆盖 bug)可长期静默累积。
我的问题是,目前Doris3.1.4 fe节点是否发现类似内存泄露反馈?