Administrator
发布于 2025-11-30 / 2775 阅读
62

一次 LLM 流式调用导致的内存泄漏

流式对话服务 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
}

三个问题叠在一起:

  1. 客户端断开 → 上游 Flux 被取消 → doOnComplete 永远不触发。审计日志丢了(这是我们后来才发现的第二个 bug)。
  2. acc 这个 StringBuilder 被 lambda 捕获,而如果我们把这个 Flux 缓存在某个 map 里做会话管理,它就永远不释放。
  3. 最关键的是,取消信号没有传导到 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));
}

maxIdleTimeevictInBackground 这两项让空闲连接能及时归还,是我们之前漏掉的(我们只配了 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 而不是 doOnCompletedoOnComplete 在取消场景下永远不执行,这是最容易漏的一类 bug;
  • 堆外内存必须单独监控。堆内存 GC 正常不代表没有内存问题,尤其是用了 Netty、gRPC、RocksDB、或者大量 ByteBuffer.allocateDirect 的服务;
  • 一定要设 MaxDirectMemorySize。默认是等于堆大小,意味着堆外可以涨到和堆一样大,很容易把容器撑爆。我们设成了堆的 1/8;
  • 检查 DisableExplicitGC。很多团队为了性能加了这个参数,但会让 Netty 依赖的 System.gc() 失效。如果要用 Netty,建议改用 -XX:+ExplicitGCInvokesConcurrent 而不是完全禁用(我们就是这么改的,堆外内存回收从「不回收」变成「并发回收,停顿 12ms」)。
  • 流式接口的压测必须包含「客户端中途断开」场景。我们原来的压测全是完整跑完的请求,所以一直没发现。现在这个场景已经加进常规压测套件,占比设定为 20%(和生产的真实放弃率接近)。

留个问题

关于《一次 LLM 流式调用导致的内存泄漏》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考