Erick 2020-06-17
序列化是指将对象转换成可传输或可存储的形式的过程。常见的如文件存储,网络传输。
序列化是个过程,按照什么方式序列化呢?不同的序列化方式得到的结果也不近相同。微服务里超高的调用频率要求编解码的速度更快,大数据里要求数据存储的报文体积更小。
如上面说到序列化用于将对象转换成可传输或存储的形式的过程。反序列化使用存储或传输内容重新创建对象的过程。
JSON/XML 序列化和 二进制序列化
网络传输中我们会序列化成JSON或XML,可读性好,通用。但直观和通用的同时也带来了性能差的缺点。信息冗余了很多,键值对方面,会有重复的key值。
而序列化成二进制流,然后在二进制流中规定相关协议。我们就可按照协议来恢复成目标对象。
空间优化:
时间优化:
以上有些指标之间是相互冲突的,实现的时候必须要在他们之间做出平衡。比如体积和表达力之间很显然是矛盾的。另外往往支持多语言的话,那么他在某一个语言上的自由度就要打折扣。比如Protobuf支持跨语言,就得使用IDL。但是他的纯java版本protostuff是完全不需要IDL的,使用起来特别灵活。
TLV本身是一个电信领域的编码标准。TLV指的是由数据的类型Tag,数据的长度Length,数据的值Value组成的三元组结构体,几乎可以描任意数据类型,TLV还可以继续嵌套,Value也可以是一个TLV结构,基于这种嵌套的特性,可以让我们用来表达复杂对象。
Protobuf 就使用了 TLV 的结构,结构如下图
2. varint 算法
varint算法是一种变长的数值编码算法(variable integer)。我们都知道,在语言规范中,int总是固定为4个字节长度。取值范围为-231——231-1,即-2147483648——2147483647。但根据统计发现,程序中使用到的大部分int都不需要这么长。varint算法正是利用到了这一点来做优化。
数值非常小时,只需要使用一个字节来存储,数值稍微大一点可以使用 2 个字节,再大一点就是 3 个字节,它还可以超过 4 个字节用来表达长整形数字。
其原理也很简单,就是保留每个字节的最高位的 bit 来标识是否后面还有字节,1 表示还有字节需要继续读,0 表示到读到当前字节就结束。
对于某些局部细节还可以继续做优化。
对于负数,可以通过ZigZag 编码映射到正数处理。
zigzag 编码将整数范围一一映射到自然数范围,然后再进行 varint 编码。
0 => 0 -1 => 1 1 => 2 -2 => 3 2 => 4 -3 => 5 3 => 6
zigzag 将负数编码成正奇数,正数编码成偶数。解码的时候遇到偶数直接除 2 就是原值,遇到奇数就加 1 除 2 再取负就是原值。
序列化方案一定要反复论证,在空间使用上最好留有一定的余地。而且服务调用的时候协议要由消费侧主动传递。否则一旦大规模的使用之后,几乎无法升级。
要由框架来规定字段的顺序,保证新添加的字段在尾部。假如使用java反射这样的机制获取字段顺序。新添加的字段TLV结构体可能会跑到字节流中间。消费侧无法做丢弃。
对于比较长的对象数据(长度超过一个字节的表达能力)曾尝试过用magic byte来作为结束标志(类似Hession的 x7a)。这样就可以替代Length。结果发现破坏了TLV结构带来的成本要远大于提升的性能开销,而且理论上有碰撞的风险。
循环引用是一个需要注意的问题。可以借鉴fastjson等工具的处理方法,在整个链条上引入一个map来保存之前处理过的对象。然后把引用类型定义为一种特殊的wire_type,消费侧处理到相应的字节直接去map里拿。这个场景要注意一个特殊情况,带泛型的EmptyList。
基于get/set方法还是类的属性来做序列化?个人建议基于属性。不同于java语言规范,序列化结果可能在非标准的场景下去使用。基于get/set方法会带来很大的复杂度。
对于继承的处理需要注意一些特殊情况,父子类是可以出现同名字段的。
对语言规范要理解的全面深刻,比如枚举对象是new不出来的, final属性类似。
微服务传输用的数据结构,本身就应该是标准的,简洁的。所以没必要把框架定位成江湖百晓生。对于一些小众的数据结构,比如CollectionUtils.synchronizedCollection不支持并不丢人。
spring-data-redis RedisTemplate 操作redis时发现存储在redis中的key不是设置的string值,前面还多出了许多类似\xac\xed\x00\x05t\x00;