掘金 后端 ( ) • 2024-04-02 09:43

随着程序的不断迭代,往往伴随着存储数据的更改:有可能是增加新的字段,或者以新的方式展示现有数据。在第二节中,介绍了数据模型。对于关系模型,可以通过 ALTER 语句更改其模式,但在任一时刻有且仅有一个正确的模型。对于非关系模型,数据库不会强制校验数据写入的模型,可以让不同时间写入的不同格式数据共存。

当数据模式或格式发生变化时,往往需要应用程序进行相应的修改「例如:读取新的字段用于展示,支持写入新的字段」。对于大型应用来说,应用程序的变更不是直接从一种状态转化到另一种状态:

  • 对于服务端应用程序,往往使用滚动升级的形式更新服务。将新版本更新到少数几个节点上,检查是否可以正常运行,服务无问题则更新全部节点。
  • 对于客户端应用程序,用于可以选择是否升级应用程序。当然,也有些会采取强制更新的方式让用户升级。

这就意味着,必将存在一种状态,即新旧两种应用程序共存。即新旧两种模式的数据共存。为了确保应用程序能够稳定运行,需要保证双向兼容:

  • 向后兼容:新代码可以读写旧代码写入的数据,不影响系统功能。
  • 向前兼容:旧代码可以读写新代码写入的数据,不影响系统功能。

向后兼容实现起来并不会那么麻烦,新的代码可以知道旧代码产生的数据格式,可以额外的进行处理。然而,向前兼容处理起来相对麻烦一点,毕竟不知道未来数据格式会发生什么样的变更。

在本章将会介绍几种编码数据的格式。重点关注不同的格式如何应对模式的变化,来尽可能保证双向兼容。

编码数据的格式

程序通常使用两种形式的数据:

  • 在内存中,数据保存在对象、结构体、数组、链表、散列表、树中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
  • 如果将数据写入文件、或通过网络发送,则必须经过编码「内存中的数据转为字节序列」。相反,将字节序列转换为程序内存的数据结构则称为解码。由于每个程序都有独立地址空间,一个进程中的指针对其他进程来说没有任何意义,所以编码后的字节序列通常会与内存中使用的数据结构完全不同。

语言特定的格式

不同编程语言都内建了将内存对象编码为字节序列的支持。例如:Java 的 java.io.Serializable,go 的 json.Marshal 等。这些内建的编码库使用简单,但也会有一些其他问题:

  • 这些编码库与语言深度绑定,无法跨语言进行读取。
  • 往往不支持双向兼容。这些编码库的设计目标是快速的对数据进行编解码,不会考虑向前、向后兼容带来的各种问题。
  • 效率(编码或解码所花费的 CPU 时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭著。

因此,使用语言内建的编解码方案往往不是一个很好的主意。

JSON、XML 和二进制变体

使用编程语言内建的编解码的缺点在于实现方案不统一,没有标准的规范。提到可以被多种编程语言读写的标准编码方式,那就不得不提 JSON 和 XML。它们的优势在于人类可以直接读出含义,当然也有会带来一些其他问题:

  • 数字编码有很多模糊之处。在 XML 中,无法却分数据和碰巧由数组组成的字符串。JSON 虽然可以却分数字和字符串,但并不区分整数和浮点数,且不能制定精度。
  • 处理大数字时会有问题。例如大于 2^^53 的整数无法使用 IEEE 754 双精度浮点数精确表示,因此在使用浮点数(例如 JavaScript)的语言进行分析时,这些数字会变得不准确。
  • JSON 和 XML 对字符串有很好的支持。但不支持二进制数据(即不带字符编码的字节序列)。目前常见的解决方法是:通过 Base64 将二进制数据编码为文本来绕过限制。

尽管 JSON 和 XML 存在一些缺点,但已经可以满足大多数场景。它们之所以可以流行下去,核心还是达成了不同开发者的意见统一,这往往已经解决了大部分问题。

二进制变体

JSON 和 XML 均是使用文本的方式直接表达数据内容,但与二进制格式相比还是占用了太多的空间。为了减少数据空间,衍生出大量二进制编码版本 JSON(MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML(例如 WBXML 和 Fast Infoset)的出现。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版 JSON 和 XML 那样被广泛采用。

下面是一个使用 MessagePack 进行二进制编码的例子。左边是 JSON 文本,右边是 MessagePack 编码的二进制。

{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}

Thrift 和 Protobol Buffer

Apache Thrift 和 Protocol Buffers(ProtoBuf) 也是基于二进制的编码库,但其已经完全脱离 JSON 的格式。ProtoBuf 是由 Google 开发的,Thrift 是由 Facebook 开发的,并且都是在 2007-2008 开源的。两者都需要一个模式来编码任何数据。如下图所示:

// Thrift 模式定义
struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}
// protobuf 模式定义
message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}

Thrift 和 ProtoBuf 均提供一个代码生成工具,可以将定义的模式转化为各种编程语言中的类。应用程序可以直接调用生成的代码对模型的记录进行编解码。下面先来介绍一下通过 Thrift 协议进行编码后的数据。Thrift 提供了两种不同的二进制编码格式 BinaryProtocol 和 CompactProtocol。下面使用 BinaryProtocol 编码后的数据。

在 BinaryProtocol 中,每个字段都有一个类型标识(type),还可以根据需要制定长度。和 JSON 编码最大的区别在于二进制中没有字段名(userName、favoriteNumber、interest)。相反,编码数据包含字段标签(1、2、3)。这些是模式定义中出现的数字。字段标记相当于字段的别名,从而节省了大量的空间。

Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol,它只将相同的信息打包成只有 34 个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字 1337 不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着 - 64 到 63 之间的数字被编码为一个字节,-8192 和 8191 之间的数字以两个字节编码,等等。较大的数字使用更多的字节。

下图是使用 ProtoBuf 对相同数据进行编码后的数据。它与 Thrift CompactProtocol 非常相似。相较于 Thrift,ProtoBuf 更加紧凑,只专注于结构体的定义和序列化。

在 Thrift 和 ProtoBuf 的模式定义中,每个字段被标记为 required 或 optional,但对字段如何编码并没有影响(二进制中并没有指示字段是否必须)。区别在于,如果字段为 required,但未设置该字段,运行时将会报错。

字段标签和模式演变

上面说过,随着业务的不断迭代,对应的数据模式也会发生相应的变动,称之为模式演变。那在 Thrift 和 ProtoBuf 中,是如何保证双向兼容的呢?

在 Thrift 和 ProtoBug 中,均需要先定义结构体对应的模式。每个字段由其标签号码「tag」作为标识,并用数据类型「type」注释。一次,如果更改了模式中的结构体名称,并不会影响编解码的过程。但是,不能更改字段的标记,否则会使数据出现异常。如下图,你可以将 userName 改为 name,但不能将 1 改为 2。

// Thrift 模式定义
struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}

当添加一个新的字段时,需要新增一个标签号码。旧的代码在读到新增加的字段会直接丢弃此字段,从而保证了向后兼容。如果想要向前兼容,则需要新增的字段为 optional 的。由于旧代码写入数据没有新增的字段,由于此代码是 optional 的,新代码可以赋值一个默认值。

删除字段如何保证双向兼容?删除字段可以保证向前兼容,新代码读到旧代码写入的字段时可以直接丢弃。那如何保证向后兼容呢?为了保证旧代码读到的数据即使缺失字段也不影响系统功能,如果此字段即为 optional 的。即删除的字段为 optional 的,则可以保证双向兼容。

字段类型和模式演变

除了增加或减少字段之外,有些场景可能涉及字段类型的变更。例如,将字段类型从 i32 变成 i64。这种变更是向后兼容的,新代码永远可以处理 i32 和 i64。但旧代码在处理 i64 时将会导致精度丢失,向前不兼容。在业务系统上,通常会考虑新增一个字段来保证双向兼容。

Avro

对于 Thrift 和 ProtoBuf,按照给定的模式定义,将字段按照标签号编码到字节序列中。Avro 则使用了完全不同的方式进行编解码,不再存储字段标签和类型,从而节省了空间。对 Avro 有两种模式语言,一种基于 IDL 便于人工编辑、一种基于 JSON 便于机器读取。

record Person {
    string                userName;
    union { null, long }  favoriteNumber = null;
    array<string>         interests;
}
{
    "type": "record",
    "name": "Person",
    "fields": [
        {"name": "userName", "type": "string"},
        {"name": "favoriteNumber", "type": ["null", "long"], "default": null},
        {"name": "interests", "type": {"type": "array", "items": "string"}}
    ]
}

下图是一个编码后的数据,它是目前最紧凑的编码数据了。如果你检查字节序列,你可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟 UTF-8 字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与 Thrift 的 CompactProtocol 相同)进行编码。

那 Avro 是如何工作的呢?Write 模式:当进行数据写入时,只将字段的值写入字节流。Reader 模式:数据读取时,按照写入时的模型定义,按顺序将值赋值到对应的属性。

既然字节序列中数据没有字段标签,那如何保证新模式可以读取旧模式写入的数据呢?如下面的一个例子,数据写入时是按照左边的字段顺序进行编码,读取时的模式是右边的模式。

record Person {
    string                userName;
    union { null, long }  favoriteNumber = null;
    array<string>         interests;
    string                photoURL
}
record Person {
    string                UserID;
    union { null, long }  favoriteNumber = null;
    array<string>         interests;
}

当数据读取时,会先拿到数据写入时的模式,即左边的模式,然后按照右边模式的字段名去匹配赋值。

从上面可以看出,在读取时,在知道当前模式的定义下,还要知道数据写入时对应的模式,那如何获取数据写入的模式呢?

  • 有大量记录的大文件:在 hadoop 中,往往会有上万的文件存储数据。可以在文件头写入此文件的数据模式,数据读取时先从文件头获取到写入时对应的模式。
  • 存储到数据库中:在数据写入数据库时,增加一个版本号机制,通过版本号来明确数据写入对应模式。
  • 网络通信:在两个程序程序进行网络通信时,先约定数据的写入模式。

可以看到,Thrift 和 ProtoBuf 时将字段顺序编码到每一条记录中,与模式进行匹配。而 Avro 是将字段顺序与写入的模式保持一致。因此,需要额外的地方存储其模式以保证读取。可以存储到文件头、版本号索引、通信定义等。

模式演变

为了保证双向兼容,在 Avro 中,只能增加和删除带有默认值的字段。如果增加一个不带默认值的字段,新代码无法从旧代码写入的数据中获取值,无法向后兼容。如果删除一个不带默认值的字段,旧代码无法从新代码写入的数据中获取到字段,从而破坏向后兼容。

在 Avro 中,数据类型转换是允许的。但无法修改字段名,因为在数据读取时,时通过字段名进行匹配的。

动态生成的模式

Avro 和 Thrift 与 ProtoBuf 的本质区别在于不包含任何标签号码。从而确保了 Avro 对动态生成的模式更加友好。当模式发生变更时,无需像 Thrift 和 ProtoBuf 更新模式定义,同时生成对应的代码,并重新部署代码。只需要运行时重新加载新的模式即可。

当然,Avro 也有自己的缺点。例如:语言支持有限,性能相对较低,需要额外的模式存储。

总结

编码的本质是将内存中的数据结构转化为字节序列,用于持久化存储,或跨进程、网络进行通信。使用 JSON 和 XML 模式,其可读性更强,可以直观的看到存储的数据,且支持字符串匹配、正则表达式等能力。缺点就是占用空间较大。Thrift、ProtoBuf、Avro 是使用二进制编码的模式,其特点就是编码后的空间较小。在这三种模式中,均需要定义数据模式,通过数据模式进行编解码。在 Thrift 和 ProtoBuf 中,均是将字段标签编码到字节序列中,用于匹配模式中的字段。相较于 Thrift,ProtoBuf 更加紧凑,但只支持结构体定义。Avro 是一种更紧凑的二进制编码模式,与 Thrift 和 ProtoBuf 不同的是。Avro 并没有将字段顺序编码到字节序列中,这就意味着无需通过工具生成对应的结构体代码进行编解码。从而支持动态加载的能力。但是,使用 Avro 需要保证能够拿到数据写入时的模式定义,否则无法进行解析。