Netty 权威指南,万字长文带你深入理解 Netty

目录

一. 前言

二. IO 和 NIO

2.1. 面向流和面向 Buffer

2.2. 选择器

2.3. IO 和 NIO 的区别

2.4. JDK 原生 NIO 程序的问题

三. Netty 详解

3.1. Netty 的特点

3.2. Netty 的线程模型

3.2.1. 串行化处理模型

3.2.2. 并行化处理模型

3.2.3. Netty 具体线程模型

3.3. Netty 工作原理

3.3.1. Server 端工作原理

3.3.2. Client 端工作原理

3.4. Netty 的启动

3.4.1. 服务端启动流程

3.4.2. 客户端启动流程

3.5. ByteBuf

3.6. 支持池技术(如:线程池、数据库连接池)

3.6.1. ByteBufAllocator - 抽象工厂模式

3.6.2. CompositeByteBuf - 组合模式

3.6.3. ByteBufInputStream - 适配器模式

3.6.4. ReadOnlyByteBuf - 装饰器模式

3.6.5. ByteBuf - 工厂方法模式

3.7. channelHandler

3.8. NioEventLoop

3.9. 通信协议编解码

3.10. Netty 内存池和对象池

3.11. 心跳与空闲检测

3.11.1. 连接假死

3.11.2. 服务端空闲检测

3.11.3. 客户端定时心跳

3.12. 拆包粘包理论与解决

3.13. Netty 自带的拆包器

3.13.1. 固定长度的拆包器 - FixedLengthFrameDecoder

3.13.2. 行拆包器 - LineBasedFrameDecoder

3.13.3. 分隔符拆包器 - DelimiterBasedFrameDecoder

3.13.4. 基于长度域拆包器 - LengthFieldBasedFrameDecoder

四. 其他扩充

4.1. NIO EventLoopGroup 源码流程

4.2. RPC 框架的核心流程


 

一. 前言

    Netty 是一款卓越的 Java框架,提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。它用较简单的抽象,隐藏 Java 网络编程底层实现的复杂性而提供一个易于使用的 API 的客户端/服务器框架。

二. IO 和 NIO

下表总结了 Java IO 和 NIO 之间的主要区别:

IO NIO
面向流 面向缓冲
阻塞 IO 非阻塞 IO
选择器

2.1. 面向流和面向 Buffer

传统 IO 和 Java NIO 最大的区别是传统的 IO 是面向流,NIO 是面向 Buffer。

Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。

Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

2.2. 选择器

    Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道,这些通道里已经有可以处理的输入,或者选择已经准备写入的通道,这种选择机制,使得一个单独的线程很容易来管理多个通道。

2.3. IO 和 NIO 的区别

传统的 IO:

  1. socketServer 的 accept 方法是阻塞的;
  2. 获得连接的顺序是和客户端请求到达服务器的先后顺序相关;
  3. 适用于一个线程管理一个通道的情况;因为其中的流数据的读取是阻塞的;
  4. 适合需要管理同时打开不太多的连接,这些连接会发送大量的数据。

NIO:

  1. 基于事件驱动,当有连接请求,会将此连接注册到多路复用器上(selector);
  2. 在多路复用器上可以注册监听事件,比如监听 accept、read;
  3. 通过监听,当真正有请求数据时,才来处理数据;
  4. 会不停的轮询是否有就绪的事件,所以处理顺序和连接请求先后顺序无关,与请求数据到来的先后顺序有关;
  5. 优势在于一个线程管理多个通道;但是数据的处理将会变得复杂;
  6. 适合需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据。

2.4. JDK 原生 NIO 程序的问题

JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下:

  1. NIO 的类库和 API 繁杂,使用麻烦:你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
  2. 需要具备其他的额外技能做铺垫:例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
  3. 可靠性能力补齐,开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
  4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。

三. Netty 详解

3.1. Netty 的特点

    Netty 是一个异步事件驱动的网络应用框架,用于快速开发可维护的高性能服务器和客户端。Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

Netty 的主要特点有:高性能、可靠性、可定制性、可扩展性。

1. 高性能

  1. 采用异步非阻塞的 IO 类库,基于 Reactor 模式实现,解决了传统同步阻塞 IO 模式;
  2. TCP 接收和发送缓冲区使用直接内存代替堆内存,避免了内存复制,提升了 IO 读取和写入的性能;
  3. 支持内存池的方式循环利用 ByteBuf,避免了频繁插件和销毁 ByteBuf 带来的性能消耗;
  4. 可配置的 IO 线程数、TCP 参数等,为不同的用户场景提供定制化的调优参数,满足不同的性能场景;
  5. 采用环形数组缓冲区实现无锁化并发编程,代替传统的线程安全或锁;
  6. 合理使用线程安全容器,原子类,提升系统的并发处理能力;
  7. 关键资源的处理使用单线程串行化的方式,避免多线程并发访问带来的锁竞争和 CPU 资源消耗;
  8. 通过引用计数法及时地申请释放不再被引用的对象,细粒度的内存管理降低了 GC 的频率,减少了频繁 GC 带来的时延增大和 CPU 损耗。

2. 可靠性

1>. 链路有效监测(心跳和空闲检测)

  • 读空闲超时机制
  • 写空闲超时机制

2>. 内存保护机制

  • 通过对象引用计数法对 Netty 的 ByteBuf 等内置对象进行细粒度的内存申请和释放,对非法的对象引用进行检测和保护;
  • 通过内存池来重用 ByteBuf,节省内存;
  • 可设置的内存容量上限,包括 ByteBuf、线程池线程数。

3>. 优雅停机

  • 优雅停机需要设置最大超时时间,如果达到该时间系统还没退出,则通过 Kill -9 pid 强杀当前线程。
  • JVM 通过注册的 Shutdown Hook 拦截到退出信号量,然后执行退出操作。

3. 可定制性

  1. 责任链模式:channelPipeline 基于责任链模式开发,便于业务逻辑的拦截、定制和扩展;
  2. 基于接口的开发:关键的类库都提供了接口或者抽象类,用户可以自定义实现相关接口;
  3. 提供了大量工厂类,通过重载这些工厂类可以按需创建出用户实现的对象;
  4. 提供大量的系统参数供用户按需设置,增强系统的场景定制。

4. 可扩展性

可以方便进行应用层协议定制,比如 Dubbo、RocketMQ。

3.2. Netty 的线程模型

    对于网络请求一般可以分为两个处理阶段,一是接收请求任务,二是处理网络请求。根据不同阶段处理方式分为以下几种线程模型:

3.2.1. 串行化处理模型

Netty 权威指南,万字长文带你深入理解 Netty_第1张图片

这个模型中用一个线程来处理网络请求连接和任务处理,当 worker 接受到一个任务之后,就立刻进行处理,也就是说任务接受和任务处理是在同一个 worker 线程中进行的,没有进行区分。这样做存在一个很大的问题是,必须要等待某个 task 处理完成之后,才能接受处理下一个 task。

因此可以把接收任务和处理任务两个阶段分开处理,一个线程接收任务,放入任务队列,另外的线程异步处理任务队列中的任务。

3.2.2. 并行化处理模型

Netty 权威指南,万字长文带你深入理解 Netty_第2张图片

由于任务处理一般比较缓慢,会导致任务队列中任务积压长时间得不到处理,这时可以使用线程池来处理。可以通过为每个线程维护一个任务队列来改进这种模型。

3.2.3. Netty 具体线程模型

Netty 权威指南,万字长文带你深入理解 Netty_第3张图片

1. 如何理解 NioEventLoop 和 NioEventLoopGroup

  • NioEventLoop 实际上就是工作线程,可以直接理解为一个线程。NioEventLoopGroup 是一个线程池,线程池中的线程就是 NioEventLoop。
  • 实际上 bossGroup 中有多个 NioEventLoop 线程,每个 NioEventLoop 绑定一个端口,也就是说,如果程序只需要监听1个端口的话,bossGroup 里面只需要有一个 NioEventLoop 线程就行了。

2. 每个 NioEventLoop 都绑定了一个 Selector,所以在 Netty 的线程模型中,是由多个 Selector 在监听 IO 就绪事件。而 Channel 注册到 Selector。

3. 一个 Channel 绑定一个 NioEventLoop,相当于一个连接绑定一个线程,这个连接所有的ChannelHandler 都是在一个线程中执行的,避免了多线程干扰。更重要的是 ChannelPipline 链表必须严格按照顺序执行的。单线程的设计能够保证 ChannelHandler 的顺序执行。

4. 一个 NioEventLoop 的 selector 可以被多个 Channel 注册,也就是说多个 Channel 共享一个EventLoop。EventLoop 的 Selecctor 对这些 Channel 进行检查。

3.3. Netty 工作原理

3.3.1. Server 端工作原理

Netty 权威指南,万字长文带你深入理解 Netty_第4张图片

    Server 端启动时绑定本地某个端口,将自己 NioServerSocketChannel 注册到某个 boss NioEventLoop 的 selector 上。

    Server 端包含1个 boss NioEventLoopGroup 和1个 worker NioEventLoopGroup,NioEventLoopGroup 相当于1个事件循环组,这个组里包含多个事件循环 NioEventLoop,每个NioEventLoop 包含1个 selector 和1个事件循环线程。

每个 boss NioEventLoop 循环执行的任务包含3步:

  1. 轮询 accept 事件;
  2. 处理 IO 任务,即 accept 事件,与 Client 建立连接,生成 NioSocketChannel,并将NioSocketChannel 注册到某个 worker NioEventLoop 的 selector 上;
  3. 处理任务队列中的任务,runAllTasks。任务队列中的任务包括用户调用 EventLoop.execute 或 schedule 执行的任务,或者其它线程提交到该 EventLoop 的任务。 

每个 worker NioEventLoop 循环执行的任务包含3步:

  1. 轮询 read、write 事件;
  2. 处理 IO 任务,即 read、write 事件,在 NioSocketChannel 可读、可写事件发生时进行处理;
  3. 处理任务队列中的任务,runAllTasks。

3.3.2. Client 端工作原理

Netty 权威指南,万字长文带你深入理解 Netty_第5张图片

    Client 端启动时 connect 到 Server 端,建立 NioSocketChannel,并注册到某个 NioEventLoop的 selector 上。

Client 端只包含1个 NioEventLoopGroup,每个 NioEventLoop 循环执行的任务包含3步:

  1. 轮询 connect、read、write 事件;
  2. 处理 IO 任务,即 connect、read、write 事件,在 NioSocketChannel 连接建立、可读、可写事件发生时进行处理;
  3. 处理非 IO 任务,runAllTasks。

3.4. Netty 的启动

3.4.1. 服务端启动流程

public class NettyServer {
    public static void main(String[] args) {
        NioEventLoopGroup bossGroup = new NioEventLoopGroup();
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();

        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap
                .group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer() {
                    protected void initChannel(NioSocketChannel ch) {
                    }
                });

        serverBootstrap.bind(8000);
    }
}
  1. 首先创建了两个 NioEventLoopGroup,这两个对象可以看做是传统 IO 编程模型的两大线程组,bossGroup 表示监听端口,accept 新连接的线程组,workerGroup 表示处理每一条连接的数据读写的线程组。
  2. 接下来创建了一个引导类 ServerBootstrap,这个类将引导我们进行服务端的启动工作,直接new 出来开搞。
  3. 通过 .group(bossGroup, workerGroup) 给引导类配置两大线程组,这个引导类的线程模型也就定型了。
  4. 然后指定服务端的 IO 模型为 NIO,我们通过 .channel(NioServerSocketChannel.class) 来指定 IO 模型。
  5. 最后我们调用 childHandler() 方法,给这个引导类创建一个 ChannelInitializer,这里主要就是定义后续每条连接的数据读写,业务处理逻辑。ChannelInitializer 这个类中,我们注意到有一个泛型参数 NioSocketChannel,这个类是 Netty 对 NIO 类型的连接的抽象,而我们前面 NioServerSocketChannel 也是对 NIO 类型的连接的抽象,NioServerSocketChannel 和NioSocketChannel 的概念可以和 BIO 编程模型中的 ServerSocket 以及 Socket 两个概念对应上。

总结:创建一个引导类,然后给他指定线程模型,IO 模型,连接读写处理逻辑,绑定端口之后,服务端就启动起来了。

3.4.2. 客户端启动流程

    对于客户端的启动来说,和服务端的启动类似,依然需要线程模型、IO 模型,以及 IO 业务处理逻辑三大参数。

public class NettyClient {
    public static void main(String[] args) {
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();

        Bootstrap bootstrap = new Bootstrap();
        bootstrap
                // 1.指定线程模型
                .group(workerGroup)
                // 2.指定 IO 类型为 NIO
                .channel(NioSocketChannel.class)
                // 3.IO 处理逻辑
                .handler(new ChannelInitializer() {
                    @Override
                    public void initChannel(SocketChannel ch) {
                    }
                });
        // 4.建立连接
        bootstrap.connect("csdn.im", 80).addListener(future -> {
            if (future.isSuccess()) {
                System.out.println("连接成功!");
            } else {
                System.err.println("连接失败!");
            }

        });
    }
}
  1. 首先,与服务端的启动一样,需要给它指定线程模型,驱动着连接的数据读写;
  2. 然后指定 IO 模型为 NioSocketChannel,表示 IO 模型为 NIO;
  3. 接着给引导类指定一个 handler,这里主要就是定义连接的业务处理逻辑;
  4. 配置完线程模型、IO 模型、业务处理逻辑之后,调用 connect 方法进行连接,可以看到 connect 方法有两个参数,第一个参数可以填写 IP 或者域名,第二个参数填写的是端口号,由于 connect 方法返回的是一个 Future,也就是说这个方是异步的,我们通过 addListener 方法可以监听到连接是否成功,进而打印出连接信息。

总结:创建一个引导类,然后给他指定线程模型,IO 模型,连接读写处理逻辑,连接上特定主机和端口,客户端就启动起来了。

3.5. ByteBuf

ByteBuf 是一个节点容器,里面数据包括三部分:

  1. 已经丢弃的数据,这部分数据是无效的;
  2. 可读字节,这部分数据是 ByteBuf 的主体;
  3. 可写字节。

这三段数据被两个指针给划分出来,读指针、写指针。

ByteBuf 本质上就是,它引用了一段内存,这段内存可以是堆内也可以是堆外的,然后用引用计数来控制这段内存是否需要被释放,使用读写指针来控制对 ByteBuf 的读写,可以理解为是外观模式的一种使用。

基于读写指针和容量、最大可扩容容量,衍生出一系列的读写方法,要注意 read/write 与 get/set 的区别。

多个 ByteBuf 可以引用同一段内存,通过引用计数来控制内存的释放,遵循谁 retain() 谁 release() 的原则。

ByteBuf 和 ByteBuffer 的区别:

  1. 可扩展到用户定义的 buffer 类型中;
  2. 通过内置的复合 buffer 类型实现透明的零拷贝(zero-copy);
  3. 容量可以根据需要扩展;
  4. 切换读写模式不需要调用 ByteBuffer.flip() 方法;
  5. 读写采用不同的索引;
  6. 支持方法链接调用;
  7. 支持引用计数。

3.6. 支持池技术(如:线程池、数据库连接池)

ByteBuf 和 设计模式如下。关于设计模式可以参见《24大设计模式总结》。

3.6.1. ByteBufAllocator - 抽象工厂模式

在 Netty 的世界里,ByteBuf 实例通常应该由 ByteBufAllocator 来创建。

3.6.2. CompositeByteBuf - 组合模式

    CompositeByteBuf 可以让我们把多个 ByteBuf 当成一个大 Buf 来处理,ByteBufAllocator 提供了 compositeBuffer() 工厂方法来创建 CompositeByteBuf。CompositeByteBuf 的实现使用了组合模式。

3.6.3. ByteBufInputStream - 适配器模式

    ByteBufInputStream 使用适配器模式,使我们可以把 ByteBuf 当做 Java 的 InputStream 来使用。同理,ByteBufOutputStream 允许我们把 ByteBuf 当做 OutputStream 来使用。

3.6.4. ReadOnlyByteBuf - 装饰器模式

    ReadOnlyByteBuf 用适配器模式把一个 ByteBuf 变为只读,ReadOnlyByteBuf 通过调用Unpooled.unmodifiableBuffer(ByteBuf) 方法获得:

Netty 权威指南,万字长文带你深入理解 Netty_第6张图片

3.6.5. ByteBuf - 工厂方法模式

    我们很少需要直接通过构造函数来创建 ByteBuf 实例,而是通过 Allocator 来创建。从装饰器模式可以看出另外一种获得 ByteBuf 的方式是调用 ByteBuf 的工厂方法,比如:

  • ByteBuf#duplicate()
  • ByteBuf#slice()

3.7. channelHandler

    channelHandler 只会对感兴趣的事件进行拦截和处理,Servlet 的 Filter 过滤器,负责对 IO 事件或者 IO 操作进行拦截和处理,它可以选择性地拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。

    Pipeline 与 channelHandler 它们通过责任链设计模式来组织代码逻辑,并且支持逻辑的动态添加和删除。 

ChannelHandler 有两大子接口:

  1. 第一个子接口是 ChannelInboundHandler,从字面意思也可以猜到,他是处理读数据的逻辑
  2. 第二个子接口 ChannelOutBoundHandler 是处理写数据的逻辑。

这两个子接口分别有对应的默认实现,ChannelInboundHandlerAdapter,和 ChanneloutBoundHandlerAdapter,它们分别实现了两大接口的所有功能,默认情况下会把读写事件传播到下一个 handler。

事件的传播

    AbstractChannel 直接调用了 Pipeline 的 write() 方法,因为 write 是个 outbound 事件,所以DefaultChannelPipeline 直接找到 tail 部分的 context,调用其 write() 方法:

Netty 权威指南,万字长文带你深入理解 Netty_第7张图片

context 的 write() 方法沿着 context 链往前找,直至找到一个 outbound 类型的 context 为止,然后调用其 invokeWrite() 方法:

Netty 权威指南,万字长文带你深入理解 Netty_第8张图片

3.8. NioEventLoop

NioEventLoop 除了要处理 IO 事件,主要还有:

  1. 非 IO 操作的系统Task;
  2. 定时任务。

非 IO 操作和 IO 操作各占默认值50%,底层使用 Selector(多路复用器)。

Netty 权威指南,万字长文带你深入理解 Netty_第9张图片

Selector BUG 出现的原因:若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询,CPU 使用率100%。

Netty 的解决办法:

  1. 对 Selector 的 select 操作周期进行统计,每完成一次空的 select 操作进行一次计数;
  2. 若在某个周期内连续发生 N 次空轮询,则触发了 epoll 死循环 bug;
  3. 重建 Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。

3.9. 通信协议编解码

通信协议是为了服务端与客户端交互,双方协商出来的满足一定规则的二进制格式:

  1. 客户端把一个 Java 对象按照通信协议转换成二进制数据包;
  2. 把二进制数据包发送到服务端,数据的传输油 TCP/IP 协议负责;
  3. 服务端收到二进制数据包后,按照通信协议,包装成 Java 对象。

通信协议的设计:

  1. 魔数,作用:能够在第一时间识别出这个数据包是不是遵循自定义协议的,也就是无效数据包,为了安全考虑可以直接关闭连接以节省资源。
  2. 版本号
  3. 序列化算法
  4. 指令
  5. 数据长度
  6. 数据

3.10. Netty 内存池和对象池

    内存池是指为了实现内存池的功能,设计一个内存结构 Chunk,其内部管理着一个大块的连续内存区域,将这个内存区域切分成均等的大小,每一个大小称之为一个 Page。将从内存池中申请内存的动作映射为从 Chunk 中申请一定数量 Page。为了方便计算和申请 Page,Chunk 内部采用完全二叉树的方式对 Page 进行管理。

    对象池是指 Recycler 整个对象池的核心实现由 ThreadLocal 和 Stack 及 WrakOrderQueue 构成,接着来看 Stack 和 WrakOrderQueue 的具体实现,最后概括整体实现。

Netty 权威指南,万字长文带你深入理解 Netty_第10张图片

整个设计上核心的几点:

  1. Stack 相当于是一级缓存,同一个线程内的使用和回收都将使用一个 Stack;
  2. 每个线程都会有一个自己对应的 Stack,如果回收的线程不是 Stack 的线程,将元素放入到Queue 中;
  3. 所有的 Queue 组合成一个链表,Stack 可以从这些链表中回收元素(实现了多线程之间共享回收的实例)。

3.11. 心跳与空闲检测

3.11.1. 连接假死

    连接假死的现象是:在某一端(服务端或者客户端)看来,底层的 TCP 连接已经断开了,但是应用程序并没有捕获到,因此会认为这条连接仍然是存在的,从 TCP 层面来说,只有收到四次握手数据包或者一个 RST 数据包,连接的状态才表示已断开。

假死导致两个问题:

  1. 对于服务端,每条连接都会耗费 CPU 和内存资源,大量假死的连接会耗光服务器的资源;
  2. 对于客户端,假死会造成发送数据超时,影响用户体验。

通常,连接假死由以下几个原因造成的:

  1. 应用程序出现线程堵塞,无法进行数据的读写。
  2. 客户端或者服务端网络相关的设备出现故障,比如网卡,机房故障。
  3. 公网丢包。公网环境相对内网而言,非常容易出现丢包,网络抖动等现象,如果在一段时间内用户接入的网络连续出现丢包现象,那么对客户端来说数据一直发送不出去,而服务端也是一直收不到客户端来的数据,连接就一直耗着。

3.11.2. 服务端空闲检测

    如果能一直收到客户端发来的数据,那么可以说明这条连接还是活的,因此,服务端对于连接假死的应对策略就是空闲检测。

    简化一下,我们的服务端只需要检测一段时间内,是否收到过客户端发来的数据即可,Netty 自带的 IdleStateHandler 就可以实现这个功能。

    IdleStateHandler 的构造函数有四个参数,其中第一个表示读空闲时间,指的是在这段时间内如果没有数据读到,就表示连接假死;第二个是写空闲时间,指的是在这段时间如果没有写数据,就表示连接假死;第三个参数是读写空闲时间,表示在这段时间内如果没有产生数据读或者写,就表示连接假死。写空闲和读写空闲为0,表示我们不关心者两类条件;最后一个参数表示时间单位。在我们的例子中,表示的是:如果 15 秒内没有读到数据,就表示连接假死。

    在一段时间之内没有读到客户端的数据,是否一定能判断连接假死呢?并不能为了防止服务端误判,我们还需要在客户端做点什么。

3.11.3. 客户端定时心跳

服务端在一段时间内没有收到客户端的数据有两种情况:

  1. 连接假死;
  2. 非假死确实没数据发。

所以我们要排除第二种情况就能保证连接自然就是假死的,定期发送心跳到服务端。

实现了每隔 5 秒,向服务端发送一个心跳数据包,这个时间段通常要比服务端的空闲检测时间的一半要短一些,我们这里直接定义为空闲检测时间的三分之一,主要是为了排除公网偶发的秒级抖动。

为了排除是否是因为服务端在非假死状态下确实没有发送数据,服务端也要定期发送心跳给客户端。

3.12. 拆包粘包理论与解决

    TCP 是个“流”协议,所谓流,就是没有界限的一串数据。TCP 底层并不了解上层业务数据的具体含义,它会根据 TCP 缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被 TCP 拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的 TCP 粘包和拆包的问题。

解决方法:

  1. 解决思路是在封装自己的包协议:包=包内容长度(4byte)+包内容;
  2. 对于粘包问题先读出包头即包体长度 n,然后再读取长度为 n 的包内容,这样数据包之间的边界就清楚了。
  3. 对于断包问题先读出包头即包体长度 n,由于此次读取的缓存区长度小于 n,这时候就需要先缓存这部分的内容,等待下次 read 事件来时拼接起来形成完整的数据包。

3.13. Netty 自带的拆包器

3.13.1. 固定长度的拆包器 - FixedLengthFrameDecoder

    如果你的应用层协议非常简单,每个数据包的长度都是固定的,比如 100,那么只需要把这个拆包器加到 Pipeline 中,Netty 会把一个个长度为 100 的数据包(ByteBuf)传递到下一个 channelHandler。

3.13.2. 行拆包器 - LineBasedFrameDecoder

    从字面意思来看,发送端发送数据包的时候,每个数据包之间以换行符作为分隔,接收端通过 LineBasedFrameDecoder 将粘过的 ByteBuf 拆分成一个个完整的应用层数据包。

3.13.3. 分隔符拆包器 - DelimiterBasedFrameDecoder

DelimiterBasedFrameDecoder 是行拆包器的通用版本,只不过我们可以自定义分隔符。

3.13.4. 基于长度域拆包器 - LengthFieldBasedFrameDecoder

    这种拆包器是最通用的一种拆包器,只要你的自定义协议中包含长度域字段,均可以使用这个拆包器来实现应用层拆包。

四. 其他扩充

4.1. NIO EventLoopGroup 源码流程

Netty 权威指南,万字长文带你深入理解 Netty_第11张图片

4.2. RPC 框架的核心流程

  1. 生产者 Server:加载服务接口,并缓存;服务注册,将服务接口以及服务主机信息写入注册中心(本例使用的是 Zookeeper),启动网络服务器并监听。消费方(Client)调用以本地调用方式调用服务;
  2. Client Stub 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;代理服务接口生成代理对象,服务发现(连接 Zookeeper,拿到服务地址列表,通过客户端负载策略获取合适的服务地址)。
  3. Client Stub 找到服务地址,并将消息发送到服务端;
  4. Server Stub收到消息后进行解码;
  5. Server Stub根据解码结果调用本地的服务;
  6. 本地服务执行并将结果返回给 Server Stub;
  7. Server Stub将返回结果打包成消息并发送至消费方;
  8. Client Stub接收到消息,并进行解码;
  9. 服务消费方得到最终结果。

RPC 的目标就是要 2~8 这些步骤都封装起来,让用户对这些细节透明。Java 一般使用动态代理方式实现远程调用。

 

你可能感兴趣的:(Java,Netty,ByteBuf,Netty线程模型,Netty工作原理,Netty启动,NioEventLoop)