用Netty开发中间件:网络编程基础

时间:2023-03-09 05:05:06
用Netty开发中间件:网络编程基础

用Netty开发中间件:网络编程基础

《Netty权威指南》在网上的评价不是很高,尤其是第一版,第二版能稍好些?入手后快速翻看了大半本,不免还是想对《Netty权威指南(第二版)》吐槽一下:

  • 前半本的代码排版太糟糕了,简直就是直接打印Word的版式似的。源码解析部分的条理性和代码排版好多了,感觉比其他部分的质量高多了。
  • 如果你是初学者可能会感觉很详细,几乎每部分都会来一套客户端和服务端的Demo,如果你不是入门者的话可能会感觉水分比较多。
  • 最后一部分高级特性,内容有些混乱,不少内容都在不同的章节里重复了好几遍。

不管怎样,如果你是网络通信或后台中间件的入门者,尤其是Java程序员,那么这本书还是值得入手的。尤其是书中对I/O模型、协议解析、可靠性等方面的点拨还是会让你有很多收获的。好了吐槽就到这了,以下就是《Netty权威指南(第二版)》的重点摘录,抽掉了水分,所有干货都在这里了。

1.Linux和Java的I/O演进之路

Linux从select -> poll -> epoll机制。简要说epoll的优点就是:从主动轮询+线性扫描变为被动事件通知,mmap避免到用户态的拷贝,更加简单的API。

Java方面呢,JDK 1.3之前只有阻塞I/O,到1.4加入了NIO。在JDK 1.5 update 10和Linux 2.6以上版本,JDK使用epoll替换了select/poll。1.7加入了AIO。

2.四种I/O模型

2.1 阻塞BIO

阻塞BIO是我们最常见的一种形式,就不详细说了。

2.2 伪异步I/O

伪异步I/O利用阻塞I/O的Acceptor+线程池实现的是伪异步I/O,它只是对同步阻塞I/O在系统资源方面使用方面做了“一小点”的优化(重用了线程),但是 它没法从根本上解决同步I/O导致的通信线程阻塞问题

TCP/IP知识复习:当消息接收方处理缓慢时,将不能及时从TCP缓冲区读取数据,这将会导致发送方的TCP window size不断变小直到为0。此时双方处于Keep-Alive状态,发送方将不能再向TCP缓冲区写入消息。如果使用的是同步阻塞I/O,write操作将无限期阻塞直到window size大于0或发生I/O异常。

2.3 非阻塞NIO

非阻塞NIO的特点是:

  • 1)所有数据都是用缓冲区(ByteBuffer)处理的;
  • 2)使用全双工的Channel而不是输入/输出流,能更好地映射底层操作系统的API;
  • 3)多路复用器是基础。

NIO提供了非阻塞的读写操作,相比于BIO的确是异步的,因此从这个角度我们可以说NIO是异步非阻塞的。然而如果严格按照UNIX网络编程模型定义的话,NIO并不能算是异步的,因为当事件完成时不是由系统触发回调函数,而是需要我们不断轮询

2.4 异步AIO

AIO才是真正的异步I/O:NIO只是实现了读写操作的非阻塞,但它还是要靠轮询而非事件通知(尽管前面说过JDK 1.5里升级为epoll,但上层API还是轮询没有变化)。说它是异步的其实就是想说它是非阻塞的。JDK 1.7 NIO 2中提供的AIO才是真正的异步I/O。

3.Netty介绍

3.1 为什么选择Netty

使用原生NIO开发的特点就是功能开发相对容易,但后续的可靠性方面的工作量非常大,需要我们自己处理如断连重连、半包读写、网络拥堵等问题。并且,NIO中还可能有bug,如“臭名昭著”的Selector空轮询导致CPU使用率100%(大学做大作业就碰到过这个问题,当时还纳闷呢,原来是个bug啊)。

所以,要想自己快速开发出健壮可靠的高性能网络公共组件,还真不是件容易事!Netty为我们提供了开箱即用的高性能、高可靠、安全可扩展的网络组建,同时还修复了NIO的一些bug,社区非常活跃,版本升级快。相比而言,Netty真是个不错的选择!

3.2 核心API简介

Netty有以下几个核心API:

  • ByteBuf:JDK的ByteBuffer只有一个位置指针,每次读写都要flip(),clear()等。ByteBuf有readerIndex和writerIndex两个指针。(0,readerIndex)是已读数据,[readerIndex,writerIndex)是未读的数据,[writerIndex,capacity)是可写空间。
  • Channel:封装了JDK Channel的操作,统一了接口。
  • EventLoop:负责轮询事件并分发给对应Channel的线程。

4.协议解析设计

4.1 TCP拆包和粘包

TCP是流协议,TCP底层并不了解上层业务数据的含义,它会根据TCP缓冲区的实际情况进行包的划分,一个完整的包可能被TCP拆分成多个包发送,也可能与其他小包封装成一个大的数据包发送,这就是所谓的拆包和粘包。

发生拆包的原因可能有:

  • 1)应用程序write写入的数据大小大于Socket发送缓冲区大小;
  • 2)进行MSS大小的TCP分段;
  • 3)以太网帧的payload大于MTU进行IP分片。

常用的解决策略:

  • 1)消息定长(FixedLengthFrameDecoder);
  • 2)包尾加分割符,如回车(DelimiterBasedFrameDecoder);
  • 3)将消息分为消息头和消息体,在消息头中包含消息或消息体的长度(LengthFieldPrepender和LengthFieldBasedFrameDecoder)。

4.2 反序列化

我们可以在自定义Decoder和Encoder中实现序列化和反序列化,如常见的Jackson,MsgPack,ProtoBuf等等。

5.高性能设计

5.1 Reactor模型

Reactor模型主要由多路复用器(Acceptor)、事件分发器(Dispatcher)、事件处理器(Handler)三部分组成。深入研究的话,Reactor模型可以细分成三种:

  • 单线程:所有I/O操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor线程上完成的。因为所有I/O操作都不会阻塞,所以理论上是可能的。在一些小型应用场景下也的确可以使用单线程模型。但对于高并发应用是不合适的,即便这个NIO线程将CPU跑满也无法满足海量消息的编解码和读写。此外这种模型在可靠性上也存在问题,因为一旦这个NIO线程进入死循环就会导致整个系统的不可用。
  • 多线程:一个专门的NIO线程(Acceptor线程)负责监听和接收客户端的TCP连接请求,而读写由一个NIO线程池负责。每个NIO可以对应多个链路,但为了防止并发问题,每个链路只对应一个NIO线程。绝大多数场景下,多线程模型都可以满足性能需求了。但在处理百万客户端连接,或需要对客户端进行比较耗时的安全认证时,单一Acceptor还是可能存在性能不足的问题。
  • 主从Reactor:Acceptor不再是一个单独的线程,而是独立的线程池,负责客户端的登录、握手和安全认证,一旦链路建立成功就将链路注册到后端负责I/O读写的SubReactor线程池上。

Netty对这三种都支持,通过调整线程池的线程个数、是否共享线程池等参数在三种方式间方便的切换。一般的Netty最佳实践如下:

  • 创建两个NioEventLoopGroup来隔离Acceptor和I/O线程。
  • 如果业务逻辑非常简单,就不要在Handler中启动用户线程,直接在I/O线程中完成业务。
  • 如果业务逻辑复杂,有可能导致线程阻塞的磁盘、数据库、网络等操作,则可将解码后的消息封装成Task派发到业务线程池执行。
  • 不要在用户线程中解码,而要在I/O线程上的解码Handler中完成。

5.2 无锁化

由于在Handler内的数据读写、协议解析经常要保存一些状态,所以为了避免资源竞争,Netty对Handler采用串行化设计。即一个I/O线程会对我们配置到Netty中的Handler链的执行“负责到底”。正是有了这样的设计,我们就可以放心的在Handler中保存各种状态,甚至使用ThreadLocal,完全无锁化的设计。

Netty的Handler在这一点上是不是与Struts2中的Action有点像呢?

5.3 零拷贝

在Netty内部,ByteBuffer默认使用堆外内存(Direct Buffer)作为缓冲区,这就避免了传统堆内存作缓冲区时的拷贝问题。使用传统堆内存时进行Socket读写时,JVM会先将堆内存缓冲区中的数据拷贝到直接内存中,然后再写入Socket。

此外,Netty也提供给开发者一些工具实现零拷贝,这些工具都是我们可以利用的,例如:

  • ByteBufHolder:由于不同的协议消息体可以包含不同的协议字段和功能,使用者继承ByteBufHolder接口后可以按需封装自己的实现。例如Netty内部已提供的MemcacheContent就是继承自ByteBufHolder。
  • CompositeByteBuf:对外将多个ByteBuf“装饰”成一个ByteBuf,但实际上未产生任何数据拷贝。
  • DefaultFileRegion:提供了transferTo方法,将文件内容直接发送到目标Channel,实现了文件传输的零拷贝。

5.4 内存池

随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收成了一件非常轻量级的工作。但是对于缓冲区,特别是对于堆外直接内存,分配和回收却仍然是一件耗时的操作。所以,Netty提供了内存池来实现缓冲区的重用机制。

这里再简单介绍一下Netty内部的内存管理机制。首先,Netty会预先申请一大块内存,在内存管理器中一般叫做Arena。Netty的Arena由许多Chunk组成,而每个Chunk又由一个或多个Page组成。Chunk通过二叉树的形式组织Page,每个叶子节点表示一个Page,而中间节点表示内存区域,节点自己记录它在整个Arena中的偏移地址。当区域被分配出去后,中间节点上的标记位会被标记,这样就表示这个中间节点以下的所有节点都已被分配了。

6.可靠性设计

6.1 心跳检测

在凌晨等业务低谷期,如果发生网络闪断、连接Hang住等问题时,由于没有业务消息,应用进程很难发现。到了白天业务高峰期时,会发生大量的网络通信失败,导致应用进程一段时间内无法处理业务消息。因此可以采用心跳检测机制,一旦发现网络故障则立即关闭链路,并主动重连。

具体来看,心跳检测机制一般的设计思路是:

1)当连续周期T没有读写消息,客户端主动发送Ping心跳消息给服务端。

2)如果在下一周期T到来时没有收到服务端的Pong心跳或业务消息,则心跳失败计数器加1。

3)每当客户端接收到服务端的Pong心跳或业务消息,则心跳失败计数器清零;当计数器达到N次,则关闭链路,间隔INTERVAL后发起重连操作(保证服务端有充足的时间释放资源,所以不能失败后立即重连)。

4)同理,服务端也要用上面的方法检测客户端(保证无论通信哪一方出现网络故障,都能被及时检测出来)。

6.2 内存保护

Netty根据ByteBuf的maxCapacity保护内存不会超过上限。此外默认的TailHandler会负责自动释放ByteBuf的缓冲区。

6.3 优雅停机

Netty利用JVM注册的Shutdown Hook拦截到退出信号量,然后执行退出操作:释放各个模块的占用资源、将缓冲区中剩余的消息处理完成或者清空、将待刷新的数据持久化磁盘或数据库等。

6.安全性设计

(略)

7.扩展性设计

7.1 灵活的TCP参数配置

在Netty中可以很方便地修改TCP的参数。例如缓冲区大小的参数SO_RCVBUF/SO_SNDBUF、关闭将大量小包优化成大包的Nagle算法的参数SO_TCPNODELAY参数来避免对时延敏感应用的影响、以及Linux软中断等。

- Netty协议栈不区分服务端和客户端,开发完成后可同时支持。
- 可靠性设计:心跳机制;重连机制
- 安全性设计:内网采取IP白名单进行安全过滤;外网采取更加严格的SSL/TSL安全传输。
- 扩展性设计:业务功能可以在消息头中附加流水号等,利用Netty提供的attachment字段扩展。

7.2 可定制的API

Netty中关键的类库都提供了接口或抽象类以及大量的工厂类供开发者扩展,像Handler则是直接提供了ChannelPipeline实现了责任链模式,方便我们做任意的组合和扩展。