第四章数据编码和更新-part1,esigning Data-Intensive Applications 中文翻译摘要

综述

我们的应用往往不可避免的需要进行更新,添加新的功能。在第一章讲过了应用具有可进化性,也就是说我们的应用应该在设计之初就拥抱变化。大多数情况下,一旦功能发生变化,底层的数据可能也要跟着变化。会加新的字段,或者已有数据展示方式有变化。

第二章在数据模型时讲过不同的模型在应对数据变化时有不同的解决方法。关系数据库总的来说假设所有数据是一种结构,虽然数据结构可以修改,譬如alter table,但是他在一个时间点要求,强制要求所有数据满足相同的结构。读式结构,或者说非结构化数据库并不强求所有数据满足同一个结构。所以他可以同时包含新老两个版本的数据

当数据结构发生变化时,应用代码也要随之修改。但是在大型系统中,这并不是短时间就可以搞定的。

  • 服务端程序往往采用灰度上线,也就是先把新版程序发布到少数几个节点上,没问题再发几台,渐进发布。这样可以保证从外部来看,服务在发布过程中依旧可以正常工作。
  • 但是客户端程序比较惨了,因为即使你发布了新代码,可能用户也不会去下载安装它。

这就意味着新老版本的程序和数据在一段时间内是共存的。为了你的应用能够正常运行,你需要做两个方向的兼容。

  • 向后兼容
    新版程序可以读写老版数据
  • 向前兼容
    老版程序可以读写新版数据

向后兼容往往比较简单,因为你知道老版数据的组织格式,所以可以针对他做特殊处理。但是向前兼容就比较难了,因为你需要代码能够忽略掉新版数据所引入的额外变化。

这章主要介绍几种数据的编码形式,来分析他们是如何应对这种新老数据的变化。随后讲述这些数据编码方式如何应用在数据存储,数据通信的应用当中。

几种编码数据的格式

一个数据最少有两种存储表达方式

  • 内存中,数据是以对象,结构体,队列,hash表,树的结构表达存储的。不同的结构的目标是能够高效的让CPU找到需要的数据并对之操作。
  • 当你把一份数据写入磁盘或者通过互联网传送的时候,你需要把他转化成字节流。这个时候,例如指针对于其他进程来说就没办法理解了,所以可能与他在内存当中的结构全然不同。

因为这两种方式不太相同,我们需要在这其中进行转化,我们把从内存到字节流的转换叫编码,反过来的叫解码。其实序列化和反序列化更合适,但是因为后面的章节有用到这个词,所以这就这么叫吧。下面来介绍下几种常见的编解码的库。

编程语言自带的编解码库

很多语言都有自己的编解码库,比如java里面的java.io.Serializable , python里面有pickle,第三方库也有很多。这些库用起来非常方便,只要几行代码就能完成序列化。但是他们有很多问题。

  • 编解码的方式和语言绑死,如果你用JAVA做编码,那如果想用python做解码就很难了。这也就意味着你的整个系统必须都用一种语言实现。一旦你需要和别的语言的系统做集成,就死翘翘了。
  • 安全隐患, 因为数据都存储到相同的对象类型中去,如果都能够解码,那就要求解码程序就必须有实例化任何类的能力。这个能力很危险,因为攻击者就能够借由你的解码程序去实例化一些危险的类,比如执行远程代码。
  • 数据版本兼容问题, 这些库往往不具备向前兼容和向后兼容的能力
  • 效率, 无论是编解码的速度还是编码后数据的大小,都不太好。

如果用一句话来说就无论任何时候,都不要用编程语言自带的编解码库,不要用!!

XML,JSON和其他二进制变种

谈到标准的编解码方式,就是那些可以跨语言编解码的方式。JSON和XML无疑是其中翘楚。他们被广泛的传播,广泛的支持,同时也被广泛的厌恶着。XML往往因为其过于冗长,而且十分复杂而广受诟病。json的流行主要依托于浏览器和javascript对他的良好支持,另外跟xml相比,他还是要轻巧很多的。CSV也是一种序列化方式,但是相比前两位,他的表达能力就有些弱了。

JSON,XML, CSV都是文本格式,在一定程度上是人类可读的。但是他们都有一些共性的问题。

  • 表达数字上有歧义, 对于XML和CSV而言,除非有一个外部的schema,否则当你看到一个数字的时候,例如1, 你不知道他想表达的是数字1,还是一个字符串"1",JSON在这一方面是能够区分的,但是如果json看到的是1,你知道他不是字符串,但是却不知道他是一个int还是一个float
    另外就是当数据很大的时候,超过253,用javascript解析会丢精度,因为javascript解析的时候是当做float处理的。正因为此,twitter在返回json接口的时候,twitter的id这个字段是给了两遍的,一遍用的是数字,一遍给的就是十进制的字符串。因为这个id是64位int,太大了。
  • 二进制字符支持不好, XML和JSON都是能很好的支持unicode字符,也就是可读字符,但是对那些二进制不可读字符就很差,比如\x01,虽然我们可以用base64把这些转成可读字符。但是可能会使编码后文件增大33%
  • schema支持不好, 针对歧义问题,其实可以用外部定义schema的方式解决。但是这类定义语言往往很复杂, XML的schema用的人还很多,但是JSON的就很少用了。一旦你没有用到这类工具,那一个数据的类型,譬如到底是int还是string就需要在你的代码里面写死了。
  • CSV无schema, 所以如何解析完全取决于你的解码代码。如果加一行或一列,你也需要手动去控制编解码。另外就是他的分隔符比较模棱两可,因为当数据出现逗号的时候就容易说不清楚。尽管他的分割规则有很明确的定义,但是总有人写不对。

尽管有这些缺点,但是JSON,XML,CSV在大多数场景下已经够用了。尤其在跨部门,跨组织的数据交换场景下。一旦人们对一个数据格式都认可,那他是不是好,是不是高效就不那么重要了。因为能让这些人达成一致真的不容易.

二进制编码

如果你在内部使用,其实不一定要用这么朴素的编码方式,可以选一些进阶版,这样有更好的兼容性和更快的解析速度。对于一个小数据集来说可能意义不大,但一旦你有PB级的数据,那小一点就小很多了。

JSON相比XML要简洁一些,但是相比二进制格式,他还是有很多冗余的内容。这就衍生出了一系列用二进制编码json的方法(MessagePack, BSON, BJSON, UBJSON, BISON)。对于XML来说,有WBXML, Fast Infoset。这些用的也很多,但是相比XML和JSON来说,流传度就小很多了。
举个例子,假设我们用MessagePcak来编码下面的json内容,如Figure 4-1所示

Example 4-1
{
  "userName": "Martin",
  "favoriteNumber": 1337,
  "interests": ["daydreaming", "hacking"]
}

简单讲下他的含义

  1. 第一个字节0x83表示他是一个object(0x80), 有3个属性(0x03),如果有一个类有超过15个属性,那1个字节就不够了,这个时候这个object就会有一个别的编码,他的属性个数就可以用2个或者4个字节来表示了
  2. 第二个字节0xa8,表示是一个字符串(0xa0),他有8个字节长(0x08)
  3. 后面跟着的8个字节是userName的ascii码
  4. 再往后的7个字节,用重复2和3的方式表达了内容是Martin

这样编码之后将原始有88个字节的json压缩到了66个字节。为了省22个字节,搞得数据不可读是不是值得,这个就是因人而异了。后面会有其他方法把他压缩到32字节。

Thrift 和 Protocol Buffers

Thrift和Protocol Buffers都需要给他们编码的对象定义一个schema, 如果需要给Example 4-1里面的数据进行编码,需要下面这样的一个接口定义语言( interface definition language, IDL)

Thrift
struct Person {
  1: required string userName,
  2: optional i64 favoriteNumber,
  3: optional list interests
}

Protocol Buffer
message Person {
  required string user_name = 1;
  optional int64 favorite_number = 2;
  repeated string interests = 3;
}

Thrift 和Protocol Buffer都有对应的代码生成工具将IDL转化为不同编程语言的实现类。开发者可以调用类的接口来进行编码解码操作。

编码后的数据长什么样呢?首先,Thrift有2种编码格式,一种叫BinaryProtocol, 一种叫CompactProtocol。 先看BinaryProtocol,如Figure 4-2


跟Figure 4-1很像,每个变量有一个类型的声明,还需要说明他的长度。里面的具体值得内容,比如Martin, daydreaming和之前都没有区别。但是有一点差异很大,就是他没有属性的名字了,也就是之前的userName, favoriteNumber这些内容都没有了。这些内容被编码成了field tag中的1、2、3.这些数字在之前的schema定义中被定义好,这就好像是每个属性名的别名一样。

CompactProtocol编码格式在语法和BinaryProtocol是一样的,但是他却只用了34个字节。首先他把field tag 和 type编码成了1个字节,其次用了一个变长的int类型来存储1337,而不是用8字节来表示一个int64。这种方式用每个字节的第一个bit来表示这个数字是否结束了。这样,–64到63只需要一个字节,–8192到8191需要两个,数字绝对值越大,需要的字节就越多。


最后来看看Protocol Buffer,他和CompactProtocol的编码方式非常像,这货只需要33个字节就搞定了,因为他少了一个结束的字节。


4-4.png

另外有一个地方值得注意,在IDL里面会定义一个变量是optional 还是 required,这对于编码而言其实没有区别,这个字段的区别只是在程序解码的时候,如果这个字段没有,是否需要报错。这样可能会有助于发现bug

field tag引发的schema的变化

我们说过,schema无法避免会发生变化,那Thrift和Protocol Buffer是如何解决这个问题以做到前向兼容和后向兼容呢?

正如几个例子看到的,一个编码后的结果其实就是把每个属性编码之后再拼接起来。每个属性用它的tag编号来作为唯一的标记,用一个type声明他的类型。如果这个值没内容,编码时候就直接忽略掉了。如此我们可以发现field tag对于编码数据尤为关键。属性的名字其实可以随便改因为反正数据里面也没存他。但是如果你把他的tag改了,那这个编码的数据就不可用了。

当你需要加字段的时候,就可以直接在schema里面加,然后给他一个全新的数字作为field tag,如果老代码看到这个他不认识的数字,他可以直接忽略掉这个字段。这个属性的数据类型的声明可以告诉他需要跳过多少个字节继续往后解析。这就保证了向前兼容,老代码可以解码新代码编码的数据

那向后兼容呢?因为每个属性有一个自己唯一的数字作为filed tag,新代码永远可以解码老代码编码的数据。因为这些数字还是有着同样的含义的。唯一值得注意的是一旦你加了一个属性,你可不能把他设为required,因为这样老数据不具备这个字段,那在解码的时候会因为没有这个字段而报错。所以为了向前兼容,你的所有属性必须设为optional 且有初始值。

删字段的时候其实就好像过去的时间点要加一个字段一样,向前向后兼容就跟加字段是反着的。你只能删一个是optional的字段,并且以后绝不再用它对应的tag field的数字。

数据类型引发的schema的变化

如果一个属性的类型变了,其实也可以,但是有可能会损失精度。比如你把一个int32的属性改成了int64,那新代码解析老程序很简单,只需要把剩余位补0就可以了。但是老代码处理新数据时会把这个数据强转成int32,就会产生截断了。

还有一个问题是,如果一个属性类型从list变成单个或者反过来怎么办?Protocol Buffer并没有类似list或者array的对象,他是用repeated来表示一个属性有多个。其实从Figure 4-4可以看到,repeated这个字段并没有实际在编码数据中有表现,他只是多个field tag 重复出现而已。所以如果是新代码读老数据的时候,可以理解成一个list里面最多只有一个元素,而老代码读新数据的时候会把最后一次出现赋值到属性中,都挺好。但是Thrift是有list的定义的,他不允许一个变量从单个的变成一个list.但是它支持嵌套list.

你可能感兴趣的:(第四章数据编码和更新-part1,esigning Data-Intensive Applications 中文翻译摘要)