目前企业的后端项目基本是由一个个微服务所构成,而各个服务之间就涉及一个比较关键的问题,也就是网络通信。设计各个服务互相之间的通信以及和其他中间件通信的代码是各位开发者必须面临的问题。
而rpc就是将网络通信中复杂的过程(对端节点的查找、网络连接的建立、传输数据的编码解码以及网络连接的管理等等)进行了一个封装,使网络通信的逻辑变得简单,并且更加可靠。
总结一下,它的作用主要体现在:
没有rpc会如何?
举个例子:
所有的功能代码都会被我们堆砌在一个大项目中,开发过程中你可能要改一行代码,但改完后编译会花掉你 2 分钟,编译完想运行起来验证下结果可能要 5 分钟,是不是很酸爽?更难受的是在人数比较多的团队里面,多人协同开发的时候,如果团队其他人把接口定义改了,你连编译通过的机会都没有,系统直接报错,从而导致整个团队的开发效率都会非常低下。而且当我们准备要上线发版本的时候,QA 也很难评估这次的测试范围,为了保险起见我们只能把所有的功能进行回归测试,这样会导致我们上线新功能的整体周期都特别长。
首先RPC 常用于业务系统之间的数据交互,需要保证其可靠性,所以 RPC 一般默认采用 TCP 来传输。
对象需要进行序列化操作转为二进制,并且这个算法一定要是可逆的,也就是说要可以进行反序列化操作。
之后我们还需要规定服务端客户端的一些“约定”,也就是我们所说的协议。协议一般会分成两部分,主要是消息头和消息体,消息头包括协议标识、数据大小、请求类型、序列化类型等信息;消息体主要是请求的业务参数信息和扩展属性等
根据协议格式,服务提供方就可以正确地从二进制数据中分割出不同的请求来,最后进行反序列化将请求变为对象。
服务提供方再根据反序列化出来的请求对象找到对应的实现类,完成真正的方法调用,然后把执行结果序列化后,回写到对应的 TCP 通道里面。调用方获取到应答的数据包后,再反序列化成应答对象,这样调用方就完成了一次 RPC 调用。
调用过程中主要是用了动态代理,通过生成调用对应接口的代理类,然后通过反射调用相关的方法,其中还封装了一些其他方法,并把远程调用结果返回给调用方,具体流程如下图:
为什么需要设计RPC协议?
主要就是因为在通信时RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(同一连接上的数据)。比如说有三个请求数据 AA,BB,CC 但是服务端收到的请求数据可能是AAB,BCC,这样就不大行了。所以主要起一个首尾定界的作用。目前有个疑问,为啥不链路层封装好的协议?
后来了解到主要有两个原因:
但 HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;
还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。
因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。
主要是要设计协议头和协议体。
在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息 ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的 RPC 协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变的,具体协议如下图所示:
但是这种协议有一个问题,也就是说消息头是定长的。消息头是定长的话就会出现一个比较棘手的问题,也就是说当我们向往消息头重添加新的参数时就没啥办法了。
这样的话我们需要增加一个固定读协议头内容的位置。
具体过程:
之前用过java的默认序列化方法,主要是添加了一些分割符。
序列化方式可以由下图概括:
文章里我看也提到了json,json的可读性非常好,也比较常用。但是有两个问题
还有一些向Hessian、Protobuf的标准 这块就不多赘述了。
so RPC如何选择序列化方法
优先级从高到低如下图所示:
目前来说首选的序列化协议 主要还是Hessian 与 Protobuf
Hessian使用方便 对象兼容性好
Protobuf更加高效,通用性上优势更大
序列化过程中要注意序列化对象的一些问题:
rpc在选择网络io模型中主要倾向使用阻塞 IO 和 IO 多路复用。这两种模型已经能基本满足大部分网络io的应用场景。
多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求,但使用难度比较高。
在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,开销上还要比 IO 多路复用低。
零拷贝
系统内核处理io主要分为两个阶段:等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
零拷贝带来的好处就是避免没必要的 CPU 拷贝,让 CPU 解脱出来去做其他的事,同时也减少了 CPU 在用户空间与内核空间之间的上下文切换,从而提升了网络通信效率与应用程序的整体性能。
零拷贝的技术主要有两种:
一种是mmap+write
mmap()
后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;write()
,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;还有一种是sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
,函数形式如下:
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
他可以代替read和write 从而减少两次系统调用
在netty中主要提供了两种方法实现了零拷贝
Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行 Socket 的读写操作,最终的效果与我刚才讲解的虚拟内存所实现的效果是一样的。Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷贝,这与 Linux 中的 sendfile 方式在原理上也是一样的。
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。
这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
如何考虑技术选型?