当前位置: 首页 > 后端技术 > Java

记得一个WebFlux应用内存泄漏排查

时间:2023-04-01 15:10:28 Java

背景公司项目中有一个服务,类似于爬虫。它需要解析给定的URL,并从返回的HTML中提取页面的标题、封面图片、摘要、图标等信息。由于这是一个没有DB访问的纯内存服务,下游服务(需要解析的URL地址)也不是内部服务,所以不需要考虑并发压力。构建服务时选择WebFlux作为web层框架,选择Spring的WebClient作为请求下游服务HTTP客户端。服务部署在k8s容器中,JDK版本为OpenJDK11,Pod配置为4C4G,Java服务配置最大堆内存2G。问题描述服务上线后,请求压力并不大,但是运行时间长了,服务堆内存占用达到99%,监控日志报大量OOM错误,然后容器Pod重新启动。重启后可以正常工作一段时间,然后堆内存再次占用99%,出现OOM错误。解决过程初步分析通过容器监控,查看机器在Pod重启前一段时间的内存使用图,发现该图呈持续上升趋势,达到堆内存分配上限后Pod重启.初步推测是发生了内存泄漏。使用jmap-histo:live1查看存活对象的分布情况。发现byte数组占用内存大,PoolSubpage对象数量也多。怀疑netty有内存泄漏。查看ELK中的ERROR日志。除了OOM错误外,还发现了少量的netty错误信息。异常堆栈如下:LEAK:ByteBuf.release()在被垃圾回收之前未被调用。更多信息见https://netty.io/wiki/reference-counted-objects.html最近访问记录:创建于:io.netty.buffer.PooledByteBufAllocator.newHeapBuffer(PooledByteBufAllocator.java:332)io.netty.buffer.AbstractByteBufAllocator.heapBuffer(AbstractByteBufAllocator.java:168)io.netty.buffer.AbstractByteBufAllocator.heapBuffer(AbstractByteBufAllocator.java:159)io.netty.handler.codec.compression.JdkZlibDecoder.decode(JdkZlibDecoder.java.:18)493)io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:432)...从异常信息可以看出,netty的堆内存ByteBuf没有释放就被GC回收了,netty使用内存池用于堆内存管理。如果ByteBuff在没有调用release()方法的情况下被GC回收,内存池中大量内存块的引用计数无法归零,内存无法回收。并且ByteBuf被GC回收后,应用程序不能再调用release()方法,从而导致内存泄漏。出现定位问题的地方项目中使用netty的地方有:Redisson、WebFlux、WebClient。考虑到第三方库已经很成熟,已经在很多商业项目中得到应用,问题出现在库代码中的可能性不大,可能是你的使用方式不对。应用中使用的主要代码是WebClient,用于请求第三方页面HTML。在业务使用场景中,需要读取ResponseHeader和ResponseBody两部分。Header用于从Content-Type解析编码;body用于直接读取二进制数据,判断页面真正的编码格式。之所以需要判断页面的真实编码格式是因为在某些第三方页面上,通过响应头中的Content-Type声明了编码格式为UTF-8,但实际编码格式是GBK或者GB2312,导致解析中文摘要时出现乱码。因此,在读取二进制流后,需要根据流的内容来判断真正的编码格式。写过爬虫的兄弟应该明白。WebClient提供了以下几种获取Response的方法:WebClient.RequestHeadersSpec#retrieve可以直接将body作为指定类型的对象进行处理,但不能直接操作response;WebClient.RequestHeadersSpec#exchange可以直接操作response,但是body读取操作需要自己处理;为了满足需求,项目中使用了WebClient.RequestHeadersSpec#exchange方法,这也是项目中唯一可以直接操作ByteBuf数据的地方。使用该方法时,只进行数据读取操作,不释放body。在方法的注释中,只有这么一段话:NOTE部分的翻译大意是:不同于retrieve(),在使用exchange()时,无论在任何情况下(成功、异常、不可处理的数据)等),应用程序应该消费响应内容。否则可能会导致内存泄漏。有关使用正文的可用方法,请参阅ClientResponse。您通常应该使用retrieve()除非您有充分的理由使用exchange(),它允许您检查响应状态和标头,然后使用它来决定是否以及如何使用正文。但是当某些业务校验失败时,比如Content-Type标识的返回数据不是HTML内容,应用代码不消费body就直接返回,造成内存泄漏。//请求代码示例WebClient.builder().build().get().uri(ctx.getUri()).headers(headers->{headers.set(HttpHeaders.USER_AGENT,CHROME_AGENT);headers.set(HttpHeaders.HOST,ctx.getUri().getHost());}).cookies(cookies->ctx.getCookies().forEach(cookies::add)).exchange().flatMap(response->{//再次检查是否超时//注意这里直接返回Mono.error,没有释放响应if(ctx.isParseTimeout(PARSE_TIMEOUT)){returnMono.error(ReadTimeoutException.INSTANCE);}//先解析重定向,那里是没有replayOrientationparsesbodyreturnjudgeRedirect(response,ctx).flatMap(redirectTo->followRedirect(ctx,redirectTo)).switchIfEmpty(Mono.defer(()->Mono.just(parser.parse(ctx)))).map(LinkParseResult::detectParseFail);})解决问题问题原因已经定位,官方文档给出了解决方案。消费body的方法可以参考ClientResponse。在ClientResponse接口的注释中,列出了所有用于消费Response的方法:各个方法的作用不再详述。根据业务场景,当body不需要被消费释放时,应该调用releaseBody()方法。修改后的代码如下://请求代码示例WebClient.builder().build().get().uri(ctx.getUri()).headers(headers->{headers.set(HttpHeaders.USER_AGENT,CHROME_AGENT);headers.set(HttpHeaders.HOST,ctx.getUri().getHost());}).cookies(cookies->ctx.getCookies().forEach(cookies::add)).exchange().flatMap(response->{//再次检查超时并释放响应if(ctx.isParseTimeout(PARSE_TIMEOUT)){returnresponse.releaseBody().then(Mono.error(ReadTimeoutException.INSTANCE));}//先解析重定向,如果没有重定向,解析body返回judgeRedirect(response,ctx).flatMap(redirectTo->followRedirect(ctx,redirectTo)).switchIfEmpty(Mono.defer(()->Mono.just(parser.parse(ctx))))).map(LinkParseResult::detectParseFail);})小结在使用响应式HTTP客户端WebClient时,通过exchange()方法接受响应数据,但有些地方并没有调用ClientResponse#releaseBody()方法进程分支,导致大量数据无法释放,netty内存池满,后续请求申请内存上报OOM异常的经验教训:在使用不熟悉的第三方库时,一定要阅读方法注解和类注解。参考文档:NettyMemoryLeakTroubleshootingWebonReactiveStack