背景
当前ElasticSearch集群数据写入使用Transport Client,由于Transport Client在8.0版本后弃用并且存在性能问题,所以要将数据写入的客户端迁移到Rest Client
现象
将客户端迁移为Rest Client之后,部分ElasticSearch节点出现内存被打满的现象,节点重启后过一段时间又被打满。dump进程内存后立刻回滚了客户端并将ElasticSearch集群节点全量重启。
问题分析
使用Eclipse Memory Analyzer Tools进行dump文件的内存分析,得到以下结果。
通过Dominator Classes可以看出是CopyBytesSocketChannel和Transport$ResponseHandlers占据了大部分内存,下面进行分别分析。
CopyBytesSocketChannel分析
CopyBytesSocketChannel为netty中的channel,每个TCP连接创建一个。
CopyBytesSocketChannel实例的数量和我们ES节点的连接数相同,异常的地方在于CopyBytesSocketChannel实例的大小。
通过上面的类内存占用排行可以看出多个类占用大小为16GB,这些类之间的引用关系如下:
CopyBytesSocketChannel -> DefaultChannelPipeline -> DefaultChannelHandlerContext -> Netty4CorsHandler -> Netty4HttpRequest->CompositeBytesReference(content)(->byte[](headers FullHttpRequest))-> BytesReference[] -> BytesReference -> byte[]
Netty4HttpRequest是最底层且有现实意义的类,Netty4HttpRequest是客户端发起的http请求,他引用的属性为请求的相关信息例如content、header。抽查发现Netty4HttpRequest为bulk写入请求。Netty4HttpRequest预期应该请求处理完成就被回收了,为什么还会在内存中堆积了4000+的实例?Netty4HttpRequest的引用方是Netty4CorsHandler,那我们来看下Netty4CorsHandler引用和释放Netty4CorsHandler引用的时间。代码如下:
public class Netty4CorsHandler extends ChannelDuplexHandler {
public static final String ANY_ORIGIN = "*";
private static Pattern SCHEME_PATTERN = Pattern.compile("^https?://");
private final CorsHandler.Config config;
private Netty4HttpRequest request; // Netty4HttpRequest的引用
/**
* Creates a new instance with the specified {@link CorsHandler.Config}.
*/
public Netty4CorsHandler(final CorsHandler.Config config) {
if (config == null) {
throw new NullPointerException();
}
this.config = config;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
assert msg instanceof Netty4HttpRequest : "Invalid message type: " + msg.getClass();
if (config.isCorsSupportEnabled()) {
request = (Netty4HttpRequest) msg;
if (isPreflightRequest(request.nettyRequest())) {
try {
handlePreflight(ctx, request.nettyRequest());
return;
} finally {
releaseRequest();
}
}
if (!validateOrigin()) {
try {
forbidden(ctx, request.nettyRequest());
return;
} finally {
releaseRequest();
}
}
} // 没有释放request的引用
ctx.fireChannelRead(msg);
}
private void handlePreflight(final ChannelHandlerContext ctx, final HttpRequest request) {
final HttpResponse response = new DefaultFullHttpResponse(request.protocolVersion(), HttpResponseStatus.OK, true, true);
if (setOrigin(response)) {
setAllowMethods(response);
setAllowHeaders(response);
setAllowCredentials(response);
setMaxAge(response);
setPreflightHeaders(response);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
} else {
forbidden(ctx, request);
}
}
private void releaseRequest() {
request.release();
request = null;
}
private static boolean isPreflightRequest(final HttpRequest request) {
final HttpHeaders headers = request.headers();
return request.method().equals(HttpMethod.OPTIONS) &&
headers.contains(HttpHeaderNames.ORIGIN) &&
headers.contains(HttpHeaderNames.ACCESS_CONTROL_REQUEST_METHOD);
}
}
通过代码可以看出,在Netty4CorsHandler#channelRead方法被调用时引用了Netty4HttpRequest,但在channelRead方法结束后也没释放引用,并且此时Netty4HttpRequest引用不会被Netty4CorsHandler实例使用完全可以释放掉。所以Netty4HttpRequest的生命周期非预期的和Netty4CorsHandler绑定在一起了。Netty4CorsHandler是netty中的ChannelHandler,声明周期与Channel相同,Channel的生命周期为客户端与服务端间连接的生命周期,也就是Rest Client与ES节点间连接的生命周期,该连接为长连接,也就是说除非Rest Client挂掉,Netty4HttpRequest永远不会被回收。
现在我们找到了原因,即ES服务端的Netty4CorsHandler发生了内存泄漏,导致Netty4HttpRequest不会被回收,堆积数量等于连接个数,在我们的场景下单个ES节点的连接数在10000左右,单个Netty4HttpRequest实例大小在3MB左右,10000*3MB=30GB,ES的堆内存大小也就32GB。Netty4HttpRequest占用了这么大的内存,自然ES节点内存被打满了。
ElasticSearch社区没有相关issue,但社区在对Transport代码模块整理时重构了Netty4CorsHandler,意外解决了该问题。我们使用的版本是7.9,社区进行重构的版本是7.10.0。(https://github.com/elastic/elasticsearch/pull/62007)
Transport$ResponseHandlers分析
在集群间节点通信的场景下,ES节点向另一个节点发起请求后会将标识该请求的request id和负责处理response的handler保存到map中。内存仍未打满的节点向内存被打满ES节点(由于Netty4HttpRequest占用大量内存)发起的请求不会被响应,因此ResponseHandlers会内会堆积大量的ResponseHandler,导致Transport$ResponseHandlers占用大量内存。
解决方法
在Netty4CorsHandler处理完成Netty4HttpRequest后立即释放引用,代码如下:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
assert msg instanceof Netty4HttpRequest : "Invalid message type: " + msg.getClass();
if (config.isCorsSupportEnabled()) {
request = (Netty4HttpRequest) msg;
if (isPreflightRequest(request.nettyRequest())) {
try {
handlePreflight(ctx, request.nettyRequest());
return;
} finally {
releaseRequest();
}
}
if (!validateOrigin()) {
try {
forbidden(ctx, request.nettyRequest());
return;
} finally {
releaseRequest();
}
}
}
request = null; // for GC
ctx.fireChannelRead(msg);
}
总结
- 内存问题排查重点关注实例的引用关系和声明周期
- 充分理解相关代码逻辑是问题排查的前提,例如排查该问题需要了解Netty的基础组件的调用关系和ES对Netty的使用方式以及Rest Client发起的请求和连接的特点。