一文彻底弄懂 RPC 中的协议和序列化
一文彻底弄懂 RPC 中的协议和序列化
一、协议
协议的作用
我们知道 RPC 需要将对象序列化成二进制数据,写入本地 Socket 中,然后被网卡发送到网络设备中进行网络传输。但是在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(同一个 TCP 连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和 TCP 窗口大小。对于服务提供方来说,他会从 TCP 通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?
所以我们需要对 RPC 传输数据的时候进行“断句”,在应用发送请求的数据包里面加入“句号”,这样接收方应用数据流里面分割出正确的数据,“句号”就相当于是消息的边界,用于标识请求数据的结束位置。于是需要在发送请求的时候定一个边界,在请求收到的时候按照这个设定的边界进行数据分割,避免语义不一致的事情发生,而这个边界语义的表达,就是所谓的协议。
协议的设计
我们知道协议的作用之后怎么设计一个协议呢?或者说为啥不直接使用现有的 HTTP 协议呢?
相对于 HTTP 的用处,RPC 给更多的是负责应用间的通信,所以性能要求更高。但是 HTTP 协议的数据包大小相对于请求数据本身要大很多,有需要加入很多无用的内容,比如换行符号、回车符等等;还有一个重要的原因就是 HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完之后再关闭连接。所以对于要求高性能的 RPC 来说,HTTP 协议很难满足需求,RPC 会选择设计更紧凑的私有协议。
上面我们说了需要对传输的数据进行“断句”来确定消息边界,由于 RPC 每次发请求的大小都是不固定的,所以我们的协议必须能让接收方正确的读出不定长的内容。可以固定一个长度(比如4字节)用来保存请求数据大小,这样接收到数据的时候,可以先读取固定长度位置里面的值,值的大小就代表协议的长度,接着根据值的大小来读取协议体的数据,于是协议这个设计成这样:
这样的协议就万无一失了吗,上面的这个协议只实现了正确的断句效果,在 RPC 里面还行不通。因为对于服务方来说,它不知道这个协议体里面的二进制数据是通过哪种序列化方式生成的,也就不能还原出正确的语义,即不能把二进制数据转换为对象了,那服务方收到这个数据后也就不能完成调用了。于是我们需要把序列化方式单独拿出来,类似于协议长度一样用固定的长度存放,这些固定长度存放的参数可统称为“协议头”,于是协议被拆分成“协议头”和“协议体”。
在协议头里面,除了放入协议长度、序列化方式,还会放入一些协议提示、消息 ID、消息类型这样的参数,而协议体只放请求接口方法、请求的业务参数值和一些扩展属性。于是一个 RPC 协议就出来了:
可扩展的协议
上面的定长协议就一定万无一失了吗?
我们试想一下,定长的协议头会有啥问题。如果我们升级为新请求,往协议头里面多加 2bit 的参数并放在协议头最后。当用新的协议发出请求,而没有升级的应用受到请求后还是会按照原来的方式读取协议头,就会导致新加入的 2bit 协议头会被当做最开始的 2bit 的协议体而丢弃最后 2bit 的协议体,导致整个协议题的数据还是错误的。
那如果我们把参数加在不定长的协议体里面行不行,而且协议体里面也会放入一些扩展属性。但是协议体里面的内容都是经过序列化了的,要获取到参数值,需要把整个协议体里面的数据反序列化一遍,导致代价很高。那为了保证能平滑地升级改造前后的协议,就需要设计一种可支持扩展的协议,让协议头可以支持扩展,而且在这之前也需要一个固定的地方读取长度,于是协议就可以分为:固定部分、协议头长度、协议体内容,前两部分可以统称为”协议头“。具体如下:
设计一个简单的 RPC 协议并不难,难的就是这么去设计一个可 ”升级“的协议。不仅能在扩展新特性的时候做到向下兼容,还要尽可能地减少资源损耗。
二、序列化
为什么需要序列化
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输,我们需要提前把它转成可传输的二进制,并要求转换算法是可逆的,这个过程我们一般叫做“序列化”。服务提供方就可以正确的从二进制数据中分割出不同的请求,同时根据请求类型和序列化类型,把二进制消息逆向还原成请求对象,称之为”反序列化“。
总之:序列化就是将对象转换成二进制数据的过程,而反序列化就是将对象转换为二进制数据的过程。
JDK原生序列化
在使用 Java 进行开发的时候,可以通过实现 Serializable 接口来实现序列化,具体实现是由 ObjectOutputStream 和 ObjectInputStream 完成的,其过程如下:
- 头部数据同来声明序列化协议、序列化版本,用于高低版本向后兼容
- 对象数据主要包括类名、签名、属性名、属性类型及属性值,还要开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
- 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑
实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式读出对象的类型、属性类型、属性值,通过这些信息重新创建出新的对象,来完成反序列化。
JSON
JSON 是典型的 key-value 方式,没有数据类型,是一种文本型序列化框架。
JSON 序列化的两大问题:
- JSON进行序列化的额外空间开销比较大,对于数据量大的服务这意味着需要巨大的内存和磁盘开销。
- JSON 没有类型,但像 Java 这种强类型语言,需要通过反射同一解决,性能不太好。
所以如果 RPC 框架选用 JSON序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。
Hessian
Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JDK、JSON更加紧凑,性能上要币 JDK、JSON 序列化高很多,而且序列化的字节数也要更小。有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。
但是 Hessian 本身也有问题,比如:
- Linked系列,LinkedHashMap、LinkedHashSet等,但是可以通过扩展 CollectionDeserializer 类修复
- Locale 类,可以通过扩展 ContextSerializerFactory 类修复
- Byte/Short 反序列化的时候编程 Integer
Protobuf
Protobuf 是 Google 内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C ++、Go 等语言。 Protobuf 使用时需要定义 IDL,使用不同语言的 IDL 编译器,生成序列化工具类
优点:
- 序列化后体积相比 JSON 之类的小很多
- IDL 能清晰地描述语义,保证应用程序之间的类型不会丢失,无需类似 XML 解析器
- 序列化反序列化速度很快,不需要通过反射获取类型
- 消息格式升级和兼容性不错,可以做到向后兼容
但是使用 Protobuf 对于具有反射和动态能力的语言来说使用起来很费劲,可以考虑使用 Protostuff。
Protostuff不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反/序列化操作,在效率上根Protobuf差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架。
缺点:
- 不支持 null
- Protostuff 不支持单纯的 Map、List 集合对象,需要包在对象里面
RPC 框架中的序列化如何选择
序列化框架有很多种,还有 Hessian、 Message pack、kryo 等,这么多序列化框架这么选择呢?
我们可以按照上面的因素,选择我们所需要的序列化框架。
可以首选 Hessian 和 Protobuf ,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足我们的需求。
RPC 框架在使用时需要注意哪些问题
对象构造的过于复杂:属性很多,存在多层的嵌套。对象越复杂,序列化框架在序列化与反序列化时,就越浪费性能,消耗 CPU,出问题概率就会越高。
对象过于庞大:比如一个大 List 或者大 Map,这种情况同样会严重浪费性能、CPU,并且序列化如此的一个大对象是很费时间的,肯定会直接影响到请求的耗时。
使用序列化框架不支持的类作为入参类:应该尽量选择原生的,最为常用的集合类。
对象有复杂的继承关系:大多数序列化框架在序列化对象时都会将对象的属性进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。对象关系越复杂,就越浪费性能,容易出现序列化上面的问题。
总结
在使用 RPC 框架的过程中,我们构造入参、返回值对象,注意以下几点:
- 对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚
- 入参对象与返回值对象体积不要太打,更不要传太大的集合
- 尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类
- 对象不要有复杂的继承关系,最好不要有父子类的情况
参考:极客时间 RPC 实战与核心原理