最近在设计一个RPC框架,需要处理序列化的问题。有很多种序列化协议可以选择,比如Java原生的序列化协议,Protobuf, Thrift, Hessian, Kryo等等,这里说的序列化协议专指Java的基于二进制的协议,不是基于XML, JSON这种格式的协议。在实际开发中考虑了很多点,也遇到一些问题,拿出来说说。
抛开这些协议不说,结合实际的需求,一个理想的序列化协议至少考虑4个方面:
1. 序列化后的字节数大小
2. 序列化和反序列化的效率
3. 是否支持被序列化对象新旧版本的兼容性问题。这个需求在实际开发中经常遇到,比如发布了一个服务,有很多客户端使用。当服务需要修改,新 添加1个参数时,不可能要求所有客户端都更新,那样牵扯的面太大,所以要做到新旧版本的兼容
4. 是否可以直接序列化对象,而不需要额外的辅助类,比如用IDL生成辅助的序列化类
前3个要求是衡量一个序列化协议好坏的重点,第4点是一个使用性的考虑,毕竟在不考虑跨平台调用的情况下,不需要使用IDL。使用IDL的开发方式一般是从IDL文件开始的,而不是直接从Java类开始。
序列化这件事说白了就是把一个对象变成一个二进制流,然后把二进制流再转化成对象的过程。前者好说,关键是后者,后者其实就是一个如何分帧(Frame)的问题,即从哪个字节开始读几个字节来还原成数据的问题。常见的分帧方式有:
1. 加结束符,比如http协议
2. 定长
3. 消息头+消息,消息头可以包含长度,类型信息
对于Java序列化来说,肯定是第三种方式,但是如何设计这个分帧方式又有很多实现。下面说说上述的4个方面具体有哪些考虑和问题。
第一是序列化后的字节数大小。最优的序列化后的字节数大小肯定是只有数据的二进制流,这样没有任何多余的分帧信息。如果要做到在二进制流里不加任何分帧信息来反序列化二进制流,有两个关键点:
1. 确定具体的分帧方式
2. 肯定要有个地方存放这个分帧方式,并且是序列化方和反序列化方都能拿到。
我把这个双方约定分帧方式叫做契约。实际操作的时候只需要序列化方按照契约把对象的数据转成二进制流,反序列化方按照契约把二进制流转成对象数据。
如果二进制流里面不加任何的分帧信息,那么反序列化方只能按照字段的顺序来依次分帧。理解一下这句话,如果单纯拿到一个只有纯数据的二进制流,那么只能按照约定的顺序依次来读取,并且还得知道每个字段的长度,这样才能知道读取几个字节来还原数据。在这里把顺序本身作为一个隐形的契约,双方按照顺序来读写。一旦顺序错了,就有可能发生反序列化的错误。
第二点,必须有个地方存放这个分帧方式信息,而且双方都能拿到这个信息。我们很自然而然想到被序列化对象的Class对象是最自然的选择,而且它还包含了字段的信息,Class.getDeclaredFields()可以返回类的所有实例字段。如果getDeclaredFields()方法返回的字段在任意JVM上都是同样的顺序,那么我们岂不就是可以指依靠序列化反序列化双方拿到被序列化的Class对象,然后利用反射机制拿到字段信息就可以实现最优的序列化后字节数大小吗?
但是经过我的调研发现,利用反射技术Class.getDeclared()方法返回的字段数组是没有排序也没有特定顺序的,比如按照声明的顺序。
[java] view plain copy
-
/**
-
* Returns an array of {@code Field} objects reflecting all the fields
-
* declared by the class or interface represented by this
-
* {@code Class} object. This includes public, protected, default
-
* (package) access, and private fields, but excludes inherited fields.
-
* <strong><span style="color:#FF0000;">The elements in the array returned are not sorted and are not in any
-
* particular order</span></strong>. This method returns an array of length 0 if the class
-
* or interface declares no fields, or if this {@code Class} object
-
* represents a primitive type, an array class, or void.
-
*
-
* <p> See <em>The Java Language Specification</em>, sections 8.2 and 8.3.
-
*
-
* @return the array of {@code Field} objects representing all the
-
* declared fields of this class
-
* @exception SecurityException
-
* If a security manager, <i>s</i>, is present and any of the
-
* following conditions is met:
-
*
-
* <ul>
-
*
-
* <li> invocation of
-
* {@link SecurityManager#checkMemberAccess
-
* s.checkMemberAccess(this, Member.DECLARED)} denies
-
* access to the declared fields within this class
-
*
-
* <li> the caller's class loader is not the same as or an
-
* ancestor of the class loader for the current class and
-
* invocation of {@link SecurityManager#checkPackageAccess
-
* s.checkPackageAccess()} denies access to the package
-
* of this class
-
*
-
* </ul>
-
*
-
* @since JDK1.1
-
*/
-
@CallerSensitive
-
public Field[] getDeclaredFields() throws SecurityException {
-
// be very careful not to change the stack depth of this
-
// checkMemberAccess call for security reasons
-
// see java.lang.SecurityManager.checkMemberAccess
-
checkMemberAccess(Member.DECLARED, Reflection.getCallerClass(), true);
-
return copyFields(privateGetDeclaredFields(false));
-
}
那不能利用反射技术获得字段顺序,能不能利用字节码技术来获得这个类声明时存放的字段顺序呢?比如用ASM来直接读Class文件。但是我查阅了Java虚拟机规范,虚拟机规范只规定了Class文件中的元素,并没有要求实际存储的Filed[]按照声明顺序存储。这也是对的,实际的虚拟机实现可以按照各自的算法来优化。
事实上目前没有哪个协议做到最优的序列化后字节数,间接证明了只使用Class元数据来分帧是不能满足所有平台的,是不可靠的。
既然顺序这种弱契约关系不可靠,那么需要一种强契约关系,需要把一些分帧信息加入到二进制流,然后通过某种方式来获取这些分帧信息。加入哪些分帧信息和如何共享这些分帧信息有几种做法:
1. Java原生的序列化协议把字段类型信息用字符串格式写到了二进制流里面,这样反序列化方就可以根据字段信息来反序列化。但是Java原生的序列化协议最大的问题就是生成的字节流太大
2. Hessian, Kryo这些协议不需要借助中间文件,直接把分帧信息写入了二进制流,并且没有使用字符串来存放,而是定义了特定的格式来表示这些类型信息。Hessian, Kryo生成的字节流就优化了很多,尤其是Kryo,生成的字节流大小甚至可以优于Protobuf.
3. Protobuf和Thrift利用IDL来生成中间文件,这些中间文件包含了如何分帧的信息,比如Thrift给每个字段生成了元数据,包含了顺序信息(加了id信息),和类型信息,实际写的二进制流里面包含了每个字段id, 类型,长度等分帧信息。序列化方和反序列化方共享这些中间文件来进行序列化操作。
Hessian, Kryo, Protobuf, Thrift在生成的字节数都有了优化,并且可以只发送部分设置了值的字段信息来完成序列化,这样节省的字节数就更多了。但是还有些问题:
1. Hessian, Kryo不满足第三个方面,支持被序列化对象的新旧版本兼容,只依靠Class信息没有办法知道新旧Class的区别
2. Protobuf和Thrift已经很优化了,但是需要用IDL来生成静态的中间文件。
第二个方面考量序列化和反序列化效率,算法越简单当然效率就越高。实际的对比来说,Kryo, Protobuf > Thrift > Hessian > Java原生序列化协议
第三方面是个重要考量,比如服务方给方法的参数新增加了一个字段,要能做到老的客户端还可以使用这个新服务。这就要求序列化协议读取到不能识别的字段后能够处理异常。比如Thrift可以通过字段的id信息来知道是否支持这个字段,如果不支持读取,就跳过,从而做到新旧版本的兼容。而Kryo这种不依赖中间文件的协议很难做到这点,因为单纯的Class信息在不同的平台下字段顺序是不确定的,并且同一个Java文件在不同平台下编译后的Class文件中,字段信息也是不确定的。
第四方面,不依赖中间文件来序列化并同时满足前3点,从上面的分析来看很难做到。Protobuf和Thrift这种使用IDL来生产中间文件的协议,除了从跨平台调用的角度的需要,也包含了序列化的需要。
目前我还没有看到同时满足4个方面的序列化协议,上面的分析很多是自己的思考,可能有不对的地方,多交流。后面会陆续分析几种协议的实现。
序列化框架选型
分布式应用中,序列化很关键,选择一个合适的序列化框架能给分布式应用带来性能红利,还能减少不必要的麻烦.本文仅仅从我遇到的一些实际问题来说明序列化的选型.更深入部分也可以参考java序列化
序列化框架的性能可以参考jvm-serializers,Kryo
的性能还是很牛叉的.
除了性能,还要考虑兼容性/序列化后的大小.如果仅仅考虑性能,我们选择Kryo
就足够了.但是Kryo有些用着不爽的地方,比如不是线程安全的/兼容性.
1.兼容性
字段增删不兼容,这个问题有时候很麻烦.比如用memache做缓存,把对象序列化后存入memcache,如果出现字段增删的情况,必须在服务重启的时候把缓存清空,不然就会导致灰常严重的BUG.但是如果应用服务器有多台,这个问题还是避免不了.总会有个时间窗口会出现不同服务器上的同一个应用有不同的类版本,仍然可能会出现灰常严重的BUG.
现在的Kryo
提供了兼容性的支持,使用CompatibleFieldSerializer.class
,在kryo.writeClassAndObject
写入的信息如下:
class name|field lenght|field1 name|field2 name|field1 value| filed2 value
读入kryo.readClassAndObject
时,会先读入field names
.然后匹配当前反序列化类的field和顺序,构造结果.
子类和父类中有同名的字段时,kryo反序列化会丢失字段值,出现问题的原因和hessian出问题一样.
给kryo提交了一个improvement,在初始化类型信息时,去掉父类中重复名称的field.
2.线程安全
非线程安全也很好处理,每次都new对象出来,当然这样不是最佳的使用方式.通过线程变量来解决会比较合理,保证了性能还能提供很方便使用的工具类.
3.如何生成对象
对于没有无参构造器的类来说,生成新对象是个问题,可以使用java内部的机制来new一个对象。 可以参考下KryoReflectionFactorySupport的实现方式
4.性能
下面测试了java下的各种序列化实现方式的性能
0 Serialisation write=4,206ns read=16,945ns total=21,151ns 1 Serialisation write=3,626ns read=18,205ns total=21,831ns0 MemoryByteBuffer write=270ns read=324ns total=594ns 1 MemoryByteBuffer write=270ns read=330ns total=600ns 0 MemoryByteBufferWithNativeOrder write=357ns read=360ns total=717ns 1 MemoryByteBufferWithNativeOrder write=323ns read=359ns total=682ns 0 DirectByteBuffer write=236ns read=325ns total=561ns 1 DirectByteBuffer write=231ns read=301ns total=532ns 0 DirectByteBufferWithNativeOrder write=261ns read=310ns total=571ns 1 DirectByteBufferWithNativeOrder write=243ns read=290ns total=533ns 0 UnsafeMemory write=28ns read=82ns total=110ns 1 UnsafeMemory write=24ns read=75ns total=99ns 0 kryo write=373ns read=348ns total=721ns 1 kryo write=390ns read=386ns total=776ns 0 kryoWithCompatibleFields write=1,037ns read=1,625ns total=2,662ns 1 kryoWithCompatibleFields write=1,038ns read=1,657ns total=2,695ns 0 kryoWithCompatibleFieldsAndDuplicateFieldAccept write=1,077ns read=1,560ns total=2,637ns 1 kryoWithCompatibleFieldsAndDuplicateFieldAccept write=1,064ns read=1,583ns total=2,647ns 0 kryoWithUnsafe write=164ns read=204ns total=368ns 1 kryoWithUnsafe write=168ns read=210ns total=378ns 0 fastjson write=1,942ns read=5,834ns total=7,776ns 1 fastjson write=1,873ns read=5,879ns total=7,752ns
每种序列化执行1000000次,并且有预热. 各组数据相对比较,可以得出一些结论:
-
直接调用unsafe,最快,但是最麻烦
-
java自带的序列化很慢,最好不要用
-
kryo2.22提供的unsafe支持,性能非常卓越
-
kryo兼容性序列化器,开销挺大.写需要写入字段名,读的时候还需要做匹配撮合,读比写慢
-
fastjson也挺快的,兼容性\跨语言互操性俱佳.
序列化后的字节大小可以参考jvm-serializers
转载请注明:学时网 » 聊聊序列化(一)关于序列化协议的一些思考和问题