20. Netty架构剖析
20.1 Netty逻辑架构
Netty采用了典型的三层网络架构进行设计和开发,逻辑架构如图20-1所示。
-
Reacotr通信调度层:
-
包含Reactor 线程 NioEventLoop 及其父类,NioSocketChannel / NioServerSocketChannel及其父类,ByteBuffer以及由其衍生出来的各种Buffer,Unsafe以及其衍生出的各种内部类等。
-
该层的主要职责就是监听网络的读写和连接操作,负责将网络层的数据读取到内存缓冲区中,然后触发各种网络事件,例如连接创建、连接激活、读事件、写事件等,将这些事件触发到PipeLine中,由PipeLine管理的职责链来进行后续的处理。
-
-
职责链ChannelPipeline:
-
它负责事件在职责链中的有序传播,同时负责动态地编排职责链。职责链可以选择监听和处理自己关心的事件,它可以拦截处理和向后/向前传播事件。
-
通常情况下,往往会开发编解码Hanlder用于消息的编解码,它可以将外部的协议消息转换成内部的POJO对象
-
-
业务逻辑编排层(Service ChannelHandler):
-
业务逻辑编排层通常有两类:一类是纯粹的业务逻辑编排,还有一类是其他的应用层协议插件,用于特定协议相关的会话和链路管理。例如CMPP协议,用于管理和中国移动短信系统的对接。
-
通常情况下,对于业务开发者,只需要关心职责链的拦截和业务Handler的编排。
-
20.2 关键架构质量属性
20.2.1 高性能
Netty的架构设计是如何实现高性能的。
-
采用异步非阻塞的I/O类库,基于Reactor模式实现,解决了一个服务端无法平滑地处理线性增长的客户端的问题。
-
TCP接收和发送缓冲区使用直接内存代替堆内存,避免了内存复制,提升了1/O读取和写入的性能。
-
支持通过内存池的方式循环利用ByteBuf,避免了频繁创建和销毁ByteBuf带来的性能损耗。
-
可配置的I/O线程数、TCP参数等,为不同的用户场景提供定制化的调优参数,满足不同的性能场景。
-
采用环形数组缓冲区实现无锁化并发编程,代替传统的线程安全容器或者锁。
-
关键资源的处理使用单线程串行化的方式,避免多线程并发访问带来的锁竞争和额外的CPU资源消耗问题。
20.2.2 可靠性
-
链路有效性检测:
-
为了保证长连接的链路有效性,往往需要通过心跳机制周期性地进行链路检测。
-
可以基于读空闲超时发送心跳消息,进行链路检测;如果连续N个周期仍然没有读取到心跳消息,可以主动关闭链路。
-
可以基于写空闲超时发送心跳消息,进行链路检测;如果连续N个周期仍然没有接收到对方的心跳消息,可以主动关闭链路。
-
-
内存保护机制:
-
通过对象引用计数器对 Netty的 ByteBuf等内置对象进行细粒度的内存申请和释放,对非法的对象引用进行检测和保护。
-
通过内存池来重用ByteBuf,节省内存。
-
可设置的内存容量上限,包括ByteBuf、线程池线程数等。
-
-
优雅停机:
-
优雅停机功能指的是当系统退出时释放相关模块的资源占用,将缓冲区的消息处理完成或者清空,将待刷新的数据持久化到磁盘或者数据库中,等到资源回收和缓冲区消息处理完成之后,再退出。
-
优雅停机往往需要设置个最大超时时间T,如果达到T后系统仍然没有退出,则通过Kill-9 pid 强杀当前的进程。
-
Netty所有涉及到资源回收和释放的地方都增加了优雅退出的方法。
-
20.2.3 可定制和扩展性
Netty的可定制性主要体现在以下几点。
-
责任链模式:ChannelPipeline基于责任链模式开发,便于业务逻辑的拦截、定制和扩展。
-
基于接口的开发:关键的类库都提供了接口或者抽象类,如果 Netty自身的实现无法满足用户的需求,可以由用户自定义实现相关接口。
-
提供了大量工厂类,通过重载这些工厂类可以按需创建出用户实现的对象。
-
提供了大量的系统参数供用户按需设置,增强系统的场景定制性。
基于Netty的基础NIO框架,可以方便地进行应用层协议定制,例如HTTP协议栈、Thrift协议栈、FTP协议栈等。这些扩展不需要修改Netty的源码,直接基于Netty的二进制类库即可实现协议的扩展和定制。
目前,业界存在大量的基于Netty框架开发的协议,例如基于Netty的HTTP协议、Dubbo协议、RocketMQ内部私有协议等。
21. Java多线程编程在Netty中的应用
21.1 Java内存模型
Java内存模型规定所有的变量都存储在主内存中(JVM内存的一部分),每个线程有自己独立的工作内存,它保存了被该线程使用的变量的主内存复制。
线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其他工作内存中存储的变量或者变量副本。线程间的变量访问需通过主内存来完成,三者的关系如图21-1所示。
Java内存模型定义了8种操作来完成主内存和工作内存的变量访问。
主流的操作系统提供了线程实现,目前实现线程的方式主要有三种,分别如下。
-
内核线程(KLT)实现,这种线程由内核来完成线程切换,内核通过线程调度器对线程进行调度,并负责将线程任务映射到不同的处理器上。
-
用户线程实现(UT),通常情况下,用户线程指的是完全建立在用户空间线程库上的线程,用户线程的创建、启动、运行、销毁和切换完全在用户态中完成,不需要内核的帮助,因此执行性能更高。
-
混合实现,将内核线程和用户线程混合在一起使用的方式。
21.2 Netty并发编程
21.2.1 锁和volatile
-
synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法或者代码块。
-
volatile是Java提供的最轻量级的同步机制,当一个变量被volatile修饰后,它将具备以下两种特性。
-
线程可见性:当一个线程修改了被 volatile 修饰的变量后,无论是否加锁,其他线程都可以立即看到最新的修改、
-
禁止指令重排序:普通的变量不能保证变量赋值操作的顺序与程序代码的执行顺序一致。
-
volatile最适合使用的是一个线程写,其他线程读的场合,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器
21.2.2 CAS和线程安全类
-
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能的额外损耗,因此被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。
-
非阻塞同步,被称为乐观锁。简单地说,就是先进行操作,操作完成之后再判断操作是否成功,是否有并发问题,如果有则进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同步锁的弊端。
-
可以利用CAS自旋操作,本次原子操作成功则退出循环,代码继续执行;如果失败,说明在本次操作的过程中计数器已经被其他线程更新成功,需要进入循环。CAS包装类如Atomiclnteger。
-
在实际编码过程中,我们建议通过使用线程池、Task(Runnable/Callable)、原子类和线程安全容器来代替传统的同步锁、wait和notify,以提升并发访问的性能、降低多线程编程的难度。
21.2.3 读写锁
JDK的线程安全容器底层采用了CAS、volatile和ReadWriteLock实现,相比于传统重量级的同步锁,采用了更轻量、细粒度的锁。
读写锁的使用场景总结如下。
-
主要用于读多写少的场景,用来替代传统的同步锁,以提升并发访问性能。
-
读写锁是可重入、可降级的,一个线程获取读写锁后,可以继续递归获取;从写锁可以降级为读锁,以便快速释放锁资源。
-
ReentrantReadWriteLock 支持获取锁的公平策略,可以提升并发访问的性能,同时兼顾线程等待公平性。
-
读写锁支持非阻塞的尝试获取锁,如果获取失败,直接返回false,而不是同步阻塞。
-
获取锁之后一定要释放锁,否则会发生锁溢出异常。通常的做法是通过finally块释放锁。如果是tryLock,获取锁成功才需要释放锁。
22. 高性能之道
传统RPC调用性能差的三个原因:
-
网络传输方式问题。传统的RPC框架或者基于RMI等方式的远程服务(过程)调用采用了同步阻塞I/O,当客户端的并发压力或者网络时延增大之后,同步阻塞I/O会由于频繁的wait导致I/O线程经常性的阻塞,由于线程无法高效的工作,I/O处理能力自然下降。
-
序列化性能差。Java序列化存在如下几个典型问题:无法跨语言使用、码流太大和序列化性能差
22.1 Netty高性能之道
-
异步非阻塞通信:
-
当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。
-
Netty的l/O线程NioEventLoop由于聚合了多路复用器Selector,可以同时并发处理成百上千个客户端SocketChannel。
-
由于读写操作都是非阻塞的,这就可以充分提升I/O线程的运行效率,避免由频繁的I/O阻塞导致的线程挂起。
-
Netty采用了异步通信模式,一个I/O线程可以并发处理N个客户端连接和读写操作。
-
-
高效的Reactor线程模型:
-
常用的Reactor线程模型有三种,Reactor单线程模型;Reactor多线程模型;主从Reactor多线程模型
-
构造方法的参数和线程组实例化个数不同,就能灵活地切换到不同的Reactor线程模型上
-
利用主从NIO线程模型,可以解决1个服务端监听线程无法有效处理所有客户端连接的性能不足问题。
-
-
无锁化的串行设计:
-
为了尽可能地避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。
-
Netty采用了串行无锁化设计,在I/O线程内部进行串行操作,避免多线程竞争导致的性能下降。
-
Netty 的NioEventLoop 读取到消息之后,直接调用 ChannelPipeline 的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换。这种串行化处理方式避
-
-
高效的并发编程:
-
volatile的大量、正确使用;
-
CAS和原子类的广泛使用;
-
线程安全容器的使用;
-
通过读写锁提升并发性能。
-
-
零拷贝:
-
第一种情况。Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
-
第二种”零拷贝”的实现 CompositeByteBuf,它对外将多个ByteBuf封装成一个ByteBuf,对外提供统一封装后的ByteBuf接口
-
第三种”零拷贝”就是文件传输,Netty文件传输类DefautFieRegion通过transferTo方法将文件发送到目标Channel中
-
很多操作系统直接将文件缓冲区的内容发送到目标Channel中,而不需要通过循环拷贝的方式,这是一种更加高效的传输方式,提升了传输性能,降低了 CPU 和内存占用,实现了文件传输的”零拷贝”。
-
-
内存池和灵活的TCP参数配置:
-
为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区Buffer重用机制。
-
Netty在启动辅助类中可以灵活的配置TCP参数,满足不同的用户场景。
-
23. 可靠性
Netty的主要应用场景如下。
-
RPC框架的基础网络通信框架:主要用于分布式节点之间的通信和数据交换,在各个业务领域均有典型的应用,例如阿里的分布式服务框架Dubbo、消息队列RocketMQ、大数据处理Hadoop的基础通信和序列化框架Avro。
-
私有协议的基础通信框架:例如Thrift协议、Dubbo协议等。
-
公有协议的基础通信框架:例如HTTP协议、SMPP协议等。
23.1 网络通信类故障
-
客户端连接超时:往往需要指定连接超时时间,这样做的目的是
-
在同步阻塞I/O模型中,连接操作是同步阻塞的,如果不设置超时时间,客户端I/O线程可能会被长时间阻塞。
-
业务层需要:大多数系统都会对业务流程执行时间有限制,例如WEB交互类的响应时间要小于3S。
-
-
通信对端强制关闭连接:
-
在客户端和服务端正常通信过程中,如果发生网络闪断、对方进程突然宕机或者其他非正常关闭链路事件时,TCP链路就会发生异常。
-
由于TCP是全双工的,通信双方都需要关闭和释放Socket句柄才不会发生句柄的泄漏。
-
-
链路关闭:
-
对于短连接协议,例如HTTP协议,通信双方数据交互完成之后,通常按照双方的约定由服务端关闭连接,客户端获得TCP连接关闭请求之后,关闭自身的Socket连接,双方正式断开连接。
-
连接的合法关闭不会发生I/O异常,它是一种正常场景,如果遗漏了该场景的判断和处理就会导致连接句柄泄漏。
-
23.2 链路的有效性检测
有时链路会不可用且不易被及时发现。特别是异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间的大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。
要解决链路的可靠性问题,必须周期性的对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。
-
TCP层面的心跳检测,即TCP的Keep-Alive机制,它的作用域是整个TCP协议栈;
-
协议层的心跳检测,主要存在于长连接协议中。例如SMPP协议;
-
应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。
Netty提供的空闲检测机制分为三种:1. 读空闲,链路持续时间t没有读取到任何消息;2. 写空闲,链路持续时间t没有发送任何消息;3. 读写空闲,链路持续时间t没有接收或者发送任何消息。
23.3 Reactor线程保护
-
尽管Reactor线程主要处理IO操作,实际上在一些特殊场景下会发生非IO异常,如果仅仅捕获IO异常可能就会导致Reactor线程跑飞。为了防止发生这种意外,在循环体内一定要捕获Throwable,而不是IO异常或者Exception。
-
JDK NIO类库最著名的 epoll bug,它会导致Selector空轮询,IO线程CPU100%。一旦检测发生该BUG,则重建Selector,老的问题Selector关闭,使用新建的Selector替换
23.4 内存保护
NIO通信的内存保护主要集中在如下几点:
-
链路总数的控制:每条链路都包含接收和发送缓冲区,链路个数太多容易导致内存溢出;
-
单个缓冲区的上限控制:防止非法长度或者消息过大导致内存溢出;
-
缓冲区内存释放:防止因为缓冲区使用不当导致的内存泄露;
-
NIO消息发送队列的长度上限控制。
23.5 流量整形
流量整形(TraffcShaping)是一种主动调整流量输出速率的措施。一个典型应用是基于下游网络结点的TP指标来控制本地流量的输出。
-
流量整形与流量监管的主要区别在于,流量整形对流量监管中需要丢弃的报文进行缓存—通常是将它们放入缓冲区或队列内,也称流量整形(Trafhc Shaping,简称TS)。当令牌桶有足够的令牌时,再均匀的向外发送这些被缓存的报文。
-
流量整形与流量监管的另一区别是,整形可能会增加延迟,而监管几乎不引入额外的延迟。
作为高性能的NIO框架,Netty的流量整形有两个作用:
-
防止由于上下游网元性能不均衡导致下游网元被压垮,业务流程中断;
-
防止由于通信模块接收消息过快,后端业务线程处理不及时导致的”撑死”问题。
-
全局流量整形:
-
全局流量整形的作用范围是进程级的,无论你创建了多少个Channel,它的作用域针对所有的Channel。
-
对每次读取到的ByteBuf可写字节数进行计算,将当前的流量与流量整形阈值对比。如果已经达到了阈值。则计算等待时间delay,将当前的ByteBuf放到定时任务Task中缓存,由定时任务线程池在延迟delay之后继续处理该ByteBuf。
-
-
链路级流量整形:
-
单链路流量整形与全局流量整形的最大区别就是它以单个链路为作用域,可以对不同的链路设置不同的整形策略。
-
-
优雅停机接口:
-
Java的优雅停机通常通过注册JDK的ShutdownHook来实现,当系统接收到退出指令后,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行。
-
优雅退出有个时间限制,如30S,如果到达执行时间仍然没有完成退出前的操作,则由监控脚本直接kill-9 pid,强制退出。
-
-
优化建议:
-
发送队列容量上限控制
-
回推发送失败的消息
-