本文介绍了对象序列化的应用场景、常见框架和核心实现原理,有助于大家了解更多序列化细节,对日常开发、线上问题的解决等大有裨益。
背景
随着微服务架构和大数据技术的兴起,序列化作为数据传输中的一个重要组件,几乎无处不在。在微服务里,超高的调用频率要求编解码的速度要更快。在大数据中,海量的数据存储要求报文的体积要更小。但和很多的软件工程一样,序列化组件在追求更高性能的同时,必然带来更高的复杂度。所以对于互联网行业的工程师,了解更多的序列化细节,对平时的开发,解决线上问题都大有裨益。
序列化组件在rpc过程中参与的相当频繁。上图是其在rpc中的位置。可以看到一次服务调用过程,服务方和调用方各需要做一次序列化和一次反序列化。
什么是序列化
1. 广义序列化
序列化(Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。在序列化期间,对象将其当前状态写入到临时或持久性存储区。以后,可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。
JSON和XML几乎拥有一切优点,直观,通用,流行、拥有强大的表达力和跨平台能力。但就像前文提到的,直观通用和性能经常是对立的。具体表现在两个方面:首先:JSON和XML为了做到自描述,把字段名称也作为序列化的结果一部分,极大的增加了输出结果的体积。其次:JSON和XML使用字符串表示所有的数据,对于非字符数据来说只能通过类型强转的方式来处理,处理的十分低效。
3. 狭义序列化
在大型的互联网系统中,每天都有成百上千亿次的远程调用,有以P(1024*1024G)为单位的数据需要传输。哪怕是微小的性能问题都会无限放大。所以Json/Xml这种方案行不通。在微服务和大数据系统中,一般说的序列化都是二进制的序列化。即:把内存中的对象转换成平台无关的二进制流,从而允许把这种二进制流持久地保存在本地磁盘或将这种二进制流传输到另一个网络节点。其他程序一旦获得了这种二进制流,都可以将这种二进制流恢复成期待的目标对象。
二进制序列化
下面从优化原理、关键指标和常见框架三个角度来分析一下二进制序列化。
a) 使用数值类型而非字面量来保存数值,本身就能节约一笔十分可观的空间。
b) 利用消费侧的的表达能力,去掉字段描述。
举个例子,上边的User对象。使用json得到的结果是 {"id":123,"text":"我是中国人"}。对于使用方来说,如果解码成map,那么字段名“id”和“text”必不可少。但如果使用方的目标对象是User类,它在本地已经拿到了(或者自己构建了)这个类,自然就知道还原的是“id”和“text”这2个属性,而不需要数据报文来告诉它。事实上,只要在使用上做好约定,可以完全省略序列化结果里的字段描述部分。
c) 对数值类型进行压缩,比如使用varint等算法。
a) 避免多余的拷贝。由于tcp本身就是基于二进制传输。二进制序列化省去了把二进制转成字符串,再解析字符串的过程。直接基于Socket读取到的字节流还原即可!
b) 不需要中间变量,减少了长字符串等大内存对象,大大节省了系统gc的时间。
c) 基于位置偏移按顺序逐个还原字段,减少了解析json/dom结构的时间。
评价一个序列化方案的优劣有以下几个关键指标。
a) 速度:序列化/反序列化需要的时间,越快越好。
b) 体积:数据包的长度。越小越好。
c) 表达力:指的是支持对象的复杂度,拿java来说,一个好的序列化框架首先要支持所有的基本类型和常见类型比如Date,Bigdicemal。Array,Collections等等。另外还要支持自定义java bean,多层嵌套类对象,泛型等等。
d) 自由度:有的序列化框架需要在服务方先生成一个描述文件(IDL)。然后发给消费侧使用。比如Protobuf,需要用他的命令行生成一个.proto文件,每个小版本生成的代码还不一样,相互不兼容。这样以后加字段,版本升级都很受限制。有的则完全不需要IDL,使用起来高度自由。
e) 跨语言能力:很多互联网企业内部并没有做语言要求。比如服务是java的,调用方可能是php, c#。这就要求序列化方案支持主流语言,并且各语言的反序列化结果要保持基本一致。
以上有些指标之间是相互冲突的,实现的时候必须要在他们之间做出平衡。比如体积和表达力之间很显然是矛盾的。另外往往支持多语言的话,那么他在某一个语言上的自由度就要打折扣。比如Protobuf支持跨语言,就得使用IDL。但是他的纯java版本protostuff是完全不需要IDL的,使用起来特别灵活。
市面上的 序列化框架很多,下边列举一些影响力比较大的。
a) Protobuf:Protobuf是Google出品的序列化方案。Google出品,必属精品!Protobuf知名度很高,轻便高效稳定是它的特点。
官网: https://github.com/protocolbuffers/protobufProtobuf
b) Thrift:Thrift最早由facebook开发,成熟后捐献给apache成为顶级项目。他实际上是一款rpc框架,其中的序列化部分也可以拿出来单独使用,官网: http://thrift.apache.org/
c) Hessian:Hessian是一个历史悠久的rpc框架。其中的序列化部分也可以拿出来单独使用。在java体系应用尤多。Hessian是Dubbo的默认序列化协议。官网: http://hessian.caucho.com/
d) Kyro:Kryo是一个小巧高效的java序列化库,在hive,storm,spark等大数据领域使用的比较多。在算子函数的参数传输,Shuffle,RDD持久化等方面提效显著。缺点是多语言支持比较弱。官网: https://github.com/EsotericSoftware/kryo
e) Avro:Avro是apache出品的序列化框架。和hadoop项目是同一个作者。
需要json格式的IDL文件。在数据包体积方面很有优势。官网: http:/avro.apache.org/
下图是这几款序列化框架在几项关键指标的一个对比。
深入序列化
序列化发展到今天,已经有了很多成熟的方法论。各框架的实现大同小异。下边从几个方面来分别做介绍。
TLV本身是一个电信领域的编码标准。TLV指的是由数据的类型Tag,数据的长度Length,数据的值Value组成的三元组结构体,几乎可以描任意数据类型,TLV还可以继续嵌套,Value也可以是一个TLV结构,基于这种嵌套的特性,可以让我们用来表达复杂对象。
上图是Protobuf(下文简称PB)的TLV结构。
Tag本身采用varint算法进行编码。长度为1-2个字节。生成方法为(field_number << 3)| wire_type
a)由于PB中有 6种数据类型(Start group和End group目前已弃用)。所以需要用3个bit位来表示wire_type。
b)如果字段编号在[1,15]范围内,会使用一个字节表示Tag;如果字段编号大于等于16,会使用两个字节表示Tag。
c)2个字节里边用来表示field_number部分的bit位个数是:16-3-2=11。即字段编号的数量要小于2^11=2048个。
d)PB数据类型解释。
varint: 不定长的数字,可以表达所有的数字以及boolean,枚举等类型。64-bit:固定为8个字节长度的数据。
Length-Delimited: 长度不固定的数据,典型的有字符串,字节流,嵌套消息(可以理解成类),repeated消息(可以理解成数组/集合)。
32-bit: 固定为4个字节长度的数据。
e)TLV里边Length是可选的。具体到PB里边的wire_type。64-bit和32-bit是固定长度的,可以省略。而varint类型的Value本身是能表示自己的结束位置的, Length也可以省略。所以在PB中,使用完整TLV三元素结构编码的数据类型只有Length-Delimited一种。
f)Length在项目迭代的时候起到了关键作用。有的大型服务有数十甚至几百个调用者。如果业务需要迭代,服务方单方面加字段,所有调用者不可能做到同步升级。编码和解码两侧base的实体都不一样,怎么做到不出错呢?原理很简单,调用者在反序列化的时候,已经知道了服务端告知的本对象的真实长度。本地还原的时候同时累加目前已读取的字节总数。当还原到对象尾部的时候,发现Length和给定的不一样,就直接跳到Length所在的位置。可以肯定多余的字节是服务端单方面增加的,直接舍弃就好。
最早的编码规范是把所有的Value紧凑的排列一起,后来为了解决多版本问题不得已引入了Version。很快又发现光靠Version一个字段来解决扩展性,还是略单薄。许多场景可能需要在协议里指明唯一标识,字段类型,解析方式等等(协议也可能会扩展)。把这些信息冗余在一起,放在一个字段里,这就是Tag。
这里思考一个问题?PB为什么能做到把所有的对象类型归纳到4种wire_type里边?实际上不仅数据类型高度抽象,你看PB库本身的实现,代码量也相对很少。答案是因为他有本地IDL。一个十几个字段的bean,生成出来的IDL代码可能会达到15000行之多(version 2.6.1)!仔细研究IDL文件,发现PB其实是把大量的解码细节放到了本地来完成。
而对于不需要IDL的序列化,库代码的实现就要复杂很多,需要把支持的所有类型全部枚举出来,逐个实现他们的编码解码方法。与此同时,TLV的Tag部分会增加存储一个全局的objectId。意义在哪呢?因为语言都是泛化的,声明的类型可能比真实数据的类型要大。举个例子:对象里声明的类型是Map,但是真正放进去的数据是TreeMap。如果没有objectId,怎么确定应该实例化一个HashMap还是TreeMap or HashTable来承接Value部分的数据呢?这三者拥有完全不同的特性,反序列化侧是一点都不能错的。
前文几次提到varint算法。我们下面一段来做详细介绍。它是一种变长的数值编码算法(variable integer)。我们都知道,在语言规范中,int总是固定为4个字节长度。取值范围为-2^31——2^31-1,即-2147483648——2147483647。但根据统计发现,程序中使用到的大部分int都不需要这么长。varint算法正是利用到了这一点来做优化。下图是2字节varint的结构。
a)在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节。1表示需要,0表示不需要。
b)每个字节里只有7个bit来表达数字。所以4个字节的最大表示能力为2^28。如果超出这个范围,使用的空间反而会变多,需要5个字节。
c)对于float,long,double类型同理。对于long/double类型,8个字节只能表示2^56。最大的数可能需要10个字节。
d)对于超过2^28的int和2^56的long,有一个很好的做法是使用fix32和fix64。即固定字节的wire_type。
典型的varint编码实现如下:
可以看到varint没有局限在byte层面,而是直接从bit层面来做文章。结合概率论的思维,用局部的牺牲换取全局的优势。属于很经典的微创新。我们在做系统设计和架构的时候,这种思想也非常值得学习。
有人会问varint还能更小吗,我的数字很小,就用到了一个字节最低位的几个bit。那是不是用几个bit来表示就可以了。这是行不通的,字节在大部分计算机体系结构里都是最小单位,这个原则不能破坏。
对于某些局部细节还可以继续做优化。
对于repeated消息的优化。对于数组,List这种会在一段数据内连续出现同一种类型的对象。那么TLV 中的 Tag可以做合并。比如对于连续多个varint的表达可以从TVTVTV变成TVVV。对于连续多个字符串的表达可以从TLVTLVTLV变成TLVLVLV
默认值不参与编码,比如boolean默认false,可以从TLV结构变成T,消费侧不消耗任何字节直接赋值为false即可!
关于varint上图是一种最直观的实现,实际工程里可以通过补码再反转等一些手段提高处理速度。
对于负数,可以通过ZigZag 编码映射到正数处理。
4. 踩过的坑
笔者之前也曾参与过公司内部的序列化方案的实现(主要支持java),对当时踩过的一些坑还记忆犹新。
序列化方案一定要反复论证,在空间使用上最好留有一定的余地。而且服务调用的时候协议要由消费侧主动传递。否则一旦大规模的使用之后,几乎无法升级。
要由框架来规定字段的顺序,保证新添加的字段在尾部。假如使用java反射这样的机制获取字段顺序。新添加的字段TLV结构体可能会跑到字节流中间。消费侧无法做丢弃。
对于比较长的对象数据(长度超过一个字节的表达能力)曾尝试过用magic byte来作为结束标志(类似Hession的 x7a)。这样就可以替代Length。结果发现破坏了TLV结构带来的成本要远大于提升的性能开销,而且理论上有碰撞的风险。
循环引用是一个需要注意的问题。可以借鉴fastjson等工具的处理方法,在整个链条上引入一个map来保存之前处理过的对象。然后把引用类型定义为一种特殊的wire_type,消费侧处理到相应的字节直接去map里拿。这个场景要注意一个特殊情况,带泛型的EmptyList。
基于get/set方法还是类的属性来做序列化?个人建议基于属性。不同于java语言规范,序列化结果可能在非标准的场景下去使用。基于get/set方法会带来很大的复杂度。
对于继承的处理需要注意一些特殊情况,父子类是可以出现同名字段的。
对语言规范要理解的全面深刻,比如枚举对象是new不出来的, final属性类似。
微服务传输用的数据结构,本身就应该是标准的,简洁的。所以没必要把框架定位成江湖百晓生。对于一些小众的数据结构,比如CollectionUtils.synchronizedCollection不支持并不丢人。
总结
本文讲解了序列化的定义,以及它在 RPC 和大数据中的应用。介绍了常见的序列化框架,以及一些典型的实现。序列化的应用很广,而实现难度又很大。对语言规范,存储协议,特别是二进制格式的理解要求很高。同时又要兼顾很多维度的指标。市面上可以说是百家争鸣、百花齐放。由于作 者水平所限,对此项知识也是管中窥豹。如果文中有表达的不准确、不全面的的地方,在此表示诚挚的歉意。
参考文献
1. 序列化方案选型:
http://www.sohu.com/a/325019687_612370
2. 序列化性能测试:
https://github.com/eishay/jvm-serializers/wiki
3. 通信协议TLV:
https://blog.csdn.net/qq_43296898/article/details/88824963
4. 编码规范的历史:
https://blog.csdn.net/Shiina_Orez/article/details/101949987
5. 深入 ProtoBuf:
https://www.jianshu.com/p/73c9ed3a4877
6. 58同城自研rpc框架SCF源代码
作者简介
沈成龙 / 58 同城后端架 构师,求职者业务负责人。