记一次netty线程池使用错误导致的内存泄漏

记一次netty线程池使用错误导致的内存泄漏

背景

该项目是一个重构项目,原来的工程是一个LUA+C++的项目,叫做BC服,项目转到我们组,由于我们组没人有相关经验,决定使用JAVA重写。
原始项目整体结构如下:

我们重写改造后的结构如下:

我们的接入服务中的网络框架选用的是netty,在channel初始化的最后放了一个线程池,在线程池里面的做同步的远程dubbo调用。代码如下

1
2
3
4
5
6
7
8
9
10
DefaultEventExecutorGroup defaultEventExecutorGroup=new DefaultEventExecutorGroup(64);
ServerBootstrap b = new ServerBootstrap();
b.group(workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(XXX).addLast(YYY).addLast(defaultEventExecutorGroup, new XXXhandler());
}
})

每一个调用者通过TCP连接和新的BC服的接入层连接并发起异步请求,单条TCP连接来的请求QPS在200左右(这个调用量事先我们并不知道,只能从监控中看到调用dubbo的量)。

(题外话:如果你对Netty非常熟的话,看到这里应该就知道问题出在哪里了)

问题现象

在代码上线之前的灰度测试阶段发现了接入服务的内存不断增长不释放的问题,在进行了heap dump之后,发现DefaultEventExecutor里面的taskQueue队列非常长,并且随着时间一直增长,导致内存占用不释放。

问题排查

根据出现的问题现象有两种可能:

  1. 队列消费了,但是队列中的对象没有释放。代码中最后的 XXXhandler 是继承了SimpleChannelInboundHandler的子类,我们的代码是写在read0方法里面的,read方法里面会调用释放的代码,同时我们解码之后的对象也不是ReferenceCounted的对象,就是个POJO。所以排除了这个点。

  2. 队列没有消费,或者说消费速度跟不上生产速度。

我仔细看了netty初始化pipline的代码之后,发现在有线程池的情况下会调用线程池的next方法返回的线程/线程池绑定到对应的channel上(在addLast方法的newContext里面 ),所有从该channel都由绑定的线程/线程池处理。对于我使用的DefaultEventExecutorGroup来说,他的next按照RoundRobin的顺序从池内返回一个线程,也就是DefaultEventExecutor(它是继承的SingleThreadEventExecutor,也就是一个单线程的执行器)而且堵着的队列也就是它自带的taskQueue。

由于我们的单个生产者生产速度在200qps左右,而dubbo调用的平均时长在10ms左右,最长可达500ms,也就是说单个线程消费qps最多也就100,这就是为什么队列会越来越长的原因。

解决办法

知道了问题的原因,解决起来就容易了,换一个next方法返回一个多线程线程池的ExecutorGroup就可以了,看了一下ExecutorGroup的子类,同时查了一下stackoverflow,最后换成了UnorderedThreadPoolEventExecutor这个ExecutorGroup,它的next方法返回它自身,同时内部也是使用的我们熟悉的标准Java线程池。代码改完之后重新发布,问题解决,并且调用后端的QPS也是正常水平。

总结

那么Netty的线程池要怎么选?

如果你的应用是很多客户端接入的话,每个客户端的QPS不高的情况下,请使用DefaultEventExecutorGroup,他可以保证单个客户端的消息顺序。

如果你的应用是少量客户端接入,每个客户端又会大量发送消息的话,且对单个客户端的消息顺序没有要求的话,请使用UnorderedThreadPoolEventExecutor。这种情况下如果对消息顺序有需求,建议做成业务保证最终一致性。比如在解码阶段带上解码的时间戳,后面的业务处理新戳可以覆盖老戳,老的不能覆盖新的。或者根据你的业务需求在业务上进行处理。

如果你的应用是很多客户端接入,同时每个客户端又是大量请求,同时又对单个客户端消息顺序有要求的话(比如多人实时游戏),建议使用DefaultEventExecutorGroup的同时缩短后端调用的时长,比如后端数据全部内存化,走异步刷盘策略,然后同机房调用。

或者使用全异步请求,这样线程池都不需要了哈哈。

后记

这几个月博客没更新,全在忙这个项目。吐槽一下,原来的lua代码到处都是随缘逻辑,改的真是吐血…