RPC协议的核心是让我们像调用本地一样调用远程,帮助我们的应用层屏蔽远程调用的复杂性,使得我们可以更加方便的关键分布式系统。总结起来,其实就是一个关键字,透明化。
协议的作用
为什么需要协议这个东西呢?没有协议就不能通信嘛?
-
我们知道只有二进制才能在网络中传输,所以RPC请求在发送到网络中之前,需要把方法调用的请求参数转成二进制;转成二进制之后,写入本地socket中,然后被网卡发送到网络设备中。
-
但是在传输过程中,RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个TCP连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和TCP窗口大小。对于服务提供方应用来说,它会从TCP通道里面收到很多二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?
-
这就好比让你读一篇没有标点符号的文章,你要怎么识别出每一句话到哪里结束呢?很简单啊,我们加上标点,完成断句就好了。
-
同理在RPC传输数据的时候,为了能够准确的“断句”,我们也必须在应用发送请求的数据报里面加入“句号”,这样才能帮我们的接收方应用从数据流里面分割出正确的数据。这个数据报里面的句号就是消息的边界,用于标识请求数据的结束为止。
-
举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到 ABCDEF 或者ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。
-
所以,为了避免语义不一致的事情发生,我们必须在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是我们所示的“协议”
如何设计协议
问题:HTTP协议和RPC都属于应用层协议,那有了现成的HTTP协议,为什么还需要RPC协议呢?
- 相对于HTTP的用户,RPC更多的是负责应用间的通信,所以性能要求更高
- 但HTTP协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符、回车符等
- 还有一个更重要的原因是,HTTP协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接(HTTP现在已经支持长链接了,但是性能不及TCP)
- 因此,对于要求高性能的RPC来说,HTTPP协议基本很难满足需求,所以RPC会选择设计更紧凑的私有协议
如何设计一个私有的RPC协议
在设计协议前,我们需要先梳理下要完成RPC通信时,在协议里面需要放哪些内容
-
第一个是消息边界,但是RPC每次发请求发的大小都是不固定的,所以我们的协议必须必须能够让接收方正确读出不定长的内容。我们可以先固定一个长度(比如4个字节)用来保存整个请求数据大小,这样收到数据的时候,我们先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可以设计成这样:
-
但上面这种协议,只实现了正确的断句效果,在RPC里面还行不通。因为对于服务提供方来说,它是不知道这些协议体里面的二进制数据是通过哪种序列化方式生成的。如果不能知道调用方用的序列化方式,即使服务提供方还原出了正确的语义,这不能把二进制还原成对象,那服务接收方收到这个数据后也就不能完成调用了。因此我们需要把序列化方式单独拿出来,类似协议长度一样用固定的长度存放,这些固定长度存放的参数我们可以统称为“协议头”,这样整个协议就会拆分成两部分:协议头和协议体
-
在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标识、消息ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。
-
这样一个完整的RPC协议就大概出来了,协议头是由一堆固定的长度参数组成,而协议体时根据请求接口和参数构造的,长度属于可变的,具体协议如下图:
可扩展的协议
刚才讲的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。举个具体例子,假设你设计了一个 88Bit 的协议头,其中协议长度占用 32bit,然后你为了加入新功能,在协议头里面加了 2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照88bit 读取协议头,新加的 2 个 bit 会当作协议体前 2 个 bit 数据读出来,但原本的协议体最后 2 个 bit 会被丢弃了,这样就会导致协议体的数据是错的。
“那把参数加到不定长的协议体中行不行?反正协议体中会放一些扩展属性”
- 没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。在某些场景下代价会很高。
- 比如说,服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,那就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间的话,我们现在这个超时时间加到协议体里面是不是优点重了呢?显然,会加重CPU的消耗
- 所以为了保证能平滑的升级改造前后的协议,我们有必要设计一种支持可扩展的协议。其关键在于让协议头支持可扩展,扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以我们需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头部分、协议体部分,前两部分我们还是可以称为“协议头”的
设计一个简单的 RPC 协议并不难,难的就是怎么去设计一个可“升级”的协议。不仅要让我们在扩展新特性的时候能做到向下兼容,而且要尽可能地减少资源损耗,所以我们协议的结构不仅要支持协议体的扩展,还要做到协议头也能扩展
一些疑惑
问题:我们http 请求一个资源不就对应一个返回。是一一对应的关系,为什么会有如何关联响应和请求的问题呢
- RPC为了吞吐量,会异步并发发送请求,等待应答,所以需要知道哪个应对对应哪个请求
问题:既然TCP由于HTTP,gRPC为什么会基于HTTP2
- 更容易跨语言支持
总结
我们人类区别于其他动物的一个很大原因,就是我们能够通过语言去沟通,用文字去沉淀文明,从而让我们能站在巨人的肩膀上成长,但为了保证我们记录的文字能够被其他人理解,我们必须通过符号去实现断句,否则就可能导致文件的意义被曲解。
在RPC里面,协议的作用就类似于文字中的符号,作为应用拆解请求消息的边界,保证二进制数据经过网络传输后,还能被正确的还原语义,避免调用方跟被调用方之间的“鸡同鸭
讲”。
但我们在设计协议的时候,也不能只单纯考虑满足目前功能,还应该从更高的层次出发。就好比我们设计系统架构一样,我们需要保证设计出来的系统能够能很好地扩展,支持新增功能。
在 RPC 框架中,如何设计可扩展的、向后兼容的协议,其关键点就是利用好 Header 中的扩展字段以及 Payload 中的扩展字段,通过扩展字段向后兼容。