Skip to content

04 网络:如何设计高性能的网络模块?

你好,我是文强。

今天我们讲消息队列的第二个基础知识点——网络模块。对消息队列来说,网络模块是核心组件之一,网络模块的性能很大程度上决定了消息传输的能力和整体性能。

如果你是Java技术栈的开发人员,讲到网络模块的开发,大概率第一反应就是Netty。Netty作为Java网络编程中最出名的类库,几乎主宰了Java的网络编程。 那消息队列网络模块 的选型, 是不是直接用 Netty 就可以了呢?

带着你的思考,我们开始今天的课程。

选型之前,我们得先知道要解决什么问题。消息队列是需要满足高吞吐、高可靠、低延时,并支持多语言访问的基础软件,网络模块最需要解决的是 性能稳定性、开发成本 三个问题。接下来我们就围绕这三点来思考消息队列网络模块应该怎样设计。首先我们先来分析一下网络模块的性能瓶颈可能在哪里。

网络模块的性能瓶颈分析

我们基于最基础的消息队列访问链路图分析。

对于 单个请求 来说,请求流程是:客户端(生产者/消费者)构建请求后,向服务端发送请求包 -> 服务端接收包后,将包交给业务线程处理 -> 业务线程处理完成后,将结果返回给客户端。其中可能消耗性能的有三个点。

  • 编解码的速度。上节课我们详细讲过。
  • 网络延迟。也就是客户端到服务端的网络延迟,这一点在软件层面几乎无法优化,取决于网络链路的性能,跟网络模块无关。
  • 服务端/客户端网络模块的处理速度。发送/接收请求包后,包是否能及时被处理,比如当逻辑线程处理完成后,网络模块是否及时回包。这一点属于性能优化,是网络模块设计的核心工作,我们后续会细讲。

对于 并发请求 来说,在单个请求维度的问题的基础上,还需要处理高并发、高QPS、高流量等场景带来的性能问题。主要包含三个方面。

  • 高效的连接管理:当客户端和服务端之间的TCP连接数很多,如何高效处理、管理连接。
  • 快速处理高并发请求:当客户端和服务端之间的QPS很高,如何快速处理(接收、返回)请求。
  • 大流量场景:当客户端和服务端之间的流量很高,如何快速吞吐(读、写)数据。

大流量场景,某种意义上是高并发处理的一种子场景。因为大流量分为单个请求包大并发小、单个请求包小并发大两种场景。第一种的瓶颈主要在于数据拷贝、垃圾回收、CPU占用等方面,主要依赖语言层面的编码技巧来解决,一般问题不大。第二种场景是我们需要主要解决的。

知道了瓶颈在哪里,接下来我们来具体看一下如何设计出一个高性能的网络模块。

高性能网络模块的设计实现

从技术上来看,高性能网络模块的设计可以分为如何高效管理大量的TCP连接、如何快速处理高并发的请求、如何提高稳定性和降低开发成本等三个方面。

基于多路复用技术管理 TCP 连接

从技术原理来看,高效处理大量TCP连接,在消息队列中主要有单条TCP连接的复用和多路复用两种技术思路。

1. 单条TCP连接的复用

这是在一条真实的TCP连接中,创建信道(channel,可以理解为虚拟连接)的概念。通过编程手段,我们把信道当做一条TCP连接使用,做到TCP连接的复用,避免创建大量TCP连接导致系统资源消耗过多。缺点是在协议设计和编码实现的时候有额外开发工作量,而且近年随着异步IO、IO多路复用技术的发展,这种方案有点多余。

因为语言特性、历史背景原因,RabbitMQ用的就是这种方案。

2. IO多路复用技术

主流的消息队列Kakfa、RocketMQ、Pulsar的网络模块都是基于IO多路复用的思路开发的。

IO多路复用技术,是指通过把多个IO的阻塞复用到同一个selector的阻塞上,让系统在单线程的情况下可以同时处理多个客户端请求。最大的优势是系统开销小,系统不需要创建额外的进程或者线程,降低了维护的工作量,也节省了资源。

目前支持IO多路复用的系统调用有Select、Poll、Epoll等,Java NIO库底层就是基于Epoll机制实现的。

不过,即使用了这两种技术, 单机能处理的连接数还是有上限的

第一个上限是操作系统的FD上限,如果连接数超过了FD的数量,连接会创建失败。第二个限制是系统资源的限制,主要是CPU和内存。频繁创建、删除或者创建过多连接会消耗大量的物理资源,导致系统负载过高。

所以你会发现, 每个消息队列的配置中都会提到连接数的限制和系统 FD 上限调整。Linux中可以通过命令查看系统的FD信息。

//查看能打开FD的数量
ulimit -n //用户级限制
cat /proc/sys/fs/file-max  //系统级限制

//临时修改最大数量
ulimit -n 100000 //将最大值改为100000

解决了第一个问题连接处理,我们看第二个问题:如何快速处理高并发请求。

基于Reactor模型处理高并发请求

先看单个请求的处理。

我们知道,两点之间直线最短。对于单个请求来说,最快的处理方式就是客户端直接发出请求,服务端接收到包后,直接丢给后面的业务线程处理,当业务线程处理成功后,直接返回给客户端。

这种处理模式是最快的,但是这里有两个问题需要解决。

  • 如何第一时间拿到包交给后端的业务逻辑处理?
  • 当业务逻辑处理完成后,如何立即拿到返回值返回给客户端?

我们最直观的思路就是阻塞等待模型,不断轮询等待请求拿到包,业务逻辑处理完,直接返回结果给客户端。这种处理是最快的。但是阻塞等待模型因为是串行的处理机制,每个请求需要等待上一个请求处理完才能处理,处理效率会很低。所以,单个请求,最合理的方式就是 异步的事件驱动模型,可以通过Epoll和异步编程来解决。

再看高并发请求的情况。

在高并发的情况下会有很多连接、请求需要处理,核心思路就是并行、多线程处理。那如何并行处理呢?这时候就需要用到 Reactor 模型了。

Reactor 模型是一种处理并发服务请求的事件设计模式,当主流程收到请求后,通过多路分离处理的方式,把请求分发给相应的请求处理器处理。如下图所示,Reactor 模式包含Reactor、Acceptor、Handler三个角色。

  • Reactor:负责监听和分配事件。收到事件后分派给对应的 Handler处理,事件包括连接建立就绪、读就绪、写就绪等。
  • Acceptor:负责处理客户端新连接。Reactor 接收到客户端的连接事件后,会转发给 Acceptor,Acceptor接收客户端的连接,然后创建对应的Handler,并向Reactor注册此 Handler。
  • Handler:请求处理器,负责业务逻辑的处理,即业务处理线程。

从技术上看,Reactor模型一般有三种实现模式。

  • 单 Reactor 单线程模型(单 Reactor 单线程)
  • 单 Reactor 多线程模型 (单 Reactor 多线程)
  • 主从 Reactor 多线程模型 (多 Reactor 多线程)

我们具体分析一下,看消息队列更适合哪一种。

单 Reactor 单线程模型,特点是Reactor和Handler都是单线程的串行处理。

优点是所有处理逻辑放在单线程中实现,没有上下文切换、线程竞争、进程通信等问题。缺点是在性能与可靠性方面存在比较严重的问题。

性能上,因为是单线程处理,无法充分利用 CPU 资源,并且业务逻辑Handler的处理是同步的,容易造成阻塞,出现性能瓶颈。可靠性主要是因为单Reactor是单线程的,如果出现异常不能处理请求,会导致整个系统通信模块不可用。

所以单 Reactor 单进程模型不适用于计算密集型的场景,只适用于业务处理非常快速的场景

相比起来,单 Reactor 多线程模型,业务逻辑处理Handler 变成了多线程,也就是说,获取到 IO读写事件之后,业务逻辑是一批线程在处理。

优点是 Handler 收到响应后通过 send 把响应结果返回给客户端,降低 Reactor 的性能开销,提升整个应用的吞吐。而且 Handler 使用多线程模式,可以充分利用 CPU 的性能,提高了业务逻辑的处理速度。

缺点是 Handler 使用多线程模式,带来了多线程竞争资源的开销,同时涉及共享数据的互斥和保护机制,实现比较复杂。另外,单个 Reactor 承担所有事件的监听、分发和响应,对于高并发场景,容易造成性能瓶颈。

在此基础上,主从 Reactor 多线程模型,是让Reactor也变为了多线程。

当前业界消息队列的网络模型,比如Pulsar、Kafka、RocketMQ,为了保证性能,都是基于主从 Reactor 多线程模型开发的。

这种方案,优点是Reactor的主线程和子线程分工明确。主线程只负责接收新连接,子线程负责完成后续的业务处理。同时主线程和子线程的交互也很简单,子线程接收主线程的连接后,只管业务处理即可,无须关注主线程,可以直接在子线程把处理结果返回给客户端。所以,主从Reactor 多线程模型适用于高并发场景,Netty 网络通信框架也采用了这种实现。

缺点是如果基于NIO从零开始开发,开发的复杂度和成本较高。另外,Acceptor是一个单线程,如果挂了,如何处理客户端新连接是一个风险点。

为了解决Acceptor的单点问题,有些组件为了保证高可用性,会对主从 Reactor 多线程做一些优化,把Acceptor也变为多线程的形态。我们在公有云上商业化版本的 Kafka 就是使用的这种模型。

讲到这里,基于IO多路复用技术和Reactor模型,我们已经可以解决网络模块的性能问题了。接下来我们来看如何提高网络模块的稳定性和降低开发成本。

基于成熟网络框架提高稳定性并降低开发成本

这里的“稳定性”主要指代码的稳定性。因为网络模块的特点是编码非常复杂,要考虑的细节和边界条件非常多,一些异常情况的处理也很细节,需要经过长时间的打磨。但是一旦开发完成,稳定后,代码几乎不需要再改动,因为需求是相对固定的。

在Java中,网络编程的核心是一个基础的类库——Java NIO库,它的底层是基于Linux/Unix IO复用模型Epoll实现的。

如果我们要基于Java NIO库开发一个Server,需要处理网络的闪断、客户端的重复接入、连接管理、安全认证、编解码、心跳保持、半包读写、异常处理等等细节,工作量非常大。所以在消息队列的网络编程模型中, 为了提高稳定性或者降低成本,选择现成的、成熟的NIO框架是一个更好的方案。

而Netty就是这样一个基于Java NIO封装的成熟框架。所以我们一提到Java的网络编程,最先想到的就是Netty。当前业界主流消息队列RocketMQ、Pulsar也都是基于Netty开发的网络模块,Kafka 因为历史原因是基于Java NIO实现的。

接下来我们以RocketMQ和Kafka的网络模型为例,来分析一下主流消息队列的网络模型的设计实现。

主流消息队列的网络模型实现

Kafka 网络模型

Kafka的网络层没有用Netty作为底层的通信库,而是直接采用Java NIO实现网络通信。在网络模型中,也是参照Reactor多线程模型,采用多线程、多Selector的设计。

看整个网络层的结构图。Processor线程和Handler线程之间通过RequestChannel传递数据,RequestChannel中包含一个RequestQueue队列和多个ResponseQueues队列。每个Processor线程对应一个ResponseQueue。

具体流程上:

  • 一个Acceptor接收客户端建立连接的请求,创建Socket连接并分配给Processor处理。
  • Processor线程把读取到的请求存入RequestQueue中,Handler线程从RequestQueue队列中取出请求进行处理。
  • Handler线程处理请求产生的响应,会存放到Processor对应的ResponseQueue中,Processor 线程从其对应的ResponseQueue中取出响应信息,并返回给客户端。

RocketMQ 网络模型

RocketMQ 采用Netty组件作为底层通信库,遵循Reactor多线程模型,同时又在Reactor模型上做了一些扩展和优化。所以它的网络模型是Netty的网络模型,Netty底层采用的是主从Reactor多线程模型,模型的原理逻辑跟前面讲到的主从Reactor多线程模型是一样的。

在主从Reactor多线程模型的理论基础上,我们来分析一下RocketMQ中NettyRemotingServer 的具体实现形式。

具体流程上:

  1. 一个 Reactor 主线程负责监听 TCP网络连接请求,建立好连接,创建SocketChannel,并注册到Selector上。RocketMQ的源码中会自动根据OS的类型选择NIO和Epoll,也可以通过参数配置,监听真正的网络数据。
  2. 接收到网络数据后,会把数据传递给Reactor线程池处理。
  3. 真正执行业务逻辑之前,会进行SSL验证、编解码、空闲检查、网络连接管理,这些工作在Worker线程池处理(defaultEventExecutorGroup)。
  4. 处理业务操作,放在业务Processor线程池中执行。

从Kafka和RocketMQ的网络模型的实现来看,网络模块既可以基于原生的Java NIO,也可以基于NIO的框架(如Netty)来完成开发,不过基本思想都是基于IO多路复用技术和Reactor模型来提高处理性能、完成具体的编码实现。

但是到这里还没有结束,NIO编程属于TCP层网络编程,我们还需要进行协议设计、编解码、链路的建立/关闭等工作,才算完成一个完整的网络模块的开发。有没有更好的方案可以解决这些问题,减少我们的工作量呢?

NIO 编程和 RPC 框架

要想不关心底层的调用细节(如底层的网络协议和传输协议等),我们可以调用远端机器上的函数或方法来实现,也就是RPC(Remote Procedure Call)远程过程调用。

因为RPC调用的是一个远端对象,调用者和被调用者处于不同的节点上,想完成调用,必须实现4个能力。

  • 网络传输协议:远端调用底层需要经过网络传输,所以需要选择网络通信协议,比如TCP。
  • 应用通信协议:网络传输需要设计好应用层的通信协议,比如HTTP2或自定义协议。
  • 服务发现:调用的是远端对象,需要可以定位到调用的服务器地址以及调用的具体方法。
  • 序列化和反序列化: 网络传输的是二进制数据,因此RPC框架需要自带序列化和反序列化的能力。

讲到这里,不知道你有没有发现,RPC框架完成的工作等于上节课通信协议和前面讲的网络模块设计两部分的工作。在当前的微服务架构中,RPC已经是我们很熟悉、很常用且很成熟的技术了。

那RPC框架作为消息队列中的网络模块会有哪些优缺点呢?

我们以gRPC框架举例分析。gRPC是Google推出的一个RPC框架,可以说是RPC框架中的典型代表。主要有以下三个优点:

  • gRPC 内核已经很好地实现了服务发现、连接管理、编解码器等公共部分,我们可以把开发精力集中在消息队列本身,不需要在网络模块消耗太多精力。
  • gRPC 几乎支持所有主流编程语言,开发各个消息队列的SDK可以节省很多开发成本。
  • 很多云原生系统,比如Service Mesh都集成了gRPC协议,基于HTTP2的gRPC的消息队列很容易被云原生系统中的其他组件所访问,组件间的集成成本很低。

但是当前主流的消息队列都不支持gRPC框架,这是因为如果支持就要做很大的架构改动。而且,gRPC底层默认是七层的HTTP2协议,在性能上,可能比直接基于TCP协议实现的方式差一些。但是HTTP2本身在性能上做了一些优化,从实际表现来看,性能损耗在大部分场景下是可以接受的。

所以如果是一个新设计的消息队列或者消息队列的新架构,通过成熟的RPC框架来实现网络模块是一个蛮不错的方案。比如RocketMQ 5.0中的Proxy就使用gRPC框架实现了网络模块。

总结

消息队列的网络模块主要解决的是性能、稳定性、成本三个方面的问题。

性能问题,核心是通过 Reactor 模型、IO 多路复用技术解决的。Reactor模式在Java网络编程中用得非常广泛,比如 Netty 就实现了 Reactor 多线程模型。即使不用Netty进行网络编程(比如Kafka 直接基于Java NIO编程)的情况下,网络模块也大多是参考或基于Reactor模式实现的。因为Reactor模式可以结合多路复用、异步调用、多线程等技术解决高并发、大流量场景下的网络模块的性能问题。

在Java技术栈下,网络编程的核心是Java NIO。但为了解决稳定性和开发成本的问题,建议选择业界成熟的网络框架来实现网络模块,而不是基于原生的Java NIO来实现。成熟的框架分为成熟的NIO框架(如Netty)和成熟的RPC框架(如gRPC)。

目前业界主流的消息队列都是基于Java NIO和Netty实现的。Netty是我们网络模块编程的常用选型,大部分情况下,可能还是我们的最终选择。但是Netty好用并不意味着所有的Java网络编程都必须选择Java NIO和Netty。

当你需要构建一个组件的网络模块的时候,你要先知道这个组件的业务特点是什么,需要解决哪些问题,再来考虑使用什么技术。比如在客户端连接数不多、并发不高,流量也很小的场景,只需要一个简单的网络Server就够了,完全没必要选择Java NIO或Netty来实现你的网络模块。随着技术架构的迭代,基于RPC框架的方案也是一个不错的选择。

思考题

假如你的团队需要开发一款新的消息队列,你需要完成网络模块的选型开发设计,你的思考路径是什么?

欢迎分享你的思考,如果觉得有收获,也欢迎你把这节课分享给身边的朋友。我们下节课再见!

上节课思考闭环

为什么业界的消息队列有多种标准的协议呢?

业界的消息队列有多种标准的协议,如MQTT、AMQP、OpenMessaging。主要是因为业务场景不一样,一套协议标准无法满足多种场景需要。

MQTT是为了满足物联网领域的通信而设计的,背景是网络环境不稳定、网络带宽小,从而需要极精简的协议结构,并允许可能的数据丢失。

AMQP是主要面向业务消息的协议,因为要承载复杂的业务逻辑,所以协议设计上要尽可能丰富,包含多种场景,并且在传输过程中不允许出现数据丢失。因为AMQP协议本身的设计具有很多局限,比如功能太简单,所以不太符合移动互联网、云原生架构下的消息需求。

OpenMessaging的设计初衷是设计一个符合更多场景的消息队列协议。