从多线程到分布式

项目实践(八)通信框架Netty

2023-08-31  本文已影响0人  吟游雪人

用一句简单的话来说就是:Netty封装了JDK的NIO,让你用得更方便,不用再写一大堆复杂的代码了。

Netty作为一个高性能的 NIO通信框架,涉及的知识点包括网络通信、多线程编程、序列化和反序列化、异步和同步、SSL/TLS安全、内存池、HTTP等各种协议栈

Netty是一个异步非阻塞的通信框架,所有的I/O操作都是异步的,但是为了方便使用,例如在有些场景下应用需要同步阻塞等待一些I/O操作的结果,所以提供了ChannelFuture,它主要提供以下两种能力。(1)通过注册监听器GenericFutureListener,可以异步等待I/O执行结果。(2)通过sync或者await,主动阻塞当前调用方的线程,等待操作结果,也就是通常说的异步转同步。

一些常见和问题和实践:

  1. 当系统退出时,建议通过调用EventLoopGroup的shutdownGracefully来完成内存队列中积压消息的处理、链路的关闭和EventLoop线程的退出,以实现停机不中断业务

  2. 业务ChannelHandler无法并发执行问题
    由 于 DefaultEventExecutor 继 承 自SingleThreadEventExecutor,所以执行 execute 方法就是把 Runnable 放入任务队列由单线程执行

3 Netty DefaultEventExecutor工作机制
如果业务采用自定义线程池,优化方向是尽量消除锁竞争
(1)利用Netty的ChannelId绑定业务线程池的某个业务线程,后续该Channel的所有消息读取和发送都由绑定的Netty NioEventLoop和业务线程来执行,把锁竞争降到最低。
(2)业务线程池采用一个线程对应一个消息队列的方式,降低队列的锁竞争。可以继承JDK的ExecutorService自己实现,或者利用Executors的newSingleThreadExecutor()方法创建多个SingleThreadExecutor,这样就实现了工作线程和消息队列的一对一关系。

3.海量长连接接入面临的挑战
当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的 GC,导致应用暂停(STW)的 GC 持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,会有海量的设备接入或者海量的数据发送,很可能瞬间就把服务端冲垮。

4 空闲检测调优
对于线程池的调优,主要集中在用于接收海量设备 TCP 连接、TLS 握手的 Acceptor线程池(Netty 通常叫 boss NioEventLoopGroup)上,以及用于处理网络数据读写、心跳发送的I/O工作线程池(Netty通常叫work NioEventLoopGroup)上。

针对海量设备接入的IoT服务端,心跳优化策略如下。(1)要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致OOM等问题。(2)设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代 GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停。(3)使用 Netty提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。当设备突然掉电、连接被防火墙挡住、长时间 GC 或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。

5.链路空闲检测机制
(1)读空闲,链路持续时间T没有读取到任何消息。
(2)写空闲,链路持续时间T没有发送任何消息。
(3)读写空闲,链路持续时间T没有接收或者发送任何消息。链路空闲事件被触发后并没有关闭链路,而是触发 IdleStateEvent 事件,用户订阅IdleStateEvent事件,用于自定义逻辑处理,例如关闭链路、客户端发起重新连接、告警和打印日志等。利用Netty提供的链路空闲检测机制,可以非常灵活地实现协议层的心跳检测。如果选择双向心跳,在初始化Channel时将Netty的IdleStateHandler实例添加到ChannelPipeline中,然后监听READER_IDLE 事件,一旦 READER_IDLE 事件发生,说明周期 T 内没有读取到设备的消息,触发服务端主动发送心跳,检测链路是否存活,如果发生I/O异常说明链路已经失效,则主动关闭链路;如果发送成功,则等待最终的心跳超时,即在连续N个周期T内都没有接收到端侧设备发送的业务数据或者心跳消息,则说明端侧设备已经发生故障,服务端主动关闭连接,释放资源

6.Netty 内存池实现
可以分为两类:堆外直接内存和堆内存。由于 ByteBuf 主要用于网络I/O读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷贝,所以性能更高。由于DirectByteBuf的创建成本比较高,因此如果使用DirectByteBuf,则需要配合内存池使用,否则性价比可能还不如HeapByteBuf。
Netty 默认的 I/O 读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使用ByteBuf,建议也采用内存池方式;如果不涉及网络 I/O 操作(只是纯粹的内存操作),可以使用堆内存池,这样内存的创建效率会更高一些。
最常用的log4j(1.2.X版本)为例,尽管它支持异步写日志(AsyncAppender),但是当日志队列满时,它会同步阻塞业务线程(采用等待非丢弃方式时),直到日志队列有空闲位置可用

实现流控功能:新增一个 FlowControlChannelHandler,添加到ChannelPipeline靠前的位置,继承channelActive()方法,创建TCP链路后,执行流控逻辑,如果达到流控阈值,则拒绝该连接,调用ChannelHandlerContext的close()方法关闭连接。TLS/SSL的连接数的流控相对复杂一些,可以在TLS/SSL握手成功后,监听握手成功的事件,执行流控逻辑。握手成功后发送SslHandshakeCompletionEvent事件

  1. JVM相关性能优化
    JVM GC调优的三个基本原则如下。(1)Minor GC 回收原则:每次新生代 GC 回收尽可能多的内存,减少应用程序发生Full GC的频率。(2)GC 内存最大化原则:垃圾收集器能够使用的内存越大,垃圾收集效率越高,应用程序运行也越流畅。但是过大的内存一次Full GC耗时可能较长,如果能够有效避免Full GC,就需要做精细化调优。(3)3选2原则:吞吐量、延迟和内存占用不能兼得,无法同时做到吞吐量和暂停时间都最优,需要根据业务场景做选择。对于大多数 IoT 应用,吞吐量优先,其次是延迟。当然对于时延敏感型的业务,需要调整次序。

  2. gRPC Netty HTTP/2客户端工作机制
    gRPC Netty HTTP/2客户端的创建与传统客户端的创建存在一些差异,主要体现在如下两点。◎ 创建NettyClientHandler(实际被包装成ProtocolNegotiator.Handler,用于HTTP/2的握手协商)之后,不是由传统的 ChannelInitializer 在初始化 Channel 时将NettyClientHandler 加入Pipeline,而是直接通过 Bootstrap 的 Handler 方法加入Pipeline,以便立即接收和发送任务。◎ 客户端使用的work线程组并非通常意义的EventLoopGroup,而是一个EventLoop,即 HTTP/2客户端使用的 work 线程并非一组线程(默认线程数为“CPU 内核数×2”),而是一个EventLoop线程。这其实也很容易理解,一个NioEventLoop线程可以同时处理多个 HTTP/2 客户端连接,它是多路复用的,对于单个 HTTP/2客户端,如果默认独占一个 work 线程组,将造成极大的资源浪费,同时也可能导致句柄溢出(并发启动大量HTTP/2客户端)。
    (1)NettyClientHandler的onHeadersRead(int streamId,Http2Headers headers,boolean endStream)方法会被调用两次,根据endStream判断是否是Stream的结尾。(2)请求和响应的映射关系:根据 streamId 可以关联同一个 HTTP/2 Stream,将NettyClientStream 缓存到 Stream,客户端就可以在接收到响应消息头或消息体时还原NettyClientStream,进行后续处理。(3)客户端和服务端的HTTP/2 Header和Data Frame解析共用同一个方法,即MessageDeframer的deliver方法。

  3. gRPC线程模型
    影响RPC框架性能的三个核心要素如下。(1)I/O:用什么样的 I/O 方式将数据发送给对方,BIO、NIO 或者 AIO,I/O 模型在很大程度上决定了框架通信的性能。(2)协议:采用什么样的通信协议,公有协议还是私有协议,采用JSON序列化还是其他二进制序列化框架,决定了消息的传输效率。(3)线程模型:消息读取之后的编解码在哪个线程中进行,编解码后的消息如何派发,通信线程模型不同,对性能的影响也不同。在以上三个要素中,线程模型对性能的影响非常大。
    gRPC 采用的是网络 I/O 线程和业务调用线程分离的策略,在大部分场景下该策略是最优的。但是,对于那些接口逻辑非常简单,执行时间很短,不需要与外部网元交互、访问数据库或本地存储,也不需要等待其他同步操作的场景,则建议直接在 Netty I/O 线程中调用接口,不需要再发送到后端的业务线程池,避免了线程上下文切换,同时也消除了线程并发问题。
    当前Netty NIO线程和gRPC的SerializingExecutor之间没有映射关系,当线程数量比较多时,锁竞争会非常激烈,可以采用 I/O线程和 gRPC服务调用线程绑定的方式,降低出现锁竞争的概率,提升并发性能,通过线程绑定技术降低锁竞争的概率

  4. channelReadComplete方法被调用多次问题
    TCP底层并不了解上层业务数据的具体含义,它会根据 TCP 缓冲区的实际情况进行包的拆分,所以在业务上认为一个完整的 HTTP 报文可能会被 TCP 拆分成多个包发送,也有可能把多个小的包封装成一个大的数据包发送。导致数据包拆分和重组的原因如下。
    (1)应用程序写入的字节大小大于套接口发送缓冲区大小。
    (2)进行MSS大小的TCP分段。
    (3)以太网帧的有效载荷(payload)大于MTU的IP分片。
    (4)开启了TCP Nagle算法。
    底层的 TCP 无法理解上层的业务数据,所以在底层无法保证数据包不被拆分和重组,这个问题只能通过上层的应用协议栈设计来解决,根据业界主流协议的解决方案,归纳如下。(1)消息定长,例如每个报文的大小固定为200字节,如果不够,空位补空格。(2)在包尾增加换行符(或者其他分隔符)进行分隔,例如FTP。(3)将消息分为消息头和消息体,消息头包含表示消息总长度(或者消息体长度)的字段,通常消息头的第一个字段使用int32表示消息的总长度。
    HttpObjectAggregator 可以保证 Netty 读到完整的 HTTP 请求报文后才调用一次业务ChannelHandler的channelRead方法,无论这条报文底层经过了几次SocketChannel的read调用。但是 channelReadComplete 方法并不是在业务语义上的读取消息完成后被触发的,而是在每次从SocketChannel 成功读到消息后,由系统触发,也就是说如果一个 HTTP 消息被TCP协议栈发送了N次,则服务端的channelReadComplete方法就会被调用N次。

  5. ChannelHandler使用的一些误区总结
    对于大部分协议解码器,例如Netty内置的ByteToMessageDecoder,它会调用具体的协议解码器对ByteBuf解码,只有解码成功,才会调用后续ChannelHandler的channelRead方法,代码如下(ByteToMessageDecoder类):[插图]channelReadComplete 方法则属于透传调用,即无论是否有完整的消息被解码成功,只要读到消息,都会触发后续 ChannelHandler的 channelReadComplete方法调用

  6. Netty流量整形工作机制
    Netty流量整形工作机制流量整形的工作原理:拦截channelRead和write方法,计算当前需要发送的消息大小,对读取和发送阈值进行判断,如果达到了阈值,则暂停读取和发送消息,待下一个周期继续处理,以实现在某个周期内对消息读写速度进行控制。
    如果连接正常,用户主动调用handlerRemoved删除流量整形ChannelHandler,则将积压的消息全部发送完成,清空消息发送队列。由于消息发送成功后由 Netty 负责释放ByteBuf,因此避免了内存泄漏
    通过流量整形可以控制发送速度,但是它的控制原理是将待发送的消息封装成 Ta s k放入消息队列,等待执行时间到达后继续发送,所以如果业务发送线程不判断Channel的可写状态,就可能会导致OOM 等问题

服务端并没有对客户端的连接数做限制,导致尽管ESTABLISHED状态的连接数并不会超过6000这个上限,但是由于一些 SSL连接握手失败,再加上积压在服务端的连接并没有及时被释放,最终引起了NioSocketChannel的大量积压。客户端也没有流控机制,只要连接数不够用,就会一直创建连接,达到连接池配置的最大连接数。正是由于客户端和服务端都没有对高并发时大量的 HTTPS 链路断连和重连进行保护,导致了服务端OOM异常,业务中断。

13.功能层面的可靠性优化
基于 Netty的Pipeline机制,可以对 SSL握手成功、SSL连接关闭做切面拦截(类似于Spring的AOP机制,但是没采用反射机制,性能更高),通过流控切面接口,对HTTPS连接进行计数,根据计数器进行流控,服务端的流控算法如下。(1)获取流控阈值。(2)从全局上下文中获取当前的并发连接数,与流控阈值对比,如果小于流控阈值,则对当前的计数器进行原子自增,允许客户端连接。(3)如果等于或者大于流控阈值,则抛出流控异常给客户端。(4)SSL连接关闭时,获取上下文中的并发连接数,进行原子自减。
通过userEventTriggered方法拦截SslHandshakeCompletionEvent和SslCloseCompletion-Event事件,在SSL握手成功和SSL连接关闭时更新流控计数器。(3)流控并不是仅针对ESTABLISHED状态的HTTP连接,而是针对所有状态的连接,因为客户端关闭连接,并不意味着服务端也同时关闭连接,只有触发SslCloseCompletion-Event事件时,服务端才真正关闭了NioSocketChannel,GC才会回收连接关联的内存。(4)流控 ChannelHandler 会被多个 NioEventLoop 线程调用,因此对于相关的计数器更新等操作,要保证并发安全性,避免使用全局锁,可以通过原子类等提升性能。
表面上增加数据加解密功能只对系统的性能有一些影响,但实际上系统的可靠性也会面临比较大的挑战,特别是高并发、低时延类的业务,一旦发生批量服务调用超时,就会导致大量的SSL链路重建,在业务高峰期,如果服务端可靠性设计欠缺,很有可能宕机,导致业务中断。

  1. 基于Netty的可靠性设计
    从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。心跳检测机制分为三个层面。
    (1)TCP层面的心跳检测,即TCP的Keep-Alive机制,它的作用域是整个TCP协议栈。
    (2)协议层的心跳检测,主要存在于长连接协议中,例如MQTT。
    (3)应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。作为高可靠的NIO框架,Netty也提供了心跳检测机制,利用IdleStateHandler可以方便地实现业务层的心跳检测。

NIO通信的内存保护主要集中在如下几点。
(1)链路总数的控制:每条链路都包含接收和发送缓冲区,链路个数太多容易导致内存溢出。
(2)单个缓冲区的上限控制:防止非法长度或者消息过大导致内存溢出。
(3)缓冲区内存释放:防止因为缓冲区使用不当导致的内存泄漏。
(4)NIO消息发送队列的长度上限控制。
在消息解码的时候,对消息长度进行判断,如果超过最大容量,则抛出解码异常,拒绝分配内存

最后: Netty故障定位技巧
接收不到消息如果业务的ChannelHandler接收不到消息,可能的原因如下。
(1)业务的解码ChannelHandler存在缺陷,导致消息解码失败,没有投递到后端。(2)业务发送的是畸形或者错误码流(例如长度错误),导致业务解码ChannelHandler无法正确解码业务消息。
(3)业务ChannelHandler执行了一些耗时或者阻塞操作,导致Netty的NioEventLoop被挂住,无法读取消息。
(4)执行业务ChannelHandler的线程池队列积压,导致新接收的消息排队,没有得到及时处理。
(5)对方确实没有发送消息。

定位策略如下。(1)在业务的首个 ChannelHandler的channelRead方法中设置断点进行调试,看是否能读取到消息。(2)在ChannelHandler中添加LoggingHandler,打印接口日志。(3)查看NioEventLoop线程的状态,看是否发生了阻塞。(4)通过tcpdump抓包查看消息是否发送成功。

内存泄漏通过“jmap-dump:format=b,file=xx pid”命令打印内存堆栈,然后使用MemoryAnalyzer工具对内存占用情况进行分析,查找内存泄漏点,再结合代码进行分析,定位内存泄漏的具体原因,如图20-4所示。

性能问题如果出现性能问题,首先需要确认是 Netty的问题还是业务的问题,通过 jstack命令或者jvisualvm工具打印线程堆栈,按照线程CPU使用情况进行排序(top-Hp命令采集),看线程在忙什么。通常如果采集几次都发现 Netty 的 NIO 线程堆栈停留在 select 操作上,说明I/O比较空闲,性能瓶颈不是Netty,如图20-5所示。[插图]图20-5 Netty NIO线程运行堆栈如果发现性能瓶颈在网络I/O读写上,可以适当调大NioEventLoopGroup中的work I/O线程数,直到I/O处理性能能够满足业务需求

上一篇 下一篇

猜你喜欢

热点阅读