
用户反馈请求生产网关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 FluxDataBuffer 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:MaxDirectMemorySize1000m加上 -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 这种关键参数应该显式设置而不是依赖默认值。默认值在大多数场景下是够用的但网关这种重度依赖直接内存的组件不行必须自己兜住。