Netty系列之Netty可靠性分析

 

做者 李林锋 发布于 2014年6月19日 | 29 讨论程序员

 

1. 背景

1.1. 宕机的代价

1.1.1. 电信行业

毕马威国际(KPMG International)在对46个国家的74家运营商进行调查后发现,全球通讯行业每一年的收益流失约为400亿美圆,占总收入的1%-3%。致使收益流失的因素有多种,主要缘由就是计费BUG。编程

1.1.2. 互联网行业

美国太平洋时间8月16日下午3点50分到3点55分(北京时间8月17日6点50分到6点55分),谷歌遭遇了宕机。根据过后统计,短短的5分钟,谷歌损失了54.5万美圆。也就是服务每中断一分钟,损失就达10.8万美圆。canvas

2013年,从美国东部时间8月19日下午2点45分开始,有用户率先发现了亚马逊网站出现宕机,大约在20多分钟后又恢复正常。这次宕机让亚马逊每分钟损失近6.7万美圆,在宕机期间,消费者没法经过Amazon.com、亚马逊移动端以及Amazon.ca等网站进行购物。后端

 

1.2. 软件可靠性

软件可靠性是指在给定时间内,特定环境下软件无错运行的几率。软件可靠性包含了如下三个要素:微信

1) 规定的时间:软件可靠性只是体如今其运行阶段,因此将运行时间做为规定的时间的度量。运行时间包括软件系统运行后工做与挂起(开启但空闲)的累计时间。因为软件运行的环境与程序路径选取的随机性,软件的失效为随机事件,因此运行时间属于随机变量;网络

2) 规定的环境条件:环境条件指软件的运行环境。它涉及软件系统运行时所需的各类支持要素,如支持硬件、操做系统、其它支持软件、输入数据格式和范围以及操做规程等。不一样的环境条件下软件的可靠性是不一样的。具体地说,规定的环境条件主要是描述软件系统运行时计算机的配置状况以及对输入数据的要求,并假定其它一切因素都是理想的。有了明确规定的环境条件,还能够有效判断软件失效的责任在用户方仍是提供方;架构

 

3) 规定的功能:软件可靠性还与规定的任务和功能有关。因为要完成的任务不一样,软件的运行剖面会有所区别,则调用的子模块就不一样(即程序路径选择不一样),其可靠性也就可能不一样。因此要准确度量软件系统的可靠性必须首先明确它的任务和功能。

1.3. Netty的可靠性

首先,咱们要从Netty的主要用途来分析它的可靠性,Netty目前的主流用法有三种:

1) 构建RPC调用的基础通讯组件,提供跨节点的远程服务调用能力;

2) NIO通讯框架,用于跨节点的数据交换;

3) 其它应用协议栈的基础通讯组件,例如HTTP协议以及其它基于Netty开发的应用层协议栈。

以阿里的分布式服务框架Dubbo为例,Netty是Dubbo RPC框架的核心。它的服务调用示例图以下:

图1-1 Dubbo的节点角色说明图

其中,服务提供者和服务调用者之间能够经过Dubbo协议进行RPC调用,消息的收发默认经过Netty完成。

经过对Netty主流应用场景的分析,咱们发现Netty面临的可靠性问题大体分为三类:

1) 传统的网络I/O故障,例如网络闪断、防火墙Hang住链接、网络超时等;

2) NIO特有的故障,例如NIO类库特有的BUG、读写半包处理异常、Reactor线程跑飞等等;

3) 编解码相关的异常。

在大多数的业务应用场景中,一旦由于某些故障致使Netty不能正常工做,业务每每会陷入瘫痪。因此,从业务诉求来看,对Netty框架的可靠性要求是很是的高。做为当前业界最流行的一款NIO框架,Netty在不一样行业和领域都获得了普遍的应用,它的高可靠性已经获得了成百上千的生产系统检验。

Netty是如何支持系统高可靠性的?下面,咱们就从几个不一样维度出发一探究竟。

2. Netty高可靠性之道

2.1. 网络通讯类故障

2.1.1. 客户端链接超时

在传统的同步阻塞编程模式下,客户端Socket发起网络链接,每每须要指定链接超时时间,这样作的目的主要有两个:

1) 在同步阻塞I/O模型中,链接操做是同步阻塞的,若是不设置超时时间,客户端I/O线程可能会被长时间阻塞,这会致使系统可用I/O线程数的减小;

2) 业务层须要:大多数系统都会对业务流程执行时间有限制,例如WEB交互类的响应时间要小于3S。客户端设置链接超时时间是为了实现业务层的超时。

JDK原生的Socket链接接口定义以下:

图2-1 JDK Socket链接超时接口

对于NIO的SocketChannel,在非阻塞模式下,它会直接返回链接结果,若是没有链接成功,也没有发生IO异常,则须要将SocketChannel注册到Selector上监听链接结果。因此,异步链接的超时没法在API层面直接设置,而是须要经过定时器来主动监测。

下面咱们首先看下JDK NIO类库的SocketChannel链接接口定义:

图2-2 JDK NIO 类库SocketChannel链接接口

从上面的接口定义能够看出,NIO类库并无现成的链接超时接口供用户直接使用,若是要在NIO编程中支持链接超时,每每须要NIO框架或者用户本身封装实现。

下面咱们看下Netty是如何支持链接超时的,首先,在建立NIO客户端的时候,能够配置链接超时参数:

图2-3 Netty客户端建立支持设置链接超时参数

设置完链接超时以后,Netty在发起链接的时候,会根据超时时间建立ScheduledFuture挂载在Reactor线程上,用于定时监测是否发生链接超时,相关代码以下:

图2-4 根据链接超时建立超时监测定时任务

建立链接超时定时任务以后,会由NioEventLoop负责执行。若是已经链接超时,可是服务端仍然没有返回TCP握手应答,则关闭链接,代码如上图所示。

若是在超时期限内处理完成链接操做,则取消链接超时定时任务,相关代码以下:

图2-5 取消链接超时定时任务

Netty的客户端链接超时参数与其它经常使用的TCP参数一块儿配置,使用起来很是方便,上层用户不用关心底层的超时实现机制。这既知足了用户的个性化需求,又实现了故障的分层隔离。

2.1.2. 通讯对端强制关闭链接

在客户端和服务端正常通讯过程当中,若是发生网络闪断、对方进程忽然宕机或者其它非正常关闭链路事件时,TCP链路就会发生异常。因为TCP是全双工的,通讯双方都须要关闭和释放Socket句柄才不会发生句柄的泄漏。

在实际的NIO编程过程当中,咱们常常会发现因为句柄没有被及时关闭致使的功能和可靠性问题。究其缘由总结以下:

1) IO的读写等操做并不是仅仅集中在Reactor线程内部,用户上层的一些定制行为可能会致使IO操做的外逸,例如业务自定义心跳机制。这些定制行为加大了统一异常处理的难度,IO操做愈加散,故障发生的几率就越大;

2) 一些异常分支没有考虑到,因为外部环境诱因致使程序进入这些分支,就会引发故障。

下面咱们经过故障模拟,看Netty是如何处理对端链路强制关闭异常的。首先启动Netty服务端和客户端,TCP链路创建成功以后,双方维持该链路,查看链路状态,结果以下:

图2-6 Netty服务端和客户端TCP链路状态正常

强制关闭客户端,模拟客户端宕机,服务端控制台打印以下异常:

图2-7 模拟TCP链路故障

从堆栈信息能够判断,服务端已经监控到客户端强制关闭了链接,下面咱们看下服务端是否已经释放了链接句柄,再次执行netstat命令,执行结果以下:

图2-8 查看故障链路状态

从执行结果能够看出,服务端已经关闭了和客户端的TCP链接,句柄资源正常释放。由此能够得出结论,Netty底层已经自动对该故障进行了处理。

下面咱们一块儿看下Netty是如何感知到链路关闭异常并进行正确处理的,查看AbstractByteBuf的writeBytes方法,它负责将指定Channel的缓冲区数据写入到ByteBuf中,详细代码以下:

图2-9 AbstractByteBuf的writeBytes方法

在调用SocketChannel的read方法时发生了IOException,代码以下:

图2-10 读取缓冲区数据发生IO异常

为了保证IO异常被统一处理,该异常向上抛,由AbstractNioByteChannel进行统一异常处理,代码以下:

图2-11 链路异常退出异常处理

为了可以对异常策略进行统一,也为了方便维护,防止处理不当致使的句柄泄漏等问题,句柄的关闭,统一调用AbstractChannel的close方法,代码以下:

图2-12 统一的Socket句柄关闭接口

2.1.3. 正常的链接关闭

对于短链接协议,例如HTTP协议,通讯双方数据交互完成以后,一般按照双方的约定由服务端关闭链接,客户端得到TCP链接关闭请求以后,关闭自身的Socket链接,双方正式断开链接。

在实际的NIO编程过程当中,常常存在一种误区:认为只要是对方关闭链接,就会发生IO异常,捕获IO异常以后再关闭链接便可。实际上,链接的合法关闭不会发生IO异常,它是一种正常场景,若是遗漏了该场景的判断和处理就会致使链接句柄泄漏。

下面咱们一块儿模拟故障,看Netty是如何处理的。测试场景设计以下:改造下Netty客户端,双发链路创建成功以后,等待120S,客户端正常关闭链路。看服务端是否可以感知并释放句柄资源。

首先启动Netty客户端和服务端,双方TCP链路链接正常:

图2-13 TCP链接状态正常

120S以后,客户端关闭链接,进程退出,为了可以看到整个处理过程,咱们在服务端的Reactor线程处设置断点,先不作处理,此时链路状态以下:

图2-14 TCP链接句柄等待释放

从上图能够看出,此时服务端并无关闭Socket链接,链路处于CLOSE_WAIT状态,放开代码让服务端执行完,结果以下:

图2-15 TCP链接句柄正常释放

下面咱们一块儿看下服务端是如何判断出客户端关闭链接的,当链接被对方合法关闭后,被关闭的SocketChannel会处于就绪状态,SocketChannel的read操做返回值为-1,说明链接已经被关闭,代码以下:

图2-16 须要对读取的字节数进行判断

若是SocketChannel被设置为非阻塞,则它的read操做可能返回三个值:

1) 大于0,表示读取到了字节数;

2) 等于0,没有读取到消息,可能TCP处于Keep-Alive状态,接收到的是TCP握手消息;

3) -1,链接已经被对方合法关闭。

经过调试,咱们发现,NIO类库的返回值确实为-1:

图2-17 链路正常关闭,返回值为-1

得知链接关闭以后,Netty将关闭操做位设置为true,关闭句柄,代码以下:

图2-18 链接正常关闭,释放资源

2.1.4. 故障定制

在大多数场景下,当底层网络发生故障的时候,应该由底层的NIO框架负责释放资源,处理异常等。上层的业务应用不须要关心底层的处理细节。可是,在一些特殊的场景下,用户可能须要感知这些异常,并针对这些异常进行定制处理,例如:

1) 客户端的断连重连机制;

2) 消息的缓存重发;

3) 接口日志中详细记录故障细节;

4) 运维相关功能,例如告警、触发邮件/短信等

Netty的处理策略是发生IO异常,底层的资源由它负责释放,同时将异常堆栈信息以事件的形式通知给上层用户,由用户对异常进行定制。这种处理机制既保证了异常处理的安全性,也向上层提供了灵活的定制能力。

具体接口定义以及默认实现以下:

图2-19 故障定制接口

用户能够覆盖该接口,进行个性化的异常定制。例如发起重连等。

2.2. 链路的有效性检测

当网络发生单通、链接被防火墙Hang住、长时间GC或者通讯线程发生非预期异常时,会致使链路不可用且不易被及时发现。特别是异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,因为链路不可用会致使瞬间的大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。

从技术层面看,要解决链路的可靠性问题,必须周期性的对链路进行有效性检测。目前最流行和通用的作法就是心跳检测。

心跳检测机制分为三个层面:

1) TCP层面的心跳检测,即TCP的Keep-Alive机制,它的做用域是整个TCP协议栈;

2) 协议层的心跳检测,主要存在于长链接协议中。例如SMPP协议;

3) 应用层的心跳检测,它主要由各业务产品经过约定方式定时给对方发送心跳消息实现。

心跳检测的目的就是确认当前链路可用,对方活着而且可以正常接收和发送消息。

作为高可靠的NIO框架,Netty也提供了心跳检测机制,下面咱们一块儿熟悉下心跳的检测原理。

图2-20 心跳检测机制

不一样的协议,心跳检测机制也存在差别,概括起来主要分为两类:

1) Ping-Pong型心跳:由通讯一方定时发送Ping消息,对方接收到Ping消息以后,当即返回Pong应答消息给对方,属于请求-响应型心跳;

2) Ping-Ping型心跳:不区分心跳请求和应答,由通讯双方按照约定定时向对方发送心跳Ping消息,它属于双向心跳。

心跳检测策略以下:

1) 连续N次心跳检测都没有收到对方的Pong应答消息或者Ping请求消息,则认为链路已经发生逻辑失效,这被称做心跳超时;

2) 读取和发送心跳消息的时候如何直接发生了IO异常,说明链路已经失效,这被称为心跳失败。

不管发生心跳超时仍是心跳失败,都须要关闭链路,由客户端发起重连操做,保证链路可以恢复正常。

Netty的心跳检测其实是利用了链路空闲检测机制实现的,相关代码以下:

图2-21 心跳检测的代码包路径

Netty提供的空闲检测机制分为三种:

1) 读空闲,链路持续时间t没有读取到任何消息;

2) 写空闲,链路持续时间t没有发送任何消息;

3) 读写空闲,链路持续时间t没有接收或者发送任何消息。

Netty的默认读写空闲机制是发生超时异常,关闭链接,可是,咱们能够定制它的超时实现机制,以便支持不一样的用户场景。

WriteTimeoutHandler的超时接口以下:

图2-22 写超时

ReadTimeoutHandler的超时接口以下:

图2-23 读超时

读写空闲的接口以下:

图2-24 读写空闲

利用Netty提供的链路空闲检测机制,能够很是灵活的实现协议层的心跳检测。在《Netty权威指南》中的私有协议栈设计和开发章节,我利用Netty提供的自定义Task接口实现了另外一种心跳检测机制,感兴趣的朋友能够参阅该书。

2.3. Reactor线程的保护

Reactor线程是IO操做的核心,NIO框架的发动机,一旦出现故障,将会致使挂载在其上面的多路用复用器和多个链路没法正常工做。所以它的可靠性要求很是高。

笔者就曾经遇到过由于异常处理不当致使Reactor线程跑飞,大量业务请求处理失败的故障。下面咱们一块儿看下Netty是如何有效提高Reactor线程的可靠性的。

2.3.1. 异常处理要小心

尽管Reactor线程主要处理IO操做,发生的异常一般是IO异常,可是,实际上在一些特殊场景下会发生非IO异常,若是仅仅捕获IO异常可能就会致使Reactor线程跑飞。为了防止发生这种意外,在循环体内必定要捕获Throwable,而不是IO异常或者Exception。

Netty的相关代码以下:

图2-25 Reactor线程异常保护

捕获Throwable以后,即使发生了意外未知对异常,线程也不会跑飞,它休眠1S,防止死循环致使的异常绕接,而后继续恢复执行。这样处理的核心理念就是:

1) 某个消息的异常不该该致使整条链路不可用;

2) 某条链路不可用不该该致使其它链路不可用;

3) 某个进程不可用不该该致使其它集群节点不可用。

2.3.2. 死循环保护

一般状况下,死循环是可检测、可预防可是没法彻底避免的。Reactor线程一般处理的都是IO相关的操做,所以咱们重点关注IO层面的死循环。

JDK NIO类库最著名的就是 epoll bug了,它会致使Selector空轮询,IO线程CPU 100%,严重影响系统的安全性和可靠性。

SUN在JKD1.6 update18版本声称解决了该BUG,可是根据业界的测试和你们的反馈,直到JDK1.7的早期版本,该BUG依然存在,并无彻底被修复。发生该BUG的主机资源占用图以下:

图2-26 epoll bug CPU空轮询

SUN在解决该BUG的问题上不给力,只能从NIO框架层面进行问题规避,下面咱们看下Netty是如何解决该问题的。

Netty的解决策略:

1) 根据该BUG的特征,首先侦测该BUG是否发生;

2) 将问题Selector上注册的Channel转移到新建的Selector上;

3) 老的问题Selector关闭,使用新建的Selector替换。

下面具体看下代码,首先检测是否发生了该BUG:

图2-27 epoll bug 检测

一旦检测发生该BUG,则重建Selector,代码以下:

图2-28 重建Selector

重建完成以后,替换老的Selector,代码以下:

图2-29 替换Selector

大量生产系统的运行代表,Netty的规避策略能够解决epoll bug 致使的IO线程CPU死循环问题。

2.4. 优雅退出

Java的优雅停机一般经过注册JDK的ShutdownHook来实现,当系统接收到退出指令后,首先标记系统处于退出状态,再也不接收新的消息,而后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行。

一般优雅退出有个时间限制,例如30S,若是到达执行时间仍然没有完成退出前的操做,则由监控脚本直接kill -9 pid,强制退出。

Netty的优雅退出功能随着版本的优化和演进也在不断的加强,下面咱们一块儿看下Netty5的优雅退出。

首先看下Reactor线程和线程组,它们提供了优雅退出接口。EventExecutorGroup的接口定义以下:

图2-30 EventExecutorGroup优雅退出

NioEventLoop的资源释放接口实现:

图2-31 NioEventLoop资源释放

ChannelPipeline的关闭接口:

图2-32 ChannelPipeline关闭接口

目前Netty向用户提供的主要接口和类库都提供了资源销毁和优雅退出的接口,用户的自定义实现类能够继承这些接口,完成用户资源的释放和优雅退出。

2.5. 内存保护

2.5.1. 缓冲区的内存泄漏保护

为了提高内存的利用率,Netty提供了内存池和对象池。可是,基于缓存池实现之后须要对内存的申请和释放进行严格的管理,不然很容易致使内存泄漏。

若是不采用内存池技术实现,每次对象都是以方法的局部变量形式被建立,使用完成以后,只要再也不继续引用它,JVM会自动释放。可是,一旦引入内存池机制,对象的生命周期将由内存池负责管理,这一般是个全局引用,若是不显式释放JVM是不会回收这部份内存的。

对于Netty的用户而言,使用者的技术水平差别很大,一些对JVM内存模型和内存泄漏机制不了解的用户,可能只记得申请内存,忘记主动释放内存,特别是JAVA程序员。

为了防止由于用户遗漏致使内存泄漏,Netty在Pipe line的尾Handler中自动对内存进行释放,相关代码以下:

图2-33 TailHandler的内存回收操做

对于内存池,实际就是将缓冲区从新放到内存池中循环使用,代码以下:

图2-34 PooledByteBuf的内存回收操做

2.5.2. 缓冲区内存溢出保护

作过协议栈的读者都知道,当咱们对消息进行解码的时候,须要建立缓冲区。缓冲区的建立方式一般有两种:

1) 容量预分配,在实际读写过程当中若是不够再扩展;

2) 根据协议消息长度建立缓冲区。

在实际的商用环境中,若是遇到畸形码流攻击、协议消息编码异常、消息丢包等问题时,可能会解析到一个超长的长度字段。笔者曾经遇到过相似问题,报文长度字段值居然是2G多,因为代码的一个分支没有对长度上限作有效保护,结果致使内存溢出。系统重启后几秒内再次内存溢出,幸亏及时定位出问题根因,险些酿成严重的事故。

Netty提供了编解码框架,所以对于解码缓冲区的上限保护就显得很是重要。下面,咱们看下Netty是如何对缓冲区进行上限保护的:

首先,在内存分配的时候指定缓冲区长度上限:

图2-35 缓冲区分配器能够指定缓冲区最大长度

其次,在对缓冲区进行写入操做的时候,若是缓冲区容量不足须要扩展,首先对最大容量进行判断,若是扩展后的容量超过上限,则拒绝扩展:

图2-35 缓冲区扩展上限保护

最后,在解码的时候,对消息长度进行判断,若是超过最大容量上限,则抛出解码异常,拒绝分配内存:

图2-36 超出容量上限的半包解码,失败

图2-37 抛出TooLongFrameException异常

2.6. 流量整形

大多数的商用系统都有多个网元或者部件组成,例如参与短信互动,会涉及到手机、基站、短信中心、短信网关、SP/CP等网元。不一样网元或者部件的处理性能不一样。为了防止由于浪涌业务或者下游网元性能低致使下游网元被压垮,有时候须要系统提供流量整形功能。

下面咱们一块儿看下流量整形(traffic shaping)的定义:流量整形(Traffic Shaping)是一种主动调整流量输出速率的措施。一个典型应用是基于下游网络结点的TP指标来控制本地流量的输出。流量整形与流量监管的主要区别在于,流量整形对流量监管中须要丢弃的报文进行缓存——一般是将它们放入缓冲区或队列内,也称流量整形(Traffic Shaping,简称TS)。当令牌桶有足够的令牌时,再均匀的向外发送这些被缓存的报文。流量整形与流量监管的另外一区别是,整形可能会增长延迟,而监管几乎不引入额外的延迟。

流量整形的原理示意图以下:

图2-38 流量整形原理图

做为高性能的NIO框架,Netty的流量整形有两个做用:

1) 防止因为上下游网元性能不均衡致使下游网元被压垮,业务流程中断;

2) 防止因为通讯模块接收消息过快,后端业务线程处理不及时致使的“撑死”问题。

下面咱们就具体学习下Netty的流量整形功能。

2.6.1. 全局流量整形

全局流量整形的做用范围是进程级的,不管你建立了多少个Channel,它的做用域针对全部的Channel。

用户能够经过参数设置:报文的接收速率、报文的发送速率、整形周期。相关的接口以下所示:

图2-39 全局流量整形参数设置

Netty流量整形的原理是:对每次读取到的ByteBuf可写字节数进行计算,获取当前的报文流量,而后与流量整形阈值对比。若是已经达到或者超过了阈值。则计算等待时间delay,将当前的ByteBuf放到定时任务Task中缓存,由定时任务线程池在延迟delay以后继续处理该ByteBuf。相关代码以下:

图2-40 动态计算当前流量

若是达到整形阈值,则对新接收的ByteBuf进行缓存,放入线程池的消息队列中,稍后处理,代码以下:

图2-41 缓存当前的ByteBuf

定时任务的延时时间根据检测周期T和流量整形阈值计算得来,代码以下:

图2-42 计算缓存等待周期

须要指出的是,流量整形的阈值limit越大,流量整形的精度越高,流量整形功能是可靠性的一种保障,它没法作到100%的精确。这个跟后端的编解码以及缓冲区的处理策略相关,此处再也不赘述。感兴趣的朋友能够思考下,Netty为何不作到 100%的精确。

流量整形与流控的最大区别在于流控会拒绝消息,流量整形不拒绝和丢弃消息,不管接收量多大,它总能以近似恒定的速度下发消息,跟变压器的原理和功能相似。

2.6.2. 单条链路流量整形

除了全局流量整形,Netty也支持但链路的流量整形,相关的接口定义以下:

图2-43 单链路流量整形

单链路流量整形与全局流量整形的最大区别就是它以单个链路为做用域,能够对不一样的链路设置不一样的整形策略。

它的实现原理与全局流量整形相似,咱们再也不赘述。值得说明的是,Netty支持用户自定义流量整形策略,经过继承AbstractTrafficShapingHandler的doAccounting方法能够定制整形策略。相关接口定义以下:

图2-44 定制流量整形策略

3. 总结

尽管Netty在架构可靠性上面已经作了不少精细化的设计,以及基于防护式编程对系统进行了大量可靠性保护。可是,系统的可靠性是个持续投入和改进的过程,不可能在一个版本中一蹴而就,可靠性工做任重而道远。

从业务的角度看,不一样的行业、应用场景对可靠性的要求也是不一样的,例如电信行业的可靠性要求是5个9,对于铁路等特殊行业,可靠性要求更高,达到6个9。对于企业的一些边缘IT系统,可靠性要求会低些。

可靠性是一种投资,对于企业而言,追求极端可靠性对研发成本是个沉重的包袱,可是相反,若是不重视系统的可靠性,一旦不幸遭遇网上事故,损失每每也是惊人的。

对于架构师和设计师,如何权衡架构的可靠性和其它特性的关系,是一个很大的挑战。经过研究和学习Netty的可靠性设计,也许可以给你们带来一些启示。

4. Netty学习推荐书籍

目前市面上介绍netty的文章不少,若是读者但愿系统性的学习Netty,推荐两本书:

1) 《Netty in Action》

2) 《Netty权威指南》