关于java:记一次WebFlux应用内存泄漏排查

42次阅读

共计 4000 个字符,预计需要花费 10 分钟才能阅读完成。

背景

公司我的项目存在一个服务,相似于爬虫,须要解析给定的 URL,从返回的 HTML 中提取页面的题目、封面图、摘要、icon 等信息。因为这是一个无 DB 拜访的纯内存服务,且上游服务(需解析的 URL 地址)并非外部服务,无需思考并发压力,在服务搭建时选用 WebFlux 作为 web 层框架,选用 spring 的 WebClient 作为申请上游服务的 HTTP 客户端。

服务部署于 k8s 容器内,JDK 版本为 OpenJDK11,Pod 配置 4C4G,Java 服务配置最大堆内存 2G。

问题形容

服务上线后申请压力不大,但长时间运行后,服务堆内存占用达到 99%,日志监控呈现大量 OOM 报错,继而容器 Pod 重启。重启后可失常工作一段时间,之后再次堆内存占用 99%,呈现 OOM 报错。

解决过程

初步剖析

通过容器监控,查看 Pod 重启前一段时间的机器内存占用图,发现图出现持续上升趋势,且达到堆内存调配下限后,Pod 产生重启。初步揣测是产生了内存透露。

应用 jmap -histo:live 1 查看存活对象散布,发现 byte 数组占用内存较多,且 PoolSubpage 对象数量也较多,狐疑是 netty 产生了内存透露。

排查 ELK 中的 ERROR 日志,除 OOM 报错外,另发现大量 netty 的报错信息,异样堆栈如下:

LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records: 
Created at:
    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:180)
    io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:493)
    io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:432)
    ...

从异样提示信息可见,netty 的堆内存 ByteBuf 在未被开释的状况下被 GC 回收,而 netty 应用内存池进行堆内存治理,如 ByteBuff 未通过 release() 办法调用即被 GC 回收,将导致内存池中大量内存块的援用计数无奈归零,导致内存无奈回收。且 ByteBuf 被 GC 回收后,应用程序曾经无奈再调用 release() 办法,即导致了内存透露。

定位问题呈现地位

我的项目中应用 netty 的中央有:RedissonWebFluxWebClient。思考到第三方库很成熟,通过很多商业我的项目利用,问题不太可能呈现在库代码中,可能是本人的应用形式有误。应用程序中本人编码应用的次要是WebClient,用于申请第三方页面 HTML。

业务应用场景中,须要读取 ResponseHeader 和 ResponseBody 两局部内容。Header 用于从 Content-Type 中解析编码;Body 用于间接读取二进制数据,确定页面真正的编码格局。

之所以须要确定页面真正编码格局,是因为有些第三方页面,response header 中通过 Content-Type 申明编码格局为 UTF-8,但真正的编码格局却是 GBK 或 GB2312,导致解析中文摘要时乱码。因而须要读取二进制流后,依据流内容判断实在编码格局。写过爬虫的兄弟应该了解。

WebClient提供了如下多个获取 Response 的办法:

  1. WebClient.RequestHeadersSpec#retrieve
    能够将 body 间接解决为指定类型的对象,然而无奈间接操作 response;
  2. WebClient.RequestHeadersSpec#exchange
    能够间接操作 response,但 body 的读取操作须要自行处理;

为满足需要,我的项目中应用了 WebClient.RequestHeadersSpec#exchange 办法,这也是我的项目中惟一一处能够间接操作 ByteBuf 数据的中央。在应用此办法时,仅进行了数据读取操作,并没有开释 body。而在办法的正文上,刚好有这么一段:

NOTE 局部翻译过去的大抵意思是:

与 retrieve() 不同,在应用 exchange() 时,不管在任何状况下(胜利、异样、无奈解决的数据等),应用程序都该当生产掉响应内容。不这样做可能会导致内存透露。请参阅 ClientResponse 以获取可用于生产 body 的形式。通常应该应用 retrieve(),除非您有充沛的理由应用 exchange(),它容许您查看响应状态和题目,并在之后用于决定是否生产 body、如何生产 body。

而刚好在一些业务校验失败的状况下,如 Content-Type 中标识返回的数据不是 HTML 内容时,利用代码间接进行了 return,而没有生产 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,而没有开释 response
        if (ctx.isParseTimeout(PARSE_TIMEOUT)) {return Mono.error(ReadTimeoutException.INSTANCE);
        }

        // 先解析重定向,不存在重定向则解析 body
        return judgeRedirect(response, ctx)
                .flatMap(redirectTo -> followRedirect(ctx, redirectTo))
                .switchIfEmpty(Mono.defer(() -> Mono.just(parser.parse(ctx))))
                .map(LinkParseResult::detectParseFail);
    })

解决问题

曾经定位到问题产生的起因,且官网文档已给出了解决办法 参阅 ClientResponse 以获取可用于生产 body 的形式 。在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 -> {
        // 再次检测是否超时,并开释 response
        if (ctx.isParseTimeout(PARSE_TIMEOUT)) {return response.releaseBody()
                    .then(Mono.error(ReadTimeoutException.INSTANCE));
        }

        // 先解析重定向,不存在重定向则解析 body
        return 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 异样。

失去经验教训:应用不相熟的三方库时,肯定要浏览办法正文、类正文。


参考文档:

  1. Netty 内存透露排查
  2. Web on Reactive Stack

    本文由博客群发一文多发等经营工具平台 OpenWrite 公布

正文完
 0