资讯动态

SpringCloud Gateway堆外内存溢出排查:Netty直接内存泄漏定位与调优

发布时间:2026/10/19 17:07:34 来源:尧图企业网站定制
1. 从一次线上告警说起堆外内存溢出到底有多隐蔽那天凌晨两点多监控面板突然弹出一连串红色告警网关节点在十分钟内连续重启了三次。登录机器翻看日志满屏都是java.lang.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory。这个报错和常见的堆内存溢出长得不一样它指向的是 JVM 堆外的那块直接内存区域。很多同学第一次遇到这个错会本能地去调大-Xmx结果发现毫无作用因为方向从一开始就错了。SpringCloud Gateway 作为微服务架构里的流量入口底层跑的是 Spring WebFlux 加 Netty 的响应式模型。Netty 在处理网络 IO 时为了减少数据在堆内堆外之间的拷贝次数大量使用ByteBuf这类直接内存缓冲区。请求体、响应体、连接缓冲区统统走的是堆外内存。这就意味着网关这个组件的内存画像和传统的 Servlet 应用完全不同——堆内存可能很平稳但堆外内存却在悄悄膨胀直到某个临界点突然爆掉。这篇文章适合两类人看一类是正在用 SpringCloud Gateway 做网关、已经被这个报错折腾过的后端开发另一类是想提前搞清楚响应式网关内存模型、避免以后踩坑的架构同学。我会把整个排查链路完整还原出来包括一开始怎么误判、中间怎么用工具定位、最后怎么从代码和参数两个层面把问题摁住。整个过程不涉及任何特殊网络环境纯粹是 JVM 和框架层面的技术问题。需要先建立一个基本认知OutOfDirectMemoryError和OutOfMemoryError: Java heap space是两码事。前者说的是直接内存这块内存不受-Xmx控制默认情况下它的大小约等于-Xmx的值但可以通过-XX:MaxDirectMemorySize单独指定。当 Netty 申请直接内存缓冲区时如果剩余额度不够就会抛出这个错误。理解这一点后面的排查才有方向。2. 先搞清楚 Netty 的直接内存是怎么被吃掉的2.1 ByteBuf 的分配与释放机制Netty 里的数据载体是ByteBuf它分两种堆缓冲区HeapByteBuf和直接缓冲区DirectByteBuf。网关场景下Netty 默认优先使用直接缓冲区因为数据最终要经过 Socket 发送直接内存可以避免一次从堆内到堆外的拷贝。每次有请求进来Netty 会从内存池里分配一块ByteBuf给这个连接用请求处理完这块内存需要被归还到池子里。问题就出在归还这个动作上。Netty 采用的是引用计数reference counting机制每个ByteBuf有一个refCnt。分配出来时计数为 1每次retain()加 1每次release()减 1减到 0 才会真正回收。如果某条代码路径上漏掉了一次release()这块内存就永远不会被归还池子里的可用内存越来越少最终耗尽。在 SpringCloud Gateway 里请求体和响应体的读写都封装在框架内部正常情况下框架会帮你管理好引用计数。但一旦你写了自定义的GlobalFilter或者GatewayFilter手动去操作ServerWebExchange里的DataBuffer就很容易破坏这个平衡。这是后面排查的重点方向之一。2.2 内存池化带来的假象Netty 默认开启了池化分配器PooledByteBufAllocator。池化的好处是分配和回收效率高但它也带来一个排查上的麻烦内存被池子持有即使业务上已经不再使用从操作系统层面看这块内存也没有立刻还给系统。所以你用top或者free看机器内存可能一直居高不下但这不一定是泄漏可能只是池子还没释放。真正判断是否泄漏要看的是已分配但未归还的缓冲区数量是否持续增长。这就需要用 Netty 提供的泄漏检测工具或者通过 JMX 指标来观察。单纯看进程的 RSS 内存很容易被误导把正常的池化行为当成泄漏来处理白白浪费时间。2.3 网关场景下直接内存的几个主要消耗方在网关这个特定场景里直接内存的消耗主要来自这么几块一是每个连接对应的读写缓冲区二是请求体和响应体的聚合缓冲区三是 SSL/TLS 握手时的加密缓冲区如果开启了 HTTPS。其中第二块是最容易出问题的因为网关经常需要读取完整请求体做鉴权、限流、日志记录一旦用了DataBufferUtils.join()把请求体聚合成一个大的DataBuffer这块内存就完全由业务代码负责释放了。我见过不少项目在过滤器里把请求体读出来做签名校验读完就忘了释放每个请求泄漏一点QPS 一上来几小时就能把直接内存吃光。这种泄漏的特点是增长曲线很平滑和流量成正比重启后恢复流量上来又复发非常有规律。3. 排查工具链从现象到根因的完整路径3.1 第一步确认是直接内存而非堆内存拿到OutOfDirectMemoryError之后第一件事是确认堆内存是否正常。用jstat -gc pid 1000观察几轮如果老年代和新生代都没有持续增长Full GC 后也能回落那基本可以排除堆内存问题。同时用jcmd pid VM.native_memory summary看 JVM 的本地内存分布重点关注Internal和Other这两块直接内存通常会计入其中。如果 JVM 启动时加了-XX:NativeMemoryTrackingdetail还能看到更细的分配来源。不过这个参数有性能开销生产环境一般不开可以在预发环境复现时打开。另一个更直接的办法是加-XX:MaxDirectMemorySize并配合-XX:HeapDumpOnOutOfMemoryError虽然直接内存溢出不会生成堆转储但至少能确认溢出时的内存上限。3.2 第二步用 Netty 泄漏检测定位未释放的缓冲区Netty 内置了泄漏检测机制通过-Dio.netty.leakDetection.levelparanoid可以开启最严格的检测级别。这个级别下每次ByteBuf被 GC 回收时如果发现引用计数不为 0就会打印一条详细的泄漏报告包含分配时的堆栈信息。这条堆栈就是破案的关键它能直接告诉你这块内存是在哪行代码分配的。需要提醒的是paranoid级别对性能有影响只适合在预发或压测环境短期开启。生产环境可以用simple级别它只采样 1% 的缓冲区开销小很多但也能捕获到持续泄漏的场景。开启方式是在启动参数里加-Dio.netty.leakDetection.levelsimple或者通过代码ResourceLeakDetector.setLevel()动态设置。3.3 第三步结合火焰图和堆外内存采样如果泄漏检测没有捕获到说明问题可能不在单个ByteBuf的引用计数上而是整体分配速率超过了回收速率。这时候可以用async-profiler做一次堆外内存分配采样命令是./profiler.sh -e alloc -d 60 -f alloc.html pid。生成的火焰图里能看到哪些调用路径在疯狂申请直接内存。我实际排查时遇到过一个案例火焰图显示大量分配来自SslHandler的握手缓冲区。原因是网关开启了 HTTPS但客户端连接池配置不合理导致频繁重建连接每次重建都要分配 SSL 缓冲区。这种情况不是泄漏而是分配压力过大解决思路是优化连接复用而不是去查引用计数。3.4 第四步观察指标确认泄漏还是压力区分泄漏和压力过大有个简单办法看重启后的表现。如果是泄漏重启后内存会从低位开始缓慢爬升爬升速度和请求量正相关最终触顶如果是压力过大重启后很快又会触顶甚至压测时几秒就爆。另外可以观察PooledByteBufAllocator的 JMX 指标重点看direct.arenas下各区域的chunkSize和used变化如果used只增不减基本可以确定是泄漏。4. 代码层面的几个高频泄漏点与修复方案4.1 自定义过滤器里忘记释放 DataBuffer这是最常见的一种。看下面这段有问题的代码Component public class AuthFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return DataBufferUtils.join(exchange.getRequest().getBody()) .flatMap(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); // 这里读完就返回了没有 release String body new String(bytes, StandardCharsets.UTF_8); if (!checkSign(body)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); }); } }DataBufferUtils.join()返回的DataBuffer是池化的用完必须调用DataBufferUtils.release(dataBuffer)。正确写法是在flatMap里用try-finally或者doFinally确保释放return DataBufferUtils.join(exchange.getRequest().getBody()) .flatMap(dataBuffer - { try { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); String body new String(bytes, StandardCharsets.UTF_8); if (!checkSign(body)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } finally { DataBufferUtils.release(dataBuffer); } });这里有个细节要注意DataBufferUtils.join()之后原始的请求体已经被消费掉了下游过滤器再想读就读不到了。如果确实需要下游也能读得用exchange.getAttributes()把读出来的字节缓存起来或者用ServerHttpRequestDecorator包装一个可重复读的请求。但包装的时候同样要注意装饰器里的DataBuffer也要正确释放。4.2 响应体改写时的缓冲区管理改写响应体比读请求体更麻烦因为响应是流式的你得先拦截、缓存、修改、再写回。很多实现会用ServerHttpResponseDecorator在writeWith方法里做文章。这里如果处理不当很容易出现缓冲区没释放或者重复释放的问题。一个稳妥的做法是在装饰器里用DataBufferFactory重新分配一块缓冲区来存放修改后的内容原来的缓冲区在读取完后立即释放。写回时用response.writeWith(Mono.just(newBuffer))让框架负责新缓冲区的释放。切记不要直接把读到的DataBuffer原样返回那样引用计数会乱掉。4.3 限流和熔断组件对缓冲区的隐式持有有些限流实现会基于请求体内容做规则匹配比如根据请求参数里的某个字段决定限流阈值。这类组件如果在内部缓存了请求体又没有设置合理的过期策略就会导致缓冲区被长期持有。排查时如果发现泄漏和特定接口相关可以重点检查这类组件。另外熔断降级返回兜底响应时如果兜底内容是通过读取原始响应体再拼接生成的同样要注意释放原始缓冲区。我建议在写这类组件时统一用一个工具方法封装读取并释放的逻辑避免每处都手写release导致遗漏。5. 参数调优给直接内存留出合理空间5.1 MaxDirectMemorySize 的设置逻辑默认情况下-XX:MaxDirectMemorySize等于-Xmx。假设你的网关容器给了 4G 内存-Xmx设了 2G那直接内存上限也是 2G加起来 4G再加上 Metaspace、线程栈、JVM 自身开销很容易超过容器限制被 OOM Killer 干掉。所以网关这类大量使用直接内存的应用必须显式设置这个参数。我的经验值是如果-Xmx设 2G直接内存可以设 1G 到 1.5G给堆外留出足够空间同时容器总内存要留 20% 以上的余量。具体数值要看压测结果观察稳定运行时的直接内存水位一般不要超过上限的 70%留出应对流量尖峰的缓冲。5.2 池化参数的调整Netty 的池化分配器有几个关键参数-Dio.netty.allocator.numDirectArenas控制直接内存的 arena 数量默认是 CPU 核数的 2 倍。arena 太多会导致内存碎片化每个 arena 都要预分配 chunk总占用反而上升。对于网关这种场景建议把这个值调到 CPU 核数或者更小比如 4 核机器设 4 到 8 就够了。-Dio.netty.allocator.chunkSize控制每个 chunk 的大小默认是 16MB。如果网关处理的请求体普遍较小可以适当调小 chunk减少浪费如果经常处理大文件上传下载则要保持较大 chunk 避免频繁分配。这个参数需要结合业务特征来定没有万能值。5.3 连接与超时参数的配合直接内存的消耗和连接数强相关。每个活跃连接都会占用读写缓冲区连接数越多缓冲区总量越大。所以maxConnections、pendingAcquireMaxCount这些参数不能拍脑袋设太大。同时空闲连接要及时回收idleTimeout设得太长会导致大量连接挂着占内存设得太短又会导致频繁重建连接增加分配压力。我一般会把空闲超时设在 30 到 60 秒之间配合合理的连接池上限让连接数稳定在一个可控范围。压测时重点观察连接数和直接内存的对应关系找到那个内存不涨、吞吐达标的平衡点。6. 几个容易误判的场景与我的实操心得6.1 把池化缓存当成泄漏前面提过Netty 池化分配器会缓存一部分内存不还给系统。有一次我在预发环境看到进程 RSS 一直维持在 3G 不降以为泄漏了折腾半天加泄漏检测结果什么都没抓到。后来才明白那是池子里的 chunk 被缓存着属于正常行为。判断标准应该是已分配未释放的缓冲区数量而不是进程 RSS。如果你确实想让池化内存在空闲时归还可以设置-Dio.netty.allocator.useCacheForAllThreadsfalse但这会降低性能一般不建议在生产开。更合理的做法是接受池化行为把监控重点放在缓冲区计数上。6.2 大请求体导致的瞬时峰值有时候不是泄漏而是某个时刻来了几个超大请求体比如批量导入接口一次传几十 MB网关聚合请求体时瞬间申请大量直接内存直接把上限打爆。这种情况的特征是溢出时间点和特定请求强相关日志里能查到对应的大请求。解决办法有两个方向一是限制单请求体大小在网关层加maxInMemorySize配置超过就拒绝二是对大请求体走流式处理不要用join()聚合边读边转发。SpringCloud Gateway 默认的maxInMemorySize是 256KB如果业务需要更大要显式调大但别调得离谱。6.3 压测环境复现不出来的坑有些泄漏只在生产的高并发下才出现压测环境怎么都复现不了。这通常是因为泄漏和并发竞争有关比如多线程环境下引用计数操作不是原子的或者某个异步回调在特定时序下才漏掉释放。遇到这种情况可以在生产用simple级别的泄漏检测长期挂着等它下次复现时抓现场。另外压测时要注意请求的多样性。如果压测只打一个简单接口可能刚好绕过了有问题的代码路径。我建议压测用例要覆盖鉴权、限流、响应改写这些复杂链路才能把潜在问题逼出来。6.4 升级框架版本带来的变化SpringCloud Gateway 和 Netty 的版本升级有时会改变内存管理行为。比如某个版本开始默认开启了更激进的池化策略或者修改了DataBuffer的默认实现。升级后如果突然出现直接内存问题先对比一下依赖版本的变化看看 release notes 里有没有相关改动。我遇到过升级后maxInMemorySize默认值变化导致行为不一致的情况排查时绕了弯路。建议在升级网关这类核心组件时先在预发跑一轮完整压测重点观察直接内存指标确认无异常再上生产。别小看这个步骤它能帮你省掉很多半夜被叫起来处理故障的时间。7. 一套可落地的监控与预防方案7.1 关键指标采集要提前发现问题得把几个核心指标纳入监控直接内存使用量通过BufferPoolMXBean获取、Netty 池化分配器的used和chunkSize、活跃连接数、请求体聚合次数。这些指标可以用 Micrometer 暴露到 Prometheus配上告警规则比如直接内存使用率超过 80% 持续 5 分钟就告警。BufferPoolMXBean是 JDK 自带的能直接拿到direct类型的内存池使用情况代码很简单ListBufferPoolMXBean pools ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); for (BufferPoolMXBean pool : pools) { if (direct.equals(pool.getName())) { long used pool.getMemoryUsed(); long capacity pool.getTotalCapacity(); // 上报 used 和 capacity } }7.2 泄漏检测的常态化生产环境长期开simple级别的泄漏检测开销可以忽略不计但能在泄漏发生时第一时间留下堆栈。配合日志采集一旦出现LEAK关键字就触发告警。这样即使问题在凌晨发生第二天也能拿着堆栈直接定位不用再靠猜。7.3 代码规范上的约束团队里最好定一条规矩任何手动操作DataBuffer的地方必须成对出现获取和释放并且用工具方法封装禁止裸写。Code Review 时把这块作为重点检查项。还可以写一个简单的静态检查规则扫描DataBufferUtils.join的调用点提醒开发者确认是否释放。7.4 容量规划的定期回顾业务在增长网关的流量特征也会变。建议每个季度回顾一次直接内存的水位和峰值看看当前参数是否还合适。特别是大促前要提前压测确认容量别等到流量洪峰来了才发现内存不够。容量规划这件事宁可提前做也别事后补。我在实际项目里把这套方案落地之后网关的直接内存问题从偶发故障变成了可观测、可预警、可定位的常规运维项。最明显的变化是再也没有因为这个问题半夜被叫起来过。踩过的那些坑最后都变成了监控面板上的几条曲线和告警规则这大概就是排查问题的意义所在——不是为了解决这一次而是为了让下一次不再发生。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑