A Sort Of A Blog
旧游无处不堪寻,无寻处,惟有少年心
『数据密集型应用系统设计』读书笔记(四)

编码与演化


在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据: 可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
我们在之前讨论的数据模型有不同的方法来应对这种变化。
当数据格式(format)或模式(schema)发生变化时,通常需要对应用程序代码进行相应的更改。但在大型应用程序中,代码变更通常不会立即完成:

  • 对于服务端(server-side)应用程序,可能需要执行滚动升级
  • 对于客户端(client-side)应用程序,用户可能相当长一段时间里都不会去升级软件,这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。

那么对于新旧格式共处系统想要继续顺利运行,就需要保持双向兼容性:

  1. 向后兼容: 新代码可以读取旧数据
  2. 向前兼容: 旧代码可以读取新数据

本章中将介绍几种编码数据的格式,包括 JSON、XML、Protocol Buffers、Thrift 和 Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信。

编码数据的格式


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

  1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等结构中。
  2. 如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的字节序列(例如 JSON 文档)。这个字节序列表示会与通常在内存中使用的数据结构完全不同

所以,需要在两种表示之间进行某种类型的翻译。从内存中表示到字节序列的转换称为编码(Encoding),也称为序列化(serialization),反过来称为解码(Decoding)或反序列化(deserialization)。

语言特定的格式

许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java 有 java.io.Serializable、Python 有 pickle 等。这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一些深层次的问题:

  1. 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据
  2. 数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题
  3. 效率往往也是事后才考虑的。例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭著。

因此,除非临时使用,采用语言内置编码通常是一个坏主意。

JSON、XML

JSON、XML 和 CSV 属于文本格式,因此具有人类可读性。除了表面的语法问题之外,它们也存在一些微妙的问题:

  • 数值的编码多有歧义之处。XML 和 CSV 不能区分数字和字符串。JSON 虽然区分字符串与数值,但不区分整数和浮点数,而且不能指定精度
  • JSON 和 XML 对 Unicode 字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据

尽管存在这些缺陷,但 JSON、XML 和 CSV 对很多需求来说已经足够好了,特别是作为数据交换格式来说。

二进制编码

对于仅在组织内部使用的数据,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计,但一旦达到 TB 级别,数据格式的选型就会产生巨大的影响。
JSON 比 XML 简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本 JSON 和 XML 的出现。

我们来看一个例子,原始 JSON 如下:

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

如果使用 MessagePack 对 JSON 文档进行编码,结果如下:

字节序列解释如下:

  1. 第一个字节 0x83 表示接下来是 3 个字段(低四位=0x03)的对象 object(高四位=0x80)
  2. 第二个字节 0xa8 表示接下来是 8 字节长(低四位=0x08)的字符串(高四位=0xa0)
  3. 接下来八个字节是 ASCII 字符串形式的字段名称 userName。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置
  4. 接下来的七个字节对前缀为 0xa6 的六个字母的字符串值 Martin 进行编码,依此类推

Protocol Buffers

Apache Thrift 和 Protocol Buffers(protobuf)是基于相同原理的二进制编码库。Protocol Buffers 最初是在 Google 开发的,Thrift 最初是在 Facebook 开发的。
Thrift 和 Protocol Buffers 都需要一个模式来编码任何数据。
对数据进行编码,可以使用 Thrift 接口定义语言(IDL)来描述模式:

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

Protocol Buffers 的等效模式定义看起来非常相似:

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

Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类。你的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。

我们主要介绍一下 protobuf。
Protocol Buffers(只有一种二进制编码格式)对上述例子进行相同的数据进行编码,只需要 33 个字节,结果如下:

数据流的类型


上面说过,无论何时你想要将某些数据发送到不共享内存的另一个进程,例如,只要你想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序列。

数据可以通过多种方式从一个流程流向另一个流程:

  1. 通过数据库
  2. 通过服务调用
  3. 通过异步消息传递

数据库中的数据流

在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。

服务中的数据流: REST 与 RPC

有两种流行的 Web 服务方法: REST 和 SOAP。他们在哲学方面几乎是截然相反的。
REST 不是一个协议,而是一个基于 HTTP 原则的设计哲学。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制,身份验证和内容类型协商。根据 REST 原则设计的 API 称为 RESTful。
相比之下,SOAP 是用于制作网络 API 请求的基于 XML 的协议。

RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同。在上面提到的所有编码的基础上构建了各种 RPC 框架: 例如,Thrift 和 Avro 带有 RPC 支持,gRPC 是使用 Protocol Buffers 的 RPC 实现。

使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是,RESTful API 还有其他一些显著的优点: 方便实验和调试。由于这些原因,REST 似乎是公共 API 的主要风格。 RPC 框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。

消息传递中的数据流

最后,我们简要介绍一下 RPC 和数据库之间的异步消息传递系统。与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
与 RPC 相比,差异在于消息传递通信通常是单向的: 发送者通常不期望收到其消息的回复。

通常情况下,消息代理的使用方式如下: 一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。

消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。