02 RPC基础

0.1. 协议

RPC 协议是围绕应用层协议展开的。

只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。

但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据(包合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。

在 RPC 传输数据的时候,在应用发送请求的数据包里面加入消息的边界,用于标示请求数据的结束位置,这样接收方应用才能从数据流里面分割出正确的数据。

这个边界语义的表达,就是协议。

  • RPC 更多的是负责应用间的通信,所以性能要求相对更高。
  • HTTP 协议的数据包大小相对请求数据本身要大,又需要加入很多无用的内容,它是无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。

因此,对于要求高性能的 RPC 来说,HTTP 协议很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。

0.1.1. 定长协议头

RPC 每次请求的大小都是不固定的,所以协议必须能让接收方正确地读出不定长的内容。

  1. 先固定一个长度(比如 4 个字节)用来保存整个请求数据大小,
  2. 收到数据后,先读取固定长度的位置里面的值,值的大小就代表协议体的长度,
  3. 再根据值的大小来读取协议体的数据,

整个协议可以设计成这样:

image

上面这种协议,只实现了正确的断句效果,对于服务提供方来说,不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。

如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。

因此把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。

在协议头里面,除了会放协议长度、序列化方式,还有如协议标示、消息 ID、消息类型等参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。

这就形成一个完整的 RPC 协议,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:

image

0.1.2. 可扩展协议

上面协议属于定长协议头,之后不能再往协议头里加新参数,如果加参数就会导致线上兼容问题。

如果将新参数放在协议体中,有一个关键问题,协议体的内容是经过序列化的,要获取新加的参数值,必须把整个协议体反序列化。但在某些场景下,这样做的代价有点高啊!

比如,要在原协议中增加请求超时这个参数,如果放在请求体中,需要消耗CPU解析出这个参数后再丢弃请求,想对于直接在请求头中判断请求已经过期直接丢弃代价高太多。

为了保证能平滑地升级改造前后的协议,有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头长度不固定

要实现读取不定长的协议头中的内容,需要有一个固定的地方读取协议头长度,因此,需要固定的写入协议头的长度。

整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分可以统称为“协议头”,具体协议如下:

image

设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。

0.2. 序列化

在不同的场景下合理地选择序列化方式,对提升 RPC 框架整体的稳定性和性能是至关重要的。

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。

  • 对象是不能直接在网络中传输的,所以需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程叫做“序列化”。
  • 服务提供方可以正确地从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制的消息体逆向还原成请求对象,这个过程称为“反序列化”。

image

0.2.1. 常用序列化方式

  • JDK 原生序列化:序列化具体的实现是由 ObjectOutputStream 完成的,而反序列化的具体实现是由 ObjectInputStream 完成的。序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。
  • JSON:典型的 Key-Value 方式,没有数据类型,是一种文本型序列化框架。JSON 进行序列化的额外空间开销比较大,JSON 没有类型,需要通过反射统一解决,所以性能不会太好。
  • Hessian:是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。
  • Protobuf:一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类。序列化后体积相比 JSON、Hessian 小很多;IDL 能清晰地描述语义,序列化反序列化速度很快,可以做到向后兼容。

Protobuf 非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点不如 Hessian,比如用 Java 的话,这个预编译过程不是必须的,可以考虑使用 Protostuff。Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反/序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架,但也有不支持的情况,1-不支持 null;2-ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。

0.2.2. 序列化方式选择

  1. 序列化与反序列化过程是 RPC 调用的一个必须过程,它的性能和效率将直接关系到 RPC 框架整体的性能和效率。
  2. 空间开销,序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,RPC 是远程调用,网络传输的速度将直接关系到请求响应的耗时。
  3. 序列化协议的通用性和兼容性,优先级更高一些,这直接关系到服务调用的稳定性和可用率。
  4. 序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵。

安全性 > 通用性 > 兼容性 > 性能 > 效率 > 空间开销

0.2.3. 序列化注意事项

  1. 对象构造得过于复杂:属性很多,并且存在多层的嵌套,序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗 CPU,这会严重影响 RPC 框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
  2. 对象过于庞大,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
  3. 使用序列化框架不支持的类作为入参类,大多数情况下最好不要使用第三方集合类,很多开源的序列化框架都是优先支持编程语言原生的对象。
  4. 对象有复杂的继承关系,和第一个问题一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。

在 RPC 框架的使用过程中,要尽量构建简单的对象作为入参和返回值对象,避免上述问题。

在使用 RPC 框架的过程中,我们构造入参、返回值对象,主要记住以下几点:

  1. 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
  2. 入参对象与返回值对象体积不要太大,更不要传太大的集合;
  3. 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
  4. 对象不要有复杂的继承关系,最好不要有父子类的情况。

0.3. 网络通信

一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程

  1. 服务调用者通过网络 IO 发送一条请求消息,
  2. 服务提供者接收并解析,
  3. 处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,
  4. 服务调用者接收并解析响应消息,
  5. 处理完相关的响应逻辑,一次 RPC 调用便结束了。

网络通信是整个 RPC 调用流程的基础。

0.3.1. 常见网络IO模型

常见的网络 IO 模型分为四种:

  • 同步阻塞 IO(BIO)
  • 同步非阻塞 IO(NIO)
  • IO 多路复用
  • 异步非阻塞 IO(AIO)

在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。其中,最常用的就是同步阻塞 IO 和 IO 多路复用。

0.3.1.1. 同步阻塞 IO(BIO)

同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 socket 都是 blocking 的。

  1. 首先,应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。
  2. 之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。
  3. 最后,应用的进程解除阻塞状态,运行业务逻辑。

这里可以看到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态

0.3.1.2. IO 多路复用(IO multiplexing)

多路复用 IO (又称事件驱动) 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。

多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。

  1. 多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。
  2. 同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。
  3. 这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。

当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。

但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。

而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

实际在网络 IO 的应用上,需要的是系统内核的支持以及编程语言的支持。

  • 在系统内核的支持上,现在大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持。
  • 在编程语言上,无论 C++ 还是 Java,在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路复用的。当然,在非高并发场景下,同步阻塞 IO 是最为常见的。

0.3.2. 网络IO模型选择

  • IO 多路复用更适合高并发的场景,用较少的进程(线程)处理较多的 socket 的 IO 请求,但使用难度比较高。当然高级的编程语言支持得还是比较好的,比如 Java 语言有很多的开源框架对 Java 原生 API 做了封装,如 Netty 框架,使用非常简便;而 GO 语言,语言本身对 IO 多路复用的封装就已经很简洁了。
  • 阻塞 IO 与 IO 多路复用相比,阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。

RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,选择 IO 多路复用的方式。

开发语言的网络通信框架的选型上,最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。

0.3.3. 零拷贝

阻塞 IO 中,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。

  • 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;
  • 拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。

image

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。

零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

image

用户空间与内核空间都将数据写到一个地方即虚拟内存,这样就不需要拷贝数据了。这是操作系统层面上的零拷贝,主要目标是避免用户空间与内核空间之间的数据拷贝操作,可以提升 CPU 的利用率。

零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,mmap+write 方式的核心原理就是通过虚拟内存来解决的。

0.4. 动态代理

RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入远程调用逻辑。

通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示:

image

对于服务调用方来说,在使用 RPC 的时候本来就是面向接口来编程的。

动态代理是一种具体的技术框架,选型从这三个角度去考虑:

  1. 代理类是在运行中生成的,代理框架生成代理类的速度、生成代理类的字节码大小等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。
  2. 生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
  3. 从使用角度出发的,选择一个使用起来很方便的代理类框架,如API 设计是否好理解、社区活跃度、依赖复杂度等。

gRPC详解点这里

gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。

上次修改: 14 May 2020