RPC简介
RPC的全称是Remote Procedure Call,即远程过程调用。简单解读字面上的意思,远程肯定是指要跨机器而非本机,所以需要用到网络编程才能实现,但是不是只要通过网络通信访问到另一台机器的应用程序,就可以称之为RPC调用了?显然并不够。我理解的RPC是帮助我们屏蔽网络编程细节,实现调用远程方法就跟调用本地(同一个项目中的方法)一样的体验,我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码。
RPC的作用体现在两个方面:
屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法;
隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
RPC通信流程
RPC在架构中的位置
RPC是解决应用间通信的一种方式,而无论是在一个大型的分布式应用系统还是 中小型系统中,应用架构最终都会从“单体”演进成“微服务化”,整个应用系统会被拆分 为多个不同功能的应用,并将它们部署在不同的服务器中,而应用之间会通过RPC进行通信, 可以说RPC对应的是整个分布式应用系统,就像是“经络”一样的存在。
RPC框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远 程方法。利用RPC我们不仅可以很方便地将应用架构从“单体”演进成“微服务化”,而且还 能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清 晰、健壮,应用可运维度增强。
·
协议化:设计可扩展且向后兼容的协议
在设计协议前,我们先梳理下要完成RPC通信的时候,在协议里面需要放哪些内容。
如何设计一个私有RPC协议
首先要想到的就是我们前面说的消息边界了,但RPC每次发请求发的大小都是不固定的,所以 我们的协议必须能让接收方正确地读出不定长的内容。我们可以先固定一个长度(比如4个字 节)用来保存整个请求数据大小,这样收到数据的时候,我们先读取固定长度的位置里面的 值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可 以设计成这样
但上面这种协议,只实现了正确的断句效果,在RPC里面还行不通。因为对于服务提供方来 说,他是不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道 调用方用的序列化方式,即使服务提供方还原出了正确的语义,也并不能把二进制还原成对 象,那服务提供方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数我们可以统称 为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。
在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息ID、消 息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。 这样一个完整的RPC协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根 据请求接口和参数构造的,长度属于可变的,具体协议如下图所示
可扩展协议
刚才讲的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参 数就会导致线上兼容问题。举个具体例子,假设你设计了一个88Bit的协议头,其中协议长度 占用32bit,然后你为了加入新功能,在协议头里面加了2bit,并且放到协议头的最后。升级 后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照88bit读取 协议头,新加的2个bit会当作协议体前2个bit数据读出来,但原本的协议体最后2个bit会被丢弃了,这样就会导致协议体的数据是错的。
所以为了保证能平滑地升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前 两部分我们还是可以统称为“协议头”,具体协议如下:
RPC如何实现请求和响应关联
RPC不直接用HTTP协议的 一个原因是无法实现请求跟响应关联,每次请求都需要重新建立连接,响应完成后再关闭连 接,所以我们要设计私有协议。那么在RPC里面,我们是怎么实现请求跟响应关联的呢?
序列化:对象在网络中传输的关键
常用的序列化协议
JDK原生化序列协议
1 | import java.io.*; |
JDK的序列化过程
序列化的核心
实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类
型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一按照读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。
JSON
JSON可能是我们最熟悉的一种序列化格式了,JSON是典型的Key-Value方式,没有数据类
型,是一种文本型序列化框架,
存在的问题
- JSON进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
- JSON没有类型,但像Java这种强类型语言,需要通过反射统一解决,所以性能不会太好。
Hessian
Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian协议 要比JDK、JSON更加紧凑,性能上要比JDK、JSON序列化高效很多,而且生成的字节数也更小。
1 | Student student = new Student(); |
Hessian本身也有问题,官方版本对Java里面一些常见对象的类型不支持,比如:
Linked系列,LinkedHashMap、LinkedHashSet等,但是可以通过扩展
CollectionDeserializer类修复;
Locale类,可以通过扩展ContextSerializerFactory类修复;
Byte/Short反序列化的时候变成Integer。
Protobuf
Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格 式,可以用于结构化数据序列化,支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义IDL(Interface description language),然后使用不同语言的IDL编译器,生 成序列化工具类。
优点:
序列化后体积相比 JSON、Hessian小很多;
IDL能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似XML 解析器;
序列化反序列化速度很快,不需要通过反射获取类型;
消息格式升级和兼容性不错,可以做到向后兼容。
1 | /** |
Protostuff不需要依赖IDL文件,可以直接对Java领域对象进行反/序列化操作,在效率上跟 Protobuf差不多,生成的二进制格式和Protobuf是完全相同的,可以说是一个Java版本的 Protobuf序列化框架。
ProtoStuff不支持单纯的Map、List集合对象,需要包在对象里面。
RPC框架如何选择序列化协议
RPC框架在 序列化的选择上,我们更关注序列化协议的安全性、通用性、兼容性,其次才关注序列化协议的性能、效率、空间开销。
RPC框架在使用中需要注意的问题
- 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚;
属性很多,并且存在多层的嵌套,比如A对象关联B对象,B对象又聚 合C对象,C对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗CPU,这会严重影响RPC框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。
- 入参对象与返回值对象体积不要太大,更不要传太大的集合;
一个大List或者大Map,序列化之后字节长度达到了上兆字节。这 种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。
- 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类;
比如Hessian框架,他天然是不支持 LinkHashMap、LinkedHashSet等,而且大多数情况下最好不要使用第三方集合类,如Guava中的集合类,很多开源的序列化框架都是**优先支持编程语言原生的对象**。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如HashMap、ArrayList。
- 对象不要有复杂的继承关系,最好不要有父子类的情况
大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题1一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。
网络通信:RPC框架的网络IO模型
网络通信在RPC调用中的作用
RPC是解决进程间通信的一种方式。一次RPC调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络IO发送一条请求消 息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用 者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次RPC调用便结束了。可以 说,网络通信是整个RPC调用流程的基础。
常见网络IO模型
常见的网络IO模型分为四种:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用和
异步非阻塞IO(AIO)。在这四种IO模型中,只有AIO为异步IO,其他都是同步IO。
阻塞IO(blocking IO)
同步阻塞IO是最简单、最常见的IO模型,在Linux中,默认情况下所有的socket都是blocking的。
首先,应用进程发起IO系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回 进程。最后应用的进程解除阻塞状态,运行业务逻辑。
这里我们可以看到,系统内核处理IO操作分为两个阶段——**等待数据和拷贝数据**。而在这两
个阶段中,应用进程中IO操作的线程会一直都处于阻塞状态,如果是基于Java多线程开发, 那么每一个IO操作都要占用线程,直至IO操作结束。
IO多路复用(IO multiplexing)
多路复用IO是在高并发场景中使用最为广泛的一种IO模型,如Java的NIO、Redis、Nginx的 底层实现就是此类IO模型的应用,经典的Reactor模式也是基于此类IO模型.
那么什么是IO多路复用呢?通过字面上的理解,多路就是指多个通道,也就是多个网络连接 的IO,而复用就是指多个通道复用在一个复用器上。
多个网络连接的IO可以注册到一个复用器(select)上,当用户进程调用了select,那么整个进程会被阻塞。同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核中拷贝到用户进程。
当用户进程发起了select调用,进程会被阻塞,当发现该select负责的socket有准备好的数据时才返回,之后才发起一次read,整个流程要比阻塞IO要复杂,似乎也更浪费性能。但它最大的优势在于,用户可以在一个线程内同时处理多个socket的IO请 求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达 到这个目的。
RPC框架在网络通信上倾向选择哪种网络IO模型?
零拷贝技术
零拷贝技术出现的背景
对于阻塞IO,系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。以下是具体流程:
应用进程的每一次写操,都会把数据写到用户空间的缓冲区中,再由CPU将数据拷贝到系统内核的缓冲区中,之后再由DMA将这份数据拷贝到网卡中,最后由网卡发送出去。这里我们可以看到,**一次写操作数据要拷贝两次才能通过网卡发送出去**,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据。
缺点
应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷 贝,都需要CPU进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程)
零拷贝(Zero-copy)
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过DMA将内核中的数据拷贝到网卡,或将网卡中的数据 copy到内核。
零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,mmap+write方式的
核心原理就是通过虚拟内存来解决的.
Netty中的零拷贝
而Netty的零拷贝则不大一样,他完全站在了用户空间上,也就是JVM上,它的零拷贝主要是偏向于数据操作的优化上。
在传输过程 中,RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分 成好几个数据包,也可能会合并其他请求的数据包,所以消息都需要有边界。那么一端的机 器收到消息之后,就需要对数据包进行处理,根据边界对数据包进行分割和合并,最终获得 一条完整的消息。
那收到消息后,对数据包的分割和合并,是在用户空间完成,还是在内核空间完成的呢?
当然是在用户空间,因为对数据包的处理工作都是由应用程序来处理的,那么这里有没有可 能存在数据的拷贝操作?可能会存在,当然不是在用户空间与内核空间之间的拷贝,是**用户空间内部内存中的拷贝处理操作**。Netty的零拷贝就是为了解决这个问题,在用户空间对数据操作进行优化。
Netty对于解决用户空间和内核空间的数据拷贝问题的策略
Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行Socket的读写操作,
Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷贝,这
与Linux 中的 sendfile 方式在原理上也是一样的。
动态代理:面向接口编程,屏蔽RPC处理流程
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过 Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码 里面直接调用接口的方法 。 我们都知道,**接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里**,但我们 通过调用接口方法,确实拿到了想要的结果,这就是动态代理的作用。
这里面用到的核心技术就是前面说的动态代理。RPC 会自动给接口生成一个代理类,当我们
在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方
法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以**在生成的代理类里**
面,加入远程调用逻辑。(调用服务提供方的业务逻辑)。
通过这种“偷梁换柱”的手法,就可以帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验。
动态代理实现逻辑
给 Hello 接口生成一个动态代理类,并调用接口 say() 方法,但真实返回的值居然是来自 RealHello 里面的 invoke() 方法返回值。
代理类生成的逻辑—-Proxy.newProxyInstance