RPC 协议是围绕应用层协议展开的。
只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,他需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。
但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据(包合并的前提是同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。
在 RPC 传输数据的时候,在应用发送请求的数据包里面加入消息的边界,用于标示请求数据的结束位置,这样接收方应用才能从数据流里面分割出正确的数据。
这个边界语义的表达,就是协议。
因此,对于要求高性能的 RPC 来说,HTTP 协议很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。
RPC 每次请求的大小都是不固定的,所以协议必须能让接收方正确地读出不定长的内容。
整个协议可以设计成这样:
上面这种协议,只实现了正确的断句效果,对于服务提供方来说,不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。
如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也就不能完成调用了。
因此把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。
在协议头里面,除了会放协议长度、序列化方式,还有如协议标示、消息 ID、消息类型等参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。
这就形成一个完整的 RPC 协议,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:
上面协议属于定长协议头,之后不能再往协议头里加新参数,如果加参数就会导致线上兼容问题。
如果将新参数放在协议体中,有一个关键问题,协议体的内容是经过序列化的,要获取新加的参数值,必须把整个协议体反序列化。但在某些场景下,这样做的代价有点高啊!
比如,要在原协议中增加请求超时这个参数,如果放在请求体中,需要消耗CPU解析出这个参数后再丢弃请求,想对于直接在请求头中判断请求已经过期直接丢弃代价高太多。
为了保证能平滑地升级改造前后的协议,有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头长度不固定。
要实现读取不定长的协议头中的内容,需要有一个固定的地方读取协议头长度,因此,需要固定的写入协议头的长度。
整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分可以统称为“协议头”,具体协议如下:
设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展。
在不同的场景下合理地选择序列化方式,对提升 RPC 框架整体的稳定性和性能是至关重要的。
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。
ObjectOutputStream
完成的,而反序列化的具体实现是由 ObjectInputStream
完成的。序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。Protobuf 非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点不如 Hessian,比如用 Java 的话,这个预编译过程不是必须的,可以考虑使用 Protostuff。Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反/序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架,但也有不支持的情况,1-不支持 null;2-ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。
安全性 > 通用性 > 兼容性 > 性能 > 效率 > 空间开销
在 RPC 框架的使用过程中,要尽量构建简单的对象作为入参和返回值对象,避免上述问题。
在使用 RPC 框架的过程中,我们构造入参、返回值对象,主要记住以下几点:
一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。
网络通信是整个 RPC 调用流程的基础。
常见的网络 IO 模型分为四种:
在这四种 IO 模型中,只有 AIO 为异步 IO,其他都是同步 IO。其中,最常用的就是同步阻塞 IO 和 IO 多路复用。
同步阻塞 IO 是最简单、最常见的 IO 模型,在 Linux 中,默认情况下所有的 socket 都是 blocking 的。
这里可以看到,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态。
多路复用 IO (又称事件驱动) 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。
多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。
当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。
但它最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。
而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
实际在网络 IO 的应用上,需要的是系统内核的支持以及编程语言的支持。
Java
语言有很多的开源框架对 Java 原生 API 做了封装,如 Netty 框架,使用非常简便;而 GO
语言,语言本身对 IO 多路复用的封装就已经很简洁了。RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,选择 IO 多路复用的方式。
开发语言的网络通信框架的选型上,最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)。
阻塞 IO 中,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。
应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去。
零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
用户空间与内核空间都将数据写到一个地方即虚拟内存,这样就不需要拷贝数据了。这是操作系统层面上的零拷贝,主要目标是避免用户空间与内核空间之间的数据拷贝操作,可以提升 CPU 的利用率。
零拷贝有两种解决方式,分别是 mmap+write
方式和 sendfile
方式,mmap+write
方式的核心原理就是通过虚拟内存来解决的。
RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入远程调用逻辑。
通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验,整体流程如下图所示:
对于服务调用方来说,在使用 RPC 的时候本来就是面向接口来编程的。
动态代理是一种具体的技术框架,选型从这三个角度去考虑:
gRPC详解点这里。
gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。