Netty

Netty

**Netty 基于 NIO(NIO 是一种同步非阻塞的 IO 模型,在Java 1.4中引入了 NIO)**,使用 Netty 可以极大地简化 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面都非常优秀。

我们平常经常接触的 Dubbo、RocketMQ、Elasticsearch、gRPC、Spark 等等热门开源项目都用到了 Netty。大部分微服务框架底层涉及到网络通信的部分都是基于 Netty 来做的,比如说 Spring Cloud 生态系统中的网关Spring Cloud Gateway。

为什么用Netty不用NIO?

NIO 在面对断连重连、包丢失、粘包等问题时处理过程非常复杂。Netty 的出现正是为了解决这些问题。

  • 因为 Netty 具有下面这些优点,并且相比于直接使用 JDK 自带的 NIO 相关的 API 来说更加易用,Java NIO 的类库和 API 庞大繁杂,使用起来很麻烦,开发工作量大。
  • 统一的 API,支持多种传输类型,阻塞和非阻塞的。
  • 简单而强大的线程模型
  • 自带编解码器解决 TCP 粘包/拆包问题
  • 自带各种协议栈
  • 真正的无连接数据包套接字支持。
  • 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。
  • 安全性不错,有完整的 SSL/TLS 以及 StartTLs 支持。
  • 社区活跃
  • 成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了Netty,比如我们经常接触的Dubbo、RocketMQ 等等

Netty的应用场景

  • 作为 RPC 框架的网络通信工具: 我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务节点的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧
  • 实现一个自己的 HTTP 服务器: 通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。
  • 实现一个即时通讯系统: 使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。
  • 实现消息推送系统: 市面上有很多消息推送系统都是基于 Netty 来做的。

Netty的线程模型

在 Netty 主要靠NioEventLoopGroup线程池来实现具体的线程模型的 。
我们实现服务端的时候,一般会初始化两个线程组:

  • bossGroup:负责处理客户端的连接请求
  • workerGroup:负责处理网络的读写操作。一旦BossGroup接收到客户端的连接请求,就会将这个连接注册到WorkerGroup中的某个EventLoop上进行后续的读写操作。

NioEventLoop:表示一个不断循环执行处理任务的线程,每个NioEventLoop都有一个Selector,用于监听绑定在其上的socket的网络通讯。

NioEventLoopGroup:BossGroup和WorkerGroup都是NioEventLoopGroup的实例。NioEventLoopGroup是一个事件循环组,包含多个事件循环(NioEventLoop),每个事件循环负责一个或多个Channel的IO操作。

Netty支持不同的线程模型

单Reactor单线程模型

单Reactor单线程模型是指所有的IO操作都由一个Reactor线程来处理。Netty使用NioEventLoop来实现这个模型,NioEventLoop既负责IO操作的处理,也负责事件的处理。当IO操作就绪时,NioEventLoop会立即处理该操作,并调用相应的事件处理函数。

这种模式的优点是:

模型简单,没有多线程、进程通信、竞争的问题,一个线程完成所有的事件响应和业务处理。

当然缺点也很明显:

1)存在性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。

2)存在可靠性问题,若线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。

单 Reactor 单线程模式使用场景为:客户端的数量有限,业务处理非常快速,比如 Redis 在业务处理的时间复杂度为 O(1)的情况。

image-20241004145941971
1
2
3
4
5
6
7
//1.eventGroup既用于处理客户端连接,又负责具体的处理。
EventLoopGroup eventGroup = new NioEventLoopGroup(1);

//2.创建服务端启动引导/辅助类:ServerBootstrap
ServerBootstrap b = new ServerBootstrap();
boobtstrap.group(eventGroup, eventGroup)
//......

单Reactor多线程模型

一个 Acceptor 线程只负责监听客户端的连接,多个Worker线程负责处理 I/O读写。

这种模式的优点是可以充分的利用多核 cpu 的处理能力,缺点是多线程数据共享和控制比较复杂,Reactor 处理所有的事件的监听和响应,在单线程中运行,面对高并发场景还是容易出现性能瓶颈。

*使用 NioEventLoopGroup类的无参构造函数设置线程数量的默认值就是 CPU 核心数 2

image-20241004150849612
1
2
3
4
5
6
7
8
9
// 1. bossGroup 用于接收连接,workerGroup 用于具体处理。
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//2. 创建服务器端引导/辅助类: ServerBootstrap
ServerBootstrap b = new ServerBootstrap();
//3. 为引导类配置两个主要线程组,确定线程模型
b.group(bossGroup, workerGroup)
//......

主从Reactor多线程模型

针对单 Reactor 多线程模型中,Reactor 在单个线程中运行,面对高并发的场景易成为性能瓶颈的缺陷,主从 Reactor 多线程模式让 Reactor 在多个线程中运行(分成 MainReactor 线程与 SubReactor 线程)。这种模式的基本工作流程为:

1)Reactor 主线程 MainReactor 对象通过 select 监听客户端连接事件,收到事件后,通过 Acceptor 处理客户端连接事件。

2)当 Acceptor 处理完客户端连接事件之后(与客户端建立好 Socket 连接),MainReactor 将连接分配给 SubReactor。(即:MainReactor 只负责监听客户端连接请求,和客户端建立连接之后将连接交由 SubReactor 监听后面的 IO 事件。)

3)SubReactor 将连接加入到自己的连接队列进行监听,并创建 Handler 对各种事件进行处理。

4)当连接上有新事件发生的时候,SubReactor 就会调用对应的 Handler 处理。

5)Handler 通过 read 从连接上读取请求数据,将请求数据分发给 Worker 线程池进行业务处理。

6)Worker 线程池会分配独立线程来完成真正的业务处理,并将处理结果返回给 Handler。Handler 通过 send 向客户端发送响应数据。

7)一个 MainReactor 可以对应多个 SubReactor,即一个 MainReactor 线程可以对应多个 SubReactor 线程。

image-20241004150641742
1
2
3
4
5
6
7
8
9
// 1. bossGroup 用于接收连接,workerGroup 用于具体处理。
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//2. 创建服务器端引导/辅助类: ServerBootstrap.
ServerBootstrap b = new ServerBootstrap();
//3. 为引导类配置两个主要线程组,确定线程模型
b.group(bossGroup, workerGroup)
//......

Netty核心组件

参考:45 张图深度解析 Netty 架构与原理-腾讯云开发者社区-腾讯云 (tencent.com)

image-20241004151251237

1)Netty 抽象出两组线程池:BossGroup 和 WorkerGroup,也可以叫做 BossNioEventLoopGroup 和 WorkerNioEventLoopGroup。每个线程池中都有 NioEventLoop 线程。BossGroup 中的线程专门负责和客户端建立连接,WorkerGroup 中的线程专门负责处理连接上的读写。BossGroup 和 WorkerGroup 的类型都是 NioEventLoopGroup。

2)NioEventLoopGroup 相当于一个事件循环组,这个组中含有多个事件循环,每个事件循环就是一个 NioEventLoop。

3)NioEventLoop 表示一个不断循环的执行事件处理的线程,每个 NioEventLoop 都包含一个 Selector,用于监听注册在其上的 Socket 网络连接(Channel)。

4)NioEventLoopGroup 可以含有多个线程,即可以含有多个 NioEventLoop。

5)每个 BossNioEventLoop 中循环执行以下三个步骤:

5.1)select:轮训注册在其上的 ServerSocketChannel 的 accept 事件(OP_ACCEPT 事件)

5.2)processSelectedKeys:处理 accept 事件,与客户端建立连接,生成一个 NioSocketChannel,并将其注册到某个 WorkerNioEventLoop 上的 Selector 上

5.3)runAllTasks:再去以此循环处理任务队列中的其他任务

6)每个 WorkerNioEventLoop 中循环执行以下三个步骤:

6.1)select:轮训注册在其上的 NioSocketChannel 的 read/write 事件(OP_READ/OP_WRITE 事件)

6.2)processSelectedKeys:在对应的 NioSocketChannel 上处理 read/write 事件

6.3)runAllTasks:再去以此循环处理任务队列中的其他任务

7)在以上两个processSelectedKeys步骤中,会使用 Pipeline(管道),Pipeline 中引用了 Channel,即通过 Pipeline 可以获取到对应的 Channel,Pipeline 中维护了很多的处理器(拦截处理器、过滤处理器、自定义处理器等)。

Netty的长连接

我们知道 TCP 在进行读写之前,server 与 cient 之间必须提前建立一个连接。建立连接的过程需要我们常说的三次握手,释放/关闭连接的话需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的。

所谓短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的优点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。

长连接说的就是 client 向 server 双方建立连接之后,即使 client与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。

Netty的心跳机制

在 TCP 保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候, cient与 server 之间如果没有交互的话,它们是无法发现对方已经掉线的。为了解决这个问题,我们就需要引入心跳机制。

心跳机制的工作原理是:在 client 与 server 之间在一定时间内没有数据交互时,即处于 idle 状态时,客户端或服务器就会发送一个特殊的数据包给对方,当接收方收到这个数据报文后,也立即发送一个特殊的数据报文,回应发送方,此即一个 PING-PONG 交互。所以,当某一端收到心跳消息后,就知道了对方仍然在线,这就确保 TCP 连接的有效性。

TCP 实际上自带的就有长连接选项,本身是也有心跳包机制,也就是TCP的选项:SO_KEEPALIVE 。 但是TCP 协议层面的长连接灵活性不够。所以,一般情况下我们都是在应用层协议上实现自定义心跳机制的,也就是在 Netty 层面通过编码实现。通过 Netty 实现心跳机制的话,核心类是 IdlestateHandler。

Netty的零拷贝

Netty 中的零拷贝体现在以下几个方面:

  • 堆外内存,避免 JVM 堆内存到堆外内存的数据拷贝。
  • 使用 Netty提供的 CompositeByteBuf类,可以将多个 ByteBuf合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。
  • 通过 Unpooled.wrappedBuffer可以将 byte数组包装成 ByteBuf 对象,包装过程中不会产生内存拷贝。
  • ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
  • 通过 FileRegion 包装的 Filechannel#tranferTo()实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环 write方式导致的内存拷贝问题

什么是 TCP 粘包/拆包?有什么解决办法呢?

TCP的粘包问题是指在数据传输过程中,多个发送的数据包被合并成一个或者少于预期的数据包的现象。这种情况可能导致接收端无法正确解析数据,造成数据解析错误或者数据丢失。

TCP的粘包问题主要有两种情况:

多个数据包合并成一个

  • 发送端连续发送多个小数据包,但是由于网络传输的不确定性,这些数据包在传输过程中可能被合并成一个大的数据包。接收端接收到的数据包可能会包含多个消息,导致消息边界模糊,无法正确解析。

一个数据包被拆分成多个

  • 发送端发送一个大的数据包,但是由于网络传输的MTU(最大传输单元)限制或者其他网络因素,这个数据包在传输过程中被拆分成多个小的数据包。接收端接收到的数据包可能只包含部分消息,导致消息不完整或者丢失。
image-20240415212904363

造成TCP粘包问题的主要原因是TCP协议的特性,TCP是一种面向流的协议,数据被视为一个连续的字节流进行传输,而不是一个个独立的消息。因此,在数据传输过程中,TCP并不会考虑消息的边界,而是根据缓冲区的大小和网络状况来进行数据分段和合并,导致出现粘包问题。

解决方法

netty自带的解码器

LineBasedFrameDecoder:发送端发送数据包的时候,每个数据包之间以换行符作为分隔,LineBasedhrameDecoder 的工作原理是它依次遍历 ByteBuf 中的可读字节,判断是否有换行符,然后进行相应的截取。

DelimiterBasedFrameDecoder:可以自定义分隔符解码器,实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器。

FixedLengthFrameDecoder:固定长度解码器,它能够按照指定的长度对消息进行相应的拆包。如果不够指定的长度,则空格补全

LengthFieldBasedFrameDecoder:长度域解码器,它能够根据发送的数据中消息长度相关参数(比如长度域偏移量 lengthFieldOffset)来进行拆包。

自定义序列化编解码器

在 Java 中自带的有实现 Serializable 接口来实现序列化,但由于它性能、安全性等原因一般情况下是不会被使用到的。
通常情况下,我们使用 Protostuff、Hessian2、json 序列方式比较多,另外还有一些序列化性能非常好的序列化方式也是很好的选择

专门针对 Java 语言的:Kryo,FST等等

跨语言的:Protostuff(基于 protobuf发展而来),ProtoBuf,Thrift,Avro,MsgPack等等


Netty
http://example.com/2024/04/25/项目/微服务/Netty/
作者
PALE13
发布于
2024年4月25日
许可协议