原文:link

RPC(Remote Procedure Call),远程过程调用,是一种分布式系统中常用跨机器的通信方法,更多的是指跨机器交互中的长连接交互。

RPC传统意义上讲是指长连接数据交互,区别于http即用即走的短连接,严格来说http也算是一种特殊的RPC服务,随着http1.1中对长连接的支持,区别已经越来越小。
RPC的应用非常广泛,主要应用在大中型分布式系统中的组件交互,可以这样理解:各种夸网络协议的「长连接」系统交互中都使用了RPC或者类RPC。
其他分布式通信解决方案还有:分布式消息队列、HTTP请求调用、数据库和分布式缓存等。
各大厂开源的RPC框架有:

  • Google: gRPC
  • Facebook: Thrift
  • Twitter: Finagle
  • 百度:bRPC
  • 腾讯:Tars
  • 阿里:Dubbo、SOFA
  • 新浪:Motan

RPC作为一种方便的网络通信编程模型,由于和编程语言的高度结合,大大减少了处理网络数据的复杂度,让代码可读性也有可观的提高。但是RPC本身的构成却比较复杂,由于受到编程语言、网络模型、使用习惯的约束,有大量的妥协和取舍之处。

RPC是两个子系统之间进行的直接消息交互,它使用操作系统提供的套接字(sockect)来作为消息的载体,以特定的消息格式(需要序列化)来定义消息内容和边界。RPC 是对底层通信和交互协议的一个封装,便于上层使用。
RPC消息协议设计 - 图1
RPC 消息协议组成:
RPC消息协议设计 - 图2
对于一串消息流,我们必须能确定消息边界,提取出单条消息的字节流片段,然后对这个片段按照一定的规则进行反序列化来生成相应的消息对象。
消息表示指的是序列化后的消息字节流在直观上的表现形式,它看起来是对人类友好还是对计算机友好。文本形式对人类友好,二进制形式对计算机友好。
每个消息都有其内部字段结构,结构构成了消息内部的逻辑规则,程序要按照结构规则来决定字段序列化的顺序。

消息边界

RPC需要在一条TCP链接上进行多次消息传递。在连续的两条消息之间必须有明确的分割规则,以便接收端可以将消息分割开来,这里的接收端可以是RPC服务器接收请求,也可以是RPC客户端接收响应。
基于TCP链接之上的单条消息如果过大,就会被网络协议栈拆分为多个数据包进行传送。如果消息过小,网络协议栈可能会将多个消息组合成一个数据包进行发送。对于接收端来说它看到的只是一串串的字节数组,如果没有明确的消息边界规则,接收端是无从知道这一串字节数组究竟是包含多条消息还是只是某条消息的一部分。
比较常用的两种分割方式是特殊分割符法长度前缀法
RPC消息协议设计 - 图3
消息发送端在每条消息的末尾追加一个特殊的分割符,并且保证消息中间的数据不能包含特殊分割符。比如最为常见的分割符是\r\n。当接收端遍历字节数组时发现了\r\n,就可以断定\r\n之前的字节数组是一条完整的消息,可以传递到上层逻辑继续进行处理。HTTP和Redis协议就大量使用了\r\n分割符。此种消息一般要求消息体的内容是文本消息。
RPC消息协议设计 - 图4

消息发送端在每条消息的开头增加一个4字节长度的整数值,标记消息体的长度。这样消息接受者首先读取到长度信息,然后再读取相应长度的字节数组就可以将一个完整的消息分离出来。此种消息比较常用于二进制消息。
基于特殊分割符法的优点在于消息的可读性比较强,可以直接看到消息的文本内容,缺点是不适合传递二进制消息,因为二进制的字节数组里面很容易就冒出连续的两个字节内容正好就是分割符的ascii值。如果需要传递的话,一般是对二进制进行base64编码转变成普通文本消息再进行传送。
基于长度前缀法的优点和缺点同特殊分割符法正好是相反的。长度前缀法因为适用于二进制协议,所以可读性很差。但是对传递的内容本身没有特殊限制,文本和内容皆可以传输,不需要进行特殊处理。HTTP 协议的 Content-Length 头信息用来标记消息体的长度,这个也可以看成是长度前缀法的一种应用。

RPC消息协议设计 - 图5

HTTP协议是一种基于特殊分割符和长度前缀法的混合型协议。比如HTTP的消息头采用的是纯文本外加 分割符,而消息体则是通过消息头中的Content-Type的值来决定长度。HTTP协议虽然被称之为文本传输协议,但是也可以在消息体中传输二进制数据数据的,例如音视频图像,所以HTTP协议被称之为「超文本」传输协议。

消息的结构

每条消息都有它包含的语义结构信息,有些消息协议的结构信息是显式的,有些是隐式的。
显示的消息结构由自身决定,可读性高,但是为了表示结构,传输时冗余字段多,消耗更多的流量。比如 json 消息就是一种显式结构的消息协议,它的结构就可以直接通过它的内容体现出来。

RPC消息协议设计 - 图6

json这种直观的消息协议的可读性非常棒,但是它的缺点也很明显,有太多的冗余信息。比如每个字符串都使用双引号来界定边界,key/value之间必须有冒号分割,对象之间必须使用大括号分割等等。这些还只是冗余的小头,最大的冗余还在于连续的多条json消息即使结构完全一样,仅仅只是value的值不一样,也需要发送同样的key字符串信息。

消息的结构在同一条消息通道上是可以复用的,比如在建立链接的开始RPC客户端和服务器之间先交流协商一下消息的结构,后续发送消息时只需要发送一系列消息的 value 值,接收端会自动将value值和相应位置的key关联起来,形成一个完成的结构消息。在 Hadoop 系统中广泛使用的 avro 消息协议就是通过这种方式实现的,在 RPC 链接建立之处就开始交流消息的结构,后续消息的传递就可以节省很多流量。

消息的隐式结构一般是指那些结构信息由代码来约定的消息协议,在RPC交互的消息数据中只是纯粹的二进制数据,由代码来确定相应位置的二进制是属于哪个字段。在TCP链接创建时,服务端和客户端便规定好消息结构,以后交互直接发送消息的值即可。消息的可读性差,但是确实节省了不少传输流量。比如下面的这段代码

RPC消息协议设计 - 图7
如果纯粹看消息内容是无法知道节点消息内容中的哪些字节的含义,它的消息结构是通过代码的结构顺序来确定的。这种隐式的消息的优点就在于节省传输流量,它完全不需要传输结构信息。

消息压缩

如果消息的内容太大,就要考虑对消息进行压缩处理,这可以减轻网络带宽压力。但是这同时也会加重 CPU的负担,因为压缩算法是 CPU 计算密集型操作,会导致操作系统的负载加重。所以,最终是否进行消息压缩,一定要根据业务情况加以权衡,不要为了压缩占用大量系统资源,导致正常系统服务出现问题。
如果确定压缩,那么在选择压缩算法包时,务必挑选那些底层用C语言实现的算法库,因为Python的字节码执行起来太慢了。比较流行的消息压缩算法有Google的snappy算法,它的运行性能非常好,压缩比例虽然不是最优的,但是离最优的差距已经不是很大。阿里的 SOFA RPC 就使用了snappy作为协议层压缩算法。

流量的极致优化

消息传递中,必然是占用字节越少,需要的带宽越少,传输速度也越快了。优化流量这里思路便是,尽量减少消息的字节占用。开源的流行RPC消息协议往往对消息流量优化到了极致。两种流量的极致优化方法:

  • 使用变长整数varint来表示整数。
  • 使用zigzag编码来表示负数。

对于一个整形数字,一般使用4个字节来表示一个整数值。但是经过研究发现,消息传递中大部分使用的整数值都是很小的非负整数,如果全部使用4个字节来表示一个整数会很浪费。所以就发明了一个类型叫变长整数varint。数值非常小时,只需要使用一个字节来存储,数值稍微大一点可以使用2个字节,再大一点就是3个字节,它还可以超过4个字节用来表达长整形数字。
其原理也很简单,就是保留每个字节的最高位的bit来标识是否后面还有字节,1表示还有字节需要继续读,0表示到读到当前字节就结束。
RPC消息协议设计 - 图8
那如果是负数该怎么办呢?-1的16进制数是0xFFFFFFFF,如果要按照这个编码那岂不是要 6 个字节才能存的下。-1也是非常常见的整数啊。
于是zigzag编码来了,专门用来解决负数问题。zigzag编码将整数范围一一映射到自然数范围,然后再进行varint编码。
RPC消息协议设计 - 图9
zigzag将负数编码成正奇数,正数编码成偶数。解码的时候遇到偶数直接除 2 就是原值,遇到奇数就加1除以2再取负就是原值。

RPC通信协议列举分析

REDIS 通信协议分析

Redis作者自己设计了一套本文通信协议RESP。按照RPC消息结构来分析如下:

  • 消息边界:RESP使用特殊符号 \r\n来区分多次消息。
  • 消息结构:使用文本形式来传送消息。
  • 流量优化:网络流量倾斜进行极致优化,而是选择了照顾协议的直观性、可理解性。

PROTOBUF

Protobuf协议是Google开源的二进制RPC通讯协议,它可能是互联网开源项目中使用最为广泛的RPC 协议。

  • 消息边界:没有定义消息边界,也就是没有消息头。消息头一般由用户自己定义,通常使用长度前缀法来定义边界
  • 消息结构:使用二进制流传送消息。
  • 流量优化:通过对消息格式的设计优化,充分压榨了消息体积,减小了传输使用的流量。

    RPC vs HTTP vs WebService

    RPC远程过程调用,就是在另外一台服务器上有一段代码(函数),你可以通过网络远程调用它。用什么协议(http,tcp,udp…), 传输什么数据格式(json,xml,二进制…)你都可以自己控制。
    HTTP API 基于应用层的 HTTP 协议,通常是以一种 web 的方式,对外提供以JSON或字符串作为数据格式的接口服务,例如著名的restful规则。
    WebService是一种SOAP方式的web服务。SOAP用来描述传递信息的格式, WSDL用来描述如何访问具体的接口,UDDI用来管理,分发,查询webService。基于http协议,使用xml格式来传递数据。
    RPC广义上来讲,是远程过程调用,即跨机器的函数调用。传输协议和格式可自己控制。包括基于TCP协议的一些通信协议实现的过程调用和基于http协议实现的过程调用。如:restful API、WebService及gRPC等一些框架实现。
    RPC狭义上来讲的话,通常是基于TCP/IP协议,通过二进制流或文本的数据格式来传输的一些通信方法,如:gRPC。

    协议的基本要素

    魔数

    报文是在网络上传输的,安全性比较低,因此有必要采取一些措施使得并不是任何人都可以随随便便往我们的端口上发东西,因此我们对报文要有一个初步的识别功能,这时候“魔数(magic number)”就派上用场了。魔数并不受任何规范约束,没有人可以要求你的魔数应该遵循什么规范,实际上魔数只是我们通信双方都约定的一个“暗号”,不知道这个暗号的人就无法参与进通信中。例如Java源文件编译后的 class文件开头就有一个魔数:0xCAFEBABE。java虚拟机加载class文件的时候会先验证魔数。如果不是0xCAFEBABE就认为是不合法的class文件,并拒绝加载。

    协议版本

    一个协议可能也会有多个版本,例如说 HTTP1.0和HTTP1.1,不同版本的协议元素可能发生了改变,解析方式也会发生改变,因此协议设计这一块,需要预留出地方声明协议的版本,通信双方在解析协议或者拼装协议的时候才有迹可循。

    报文类型

    对于RPC框架来说,报文可能有多种类型:心跳类型报文、认证类型报文、请求类型报文、响应类型报文等。

    上下文 ID

    RPC调用其实是一个“请求-响应”的过程,并且跨物理机器,因此每次请求和响应,都必须带上上下文 ID,通信双方才能把请求和响应对应起来。

    状态

    状态用来标识一次调用时正常结束还是异常结束,通常由被调用方置状态。

    请求数据

    即发送到服务端的调用请求,通常是序列化后的二进制流,长度不定。

    长度编码字段

    收报文的一方怎么知道发报文的那一方发了多少字节呢?因此发送方必须在协议里告诉接收方需要接受多少字节才算一个完整的报文。

    保留字段

    协议一旦被设计,并非一成不变的,日后可能有变动的可能,因此还需要考虑保留一些字节空间作为保留字段,以备日后协议的扩展。
    结合以上的一些设计原则,下面是一个具体协议设计的例子:
  1. ------------------------------------------------------------------------
  2. | magic (2bytes) | version (1byte) | type (1byte) | reserved (7bits) |
  3. ------------------------------------------------------------------------
  4. | status (1byte) | id (8bytes) | body length (4bytes) |
  5. ------------------------------------------------------------------------
  6. | |
  7. | body ($body_length bytes) |
  8. | |
  9. ------------------------------------------------------------------------

魔数+协议版本+报文类型+上下文Id+状态+上下文id+报文体长度

链路可靠性

客户端与服务端之间的连接采用TCP长连接,一个客户端与服务端之间保持至少一条长连接。接口调用请求的发送,在多条连接之间进行负载均衡。
每条连接在空闲的时候,由客户端主动向服务端发送心跳报文,并且客户端在发现连接失效或断开的时候,自动进行重连。
每个客户端向服务端建立连接后,在正式发起接口调用请求之前,都需要进行check in操作, check in操作主要是将客户端的身份标识(identifier)和客户端的心跳间隔告诉服务端。利用netty的 handler责任链机制和自带的IdleStateHandler,自动检测出连接是否空闲,并在空闲时触发心跳报文的发送。而服务端在客户端check in后,根据客户端的心跳频率,在自己的handler pipeline 上动态加入一个IdleStateHandler,来检测出客户端是否已经失联,如果是,则主动关闭连接。
同时,客户端本地将会起一个定时执行任务的线程,定期检查连接是否失效,如果失效,则关闭旧连接,并进行连接的重建。