**[netty memory management] netty

1. Background

The most error-prone mention of the first line

3.5. Memory Pool

The push server carries a large number of long links, and each long link is actually a session. If each session holds data structures such as heartbeat data, receive buffers, and instruction sets , and these instances live and die as messages are processed, this will bring heavy GC pressure to the server and consume a lot of memory.

The most effective solution is to use a memory pool, each NioEventLoop thread processes N links, and within the thread, the links are processed serially. If the A link is processed first, it will create objects such as the receiving buffer. After the decoding is completed, the constructed POJO object is encapsulated into a Task and delivered to the background thread pool for execution. Then the receiving buffer will be released. The reception and processing of messages repeats the creation and release of receive buffers. If the memory pool is used, when the A link receives a new datagram, it will apply for a free ByteBuf from the NioEventLoop memory pool. After decoding is complete, call release to release the ByteBuf into the memory pool for subsequent use by the B link. .

After using the memory pool optimization, the number of ByteBuf applications and GCs of a single NioEventLoop is reduced from the original N = 1000000/64 = 15625 times to at least 0 times (assuming that there is available memory for each application).

Let's take Twitter's use of Netty4's PooledByteBufAllocator for GC optimization as an example to evaluate the effect of the memory pool. The results are as follows:

Garbage generation is 1/5 the speed, and garbage removal is 5 times faster. With the new memory pool mechanism, the network bandwidth can be almost full.

The problem with versions before Netty 4 is as follows: Netty 3 creates a new heap buffer whenever new information is received or the user sends information to the remote side. This means that for every new buffer, there will be a new byte[capacity]. These buffers cause GC pressure and consume memory bandwidth. To be on the safe side, new byte arrays are allocated with zero padding, which consumes memory bandwidth. However, an array filled with zeros is likely to be filled again with actual data, which again consumes the same memory bandwidth. If the Java Virtual Machine (JVM) provided a way to create a new byte array without padding it with zeros, we could have reduced memory bandwidth consumption by 50%, but there is currently no such way.

A new ByteBuf memory pool was implemented in Netty 4, which is a pure Java version of  jemalloc  (also used by Facebook). Now, Netty no longer wastes memory bandwidth by filling buffers with zeros. However, since it does not depend on GC, developers need to be careful about memory leaks. If you forget to free the buffer in the handler, then the memory usage grows indefinitely.

Netty does not use the memory pool by default. It needs to be specified when creating the client or server. The code is as follows:

Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)

After using the memory pool, the application and release of memory must appear in pairs, that is, retain() and release() must appear in pairs, otherwise it will lead to memory leaks.

值得注意的是,如果使用内存池,完成ByteBuf的解码工作之后必须显式的调用ReferenceCountUtil.release(msg)对接收缓冲区ByteBuf进行内存释放,否则它会被认为仍然在使用中,这样会导致内存泄露。


1.1. 话题来源

最近很多从事移动互联网和物联网开发的同学给我发邮件或者微博私信我,咨询推送服务相关的问题。问题五花八门,在帮助大家答疑解惑的过程中,我也对问题进行了总结,大概可以归纳为如下几类:

  1. Netty是否可以做推送服务器?
  2. 如果使用Netty开发推送服务,一个服务器最多可以支撑多少个客户端?
  3. 使用Netty开发推送服务遇到的各种技术问题。

由于咨询者众多,关注点也比较集中,我希望通过本文的案例分析和对推送服务设计要点的总结,帮助大家在实际工作中少走弯路。

1.2. 推送服务

移动互联网时代,推送(Push)服务成为App应用不可或缺的重要组成部分,推送服务可以提升用户的活跃度和留存率。我们的手机每天接收到各种各样的广告和提示消息等大多数都是通过推送服务实现的。

随着物联网的发展,大多数的智能家居都支持移动推送服务,未来所有接入物联网的智能设备都将是推送服务的客户端,这就意味着推送服务未来会面临海量的设备和终端接入。

1.3. 推送服务的特点

移动推送服务的主要特点如下:

  1. 使用的网络主要是运营商的无线移动网络,网络质量不稳定,例如在地铁上信号就很差,容易发生网络闪断;
  2. 海量的客户端接入,而且通常使用长连接,无论是客户端还是服务端,资源消耗都非常大;
  3. 由于谷歌的推送框架无法在国内使用,Android的长连接是由每个应用各自维护的,这就意味着每台安卓设备上会存在多个长连接。即便没有消息需要推送,长连接本身的心跳消息量也是非常巨大的,这就会导致流量和耗电量的增加;
  4. 不稳定:消息丢失、重复推送、延迟送达、过期推送时有发生;
  5. 垃圾消息满天飞,缺乏统一的服务治理能力。

为了解决上述弊端,一些企业也给出了自己的解决方案,例如京东云推出的推送服务,可以实现多应用单服务单连接模式,使用AlarmManager定时心跳节省电量和流量。

2. 智能家居领域的一个真实案例

2.1. 问题描述

智能家居MQTT消息服务中间件,保持10万用户在线长连接,2万用户并发做消息请求。程序运行一段时间之后,发现内存泄露,怀疑是Netty的Bug。其它相关信息如下:

  1. MQTT消息服务中间件服务器内存16G,8个核心CPU;
  2. Netty中boss线程池大小为1,worker线程池大小为6,其余线程分配给业务使用。该分配方式后来调整为worker线程池大小为11,问题依旧;
  3. Netty版本为4.0.8.Final。

2.2. 问题定位

首先需要dump内存堆栈,对疑似内存泄露的对象和引用关系进行分析,如下所示:

我们发现Netty的ScheduledFutureTask增加了9076%,达到110W个左右的实例,通过对业务代码的分析发现用户使用IdleStateHandler用于在链路空闲时进行业务逻辑处理,但是空闲时间设置的比较大,为15分钟。

Netty的IdleStateHandler会根据用户的使用场景,启动三类定时任务,分别是:ReaderIdleTimeoutTask、WriterIdleTimeoutTask和AllIdleTimeoutTask,它们都会被加入到NioEventLoop的Task队列中被调度和执行。

由于超时时间过长,10W个长链接链路会创建10W个ScheduledFutureTask对象,每个对象还保存有业务的成员变量,非常消耗内存。用户的持久代设置的比较大,一些定时任务被老化到持久代中,没有被JVM垃圾回收掉,内存一直在增长,用户误认为存在内存泄露。

事实上,我们进一步分析发现,用户的超时时间设置的非常不合理,15分钟的超时达不到设计目标,重新设计之后将超时时间设置为45秒,内存可以正常回收,问题解决。

2.3. 问题总结

如果是100个长连接,即便是长周期的定时任务,也不存在内存泄露问题,在新生代通过minor GC就可以实现内存回收。正是因为十万级的长连接,导致小问题被放大,引出了后续的各种问题。

事实上,如果用户确实有长周期运行的定时任务,该如何处理?对于海量长连接的推送服务,代码处理稍有不慎,就满盘皆输,下面我们针对Netty的架构特点,介绍下如何使用Netty实现百万级客户端的推送服务。

3. Netty海量推送服务设计要点

作为高性能的NIO框架,利用Netty开发高效的推送服务技术上是可行的,但是由于推送服务自身的复杂性,想要开发出稳定、高性能的推送服务并非易事,需要在设计阶段针对推送服务的特点进行合理设计。

3.1. 最大句柄数修改

百万长连接接入,首先需要优化的就是Linux内核参数,其中Linux最大文件句柄数是最重要的调优参数之一,默认单进程打开的最大句柄数是1024,通过ulimit -a可以查看相关参数,示例如下:

[root@lilinfeng ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 256324
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024

......后续输出省略

当单个推送服务接收到的链接超过上限后,就会报“too many open files”,所有新的客户端接入将失败。

通过vi /etc/security/limits.conf 添加如下配置参数:修改之后保存,注销当前用户,重新登录,通过ulimit -a 查看修改的状态是否生效。

*  soft  nofile  1000000
*  hard  nofile  1000000

需要指出的是,尽管我们可以将单个进程打开的最大句柄数修改的非常大,但是当句柄数达到一定数量级之后,处理效率将出现明显下降,因此,需要根据服务器的硬件配置和处理能力进行合理设置。如果单个服务器性能不行也可以通过集群的方式实现。

3.2. 当心CLOSE_WAIT

从事移动推送服务开发的同学可能都有体会,移动无线网络可靠性非常差,经常存在客户端重置连接,网络闪断等。

在百万长连接的推送系统中,服务端需要能够正确处理这些网络异常,设计要点如下:

  1. 客户端的重连间隔需要合理设置,防止连接过于频繁导致的连接失败(例如端口还没有被释放);
  2. 客户端重复登陆拒绝机制;
  3. 服务端正确处理I/O异常和解码异常等,防止句柄泄露。

最后特别需要注意的一点就是close_wait 过多问题,由于网络不稳定经常会导致客户端断连,如果服务端没有能够及时关闭socket,就会导致处于close_wait状态的链路过多。close_wait状态的链路并不释放句柄和内存等资源,如果积压过多可能会导致系统句柄耗尽,发生“Too many open files”异常,新的客户端无法接入,涉及创建或者打开句柄的操作都将失败。

下面对close_wait状态进行下简单介绍,被动关闭TCP连接状态迁移图如下所示:

图3-1 被动关闭TCP连接状态迁移图

close_wait是被动关闭连接是形成的,根据TCP状态机,服务器端收到客户端发送的FIN,TCP协议栈会自动发送ACK,链接进入close_wait状态。但如果服务器端不执行socket的close()操作,状态就不能由close_wait迁移到last_ack,则系统中会存在很多close_wait状态的连接。通常来说,一个close_wait会维持至少2个小时的时间(系统默认超时时间的是7200秒,也就是2小时)。如果服务端程序因某个原因导致系统造成一堆close_wait消耗资源,那么通常是等不到释放那一刻,系统就已崩溃。

导致close_wait过多的可能原因如下:

  1. 程序处理Bug,导致接收到对方的fin之后没有及时关闭socket,这可能是Netty的Bug,也可能是业务层Bug,需要具体问题具体分析;
  2. 关闭socket不及时:例如I/O线程被意外阻塞,或者I/O线程执行的用户自定义Task比例过高,导致I/O操作处理不及时,链路不能被及时释放。

下面我们结合Netty的原理,对潜在的故障点进行分析。

设计要点1:不要在Netty的I/O线程上处理业务(心跳发送和检测除外)。Why? 对于Java进程,线程不能无限增长,这就意味着Netty的Reactor线程数必须收敛。Netty的默认值是CPU核数 * 2,通常情况下,I/O密集型应用建议线程数尽量设置大些,但这主要是针对传统同步I/O而言,对于非阻塞I/O,线程数并不建议设置太大,尽管没有最优值,但是I/O线程数经验值是[CPU核数 + 1,CPU核数*2 ]之间。

假如单个服务器支撑100万个长连接,服务器内核数为32,则单个I/O线程处理的链接数L = 100/(32 * 2) = 15625。 假如每5S有一次消息交互(新消息推送、心跳消息和其它管理消息),则平均CAPS = 15625 / 5 = 3125条/秒。这个数值相比于Netty的处理性能而言压力并不大,但是在实际业务处理中,经常会有一些额外的复杂逻辑处理,例如性能统计、记录接口日志等,这些业务操作性能开销也比较大,如果在I/O线程上直接做业务逻辑处理,可能会阻塞I/O线程,影响对其它链路的读写操作,这就会导致被动关闭的链路不能及时关闭,造成close_wait堆积。

设计要点2:在I/O线程上执行自定义Task要当心。Netty的I/O处理线程NioEventLoop支持两种自定义Task的执行:

  1. 普通的Runnable: 通过调用NioEventLoop的execute(Runnable task)方法执行;
  2. 定时任务ScheduledFutureTask:通过调用NioEventLoop的schedule(Runnable command, long delay, TimeUnit unit)系列接口执行。

为什么NioEventLoop要支持用户自定义Runnable和ScheduledFutureTask的执行,并不是本文要讨论的重点,后续会有专题文章进行介绍。本文重点对它们的影响进行分析。

在NioEventLoop中执行Runnable和ScheduledFutureTask,意味着允许用户在NioEventLoop中执行非I/O操作类的业务逻辑,这些业务逻辑通常用消息报文的处理和协议管理相关。它们的执行会抢占NioEventLoop I/O读写的CPU时间,如果用户自定义Task过多,或者单个Task执行周期过长,会导致I/O读写操作被阻塞,这样也间接导致close_wait堆积。

所以,如果用户在代码中使用到了Runnable和ScheduledFutureTask,请合理设置ioRatio的比例,通过NioEventLoop的setIoRatio(int ioRatio)方法可以设置该值,默认值为50,即I/O操作和用户自定义任务的执行时间比为1:1。

我的建议是当服务端处理海量客户端长连接的时候,不要在NioEventLoop中执行自定义Task,或者非心跳类的定时任务。

设计要点3:IdleStateHandler使用要当心。很多用户会使用IdleStateHandler做心跳发送和检测,这种用法值得提倡。相比于自己启定时任务发送心跳,这种方式更高效。但是在实际开发中需要注意的是,在心跳的业务逻辑处理中,无论是正常还是异常场景,处理时延要可控,防止时延不可控导致的NioEventLoop被意外阻塞。例如,心跳超时或者发生I/O异常时,业务调用Email发送接口告警,由于Email服务端处理超时,导致邮件发送客户端被阻塞,级联引起IdleStateHandler的AllIdleTimeoutTask任务被阻塞,最终NioEventLoop多路复用器上其它的链路读写被阻塞。

对于ReadTimeoutHandler和WriteTimeoutHandler,约束同样存在。

3.3. 合理的心跳周期

百万级的推送服务,意味着会存在百万个长连接,每个长连接都需要靠和App之间的心跳来维持链路。合理设置心跳周期是非常重要的工作,推送服务的心跳周期设置需要考虑移动无线网络的特点。

当一台智能手机连上移动网络时,其实并没有真正连接上Internet,运营商分配给手机的IP其实是运营商的内网IP,手机终端要连接上Internet还必须通过运营商的网关进行IP地址的转换,这个网关简称为NAT(NetWork Address Translation),简单来说就是手机终端连接Internet 其实就是移动内网IP,端口,外网IP之间相互映射。

GGSN(GateWay GPRS Support Note)模块就实现了NAT功能,由于大部分的移动无线网络运营商为了减少网关NAT映射表的负荷,如果一个链路有一段时间没有通信时就会删除其对应表,造成链路中断,正是这种刻意缩短空闲连接的释放超时,原本是想节省信道资源的作用,没想到让互联网的应用不得以远高于正常频率发送心跳来维护推送的长连接。以中移动的2.5G网络为例,大约5分钟左右的基带空闲,连接就会被释放。

由于移动无线网络的特点,推送服务的心跳周期并不能设置的太长,否则长连接会被释放,造成频繁的客户端重连,但是也不能设置太短,否则在当前缺乏统一心跳框架的机制下很容易导致信令风暴(例如微信心跳信令风暴问题)。具体的心跳周期并没有统一的标准,180S也许是个不错的选择,微信为300S。

在Netty中,可以通过在ChannelPipeline中增加IdleStateHandler的方式实现心跳检测,在构造函数中指定链路空闲时间,然后实现空闲回调接口,实现心跳的发送和检测,代码如下:

public void initChannel({@link Channel} channel) {
 channel.pipeline().addLast("idleStateHandler", new {@link   IdleStateHandler}(0, 0, 180));
 channel.pipeline().addLast("myHandler", new MyHandler());
}
拦截链路空闲事件并处理心跳:
 public class MyHandler extends {@link ChannelHandlerAdapter} {
     {@code @Override}
      public void userEventTriggered({@link ChannelHandlerContext} ctx, {@link Object} evt) throws {@link Exception} {
          if (evt instanceof {@link IdleStateEvent}} {
              //心跳处理
          }
      }
  }

3.4. 合理设置接收和发送缓冲区容量

对于长链接,每个链路都需要维护自己的消息接收和发送缓冲区,JDK原生的NIO类库使用的是java.nio.ByteBuffer,它实际是一个长度固定的Byte数组,我们都知道数组无法动态扩容,ByteBuffer也有这个限制,相关代码如下:

public abstract class ByteBuffer
    extends Buffer
    implements Comparable
{
    final byte[] hb; // Non-null only for heap buffers
    final int offset;
    boolean isReadOnly;

容量无法动态扩展会给用户带来一些麻烦,例如由于无法预测每条消息报文的长度,可能需要预分配一个比较大的ByteBuffer,这通常也没有问题。但是在海量推送服务系统中,这会给服务端带来沉重的内存负担。假设单条推送消息最大上限为10K,消息平均大小为5K,为了满足10K消息的处理,ByteBuffer的容量被设置为10K,这样每条链路实际上多消耗了5K内存,如果长链接链路数为100万,每个链路都独立持有ByteBuffer接收缓冲区,则额外损耗的总内存 Total(M) = 1000000 * 5K = 4882M。内存消耗过大,不仅仅增加了硬件成本,而且大内存容易导致长时间的Full GC,对系统稳定性会造成比较大的冲击。

实际上,最灵活的处理方式就是能够动态调整内存,即接收缓冲区可以根据以往接收的消息进行计算,动态调整内存,利用CPU资源来换内存资源,具体的策略如下:

  1. ByteBuffer支持容量的扩展和收缩,可以按需灵活调整,以节约内存;
  2. 接收消息的时候,可以按照指定的算法对之前接收的消息大小进行分析,并预测未来的消息大小,按照预测值灵活调整缓冲区容量,以做到最小的资源损耗满足程序正常功能。

幸运的是,Netty提供的ByteBuf支持容量动态调整,对于接收缓冲区的内存分配器,Netty提供了两种:

  1. FixedRecvByteBufAllocator:固定长度的接收缓冲区分配器,由它分配的ByteBuf长度都是固定大小的,并不会根据实际数据报的大小动态收缩。但是,如果容量不足,支持动态扩展。动态扩展是Netty ByteBuf的一项基本功能,与ByteBuf分配器的实现没有关系;
  2. AdaptiveRecvByteBufAllocator:容量动态调整的接收缓冲区分配器,它会根据之前Channel接收到的数据报大小进行计算,如果连续填充满接收缓冲区的可写空间,则动态扩展容量。如果连续2次接收到的数据报都小于指定值,则收缩当前的容量,以节约内存。

相对于FixedRecvByteBufAllocator,使用AdaptiveRecvByteBufAllocator更为合理,可以在创建客户端或者服务端的时候指定RecvByteBufAllocator,代码如下:

 Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT)

如果默认没有设置,则使用AdaptiveRecvByteBufAllocator。

另外值得注意的是,无论是接收缓冲区还是发送缓冲区,缓冲区的大小建议设置为消息的平均大小,不要设置成最大消息的上限,这会导致额外的内存浪费。通过如下方式可以设置接收缓冲区的初始大小:

/**
	 * Creates a new predictor with the specified parameters.
	 * 
	 * @param minimum
	 *            the inclusive lower bound of the expected buffer size
	 * @param initial
	 *            the initial buffer size when no feed back was received
	 * @param maximum
	 *            the inclusive upper bound of the expected buffer size
	 */
	public AdaptiveRecvByteBufAllocator(int minimum, int initial, int maximum) 

对于消息发送,通常需要用户自己构造ByteBuf并编码,例如通过如下工具类创建消息发送缓冲区:

图3-2 构造指定容量的缓冲区


3.6. 当心“日志隐形杀手”

通常情况下,大家都知道不能在Netty的I/O线程上做执行时间不可控的操作,例如访问数据库、发送Email等。但是有个常用但是非常危险的操作却容易被忽略,那便是记录日志。

通常,在生产环境中,需要实时打印接口日志,其它日志处于ERROR级别,当推送服务发生I/O异常之后,会记录异常日志。如果当前磁盘的WIO比较高,可能会发生写日志文件操作被同步阻塞,阻塞时间无法预测。这就会导致Netty的NioEventLoop线程被阻塞,Socket链路无法被及时关闭、其它的链路也无法进行读写操作等。

以最常用的log4j为例,尽管它支持异步写日志(AsyncAppender),但是当日志队列满之后,它会同步阻塞业务线程,直到日志队列有空闲位置可用,相关代码如下:

 synchronized (this.buffer) {
      while (true) {
        int previousSize = this.buffer.size();
        if (previousSize < this.bufferSize) {
          this.buffer.add(event);
          if (previousSize != 0) break;
          this.buffer.notifyAll(); break;
        }
        boolean discard = true;
        if ((this.blocking) && (!Thread.interrupted()) && (Thread.currentThread() != this.dispatcher)) //判断是业务线程
        {
          try
          {
            this.buffer.wait();//阻塞业务线程
            discard = false;
          }
          catch (InterruptedException e)
          {
            Thread.currentThread().interrupt();
          }

        }

Such bugs are highly concealed, and the high WIO often lasts for a very short time, or occurs occasionally. It is difficult to simulate such faults in the test environment, and it is very difficult to locate the problem. This requires readers to be careful when writing code and pay attention to those hidden mines.

3.7. TCP parameter optimization

Common TCP parameters, such as the receiving and sending buffer size settings at the TCP level, correspond to SO_SNDBUF and SO_RCVBUF of ChannelOption respectively in Netty, which need to be set reasonably according to the size of the push message. For massive long connections, usually 32K is a good choice.

Another commonly used optimization method is soft interrupts, as shown in the figure: If all soft interrupts are running on the hardware interrupts of the corresponding network card of CPU0, then cpu0 is always processing soft interrupts, and other CPU resources are used at this time. Wasteful because multiple softirqs cannot be executed in parallel.

Figure 3-3 Interrupt information

For Linux kernels of version 2.6.35 or greater, with RPS enabled, the network communication performance is improved by more than 20%. The basic principle of RPS: According to the source address, destination address, and destination and source ports of the data packet, a hash value is calculated, and then the cpu for soft interrupt operation is selected according to the hash value. From the perspective of the upper layer, that is to say, bind each connection to the CPU, and use this hash value to balance the operation of soft interrupts on multiple CPUs, thereby improving communication performance.

3.8. JVM parameters

There are two most important parameter adjustments:

  • -Xmx: The maximum memory of the JVM needs to be calculated according to the memory model and obtain a relatively reasonable value;
  • GC-related parameters: such as the ratio of the new generation, the old generation, the permanent generation, the GC strategy, the ratio of each area of ​​the new generation, etc., need to be set and tested according to specific scenarios, and constantly optimized, try to reduce the frequency of Full GC as much as possible to the minimum.

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325618867&siteId=291194637