转蝲自:http://www.open-open.com/lib/view/open1420623195375.html
1. 背景
1.1. 话题来源
最q很多从事移动互联网和物联网开发的同学l我发邮件或者微博私信我Q咨询推送服务相关的问题。问题五花八门,在帮助大家答疑解惑的q程中,我也寚w题进行了ȝQ大概可以归Uؓ如下几类Q?/p>
- Netty是否可以做推送服务器Q?/li>
- 如果使用Netty开发推送服务,一个服务器最多可以支撑多个客户端?
- 使用Netty开发推送服务遇到的各种技术问题?/li>
׃咨询者众多,x点也比较集中Q我希望通过本文的案例分析和Ҏ送服务设计要点的ȝQ帮助大家在实际工作中少走弯路?/p>
1.2. 推送服?/h3>
Ud互联|时代,推?Push)服务成ؓApp应用不可或缺的重要组成部分,推送服务可以提升用Lz跃度和留存率。我们的手机每天接收到各U各Lq告和提C消息等大多数都是通过推送服务实现的?/p>
随着物联|的发展Q大多数的智能家居都支持Ud推送服务,未来所有接入物联网的智能设备都是推送服务的客户端,q就意味着推送服务未来会面͋量的设备和l端接入?/p>
1.3. 推送服务的特点
Ud推送服务的主要特点如下Q?/p>
- 使用的网l主要是q营商的无线Ud|络Q网l质量不E_Q例如在地铁上信号就很差Q容易发生网l闪断;
- 量的客L接入Q而且通常使用长连接,无论是客Lq是服务端,资源消耗都非常大;
- ׃h的推送框架无法在国内使用QAndroid的长q接是由每个应用各自l护的,q就意味着每台安卓讑֤上会存在多个长连接。即便没有消息需要推送,长连接本w的心蟩消息量也是非常巨大的Q这׃D量和耗电量的增加Q?/li>
- 不稳定:消息丢失、重复推送、gq送达、过期推送时有发生;
- 垃圾消息满天飞,~Zl一的服务治理能力?/li>
Z解决上述弊端Q一些企业也l出了自q解决ҎQ例如京东云推出的推送服务,可以实现多应用单服务单连接模式,使用AlarmManager定时心蟩节省电量和流量?/p>
2. 家居领域的一个真实案?/h2>2.1. 问题描述
家居MQTT消息服务中间Ӟ保持10万用户在UKq接Q?万用户ƈ发做消息h。程序运行一D|间之后,发现内存泄露Q怀疑是Netty的Bug。其它相关信息如下:
- MQTT消息服务中间件服务器内存16GQ?个核心CPUQ?/li>
- Netty中bossU程池大ؓ1QworkerU程池大ؓ6Q其余线E分配给业务使用。该分配方式后来调整为workerU程池大ؓ11Q问题依旧;
- Netty版本?.0.8.Final?/li>
2.2. 问题定位
首先需要dump内存堆栈Q对疑似内存泄露的对象和引用关系q行分析Q如下所C:
![Netty百万U推送服? src=]()
我们发现Netty的ScheduledFutureTask增加?076%Q达?10W个左右的实例Q通过对业务代码的分析发现用户使用IdleStateHandler用于在链路空闲时q行业务逻辑处理Q但是空闲时间设|的比较大,?5分钟?/p>
Netty 的IdleStateHandler会根据用L使用场景Q启动三cd时Q务,分别是:ReaderIdleTimeoutTask?WriterIdleTimeoutTask和AllIdleTimeoutTaskQ它们都会被加入到NioEventLoop的Task队列中被调度 和执行?/p>
?于超时时间过长,10W个长链接链\会创?0W个ScheduledFutureTask对象Q每个对象还保存有业务的成员变量Q非常消耗内存。用L 持久代设|的比较大,一些定时Q务被老化到持久代中,没有被JVM垃圾回收掉,内存一直在增长Q用戯认ؓ存在内存泄露?/p>
事实上,我们q一步分析发玎ͼ用户的超时时间设|的非常不合理,15分钟的超时达不到设计目标Q重新设计之后将时旉讄?5U,内存可以正常回收Q问题解冟?/p>
2.3. 问题ȝ
如果?00个长q接Q即便是长周期的定时dQ也不存在内存泄露问题,在新生代通过minor GC可以实现内存回收。正是因为十万的长q接Q导致小问题被放大,引出了后l的各种问题?/p>
事实上,如果用户实有长周期q行的定时Q务,该如何处理?对于量长连接的推送服务,代码处理E有不慎Q就满盘皆输Q下面我们针对Netty的架构特点,介绍下如何用Netty实现百万U客L的推送服务?/p>
3. Netty量推送服务设计要?/h2>
作ؓ高性能的NIO框架Q利用Netty开发高效的推送服务技术上是可行的Q但是由于推送服务自w的复杂性,惌开发出E_、高性能的推送服务ƈ非易事,需要在设计阶段针对推送服务的特点q行合理设计?/p>
3.1. 最大句柄数修改
百万长连接接入,首先需要优化的是Linux内核参数Q其中Linux最大文件句柄数是最重要的调优参C一Q默认单q程打开的最大句柄数?024Q通过ulimit -a可以查看相关参数Q示例如下:
[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 ......后箋输出省略
当单个推送服务接收到的链接超q上限后Q就会报“too many open files”Q所有新的客L接入失败?/p>
通过vi /etc/security/limits.conf d如下配置参数Q修改之后保存,注销当前用户Q重新登录,通过ulimit -a 查看修改的状态是否生效?/p>
* soft nofile 1000000 * hard nofile 1000000
需要指出的是,管我们可以单个进E打开的最大句柄数修改的非常大Q但是当句柄数达C定数量之后Q处理效率将出现明显下降Q因此,需要根据服务器的硬仉|和处理能力q行合理讄。如果单个服务器性能不行也可以通过集群的方式实现?/p>
3.2. 当心CLOSE_WAIT
从事Ud推送服务开发的同学可能都有体会Q移动无U网l可靠性非常差Q经常存在客L重置q接Q网l闪断等?/p>
在百万长q接的推送系l中Q服务端需要能够正处理这些网l异常,设计要点如下Q?/p>
- 客户端的重连间隔需要合理设|,防止q接q于频繁D的连接失败(例如端口q没有被释放Q;
- 客户端重复登陆拒l机Ӟ
- 服务端正处理I/O异常和解码异常等Q防止句柄泄霌Ӏ?/li>
最 后特别需要注意的一点就是close_wait q多问题Q由于网l不E_l常会导致客L断连Q如果服务端没有能够及时关闭socketQ就会导致处于close_wait状态的链\q多?close_wait状态的链\q不释放句柄和内存等资源Q如果积压过多可能会Dpȝ句柄耗尽Q发?#8220;Too many open files”异常Q新的客L无法接入Q涉及创建或者打开句柄的操作都失败?/p>
下面对close_wait状态进行下单介l,被动关闭TCPq接状态迁Ud如下所C:
![Netty百万U推送服? src=]()
?-1 被动关闭TCPq接状态迁Ud
close_wait 是被动关闭连接是形成的,ҎTCP状态机Q服务器端收到客L发送的FINQTCP协议栈会自动发送ACKQ链接进入close_wait状态。但如果 服务器端不执行socket的close()操作Q状态就不能由close_waitq移到last_ackQ则pȝ中会存在很多close_wait?态的q接。通常来说Q一个close_wait会维持至?个小时的旉Q系l默认超时时间的?200U,也就?时Q。如果服务端E序因某个原因导 致系l造成一堆close_wait消耗资源,那么通常是等不到释放那一刻,pȝ已崩溃?/p>
Dclose_waitq多的可能原因如下:
- E序处理BugQ导致接收到Ҏ的fin之后没有及时关闭socketQ这可能是Netty的BugQ也可能是业务层BugQ需要具体问题具体分析;
- 关闭socket不及Ӟ例如I/OU程被意外阻塞,或者I/OU程执行的用戯定义Task比例q高Q导致I/O操作处理不及Ӟ链\不能被及旉放?/li>
下面我们l合Netty的原理,Ҏ在的故障点进行分析?/p>
?计要?Q不要在Netty的I/OU程上处理业务(心蟩发送和除外)。Why? 对于Javaq程Q线E不能无限增长,q就意味着Netty的ReactorU程数必L敛。Netty的默认值是CPU核数 * 2Q通常情况下,I/O密集型应用徏议线E数量讄大些Q但q主要是针对传统同步I/O而言Q对于非dI/OQ线E数q不讄太大Q尽没有最?|但是I/OU程数经验值是[CPU核数 + 1QCPU核数*2 ]之间?/p>
?如单个服务器支撑100万个长连接,服务器内核数?2Q则单个I/OU程处理的链接数L = 100/(32 * 2) = 15625?假如?S有一ơ消息交互(新消息推送、心x息和其它理消息Q,则^均CAPS = 15625 / 5 = 3125?U。这个数值相比于Netty的处理性能而言压力q不大,但是在实际业务处理中Q经怼有一些额外的复杂逻辑处理Q例如性能l计、记录接口日 志等Q这些业务操作性能开销也比较大Q如果在I/OU程上直接做业务逻辑处理Q可能会dI/OU程Q媄响对其它链\的读写操作,q就会导致被动关闭的?路不能及时关闭,造成close_wait堆积?/p>
设计要点2Q在I/OU程上执行自定义Task要当心。Netty的I/O处理U程NioEventLoop支持两种自定义Task的执行:
- 普通的Runnable: 通过调用NioEventLoop的execute(Runnable task)Ҏ执行Q?/li>
- 定时dScheduledFutureTask:通过调用NioEventLoop的schedule(Runnable command, long delay, TimeUnit unit)pd接口执行?/li>
Z么NioEventLoop要支持用戯定义Runnable和ScheduledFutureTask的执行,q不是本文要讨论的重点,后箋会有专题文章q行介绍。本文重点对它们的媄响进行分析?/p>
?NioEventLoop中执行Runnable和ScheduledFutureTaskQ意味着允许用户在NioEventLoop中执行非I/O?作类的业务逻辑Q这些业务逻辑通常用消息报文的处理和协议管理相兟뀂它们的执行会抢占NioEventLoop I/Od的CPU旉Q如果用戯定义Taskq多Q或者单个Task执行周期q长Q会DI/Od操作被阻塞,q样也间接导致close_wait 堆积?/p>
所 以,如果用户在代码中使用CRunnable和ScheduledFutureTaskQ请合理讄ioRatio的比例,通过 NioEventLoop的setIoRatio(int ioRatio)Ҏ可以讄该|默认gؓ50Q即I/O操作和用戯定义d的执行时间比?Q??/p>
我的是当服务端处理v量客L长连接的时候,不要在NioEventLoop中执行自定义TaskQ或者非心蟩cȝ定时d?/p>
?计要?QIdleStateHandler使用要当心。很多用户会使用IdleStateHandler做心跛_送和,q种用法值得提倡。相比于?己启定时d发送心跻Iq种方式更高效。但是在实际开发中需要注意的是,在心跳的业务逻辑处理中,无论是正常还是异常场景,处理时g要可控,防止时g不可 控导致的NioEventLoop被意外阻塞。例如,心蟩时或者发生I/O异常Ӟ业务调用Email发送接口告警,׃Email服务端处理超Ӟ?致邮件发送客L被阻塞,U联引vIdleStateHandler的AllIdleTimeoutTaskd被阻塞,最lNioEventLoop?路复用器上其它的链\d被阻塞?/p>
对于ReadTimeoutHandler和WriteTimeoutHandlerQ约束同样存在?/p>
3.3. 合理的心跛_?/h3>
百万U的推送服务,意味着会存在百万个长连接,每个长连接都需要靠和App之间的心xl持链\。合理设|心跛_期是非常重要的工作,推送服务的心蟩周期讄需要考虑Ud无线|络的特炏V?/p>
?一台智能手上移动网l时Q其实ƈ没有真正q接上InternetQ运营商分配l手机的IP其实是运营商的内|IPQ手机终端要q接上Internet q必通过q营商的|关q行IP地址的{换,q个|关UCؓNAT(NetWork Address Translation)Q简单来说就是手机终端连接Internet 其实是Ud内网IPQ端口,外网IP之间怺映射?/p>
GGSN(GateWay GPRS Support Note)模块实CNAT功能Q由于大部分的移动无U网l运营商Z减少|关NAT映射表的负荷Q如果一个链路有一D|间没有通信时就会删除其对应 表,造成链\中断Q正是这U刻意羃短空闲连接的释放时Q原本是惌省信道资源的作用Q没惛_让互联网的应用不得以q高于正帔R率发送心xl护推送的?q接。以中移动的2.5G|络ZQ大U?分钟左右的基带空Ԍq接׃被释放?/p>
?于移动无U网l的特点Q推送服务的心蟩周期q不能设|的太长Q否则长q接会被释放Q造成频繁的客L重连Q但是也不能讄太短Q否则在当前~Zl一心蟩?架的机制下很ҎD信o风暴Q例如微信心跳信令风暴问题)。具体的心蟩周期q没有统一的标准,180S也许是个不错的选择Q微信ؓ300S?/p>
在Netty中,可以通过在ChannelPipeline中增加IdleStateHandler的方式实现心x,在构造函C指定链\I闲旉Q然后实现空闲回调接口,实现心蟩的发送和,代码如下Q?/p>
public void initChannel({@link Channel} channel) { channel.pipeline().addLast("idleStateHandler", new {@link IdleStateHandler}(0, 0, 180)); channel.pipeline().addLast("myHandler", new MyHandler()); } 拦截链\I闲事gq处理心跻I 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. 合理讄接收和发送缓冲区定w
对于镉K接,每个链\都需要维护自q消息接收和发送缓冲区QJDK原生的NIOcd使用的是java.nio.ByteBuffer,它实际是一个长度固定的Byte数组Q我们都知道数组无法动态扩容,ByteBuffer也有q个限制Q相关代码如下:
public abstract class ByteBuffer extends Buffer implements Comparable { final byte[] hb; // Non-null only for heap buffers final int offset; boolean isReadOnly;
?量无法动态扩展会l用户带来一些麻烦,例如׃无法预测每条消息报文的长度,可能需要预分配一个比较大的ByteBufferQ这通常也没有问题。但是在 量推送服务系l中Q这会给服务端带来沉重的内存负担。假讑֍条推送消息最大上限ؓ10KQ消息^均大ؓ5KQؓ了满?0K消息的处 理,ByteBuffer的容量被讄?0KQ这h条链路实际上多消耗了5K内存Q如果长链接链\Cؓ100万,每个链\都独立持?ByteBuffer接收~冲区,则额外损耗的d?Total(M) = 1000000 * 5K = 4882M。内存消耗过大,不仅仅增加了g成本Q而且大内存容易导致长旉的Full GCQ对pȝE_性会造成比较大的冲击?/p>
实际上,最灉|的处理方式就是能够动态调整内存,x收缓冲区可以Ҏ以往接收的消息进行计,动态调整内存,利用CPU资源来换内存资源Q具体的{略如下Q?/p>
- ByteBuffer支持定w的扩展和收羃Q可以按需灉|调整Q以节约内存Q?/li>
- 接收消息的时候,可以按照指定的算法对之前接收的消息大进行分析,q未来的消息大小Q按照预值灵z调整缓冲区定wQ以做到最的资源损耗满程序正常功能?/li>
q运的是QNetty提供的ByteBuf支持定w动态调_对于接收~冲区的内存分配器,Netty提供了两U:
- FixedRecvByteBufAllocatorQ固定长度的接收~冲区分配器Q由它分配的ByteBuf长度都是固定大小的,q不会根据实际数据报 的大动态收~。但是,如果定w不Q支持动态扩展。动态扩展是Netty ByteBuf的一基本功能,与ByteBuf分配器的实现没有关系Q?/li>
- AdaptiveRecvByteBufAllocatorQ容量动态调整的接收~冲区分配器Q它会根据之前Channel接收到的数据报大进行计, 如果q箋填充满接收缓冲区的可写空_则动态扩展容量。如果连l?ơ接收到的数据报都小于指定|则收~当前的定wQ以节约内存?/li>
相对于FixedRecvByteBufAllocatorQ用AdaptiveRecvByteBufAllocator更ؓ合理Q可以在创徏客户端或者服务端的时候指定RecvByteBufAllocatorQ代码如下:
Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT)
如果默认没有讄Q则使用AdaptiveRecvByteBufAllocator?/p>
另外值得注意的是Q无论是接收~冲是发送缓冲区Q缓冲区的大徏议设|ؓ消息的^均大,不要讄成最大消息的上限Q这会导致额外的内存费。通过如下方式可以讄接收~冲区的初始大小Q?/p>
/** * 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)
对于消息发送,通常需要用戯己构造ByteBufq编码,例如通过如下工具cd建消息发送缓冲区Q?/p>
![Netty百万U推送服? src=]()
?-2 构造指定容量的~冲?/p>
3.5. 内存?/h3>
推送服务器承蝲了v量的镉K接,每个镉K接实际就是一个会话。如果每个会话都持有心蟩数据、接收缓冲区、指令集{数据结构,而且q些实例随着消息的处理朝生夕灭,q就会给服务器带来沉重的GC压力Q同时消耗大量的内存?/p>
最 有效的解决策略就是用内存池Q每个NioEventLoopU程处理N个链路,在线E内部,链\的处理时串行的。假如A链\首先被处理,它会创徏接收~?冲区{对象,待解码完成之后,构造的POJO对象被封装成Task后投递到后台的线E池中执行,然后接收~冲Z被释放,每条消息的接收和处理都会重复?收缓冲区的创建和释放。如果用内存池Q则当A链\接收到新的数据报之后Q从NioEventLoop的内存池中申L闲的ByteBufQ解码完成之 后,调用releaseByteBuf释放到内存池中,供后lB链\l箋使用?/p>
使用内存池优化之后,单个NioEventLoop的ByteBuf甌和GCơ数从原来的N = 1000000/64 = 15625 ơ减ؓ最?ơ(假设每次甌都有可用的内存)?/p>
下面我们以推特用Netty4的PooledByteBufAllocatorq行GC优化作ؓ案例Q对内存池的效果q行评估Q结果如下:
垃圾生成速度是原来的1/5Q而垃圾清理速度快了5倍。用新的内存池机制Q几乎可以把|络带宽压满?/p>
Netty4 之前的版本问题如下:每当收到C息或者用户发送信息到q程端,Netty 3均会创徏一个新的堆~冲区。这意味着Q对应每一个新的缓冲区Q都会有一个new byte[capacity]。这些缓冲区会导致GC压力Qƈ消耗内存带宽。ؓ了安全v见,新的字节数组分配时会用零填充Q这会消耗内存带宽。然而,用零 填充的数l很可能会再ơ用实际的数据填充,q又会消耗同L内存带宽。如果Java虚拟机(JVMQ提供了创徏新字节数l而又无需用零填充的方式,那么?们本来就可以内存带宽消耗减?0%Q但是目前没有那样一U方式?/p>
在Netty 4中实C一个新的ByteBuf内存池,它是一个纯Java版本?nbsp;jemalloc QFacebook也在用)。现在,Netty不会再因为用零填充缓冲区而浪费内存带宽了。不q,׃它不依赖于GCQ开发h员需要小心内存泄漏。如果忘记在处理E序中释攄冲区Q那么内存用率会无限地增长?/p>
Netty默认不用内存池Q需要在创徏客户端或者服务端的时候进行指定,代码如下Q?/p>
Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
使用内存池之后,内存的申请和释放必须成对出现Q即retain()和release()要成对出玎ͼ否则会导致内存泄霌Ӏ?/p>
值得注意的是Q如果用内存池Q完成ByteBuf的解码工作之后必L式的调用ReferenceCountUtil.release(msg)Ҏ收缓冲区ByteBufq行内存释放Q否则它会被认ؓ仍然在用中Q这样会D内存泄露?/p>
3.6. 当心“日志隐Ş杀?#8221;
通常情况下,大家都知道不能在Netty的I/OU程上做执行旉不可控的操作Q例如访问数据库、发送Email{。但是有个常用但是非常危险的操作却容易被忽略Q那便是记录日志?/p>
?常,在生产环境中Q需要实时打印接口日志,其它日志处于ERRORU别Q当推送服务发生I/O异常之后Q会记录异常日志。如果当前磁盘的WIO比较高,?能会发生写日志文件操作被同步dQ阻塞时间无法预。这׃DNetty的NioEventLoopU程被阻塞,Socket链\无法被及时关闭、其 它的链\也无法进行读写操作等?/p>
以最常用的log4jZQ尽它支持异步写日志(AsyncAppenderQ,但是当日志队列满之后Q它会同步阻塞业务线E,直到日志队列有空闲位|可用,相关代码如下Q?/p>
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)) //判断是业务线E? { try { this.buffer.wait();//d业务U程 discard = false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
cMq类BUGh极强的隐蔽性,往往WIO高的旉持箋非常短,或者是偶现的,在测试环境中很难模拟此类故障Q问题定位难度非常大。这p求读者在qx写代码的时候一定要当心Q注意那些隐性地雗?/p>
3.7. TCP参数优化
常用的TCP参数Q例如TCP层面的接收和发送缓冲区大小讄Q在Netty中分别对应ChannelOption的SO_SNDBUF和SO_RCVBUFQ需要根据推送消息的大小Q合理设|,对于量长连接,通常32K是个不错的选择?/p>
另外一个比较常用的优化手段是软中断,如图所C:如果所有的软中断都q行在CPU0相应|卡的硬件中断上Q那么始l都是cpu0在处理Y中断Q而此时其它CPU资源p费了,因ؓ无法q行的执行多个Y中断?/p>
![Netty百万U推送服? src=]()
?-3 中断信息
?于等?.6.35版本的Linux kernel内核Q开启RPSQ网l通信性能提升20%之上。RPS的基本原理:Ҏ数据包的源地址Q目的地址以及目的和源端口Q计出一个hash| 然后Ҏq个hash值来选择软中断运行的cpu。从上层来看Q也是说将每个q接和cpul定Qƈ通过q个hash|来均衡Y中断q行在多个cpu 上,从而提升通信性能?/p>
3.8. JVM参数
最重要的参数调整有两个Q?/p>
- -Xmx:JVM最大内存需要根据内存模型进行计ƈ得出相对合理的|
- GC相关的参? 例如新生代和老生代、永久代的比例,GC的策略,新生代各区的比例{,需要根据具体的场景q行讄和测试,q不断的优化Q尽量将Full GC的频率降到最低?/li>
4. 作者简?/h2>
李林锋,2007q毕业于东北大学Q?008q进入华为公总事高性能通信软g的设计和开发工作,?qNIO设计和开发经验,_NNetty、Mina{NIO框架。Netty中国C创始人,《Netty权威指南》作者?/p>
]]>