三、通信框架

恭喜 RNG,恭喜 UZI

六年
恋恋不忘
必有回响


1 关键技术点分析

1.1 长连接还是短连接

绝大多数的分布式服务框架(RPC框架)都推荐使用长连接进行内部通信,为什么选择长连接而不是短连接呢?具体原因如下:

  1. 相对比短连接,长连接更节省资源。长连接只会在首次创建时或者链路断连重连才创建链路,链路创建成功之后服务提供者和消费者会通过业务消息和心跳维系链路,实现多消息复用同一个链路节省资源。
  2. 远程通信是常态,调用时延是关键指标:服务化之后,本地 API 调用变成了远程服务调用,大量本地方法演进成了跨进程通信,网络时延称为关键指标之一。相比于一次简单的服务调用,链路的重建通常耗时更多,这就会导致链路层的时延消耗远远大于服务调用本身的损耗,这对于大型的业务系统而言是无法接受的。

1.2 BIO 还是 NIO

经典的 BIO 通信模型如下:

采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,它接收到客户端连接请求后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回给客户端,线程销毁,这就是典型的一请求一应答通信模型。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系。由于线程是 Java 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。

在 I/O 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或 I/O 多路复用技术进行处理。 I/O 多路复用技术通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型相比,I/O 多路复用的最大优势是系统开销小,系统不需要创建新的额外线程/进程,也不需要维护这些线程/进程的运行,节省系统资源。

JDK1.4 提供了对非阻塞I/O(NIO)的支持,JDK1.5 使用 epoll 替代了传统的 select/poll,极大提升了 NIO 通信的性能。
NIO 采用多路复用技术,一个多路复用器 Selector 可以同时轮询多个 Channel ,由于 JDK 使用了 epoll() 代替了传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
采用多路复用器 Selector 实现的 Reactor 通信模型如下:

1.3 自研还是选择开源 NIO 框架

选择 Netty!

2 功能设计

分布式服务框架的底层通信框架首先是一个通用的通信框架,它不应该与具体的协议绑定。基于通信框架智商,可以构建私有协议栈和公有协议栈。
架构原理如下图:

2.1 服务端设计

通信框架服务端的职责如下:

  1. 提供上层 API,用于初始化服务端实例,设置服务端通信相关参数,包括服务端的 I/O 线程池、监听地址、TCP 相关参数、接收和发送缓冲区大小等。
  2. 提供可扩展的编解码插件,用户可以通过扩展的方式实现自定义协议的编码和解码。
  3. 提供拦截面,用于私有协议栈开发。例如通过新增鉴权插件实现服务端对客户端的安全认证。

2.2 客户端设计

客户端需要考虑网络连接超时、连接失败等异常场景。

3 可靠性设计

3.1 链路有效性检测

当网络发生单通、连接被防火墙 Hang 住,长时间 GC 或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间的大批量业务失败或者超时。

从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和最通用的做法就是心跳检测。

  1. TCP 层面的心跳检测,即 TCP 的 Keep-Alive 机制,它的作用域是整个 TCP 协议栈。
  2. 协议层的心跳检测,主要存在于长连接协议中,例如 SMPP 协议。
  3. 应用层的心跳检测,主要由各业务产品通过约定方式定时给对方发送心跳消息实现。

心跳的检测原理如下:

不同的协议,心跳检测机制也存在差异,归类两类:

  1. Ping-Pong 型心跳:由通信一方定时发送 Ping 消息,对方接收到 Ping 消息之后,立即返回 Pong 应答消息给对方,属于请求-响应型心跳。
  2. Ping-Ping 型心跳:不区分心跳请求和应答,由通信双方按照约定定时向对方发送心跳 Ping 消息,属于双向心跳。

心跳检测策略如下:

  1. 连续 N 次心跳检测都没有收到对方的 Pong 应答消息或者 Ping 请求消息,则认为链路已经发生逻辑失效,这被称为心跳超时。
  2. 读取和发送心跳消息的时候如果直接发生了 I/O 异常,说明链路已经失效,这被称为心跳失败。

无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。
Netty 的心跳检测实际上是利用了链路空闲检测机制实现的,它的空闲检测机制分为三种:

  • 读空闲,链路持续时间 t 没有读取到任何消息。
  • 写空闲,链路持续时间 t 没有发送任何消息。
  • 读写空闲,链路持续时间 t 没有接受或者发送任何消息。

3.2 断连重连机制

为了保证服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待 INTERVAL 时间之后再发起重连,而不是失败后就立即重连。
为了保证句柄资源能够及时释放,无论什么场景下的重连失败,客户端都必须保证自身的资源被及时释放,包括但不限于 SocketChannel、Socket等。

3.3 消息缓存重发

当我们调用消息发送接口的时候,消息并没有真正被写入到 Socket 中,而是先放入 NIO 通信框架的消息发送队列中,由 Reactor 线程扫描待发送的消息队列,异步地发送给通信对端。假如很不辛,消息队列中积压了部分消息,此时链路中断,这会导致部分消息并没有真正发送给通信对端,如下图:

发生此故障时,我们希望 NIO 框架能够自动实现消息缓存和重新发送,遗憾的是作为基础的 NIO 通信框架,无论是 Mina 还是 Netty,都没有提供该功能,需要通信框架自己封装实现。

并非所有场景都需要通信框架做重发,例如服务框架的客户端,如果某个服务提供者不可用,会自动切换到下一个可用的服务提供者之上。假定是链路中断导致的服务提供者不可用,即便链路重新恢复,也没有必要将之前积压的消息重新发送,因为消息已经通过 FailOver 机制切换到另一个服务提供者处理。所以,消息缓存重发只是一种策略,通信框架应该支持链路级重发策略。

3.4 资源优雅释放

Java 的优雅停机通常通过注册 JDK 的 ShutdownHook 来实现,当系统接收到退出指令后,首先标记系统处于退出状态,不再接收新的消息,然后将积压的消息处理完,最后调用资源回收接口将资源销毁,最后各线程退出执行。

通常优雅退出有个时间限制,例如 30s,如果到达执行时间仍然没有完成推出前的操作,则由监控监本直接 kill -9 pid,强制退出。

4 性能设计

分布式服务框架被广泛应用于大数据处理、互联网消息中间件、游戏和金融行业等。对通信框架有很高的性能要求。

4.1 性能差的三宗罪

  1. 网络传输方式:同步阻塞 I/O,采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,接收到客户端连接之后,为其创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线性正比,导致并发量的增加,发生句柄溢出。
  2. 序列化性能差:Java 序列化机制是 Java 内部的一种对象编解码技术,无法跨语言;相对其它开源的序列化框架,Java 序列化后的码流太大,导致额外的资源占用;序列化性能差,资源占用率高(主要是 CPU 资源占用高)。
  3. 线程模型问题:由于采用同步阻塞 I/O,导致每个 TCP 连接都占用了一个线程。

4.2 通信性能三原则

  1. 传输:用什么样的通道将数据发送给对方:BIO/NIO/AIO。I/O 模型在很大程度上决定了通信的性能。
  2. 协议:采用什么样的通信协议, HTTP 等公有协议或者内部私有协议。通常内部私有协议可以被设计得更优。
  3. 线程:数据报如何读取?读取之后的编解码在哪个线程进行?编解码后的消息如何派发?Reactor 线程模型的不同,对性能的影响也非常大。

4.3 高性能之道

Netty 支持高性能通信的架构特性进行总结:

  1. 异步非阻塞通信:Netty 的 I/O 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端 SocketChannel。由于读写都是非阻塞的,这就可以充分提升 I/O 的运行效率,避免由频繁的 I/O 阻塞导致的线程挂起。另外,由于 Netty 采用了异步通信模式,一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
  2. 高效的 I/O 线程模型:Netty 支持 Reactor 单线程模型、Reactor 多线程模型和主从 Reactor 多线程模型,可以满足不同的容量和性能需求。
  3. 高性能的序列化框架:默认提供了 Protobuf 二进制序列化框架,其它二进制序列化框架可以进行编解码框架扩展实现。

还支持零拷贝、内存池等其它性能相关的特性。

5 最佳实践

  1. 服务端只负责客户端的接入,不处理 I/O 读写操作,因此服务端的 boosGroup 设置线程数为1:EventLoopGroup bossGroup = new NioEventLoopGroup(1);
  2. 客户端的 NioEventLoopGroup 由于服务提供者可能有 1000个或更多,导致创建了 1000个对象,可以使用大的线程池,或者创建一个包含 NioEventLoopGroup 的数组,将客户端连接按照 Hash 算法分组,将所有连接均匀的打散在 NioEventLoopGroup 中。

6 个人总结

通信框架的重点在于 BIO、NIO、AIO,以及序列化。
这就是为什么别人面试会问 BIO/NIO/AIO/序列化。