最近不少从事移动互联网和物联网开发的同窗给我发邮件或者微博私信我,咨询推送服务相关的问题。问题五花八门,在帮助你们答疑解惑的过程当中,我也对问题进行了总结,大概能够概括为以下几类:java
Netty是否能够作推送服务器?算法
若是使用Netty开发推送服务,一个服务器最多能够支撑多少个客户端?数据库
使用Netty开发推送服务遇到的各类技术问题。数组
因为咨询者众多,关注点也比较集中,我但愿经过本文的案例分析和对推送服务设计要点的总结,帮助你们在实际工做中少走弯路。安全
移动互联网时代,推送(Push)服务成为App应用不可或缺的重要组成部分,推送服务能够提高用户的活跃度和留存率。咱们的手机天天接收到各类各样的广告和提示消息等大多数都是经过推送服务实现的。服务器
随着物联网的发展,大多数的智能家居都支持移动推送服务,将来全部接入物联网的智能设备都将是推送服务的客户端,这就意味着推送服务将来会面临海量的设备和终端接入。微信
移动推送服务的主要特色以下:网络
使用的网络主要是运营商的无线移动网络,网络质量不稳定,例如在地铁上信号就不好,容易发生网络闪断;数据结构
海量的客户端接入,并且一般使用长链接,不管是客户端仍是服务端,资源消耗都很是大;架构
因为谷歌的推送框架没法在国内使用,Android的长链接是由每一个应用各自维护的,这就意味着每台安卓设备上会存在多个长链接。即使没有消息须要推送,长链接自己的心跳消息量也是很是巨大的,这就会致使流量和耗电量的增长;
不稳定:消息丢失、重复推送、延迟送达、过时推送时有发生;
垃圾消息满天飞,缺少统一的服务治理能力。
为了解决上述弊端,一些企业也给出了本身的解决方案,例如京东云推出的推送服务,能够实现多应用单服务单链接模式,使用AlarmManager定时心跳节省电量和流量。
智能家居MQTT消息服务中间件,保持10万用户在线长链接,2万用户并发作消息请求。程序运行一段时间以后,发现内存泄露,怀疑是Netty的Bug。其它相关信息以下:
MQTT消息服务中间件服务器内存16G,8个核心CPU;
Netty中boss线程池大小为1,worker线程池大小为6,其他线程分配给业务使用。该分配方式后来调整为worker线程池大小为11,问题依旧;
Netty版本为4.0.8.Final。
首先须要dump内存堆栈,对疑似内存泄露的对象和引用关系进行分析,以下所示:
咱们发现Netty的ScheduledFutureTask增长了9076%,达到110W个左右的实例,经过对业务代码的分析发现用户使用IdleStateHandler用于在链路空闲时进行业务逻辑处理,可是空闲时间设置的比较大,为15分钟。
Netty的IdleStateHandler会根据用户的使用场景,启动三类定时任务,分别是:ReaderIdleTimeoutTask、WriterIdleTimeoutTask和AllIdleTimeoutTask,它们都会被加入到NioEventLoop的Task队列中被调度和执行。
因为超时时间过长,10W个长连接链路会建立10W个ScheduledFutureTask对象,每一个对象还保存有业务的成员变量,很是消耗内存。用户的持久代设置的比较大,一些定时任务被老化到持久代中,没有被JVM垃圾回收掉,内存一直在增加,用户误认为存在内存泄露。
事实上,咱们进一步分析发现,用户的超时时间设置的很是不合理,15分钟的超时达不到设计目标,从新设计以后将超时时间设置为45秒,内存能够正常回收,问题解决。
若是是100个长链接,即使是长周期的定时任务,也不存在内存泄露问题,在新生代经过minor GC就能够实现内存回收。正是由于十万级的长链接,致使小问题被放大,引出了后续的各类问题。
事实上,若是用户确实有长周期运行的定时任务,该如何处理?对于海量长链接的推送服务,代码处理稍有不慎,就满盘皆输,下面咱们针对Netty的架构特色,介绍下如何使用Netty实现百万级客户端的推送服务。
做为高性能的NIO框架,利用Netty开发高效的推送服务技术上是可行的,可是因为推送服务自身的复杂性,想要开发出稳定、高性能的推送服务并不是易事,须要在设计阶段针对推送服务的特色进行合理设计。
百万长链接接入,首先须要优化的就是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
须要指出的是,尽管咱们能够将单个进程打开的最大句柄数修改的很是大,可是当句柄数达到必定数量级以后,处理效率将出现明显降低,所以,须要根据服务器的硬件配置和处理能力进行合理设置。若是单个服务器性能不行也能够经过集群的方式实现。
从事移动推送服务开发的同窗可能都有体会,移动无线网络可靠性很是差,常常存在客户端重置链接,网络闪断等。
在百万长链接的推送系统中,服务端须要可以正确处理这些网络异常,设计要点以下:
客户端的重连间隔须要合理设置,防止链接过于频繁致使的链接失败(例如端口尚未被释放);
客户端重复登录拒绝机制;
服务端正确处理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过多的可能缘由以下:
程序处理Bug,致使接收到对方的fin以后没有及时关闭socket,这多是Netty的Bug,也多是业务层Bug,须要具体问题具体分析;
关闭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的执行:
普通的Runnable: 经过调用NioEventLoop的execute(Runnable task)方法执行;
定时任务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,约束一样存在。
百万级的推送服务,意味着会存在百万个长链接,每一个长链接都须要靠和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}} { //心跳处理 } } }
对于长连接,每一个链路都须要维护本身的消息接收和发送缓冲区,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资源来换内存资源,具体的策略以下:
ByteBuffer支持容量的扩展和收缩,能够按需灵活调整,以节约内存;
接收消息的时候,能够按照指定的算法对以前接收的消息大小进行分析,并预测将来的消息大小,按照预测值灵活调整缓冲区容量,以作到最小的资源损耗知足程序正常功能。
幸运的是,Netty提供的ByteBuf支持容量动态调整,对于接收缓冲区的内存分配器,Netty提供了两种:
FixedRecvByteBufAllocator:固定长度的接收缓冲区分配器,由它分配的ByteBuf长度都是固定大小的,并不会根据实际数据报的大小动态收缩。可是,若是容量不足,支持动态扩展。动态扩展是Netty ByteBuf的一项基本功能,与ByteBuf分配器的实现没有关系;
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 构造指定容量的缓冲区
推送服务器承载了海量的长连接,每一个长连接实际就是一个会话。若是每一个会话都持有心跳数据、接收缓冲区、指令集等数据结构,并且这些实例随着消息的处理朝生夕灭,这就会给服务器带来沉重的GC压力,同时消耗大量的内存。
最有效的解决策略就是使用内存池,每一个NioEventLoop线程处理N个链路,在线程内部,链路的处理时串行的。假如A链路首先被处理,它会建立接收缓冲区等对象,待解码完成以后,构造的POJO对象被封装成Task后投递到后台的线程池中执行,而后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的建立和释放。若是使用内存池,则当A链路接收到新的数据报以后,从NioEventLoop的内存池中申请空闲的ByteBuf,解码完成以后,调用release将ByteBuf释放到内存池中,供后续B链路继续使用。
使用内存池优化以后,单个NioEventLoop的ByteBuf申请和GC次数从原来的N = 1000000/64 = 15625 次减小为最少0次(假设每次申请都有可用的内存)。
下面咱们以推特使用Netty4的PooledByteBufAllocator进行GC优化做为案例,对内存池的效果进行评估,结果以下:
垃圾生成速度是原来的1/5,而垃圾清理速度快了5倍。使用新的内存池机制,几乎能够把网络带宽压满。
Netty4以前的版本问题以下:每当收到新信息或者用户发送信息到远程端,Netty 3均会建立一个新的堆缓冲区。这意味着,对应每个新的缓冲区,都会有一个new byte[capacity]。这些缓冲区会致使GC压力,并消耗内存带宽。为了安全起见,新的字节数组分配时会用零填充,这会消耗内存带宽。然而,用零填充的数组极可能会再次用实际的数据填充,这又会消耗一样的内存带宽。若是Java虚拟机(JVM)提供了建立新字节数组而又无需用零填充的方式,那么咱们原本就能够将内存带宽消耗减小50%,可是目前没有那样一种方式。
在Netty 4中实现了一个新的ByteBuf内存池,它是一个纯Java版本的 jemalloc (Facebook也在用)。如今,Netty不会再由于用零填充缓冲区而浪费内存带宽了。不过,因为它不依赖于GC,开发人员须要当心内存泄漏。若是忘记在处理程序中释放缓冲区,那么内存使用率会无限地增加。
Netty默认不使用内存池,须要在建立客户端或者服务端的时候进行指定,代码以下:
Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
使用内存池以后,内存的申请和释放必须成对出现,即retain()和release()要成对出现,不然会致使内存泄露。
值得注意的是,若是使用内存池,完成ByteBuf的解码工做以后必须显式的调用ReferenceCountUtil.release(msg)对接收缓冲区ByteBuf进行内存释放,不然它会被认为仍然在使用中,这样会致使内存泄露。
一般状况下,你们都知道不能在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(); } }
相似这类BUG具备极强的隐蔽性,每每WIO高的时间持续很是短,或者是偶现的,在测试环境中很难模拟此类故障,问题定位难度很是大。这就要求读者在平时写代码的时候必定要小心,注意那些隐性地雷。
经常使用的TCP参数,例如TCP层面的接收和发送缓冲区大小设置,在Netty中分别对应ChannelOption的SO_SNDBUF和SO_RCVBUF,须要根据推送消息的大小,合理设置,对于海量长链接,一般32K是个不错的选择。
另一个比较经常使用的优化手段就是软中断,如图所示:若是全部的软中断都运行在CPU0相应网卡的硬件中断上,那么始终都是cpu0在处理软中断,而此时其它CPU资源就被浪费了,由于没法并行的执行多个软中断。
图3-3 中断信息
大于等于2.6.35版本的Linux kernel内核,开启RPS,网络通讯性能提高20%之上。RPS的基本原理:根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash值,而后根据这个hash值来选择软中断运行的cpu。从上层来看,也就是说将每一个链接和cpu绑定,并经过这个hash值,来均衡软中断运行在多个cpu上,从而提高通讯性能。
最重要的参数调整有两个:
-Xmx:JVM最大内存须要根据内存模型进行计算并得出相对合理的值;
GC相关的参数: 例如新生代和老生代、永久代的比例,GC的策略,新生代各区的比例等,须要根据具体的场景进行设置和测试,并不断的优化,尽可能将Full GC的频率降到最低。
李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通讯软件的设计和开发工做,有6年NIO设计和开发经验,精通Netty、Mina等NIO框架。Netty中国社区创始人,《Netty权威指南》做者。
联系方式:新浪微博 Nettying 微信:Nettying
感谢郭蕾对本文的策划和审校。
给InfoQ中文站投稿或者参与内容翻译工做,请邮件至editors@cn.infoq.com。也欢迎你们经过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注咱们,并与咱们的编辑和其余读者朋友交流。
Geekbang【微信群免费直播】全球架构师峰会四大专题知识【微信群免费直播】。一、互联网金融 二、大数据背后的价值 三、数据分析与企业架构 四、研发体系构建。扫描下方二维码回复“报名”便可进入报名通道或加geekbang01公众号后回复“报名”便可进入报名通道。