干货分享Gateway踩坑实录:Netty直接内存把网关吃垮了OutOfDirectMemoryError

cad498be620027fd8eae53839d387e9f_wKDCqiJBG6LWQAAAABJRU5ErkJggg==

用户反馈请求生产网关springcloud gateway突然偶发性返回500异常,赶紧上线检查服务,发现有个别实例日志里开始零星出现 OutOfDirectMemoryError,频率还有逐渐升高的趋势,过不了一会服务基本不可用了,出现健康检测异常。完整报错信息是这样的:

io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 1006632967, max: 1013645312) =1G
        at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:667)
        at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:622)
        at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:772)
        at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:748)
        at io.netty.buffer.PoolArena.allocateNormal(PoolArena.java:245)
        at io.netty.buffer.PoolArena.allocate(PoolArena.java:227)
        at io.netty.buffer.PoolArena.allocate(PoolArena.java:147)
        at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:342)

大致意思是16MB 的分配请求被拒,此时直接内存已经用掉了 1006632967 字节(约 960MB),上限是 1013645312 字节(约 967MB)。Netty 在 Epoll 事件循环里试图给 TCP 连接分配接收缓冲区的时候,发现直接内存已经见底了,直接抛了异常。

这个问题影响的是我们的主网关。网关一旦挂掉,所有经过它转发的请求全断,影响面不小。

开始排查

看 JVM 参数

服务设置了堆内容参数:

-Xms1000m -Xmx1000m

但也只设了堆内存 1000m,对一般的服务这也没有什么问题,但对统一入口网关没有设置 -XX:MaxDirectMemorySize。这是个关键遗漏。JVM 在没显式设置这个参数的时候,会把 MaxDirectMemorySize 默认取 -Xmx 的值,也就是大约 1000MB。再扣掉 JVM 自身的一些开销,实际可用的直接内存大概 967MB,跟报错里的 max: 1013645312 完全对得上。

而我们K8s Deployment 里容器内存限制是 1500Mi,堆就吃了 1000MB,留给直接内存和其他 JVM 开销的空间本来就很紧张。

关于内存回收这一块直接内存Direct Memory是通过ByteBuffer.allocateDirect方法分配的,它与堆内存不同,直接内存不是由垃圾回收器GC管理的。直接内存的分配和释放是由操作系统的内存管理机制处理的,如果系统异常没有来得及回收,则直接内存将一直往上涨直到引起服务重启。

搞清楚直接内存被谁吃了

Spring Cloud Gateway 底层是 reactor-netty,所有的 HTTP 请求收发、连接管理都走的 Netty 的 PooledByteBufAllocator,也就是直接在堆外内存分配 ByteBuf。这是正常行为,网关天生就会大量使用直接内存。

但问题是,光 Netty 自身的 I/O 缓冲不至于把 967MB 吃光。真正的问题出在我们自己写的 Filter 里。

代码里面跟直接内存相关的定位到 GatewayContextFilter

这个 Filter 做的事情是缓存请求体——对 JSON 和 Form 类型的 POST 请求,把 Body 读出来存到 GatewayContext 里,方便后续的鉴权、签名校验、日志等 Filter 使用。

看 readBody() 方法的核心逻辑:

return DataBufferUtils.join(exchange.getRequest().getBody())
    .flatMap(dataBuffer -> {
        byte[] bytes = new byte[dataBuffer.readableByteCount()];
        dataBuffer.read(bytes);
        DataBufferUtils.release(dataBuffer);   // 这里释放了原始 buffer
        Flux<DataBuffer> cachedFlux = Flux.defer(() -> {
            DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
            DataBufferUtils.retain(buffer);    // 这里又 retain 了一个新 buffer
            return Mono.just(buffer);
        });
        // ... 用 cachedFlux 重建 request,继续走 filter chain
    });

这里有几个问题:

第一,没有大小限制。 不管请求体多大,都往里读。来个 50MB 的文件上传,也照读不误,直接就在直接内存里分配 50MB 的 ByteBuf。

第二,retain() 增加了引用计数但缺乏可靠的释放保障。 代码里确实 release了原始的dataBuffer,但后面 cachedFlux 里 retain() 出来的新 buffer,它的释放完全依赖下游 Filter 链正常消费。一旦下游出现异常、Hystrix 熔断超时,这个 buffer 就可能不会被释放。在高并发场景下,这些没释放的 buffer 会逐渐蚕食直接内存。

第三,readBody 的 flatMap 没有 onErrorResume 或 doFinally 兜底。 如果 DataBufferUtils.join() 过程中连接断开或者下游报错,中间态的 buffer 直接就成了孤儿。

自定义的AccessLogFilter 也可能存在问题

Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
return super.writeWith(fluxBody.buffer().map(dataBuffer -> {
    DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
    DataBuffer join = dataBufferFactory.join(dataBuffer);
    byte[] content = new byte[join.readableByteCount()];
    join.read(content);
    DataBufferUtils.release(join);
    responseBody.set(new String(content, StandardCharsets.UTF_8));
    return bufferFactory.wrap(content);
}));

这个 Filter 把响应体全部聚合到内存里做日志记录。bufferFactory.wrap(content) 用的是 response 的 bufferFactory,在 Netty 环境下这就是直接内存分配。而且 .buffer() 操作会把所有响应数据块攒到一起再处理,如果是大响应体直接就可能直接爆了。

解决方案

平时流量低的时候,这些问题被掩盖了。一旦并发上来,或者碰上几个大请求同时进来,直接内存就被打爆了。

赶紧止血:显式设置 -XX:MaxDirectMemorySize=1000m,加上 -XX:+UseG1GC。堆和直接内存隔离开,谁也别挤占谁的空间。K8s 容器内存限制从 1500Mi 提到 2500Mi,给 JVM 留足呼吸空间。

根治措施:在 GatewayContextFilter 里加了 MAX_CACHE_BODY_SIZE = 1MB 的门槛,超过 1MB 的请求体直接跳过缓存,记一行 warn 日志。同时对 readBody() 的响应式链路补了 doFinally 保障资源释放。AccessLogFilter 那边也做了相应的限制。

这次问题的反应了我们对 Netty 直接内存这个概念缺乏足够认知。写 Java 的人习惯了只关心堆内存,GC 日志一看没问题就觉得万事大吉。但在 Netty 体系下,直接内存是一个独立的、不受 GC 管控的内存区域,它的分配和释放需要开发者自己操心。而Spring Cloud Gateway 把 Netty 包了一层又一层,用起来是方便了,但底层 ByteBuf 的生命周期管理并没有被完全屏蔽掉。

另外就是 Filter 这种链式架构,写的时候只想着正向流程怎么跑通,对异常路径下的资源回收考虑不够。

还有一个教训是配置管理方面的。-XX:MaxDirectMemorySize 这种关键参数应该显式设置,而不是依赖默认值。默认值在大多数场景下是够用的,但网关这种重度依赖直接内存的组件不行,必须自己兜住。

posted @ 2026-08-25 07:42  欢醉  阅读(83)  评论(0)    收藏  举报