第四章 编码和演化
应用一直在改变。用户需求,商品上架等原因,应用一直在更新。第一章我们引入过“可进化性”,应用能易于改变。
当应用的特性改变时,存储的数据一般也会要求改变,可能是添加新字段,也可能是改变展示方式。
第二章的数据模型,我们讨论了如何应对数据的改变。关系型数据结构认定数据库中的数据都遵从特定的模式,即便是模式改变了,一个时间也只有一个特定的模式。“无模式”数据库不要求模式,所以数据库能够在同一时间容纳新旧数据。
当数据的模式或者格式改变时,对应的代码也需要改变(比如添加一个新字段后,应用需要使用新的字段)。但是在大型项目中,代码通常没法立刻改变。
服务端应用,你可能需要逐步升级,先在少量节点上部署新版本,然后观察新版本是否运行正常,然后逐步更新至所有节点。这允许不停止服务就能升级应用,鼓励更频繁地发布和更好的进化性。
客户端应用就只能指望用户自己升级了。
这意味着新旧版本的代码和新旧版本的数据格式可能在应用中同时存在。为保证系统平缓运行,我们需要在两方面维护系统的兼容性:
向后兼容:新代码能够读取旧代码写入的数据。
向前兼容:旧代码能够读取新代码写入的数据。
向后兼容好实现。但是向前兼容有些麻烦,需要旧代码忽略新代码添加的东西。
这一章我们来浏览不同的数据编码格式,包括JSON,XML,Protocol Buffers, Thrift, 和Avro。我们会看到他们如何处理改变,如何处理新旧代码和数据并存的系统。我们会看到这些格式是怎么用于数据存储和通信的:表述性状态传递(REST),远程程序调用(RPC),以及类似于actors和消息队列的消息传递系统。
编码数据的格式
程序中数据一般至少有两种表现形式:
1、内存中数据保存在对象,结构,列表,哈希表,树中。这些数据结构能被CPU有效访问(一般是使用指针)。
2、需要写入数据至文件或者通过网络发送时。必须将其编码成一类自我独立的字节序列(例如JSON)。因为指针对于其他进程没有意义,字节序列与内存中的数据结构差别很大。
因此我们需要在两种表现形式间做转换,从内存中数据结构转换至字节序列称之为编码(序列化),反过来称之为解码(反序列化)。
语言限定格式
很多编程语言有内置编码工具,用来将内存中数据结构编码成字节序列。例如java.io.Serializable等,也有很多第三方包等,如Java中的Kyro。
使用这些编码包用起来很方便,但是有很多问题。
1、编码过程绑定了特定的语言,那么用其他语言解码数据很困难。而且如果你用这个编码过程存储或者传输数据,那么你在未来相当长的时间内将自己绑定在了当前的编程语言,会阻止你整合其他组织的系统(很可能用不同的语言)。
2、解码过程中需要实例化任意的类。这会产生安全问题:如果一个黑客用你的应用解码任意的字节序列,那么他们可以实例化任意的类,这允许他们做些很危险的事情:比如远程执行任意的代码。
3、在这些包中,控制数据版本通常是马后炮,因为它们能快捷方便的编码数据,它们经常忽视前向兼容和后向兼容。
4、效率(CPU编码解码的时间,编码的数据结构)也是一个马后炮,例如Java内置的编码工具性能很差。
因为以上这些原因,使用语言内置的编码工具是个很糟糕的选择。
JSON,XML和二进制变体
使用标准的编码格式就可以被多个不同编程语言使用,JSON和XML就是其中的佼佼者。他们应用广泛,并且不尽相同。XML经常被批评冗长和复杂,JSON受欢迎,因为浏览器内置支持它,而且它比XML简单。CSV是另一种受欢迎的独立于编程语言的格式,尽管功能有限。
JSON,XML和CSV都是文本格式,是人可以阅读的,比起语法上的问题,它们有一些潜在的问题
1、在编码数字上有很多含糊的地方。在XML和CSV中,你不能分辨有数字组成的是数字还是字符串。JSON能够分辨数字很文本,但是它不能分辨整数和浮点数,没法指定精度。
这在处理大数据的时候会出问题,例如大于2^53的整数可以用IEEE754规范下的浮点数表示,但是一种语言解析成浮点数的时候可能不准确(如JavaScript)。一个例子就是Twitter用一个64位的数字来标注每个tweet。Twitter的api返回的JSON数据中包含两个ID,一个是JSON数据,一个是十进制字符串,为了应对JavaScript应用没法正确解析数字。
2、JSON和XML支持Unicode字符,但是不支持二进制化字符串(二进制序列,没有编码)。二进制字符串是一个很有用的性质,所以人们使用Base64将二进制数据编码成文本来绕过这一限制。然后使用一个模式表明数据使用Base64编码,但是这一工作将数据量提升了33%。
3、XML和JSON可以选择使用模式。模式语言功能强大,但是复杂,需要学习成本。XML的模式使用广泛,但是很多JSON工具没有使用模式。因为正确解析数据依赖模式中的信息,不使用模式的工具可能需要重写底层的编码和解码逻辑。
4、CSV没有任何模式,所以它有应用给它的每行和每列赋予含义,如果一个应用添加了新行或者新列,你必须手动处理这个改变。CSV是一个含糊的格式(特别是一个值中包含逗号或者是换行符)。尽管它有着公开的格式定义,不是所有的解析器都能正确解析。
尽管有这些缺陷,它们还是广泛使用的数据交换格式。在数据交换的场景中,只要人们约定了格式,漂亮或者效率就不是问题,让不同的组织在意见(关于任何事情)上取得统一,比其他事情重要。
二进制编码
如果数据只在你的组织里使用,可以使用一些小众的格式,比如更加紧凑的或者更快解析的。对于小数据库,增长可以忽视,但是一旦到了TB级别的,数据格式影响重大。
JSON比XML简单,但是比起二进制格式,仍然占用了很多空间。因此产生了很多对于JSON和XML的二进制编码的发展。这些格式都只是在小范围内使用,没有像文本格式那样广泛适用。
其中一些继承自数据类型库(例如:区分整数,浮点数或者是支持二进制字符串),并且没有改变JSON/XML的数据模型。并且,它们没有预定义模式,需要在数据中包含所有的字段名,如例4-1,一个MessagePack数据示例
现在看看MessagePack的JSON格式的二进制编码,图4-1显示了编码JSON文档的字节序列
整个编码长度为66个字节,少于JSON文本的81个字节,接下来看看怎么将相同的记录缩减至32个字节。
Thrift和Protocol Buffers
Apache Thrift和Protocol Buffers都是二进制编码库,背后的原理一样。Protocol Buffer由Google开发,Thrift由Facebook开发,都于2007年开源。
使用Thrift和Protocol Buffer编码的数据都需要模式。使用Thrift编码例4-1中的数据,我们需要描述一个模式,用Thrift的接口描述语言:
这等价于Protocol Buffer的模式定义
Thrift和Protocol Buffer自带代码生成工具来读取模式的定义,生成能够用不同语言实现模式的类。应用可以使用生成的代码来编码和解码。
使用模式编码的数据会是怎样的?Thrift有两种编码格式:BinaryProtocol和CompactProtocol。先看BinaryProtocol,图4-2显示了编码例4-1中的数据会如何:
类似于图4-1,每个字段都有类型声明(字符串,数字或者列表),必要的长度声明(字符串长度,列表长度),数据中的字符串编码为ASCII或者是UTF-8。
比起图4-1中最大的不同就是没有字段名(userName,favoriteNumber, interests),而是用字段标签代替了(数字1,2,3)。这些标签数字出现在模式定义中。字段标签类似字段的别名,告诉字段是什么不需要拼出字段名。
Thrift CompactProtocol编码在语义上等价于BinaryProtocol,图4-3所示,它只用了34字节就打包了相同的信息。
它是将字段类型和标签数字打包进一个字节,使用可变长度的数字,而不是使用整整8个字节来表示数字,如1337编码为两个字节,每个字节的第一个比特表明后续是否有字节。这意味着63,64编码成一个字节,8191,8192编码成两个字节。更大的数字编码成更多的字节。
最后,Protocol Buffers(只有一个二进制编码格式)编码相同的数据如图4-4所示。它用不同的方法打包成轻量级数据,类似CompactProtocol,Protocol Buffers打包成33个字节。
需要注意的是,之前所展示的模式中,每个字段标注为必须或者可选的,但是这在字段编码过程中没有区别(字段是否必须)。不同的只是如果必须字段没有设置,运行检测会报错,这可以用于bug检测。
字段标签和模式演化
之前说过,模式会不可避免的变化,称之为模式演化。Thrift 和 Protocol Buffers怎么保证前向和后向兼容的情况下应对模式变化的?
从之前的例子中可以看到,一条编码的记录就是编码过的字段的集合。每个字段有标签和数据类型。如果一个字段没有赋值,记录中就会忽略。从这里可以看出,字段标签对于编码数据非常重要。你可以在模式中修改字段名,因为记录中没有引用字段名,但是不能修改字段标签,那会使现存的所有的编码过的数据失效。
你可以往模式中添加新的字段,只要你给新字段添加新的标签。如果旧代码试图读取新代码写入的数据,遇上无法识别的新字段,只要忽略就行。数据类型注释告诉解析器需要跳过多少字节。这保证了前向兼容:旧代码可以读取新代码写入的数据。
那么后向兼容性呢?只要每个字段有唯一的标签数字,新代码就可以读取旧数据,因为标签数字相同代表着意义相同。需要注意的是,新增的字段不能设为必需字段。如果新增了必需字段,新代码读取旧代码写入的数据时,检测会出错,因为旧代码不会写入新增的字段。因此,为了维护后向兼容性,在初始部署的模式后添加的字段,需要为可选字段或者有默认值。
移除字段类似添加字段,只是后向和前向兼容反过来了而已。这意味着你只能移除可选字段并且不能使用相同的标签数(因为你仍然有旧代码在其他地方写入包含旧标签数的数据,这些移除的字段必须被新代码忽略)。
数据类型模式演化
如果修改字段的数据类型呢?这也是可能的,有个风险就是数据失去精度或者被截断。比如,将一个32位整数改成64位整数。新代码可以很容易读取旧数据,因为解析器可以将丢失的位补0. 但是,旧代码读取新数据,也只能解析成32位。64位的值无法适应32位,数据被截断了。
一个细节就是Protocol Buffers没有list或者array数据类型,取而代之的是repeated字段标签(required和optional之外的第三个标签)。如图4-4所示,一个repeated字段表明:相同的字段标签在记录中会重复多次。将其改为optional字段有很有趣的效果。新代码读旧数据会看到一系列0或者只有一个元素;旧代码读新数据会读到列表中最后一个元素。
Thrift声明了list数据类型,使用元素的数据类型作为参数。不允许将单一值类型转化为多值类型,但是支持嵌套列表。
Avro
Apache Avro是另一个二进制编码格式。
Avro也是使用模式来确定待编码的数据的结构。它有两个模式语言:一个(Avro IDL)用于人来编辑,一个(基于JSON),方便机器读取。
Avro IDL示例:
等价的JSON表示:
首先注意,里面没有标签数字,如果我们编码例4-1里的数据,只要32字节,目前看到的压缩率最小的编码。字节解析如图4-5:
注意,字节序列中没有表示字段或者数据类型。编码仅仅是将值组织在一起。一个字符串仅仅是一个长度前缀然后跟着UTF-8字节,但是数据中没有信息告诉你这是字符串。可能是整数或者其他什么。一个整数也是编码成可变长度的字节。
为解析数据,需要按模式中的顺序遍历字段,使用模式来识别字段类型。这意味着数据只有使用与写入时相同的模式才能正确解析。任何读取写入时的模式的不匹配都会造成解析错误。那么Avro如何支持模式演化?
写入模式和读取模式
使用Avro,当应用要编码一些数据(写入文件或者数据库),会使用它已知的模式,不论版本。这就是写入模式。
当应用像解码一些数据(从文件或者数据库读取),它期望数据符合一些模式,这就是读取模式。它是代码所依赖的模式,而代码可能是在程序构建过程中依据模式生成的。
Avro的关键的思想就是写入模式和读取模式不需要相同,只需要匹配。读取数据时,Avro库会解析读取模式和写入模式的差异,然后将数据从写入模式转换至读取模式。Avro解析规范如何工作如图4-6.
如果写入模式和读取模式的字段顺序不一致,那么没有问题,因为解析模式过程会按照字段名匹配字段。如果一个字段写入模式有,读取模式没有,那么代码读取时会忽略。如果字段是写入模式没有,读取模式有,那么代码读取时会填充读取模式声明的默认值。
模式演化规则
Avro中,前向兼容意味着有新版写入模式和旧版读取模式。相反的,后向兼容意味着新版读取模式和旧版写入模式。为维持兼容性,你可以添加或者移除一个有默认值的字段。如果你添加了一个有默认值的字段,新字段存在于新模式中,而旧模式没有。当使用新模式读取旧数据时,缺省的字段就会填充默认值。如果你添加一个没有默认值的字段,新的读取器没法读取旧数据,这样会破坏后向兼容。如果你移除了一个没有默认值的字段,那么旧读取器没法读取新数据,这样就破坏了前向兼容。
在一些编程语言中,null是一个可以接受的变量的默认值,但是在Avro中不是如此:如果你想让一个字段可以为null,你不得不使用union 类型。例如:union{null, long, string} field;表明字段可以是数字,字符串或者null。你只能将一个union类型的衍生字段的默认值设为null。将每个可为空的字段默认值设为null有些冗长,但是明确哪些能为空哪些不能能够预防bug。
总的来说,Avro没有optional和required标记,但是有union。
改变字段的数据类型是可能的,假设Avro能转换类型。修改字段名也是可行的但是有些小技巧:读取模式能够包含字段名的别名,所以它能够识别旧写入模式的字段。这意味着修改字段名是后向兼容的但不是前向兼容。类似的,添加一个union类型字段是后向兼容但不是前向兼容。
什么是写入模式?
目前我们没有讨论的一个很重要的问题是:读取器怎么通过特定的数据片段来得到写入模式?我们不可能在每个记录中都插入整个模式,因为模式数据一般比编码的数据大,这让二进制编码省下的空间浪费了。
答案是在Avro使用的上下文(context)中,一些例子:
包含大量记录的大文件
Avro的一个常见的场景就是存储大量记录至大文件中(特别是Hadoop环境),所有的记录都是相同的模式。这个场景下,写入器会在文件开头记录写入模式。Avro指定了一个文件格式来做这件事。
有着独立写入记录的数据库
在数据库中,不同记录可能使用不同写入记录在不同时间写入,你不能认为所有记录有相同的模式。最简单的解决办法就是在每个记录前面添加一个版本号,然后在数据库中保存一系列模式版本号。读取器拿到一条记录,提取版本号,然后从数据库中拿到特定版本的写入模式,使用写入模式,就可以解析剩下记录。
网络连接发送记录
当两个进程通过网络连接时,可以通过网络确定使用的模式版本号,在剩下的连接时间里使用确定的模式。
模式版本的数据库任何情况下都有用,它让你能检查模式的兼容性。可以用一个数字作为版本号,也可以用模式的哈希值。
动态生成模式
Avro的一个优势就是模式不包含任何的标签数据。为什么这是个优势?在模式中保持数据的问题在哪儿?
区别在于Avro对动态模式友好,比如你想将关系型数据库中的内容导出至文件,你想用二进制格式来避免前面所说的文本格式的缺陷。如果你使用Avro,你可以很容易地从关系型模式中生成Avro模式,用模式编码数据库内容,将这些都导出至包含Avro对象的文件。你可以给数据库中的每个表生成一个模式,每个列都对应记录中的一个字段,列名对应Avro中的字段名。
现在模式改变了(增加或者删除一列),你只需从更新后的模式生成新的Avro模式,然后按照新的模式导出数据。数据导出过程不需要关注模式的改变,可以简单地实时转换模式。任何人阅读新数据文件都会发现字段发生了改变,因为字段是用名字做区分,更新后的写入模式能够匹配旧的读取模式。
如果使用 Thrift或者Protocol Buffers做相同工作,就不得不手动指定字段标签:每次数据库模式发生改变,管理员不得不手动更新数据库列名至字段标签的映射。(或许可以自动完成,但是模式生成器不得不很小心地避免使用已经使用过的数据标签)。这种简单生成动态模式的特性不是 Thrift或者Protocol Buffers的设计目标,但是是Avro的目标。
代码生成和动态类型语言
Thrift和Protocol Buffers依赖代码生成器:模式改变之后,你可以用编程语言实现模式。这在静态类型语言中(Java,C++或者C#)很有用,因为这允许使用内存中的数据结构来解码数据,以及在接触数据类型的时候使用类型检测和IDE的自动补全功能。
在动态类型语言中(JavaS,Ruby或者Python),生成代码过程中没有多少要点,因为编译时不会检测类型是否匹配。代码生成器经常对这些语言感到麻烦,因为他们没有准确的编译步骤。此外,在动态生成的模式(例如Avro生成的模式)中,代码生成器对于数据来说是不必要的。
Avro为静态类型语言提供了可选的代码生成器,但是它可以不需要代码生成器。如果你有一个对象文件(内置写入模式),你可以只使用Avro库就可以打开并且浏览数据,类似于JSON数据。文件是自我描述的,因为它包含了所有必须的元数据。
这个特性在使用动态类型语言时尤为有用,如Apache Pig,在Pig中,你可以打开Avro文件,分析它们,将导出的数据集以Avro格式写入导出文件而不需要考虑模式。
模式的优点
Protocol Buffers, Thrift, 和 Avro都使用了模式来描述二进制编码格式。他们的模式语言比JSON和XML的模式语言简单。因为Protocol Buffers, Thrift和 Avro的实现和使用很简单,他们已经能支持大量的编程语言。
编码时使用模式的想法并不新鲜。很多数据系统也给他们的数据实现了自己的二进制编码。例如大多数关系型数据库都有一个网络协议来提供查询并返回数据。这些协议通常限定特定的数据库,数据库供应商提供特定的驱动(ODBC或者JDBC API)来将返回数据编码成内存数据结构。
所以我们可以看到类似JSON,XML的文本数据结构广泛使用,使用模式的二进制编码是可选的。他们有一些很有趣的特性:
1、因为能从编码后的数据中移除字段名,它们比“二进制JSON”更加紧凑。
2、因为需要模式进行解码,模式对于文档来说很重要,需要确保模式的更新(然而手动维护文档可能容易偏离实际)。
3、在一切部署前,维护数据集的模式能让你检查模式改变的前向兼容和后向兼容。
4、对于静态类型语言,从模式生成代码的能力很有用,因为它允许在编译时检测类型。
总的来说,模式演化能兼容无模式或者读取时模式JSON数据库提供的一些灵活性,同时给数据提供更好的保证和更好的工具。