独家|浅谈对象序列化
导语
本文介绍了对象序列化的应用场景、常见框架和核心实现原理,有助于大家了解更多序列化细节,对日常开发、线上问题的解决等大有裨益。
背景
序列化组件在rpc过程中参与的相当频繁。上图是其在rpc中的位置。可以看到一次服务调用过程,服务方和调用方各需要做一次序列化和一次反序列化。
什么是序列化
序列化(Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。在序列化期间,对象将其当前状态写入到临时或持久性存储区。以后,可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。
2. Json/Xml为什么不好?
JSON和XML几乎拥有一切优点,直观,通用,流行、拥有强大的表达力和跨平台能力。但就像前文提到的,直观通用和性能经常是对立的。具体表现在两个方面:首先:JSON和XML为了做到自描述,把字段名称也作为序列化的结果一部分,极大的增加了输出结果的体积。其次:JSON和XML使用字符串表示所有的数据,对于非字符数据来说只能通过类型强转的方式来处理,处理的十分低效。
3. 狭义序列化
在大型的互联网系统中,每天都有成百上千亿次的远程调用,有以P(1024*1024G)为单位的数据需要传输。哪怕是微小的性能问题都会无限放大。所以Json/Xml这种方案行不通。在微服务和大数据系统中,一般说的序列化都是二进制的序列化。即:把内存中的对象转换成平台无关的二进制流,从而允许把这种二进制流持久地保存在本地磁盘或将这种二进制流传输到另一个网络节点。其他程序一旦获得了这种二进制流,都可以将这种二进制流恢复成期待的目标对象。
二进制序列化
1. 优化原理
空间优化原理
a) 使用数值类型而非字面量来保存数值,本身就能节约一笔十分可观的空间。
b) 利用消费侧的的表达能力,去掉字段描述。
举个例子,上边的User对象。使用json得到的结果是 {"id":123,"text":"我是中国人"}。对于使用方来说,如果解码成map,那么字段名“id”和“text”必不可少。但如果使用方的目标对象是User类,它在本地已经拿到了(或者自己构建了)这个类,自然就知道还原的是“id”和“text”这2个属性,而不需要数据报文来告诉它。事实上,只要在使用上做好约定,可以完全省略序列化结果里的字段描述部分。
时间优化原理
2. 关键指标
评价一个序列化方案的优劣有以下几个关键指标。
以上有些指标之间是相互冲突的,实现的时候必须要在他们之间做出平衡。比如体积和表达力之间很显然是矛盾的。另外往往支持多语言的话,那么他在某一个语言上的自由度就要打折扣。比如Protobuf支持跨语言,就得使用IDL。但是他的纯java版本protostuff是完全不需要IDL的,使用起来特别灵活。
3. 常见框架介绍及对比
市面上的序列化框架很多,下边列举一些影响力比较大的。
需要json格式的IDL文件。在数据包体积方面很有优势。官网:http:/avro.apache.org/
下图是这几款序列化框架在几项关键指标的一个对比。
深入序列化
1. TLV编码
TLV本身是一个电信领域的编码标准。TLV指的是由数据的类型Tag,数据的长度Length,数据的值Value组成的三元组结构体,几乎可以描任意数据类型,TLV还可以继续嵌套,Value也可以是一个TLV结构,基于这种嵌套的特性,可以让我们用来表达复杂对象。
上图是Protobuf(下文简称PB)的TLV结构。
Tag本身采用varint算法进行编码。长度为1-2个字节。生成方法为(field_number << 3)| wire_type
varint:不定长的数字,可以表达所有的数字以及boolean,枚举等类型。64-bit:固定为8个字节长度的数据。
Length-Delimited:长度不固定的数据,典型的有字符串,字节流,嵌套消息(可以理解成类),repeated消息(可以理解成数组/集合)。
32-bit: 固定为4个字节长度的数据。
最早的编码规范是把所有的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部分的数据呢?这三者拥有完全不同的特性,反序列化侧是一点都不能错的。
2. varint算法
前文几次提到varint算法。我们下面一段来做详细介绍。它是一种变长的数值编码算法(variable integer)。我们都知道,在语言规范中,int总是固定为4个字节长度。取值范围为-2^31——2^31-1,即-2147483648——2147483647。但根据统计发现,程序中使用到的大部分int都不需要这么长。varint算法正是利用到了这一点来做优化。下图是2字节varint的结构。
典型的varint编码实现如下:
可以看到varint没有局限在byte层面,而是直接从bit层面来做文章。结合概率论的思维,用局部的牺牲换取全局的优势。属于很经典的微创新。我们在做系统设计和架构的时候,这种思想也非常值得学习。
有人会问varint还能更小吗,我的数字很小,就用到了一个字节最低位的几个bit。那是不是用几个bit来表示就可以了。这是行不通的,字节在大部分计算机体系结构里都是最小单位,这个原则不能破坏。
3. 局部再调优
对于某些局部细节还可以继续做优化。
对于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不支持并不丢人。
总结
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源代码
END