如何设计可向后兼容的RPC协议

时间:2023-02-03 12:54:00

HTTP协议(本文HTTP默认1.X)跟RPC协议又有什么关系呢?都属于应用层协议。

1 HTTP协议

浏览器收到命令后会封装一个请求,并把请求发送到DNS解析出来的IP上,抓包:

如何设计可向后兼容的RPC协议

2 协议的作用

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

传输过程中,RPC不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能合并其他请求的数据包(合并的前提是同一个TCP连接上的数据),怎么拆分合并,涉及系统参数配置和TCP窗口大小。对服务提供方应用,他会从TCP通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求?为准确“断句”,须在应用发送请求的数据包里加“句号”,帮接收方应用从数据流里面分割正确数据。

为避免语义不一致,要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,即协议。

3 如何设计协议?

有现成HTTP协议,为啥不直接用,还要为RPC设计私有协议:

  • 相对HTTP的用处,RPC更多负责应用间的通信,所以性能要求相对更高。但HTTP协议的数据包大小相对请求数据本身要大很多,又要加入很多无用内容,如换行符、回车符等
  • HTTP协议属无状态协议,客户端无法对请求和响应进行关联,每次请求都要重建连接,响应完成后再关闭。对需高性能的RPC,HTTP协议很难满足

设计一个私有RPC协议

要完成RPC通信,在协议里面要放什么?

消息边界

但RPC每次发请求发的大小都不固定,所以我们的协议须让接收方正确读出不定长内容。

可先固定一个长度(如4字节)保存整个请求数据大小,这样收到数据时,先读取固定长度的位置里面的值=协议体的长度,再读协议体的数据:

如何设计可向后兼容的RPC协议

但这只实现正确断句,对服务提供方,他不知道这个协议体里面的二进制数据是通过哪种序列化方式生成。若不能知道调用方用的序列化方式,即使服务提供方还原出正确语义,也并不能把二进制还原成对象,那服务提供方收到这个数据后也不能完成调用。因此要把序列化方式拿出来,类似协议长度一样用固定的长度存放,这些需要固定长度存放的参数统称“协议头”,这样整个协议就会拆分成两部分:协议头和协议体。

在协议头里面,我们除了会放协议长度、序列化方式,还会放一些像协议标示、消息ID、消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性。这样一个完整的RPC协议大概就出来了,协议头是由一堆固定的长度参数组成,而协议体是根据请求接口和参数构造的,长度属于可变:

如何设计可向后兼容的RPC协议

可扩展的协议

刚才讲的协议属于定长协议头,那也就是说往后就不能再往协议头里加新参数了,如果加参数就会导致线上兼容问题。举个具体例子,假设你设计了一个88Bit的协议头,其中协议长度占用32bit,然后你为了加入新功能,在协议头里面加了2bit,并且放到协议头的最后。升级后的应用,会用新的协议发出请求,然而没有升级的应用收到的请求后,还是按照88bit读取协议头,新加的2个bit会当作协议体前2个bit数据读出来,但原本的协议体最后2个bit会被丢弃了,这样就会导致协议体的数据是错的。

可能你会想:“那我把参数加在不定长的协议体里面行不行?而且刚才你也说了,协议体里面会放一些扩展属性。”

没错,协议体里面是可以加新的参数,但这里有一个关键点,就是协议体里面的内容都是经过序列化出来的,也就是说你要获取到你参数的值,就必须把整个协议体里面的数据经过反序列化出来。但在某些场景下,这样做的代价有点高啊!

比如说,服务提供方收到一个过期请求,这个过期是说服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间,既然已经过期,就没有必要接着处理,直接返回一个超时就好了。那要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,我们现在把这个超时时间加到协议体里面是不是就有点重了呢?显然,会加重CPU的消耗。

为保证平滑升级改造前后的协议,要设计一种可扩展协议。扩展后协议头的长度就不能定长了。那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以要一个固定的写入协议头的长度。整体协议三部分:

  • 固定部分
  • 协议头内容
  • 协议体内容

前两部分可统称“协议头,具体协议如下:

如何设计可向后兼容的RPC协议

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

总结

RPC里协议的作用就类似文字中的符号,作为应用拆解请求消息的边界,保证二进制数据经过网络传输后,还能被正确地还原语义,避免调用方跟被调用方之间的“鸡同鸭讲”。

FAQ

RPC不直接用HTTP协议的一个原因是无法实现请求跟响应关联,每次请求都需要重新建立连接,响应完成后再关闭连接,所以我们要设计私有协议。RPC怎么实现请求跟响应关联?

Dubbo的消费者发送请求时,使用 AtomicLong 自增,产生一个 消息 ID。Dubbo底层 I/O 操作是异步的,Dubbo 发送请求后,需阻塞等待消费者返回信息。消费者会将消息 ID 保存到 Map。为保证请求响应一一对应,就需提供者返回的响应信息带上请求者消息 ID。 通过响应的消息 ID,通过那个 Map 存储数据,就能找到对应请求。
感兴趣的同学可以看下 Dubbo 2.6 DefaultFuture 源码。

http 请求一个资源不就对应一个返回。是一一对应的关系,为什么会有如何关联响应和请求的问题?

rpc为吞吐量,会异步并发发送请求,等待应答,所以要知道哪个应答对应哪个请求。

既然基于TCP优于HTTP,gRPC为什么选择基于HTTP2?

grpc基于http2,易跨语言支持。

调用方需要维护消息ID列表,然后和返回结果中的消息ID做匹配

http现在已经支持长链接,如http2。但目前性能不如tcp好。

请求时带上消息id,响应时,响应体里面带上请求消息的id,这样可以进行关联,对吗?

异步场景用于区分应答消息。

RPC 不直接用 HTTP 协议的一个原因是无法实现请求跟响应关联,我认为是有问题的,若是同步请求,使用HTTP协议也可实现请求和相应关联的,只有异步请求才需关联。这里的HTTP协议指HTTP1.1。因为gRPC使用HTTP2.0协议,其已优化编码效率问题,且支持多路复用,不需要每次请求都需要重新建立连接,提高连接利用率。所以其实没必要设置私有协议。

rpc是为高性能和大吞吐量,基于TCP的性能更快。压力不大场景,http可以满足。grpc做的也很好。

传统rpc里,请求的消息id用连接上的自增整数就行。用于标识请求消息,同时区分应答消息。

有说RPC是异步并行发送请求,但是对于服务调用方使用http也可以多次调用啊,况且RPC的话服务调用方不也是得同步等待提供方的结果么?这和http有啥区别呢?

请求和响应对应,需请求发送方带上自己的请求标识,服务端在返回的结果中也要带上这请求标识,这样请求发送方就通过请求标识,使用不同的请求。不同类型请求处理逻辑不一样,如区分心跳请求。

一般服务端会给每个客户端socket(或channel)绑定一个标识id,在注册中心可以通过id找到该socket(或channel),然后将数据发送出去。

服务提供者会往注册中心注册,消费者从注册中心拿到提供者地址。

关于http每次请求都要重新建立连接这一块不太理解,它不是支持长连接吗?

可以支持,但应用http调用场景大部分都是短连接方式。

数据包的拆分与合并是在tcp层面进行还是rpc层面,如果是前者,会不会存在第一个包只包含了协议头,第二个包只包含了协议体的情况,那这样如何正确断句?

不太同意这个原因,http也可设置长连接,这样每次服务间的调用无需再考虑连接频繁创建的成本了。

还有一个更重要的原因是,HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。