流式对话服务 OOM,堆转储里有 47 万个 ByteBuffer
十一月中旬的一个周一早上,告警炸了:智能客服服务的三个实例在 20 分钟内相继 OOM 重启。这个服务上线两个月一直很稳,堆 8 GB,平时使用率 40% 左右。
这篇是完整的排查过程。结论不复杂——流式响应没有正确关闭,但排查过程中有几个点值得记下来。
现象:内存是「阶梯式」涨上去的
先看监控曲线。这是重启前六小时的老年代使用率:
09:00 ████████░░░░░░░░ 41%
10:00 █████████░░░░░░░ 46%
11:00 ███████████░░░░░ 54%
12:00 ████████████░░░░ 58%
13:00 ██████████████░░ 67%
14:00 ████████████████ 78%
14:20 OOM, 进程重启
典型的内存泄漏特征:每次 Full GC 后低点也在不断抬高。我们把每次 Full GC 后的老年代占用记下来,是 2.1G → 2.9G → 3.6G → 4.4G → 5.2G,几乎线性增长,斜率约 620 MB/小时。
再看 QPS,这段时间一直是 180 左右,没有突增。所以不是流量问题,是每次请求都在漏一点。
620 MB/小时 ÷ 180 QPS ÷ 3600 秒 ≈ 每次请求泄漏 957 字节。这个数不大,但一直不释放就致命了。
堆转储:47 万个 ByteBuffer
我们在下一次 OOM 前手动抓了堆转储:
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 8 GB 堆,dump 出来 6.2 GB,耗时 41 秒,期间服务停顿
ls -lh /tmp/heap.hprof
# -rw------- 1 root root 6.2G Nov 18 14:03 /tmp/heap.hprof
用 Eclipse MAT 打开(需要给 MAT 配 -Xmx12g,否则打不开 6 GB 的文件)。Leak Suspects 报告直接给出了结论:
Problem Suspect 1
-----------------
471,043 instances of "java.nio.DirectByteBuffer",
loaded by "<system class loader>" occupy 3,414,229,016 bytes (61.24%)
Keywords: java.nio.DirectByteBuffer
47 万个 DirectByteBuffer,占了 3.4 GB。注意这是堆外内存——它计入堆转储是因为堆里的 DirectByteBuffer 对象引用了堆外的内存块。
这就解释了一个反直觉的现象:我们的堆是 8 GB,但容器 limit 是 10 GB,进程被 OOMKilled 时堆才用了 6.1 GB。多出来的 3.4 GB 是堆外。
点开 Dominator Tree 看引用链:
java.lang.Thread @ 0x7a1c00280 http-nio-8080-exec-42
└── io.netty.channel.DefaultChannelPipeline
└── ...
└── java.nio.DirectByteBuffer @ 0x7f2a10000
└── janitor (Cleaner)
└── reactor.netty.http.client.HttpClientOperations
引用链指向 Reactor Netty 的 HTTP 客户端。我们调用大模型 SDK 用的是 WebClient(底层 Reactor Netty),而 Reactor Netty 默认使用堆外内存做网络缓冲。
正常情况这些缓冲会被池化复用,不该有 47 万个。所以问题变成了:为什么连接/缓冲没有被归还给池?
根因:流式响应中断时没有关闭
我们出问题的代码长这样:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.stream()
.content(); // 返回 Flux<String>
}
看起来人畜无害。问题出在客户端断开连接时。
我们的场景里,用户经常在回答生成到一半时关掉页面、切换会话、或者刷新。这时下游(浏览器)断开,Spring WebFlux 会取消上游的 Flux 订阅。但取消信号能不能正确传导到 Reactor Netty 的连接层,取决于整条链路上有没有正确的 doOnCancel/doFinally 处理。
更麻烦的是我们中间还加了一层:为了做审计日志和 token 统计,包了一个 Flux 包装器:
// 问题代码
public Flux<String> withAudit(Flux<String> upstream, String sessionId) {
StringBuilder acc = new StringBuilder(); // 注意这个
return upstream
.doOnNext(chunk -> {
acc.append(chunk); // 累积全文
})
.doOnComplete(() -> {
auditLog.save(sessionId, acc.toString()); // 结束时落库
});
// 没有 doOnCancel,没有 doFinally
}
三个问题叠在一起:
- 客户端断开 → 上游 Flux 被取消 →
doOnComplete永远不触发。审计日志丢了(这是我们后来才发现的第二个 bug)。 acc这个StringBuilder被 lambda 捕获,而如果我们把这个 Flux 缓存在某个 map 里做会话管理,它就永远不释放。- 最关键的是,取消信号没有传导到 Reactor Netty 的 response 流,导致底层的连接和缓冲区没有被归还到连接池,堆外内存就一直挂着,等待 GC 触发 Cleaner 才回收。
第 3 点是核心。DirectByteBuffer 的回收依赖 Cleaner(虚引用机制),只有当堆内的 DirectByteBuffer 对象被 GC 掉之后,堆外内存才会被释放。而我们堆内存充足(使用率 40%),GC 不频繁 → Cleaner 不执行 → 堆外内存越积越多。这是个恶性循环。
我们验证了这一点:
# 手动触发一次 Full GC 后,堆外内存立刻掉下来
jcmd <pid> GC.run
# 堆外:3.4 GB → 0.4 GB
复现:写一个压测脚本
为了确认,我写了个模拟客户端中途断开的脚本:
#!/bin/bash
# 发起流式请求,随机在 0.5~3 秒后断开
for i in $(seq 1 500); do
timeout $(awk -v s=$RANDOM 'BEGIN{printf "%.1f", 0.5 + (s%25)/10}') \
curl -sN "http://localhost:8080/chat/stream?question=介绍一下JVM" > /dev/null
done
wait
跑 500 次中途断开的请求,然后用 NMT 看:
jcmd <pid> VM.native_memory summary scale=MB | grep -A3 "Other"
# 跑之前
- Other (reserved=1044MB, committed=412MB)
# 跑 500 次之后
- Other (reserved=1044MB, committed=988MB) # +576 MB
# 手动 Full GC 后
- Other (reserved=1044MB, committed=503MB) # 回落到接近初始值
500 次请求泄漏 576 MB,平均每次 1.15 MB。比生产环境的 957 字节大得多——因为压测用的问题会产生更长的回答(缓冲区更大)。数量级对上了,机制确认。
修复一:正确传播取消信号
public Flux<String> withAudit(Flux<String> upstream, String sessionId) {
// 用 AtomicReference 持有,不用 StringBuilder 直接捕获
AtomicReference<StringBuilder> acc = new AtomicReference<>(new StringBuilder());
return upstream
.doOnNext(chunk -> acc.get().append(chunk))
.doFinally(signalType -> {
// doFinally 覆盖 complete / error / cancel / onComplete 所有情况
String full = acc.get().toString();
switch (signalType) {
case ON_COMPLETE -> auditLog.completed(sessionId, full);
case CANCEL -> auditLog.cancelled(sessionId, full); // 之前完全丢失
case ON_ERROR -> auditLog.failed(sessionId, full);
}
acc.set(null); // 显式释放引用
});
}
关键改动是把 doOnComplete 换成 doFinally,并且处理 CANCEL 信号。doFinally 在流终止的任何情况下都会执行,这是 Reactor 里做清理的正确位置。
修复二:显式限制堆外内存 + 主动回收
不能只依赖 Cleaner。我们加了三层防护。
第一层,限制 Netty 的堆外内存使用上限:
// JVM 参数:限制直接内存,超了就主动触发 GC 而不是无限增长
-XX:MaxDirectMemorySize=1g
设了 MaxDirectMemorySize 之后,堆外内存到 1 GB 时 JVM 会自动触发一次 System.gc() 来促使其回收。这不能根治泄漏,但能把影响控制在可接受范围——相当于给泄漏加了个天花板。
注意:这个参数和
-XX:+DisableExplicitGC冲突。如果你为了禁用 RMI 的定时 GC 加了DisableExplicitGC,那么 Netty 触发的System.gc()也会失效。我们确实踩了这个,两个参数同时存在时堆外内存照样涨。
第二层,配置 Reactor Netty 的连接池和缓冲参数:
@Bean
public WebClient.Builder webClientBuilder() {
ConnectionProvider provider = ConnectionProvider.builder("llm-pool")
.maxConnections(200)
.maxIdleTime(Duration.ofSeconds(20)) // 空闲连接 20 秒回收
.maxLifeTime(Duration.ofMinutes(5)) // 连接最长存活 5 分钟
.pendingAcquireTimeout(Duration.ofSeconds(30))
.evictInBackground(Duration.ofSeconds(60)) // 后台定期清理
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.SO_KEEPALIVE, true)
.doOnChannelInit((obs, ch, addr) -> ...)
.responseTimeout(Duration.ofSeconds(60));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient));
}
maxIdleTime 和 evictInBackground 这两项让空闲连接能及时归还,是我们之前漏掉的(我们只配了 maxConnections)。
第三层,在应用层加超时兜底:
// 即使客户端一直连着,单次流也有上限
return chatClient.prompt().user(question).stream().content()
.timeout(Duration.ofMinutes(5)) // 5 分钟强制结束
.onErrorResume(TimeoutException.class, e -> Flux.just("\n[响应超时]"))
.doFinally(sig -> MeterRegistry.counter("chat.stream.end", "sig", sig.name()));
修复三:监控堆外内存
这个最重要。我们之前只监控了堆内存,对堆外完全没有可见性——这也是为什么泄漏跑了两个月才发现。
@Component
public class DirectMemoryGauge {
public DirectMemoryGauge(MeterRegistry registry) {
BufferPoolMXBean direct = ManagementFactory.getPlatformMXBeans(
BufferPoolMXBean.class).stream()
.filter(b -> "direct".equals(b.getName()))
.findFirst().orElseThrow();
Gauge.builder("jvm.direct.memory.used", direct, BufferPoolMXBean::getMemoryUsed)
.register(registry);
Gauge.builder("jvm.direct.memory.total", direct, BufferPoolMXBean::getTotalCapacity)
.register(registry);
}
}
告警规则:堆外内存连续 10 分钟超过 800 MB 就告警。按我们现在的配置(上限 1 GB),留了 200 MB 的处置窗口。
这个监控上线的第一周就抓到了另一个服务的类似问题——一个用 Netty 自研的 TCP 网关,堆外内存缓慢增长。以前完全没有发现。
修复效果
同样的压测脚本,跑 500 次中途断开:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 堆外内存增长 | +576 MB | +23 MB |
| Full GC 后回落 | 回落到 +91 MB | 回落到 +2 MB |
| 审计日志丢失率(取消场景) | 100% | 0% |
生产环境跑了两周,堆外内存稳定在 180~340 MB 区间,不再单调增长。老年代使用率稳定在 38~45%。
审计日志这一项是意外收获。我们原来以为用户中途断开的会话没记录是「正常」的,修复后才发现这部分占了 23% 的会话量——之前所有的会话分析都低估了中途放弃率。
顺着这个数字又查出一个问题:中途放弃的会话里,有 41% 是在收到前 20 个字内放弃的。这部分不是「用户觉得答案不好」,而是首字延迟太长(P95 是 3.4 秒)。后来我们把首包优化单独立项,现在是 1.9 秒。
几条经验
- Reactive 编程里,清理逻辑必须写在
doFinally而不是doOnComplete。doOnComplete在取消场景下永远不执行,这是最容易漏的一类 bug; - 堆外内存必须单独监控。堆内存 GC 正常不代表没有内存问题,尤其是用了 Netty、gRPC、RocksDB、或者大量
ByteBuffer.allocateDirect的服务; - 一定要设
MaxDirectMemorySize。默认是等于堆大小,意味着堆外可以涨到和堆一样大,很容易把容器撑爆。我们设成了堆的 1/8; - 检查
DisableExplicitGC。很多团队为了性能加了这个参数,但会让 Netty 依赖的System.gc()失效。如果要用 Netty,建议改用-XX:+ExplicitGCInvokesConcurrent而不是完全禁用(我们就是这么改的,堆外内存回收从「不回收」变成「并发回收,停顿 12ms」)。 - 流式接口的压测必须包含「客户端中途断开」场景。我们原来的压测全是完整跑完的请求,所以一直没发现。现在这个场景已经加进常规压测套件,占比设定为 20%(和生产的真实放弃率接近)。
留个问题
关于《一次 LLM 流式调用导致的内存泄漏》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。