第5章 编码与演化

万物皆流,无物常驻。
——赫拉克利特(以弗所),引自柏拉图《克拉底鲁篇》(公元前360年)

应用程序不可避免地会随时间变化。随着新产品的发布、用户需求更清晰地被理解或业务环境的变化,功能会被添加或修改。在第2章中,我们引入了可演化性的概念:我们应该致力于构建易于适应变化的系统(参见第55页的“可演化性:让变更变得简单”)。

在大多数情况下,应用功能的变更也要求其所存储的数据发生变更。可能需要捕获新的字段或记录类型,或者需要以新的方式呈现现有数据。

我们在第3章中讨论的数据模型有不同的方式来应对这类变化。关系型数据库通常假设数据库中的所有数据都符合一个模式(schema)。虽然模式可以通过模式迁移(即ALTER语句)进行更改,但在任何时间点,只有一个模式生效。相比之下,读时模式(“无模式”)数据库并不强制模式,因此数据库可以包含在不同时间写入的新旧数据格式的混合体(参见第80页的“文档模型中的模式灵活性”)。

当数据格式或模式发生变化时,通常也需要相应地对应用程序代码进行更改(例如,你向记录中添加了一个新字段,应用程序代码开始读取和写入该字段)。然而,在大型应用程序中,代码变更通常无法瞬间完成,原因有多种。例如:

  • 服务器端应用:你可能希望执行滚动升级(也称为分阶段部署),一次只将新版本部署到少数几个节点上,监控其运行是否平稳,然后逐步推进到所有节点。这允许新版本在没有服务停机的情况下部署,从而鼓励更频繁的发布和更好的可演化性。
  • 客户端应用:你将受制于用户,用户可能不会立即安装更新。

这意味着旧版本和新版本的代码,以及旧版本和新版本的数据格式,有可能同时共存于系统中。为使系统继续平稳运行,你需要保持两个方向的兼容性:

  • 向后兼容性:确保新代码可以读取旧代码写入的数据。
  • 向前兼容性:确保旧代码可以读取新代码写入的数据。

在API的上下文中,若要让旧客户端能够成功调用新服务,则需要请求的向后兼容性和响应的向前兼容性。若要让新客户端调用旧服务,则需要请求的向前兼容性和响应的向后兼容性。

向后兼容性通常不难实现。作为新代码的作者,你知道旧代码写入的数据格式,因此你可以显式地处理它(如果需要,只需保留旧代码来读取旧数据)。向前兼容性可能更棘手,因为它要求旧代码忽略新版本代码所做的添加。

向前兼容性的另一个挑战如图5-1所示。假设你向记录模式添加了一个字段,新代码创建了一个包含该新字段的记录并将其存储在数据库中。随后,旧版本的代码(尚不知道新字段)读取该记录,更新它,并写回。在这种情况下,期望的行为通常是旧代码保持新字段不变,即使它无法解释该字段。但是,如果记录被解码成一个不显式保留未知字段的模型对象,数据可能会丢失,如图所示。

![图5-1:当旧版本应用更新由新版本应用之前写入的数据时,如果不小心,数据可能丢失。]

在本章中,我们将探讨几种数据编码格式,包括JSON、XML、Protocol Buffers和Avro。特别地,我们将研究它们如何处理模式变更,以及它们如何支持需要新旧数据和代码共存系统。然后,我们将讨论这些格式如何用于数据存储和通信:在数据库、Web服务、REST API、远程过程调用(RPC)、工作流引擎以及事件驱动系统(如Actor和消息队列)中。

数据编码的格式

程序通常以(至少)两种表示形式处理数据:

  • 内存中:数据以对象、结构体、列表、数组、哈希表、树等形式保存。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)。
  • 写入文件或通过网络发送时:必须将数据编码为某种自包含的字节序列(例如,JSON文档)。由于指针对其他进程没有意义,这种字节序列的表示形式通常与内存中常用的数据结构大相径庭。

因此,我们需要在这两种表示形式之间进行某种转换。从内存表示到字节序列的转换称为编码(也称为序列化或编组),反向过程称为解码(也称为解析、反序列化或解编组)。

术语冲突

不幸的是,术语“序列化”也在事务的上下文中使用(见第8章),其含义完全不同。为了避免该词的过载,本书将坚持使用“编码”,尽管“序列化”可能更为常见。

有时编码/解码并非必需——例如,当数据库直接操作从磁盘加载的压缩数据时,如第142页的“查询执行:编译与向量化”所述。此外,还有零拷贝数据格式,例如Cap’n Proto和FlatBuffers,它们旨在既用于运行时,也用于磁盘/网络,而无需显式的转换步骤。

然而,大多数系统需要在内存对象和扁平字节序列之间进行转换。由于这是一个如此普遍的问题,有大量的库和编码格式可供选择。让我们做一个简要的概述。

语言专用格式

许多编程语言内置了对内存对象编码为字节序列的支持。例如,Java有java.io.Serializable,Python有pickle,Ruby有Marshal。还有许多第三方库,例如Java的Kryo。

这些编码库非常方便,因为它们允许以最少的额外代码保存和恢复内存对象。然而,它们也存在几个深层问题:

  • 编码通常依赖于特定编程语言,用其他语言读取数据很困难。如果你以这种编码存储或传输数据,你可能在很长一段时间内被限制在当前编程语言中,并且无法将你的系统与其他组织(可能使用不同语言)的系统集成。
  • 为了将数据恢复为相同的对象类型,解码过程需要能够实例化任意类。这常常是安全问题的根源[1];如果攻击者能让你的应用程序解码一个任意的字节序列,他们就可以实例化任意类,而这往往允许他们做可怕的事情,例如远程执行任意代码[2, 3]。
  • 数据版本化在这些库中通常是事后才考虑的问题。由于它们旨在快速简便地编码数据,它们常常忽略了向前和向后兼容性这些棘手的问题[4]。
  • 效率(编码或解码所用的CPU时间,以及编码结构的大小)也常常是事后才考虑的问题。例如,Java内置的序列化因其性能差和编码臃肿而臭名昭著[5]。
  • 因此,除非用于非常短暂的目的,否则使用语言内置的编码通常是个坏主意。

JSON、XML 及其二进制变体

当转向可由多种编程语言读写且标准化编码格式时,JSON 和 XML 是显而易见的竞争者:它们广为人知且广泛支持。CSV 是另一种流行的语言无关格式,但它仅支持无嵌套的表格数据。

JSON、XML 和 CSV 都是文本格式,因此在一定程度上人类可读——尽管其语法常是争论的话题。除了这些表面的语法问题外,它们还存在其他各种问题:

  • XML 常因过于冗长和不必要的复杂性而受到批评[6]。
  • 数字编码存在大量歧义。在 XML 和 CSV 中,你无法区分一个数字和一个恰好由数字组成的字符串(除非引用外部模式)。JSON 区分字符串和数字,但它不区分整数和浮点数,也不指定精度。
    • 当处理大数字时这会成为问题——例如,大于2^53的整数无法在 IEEE 754 双精度浮点数中精确表示,因此在像 JavaScript 这样使用浮点数的语言中解析时,这些数字会变得不准确[7]。大于2^53的数字的一个例子出现在 X(推特)上,它使用64位数字来标识每条帖子。该 API 返回的 JSON 包含两次帖子 ID,一次作为 JSON 数字,另一次作为十进制字符串,以解决 JavaScript 应用对数字解析不正确的问题[8]。
  • JSON 和 XML 对 Unicode 字符串(即可读文本)支持良好,但不支持二进制字符串(无字符编码的字节序列)。二进制字符串是一个有用的特性,因此人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。然后模式用于指示该值应被解释为 Base64 编码。这可行,但有些取巧,且会使数据大小增加约三分之一。
  • XML Schema 和 JSON Schema 功能强大,因此学习和实现起来相当复杂。由于数据的正确解释(例如数字和二进制字符串)依赖于模式中的信息,不使用 XML/JSON Schema 的应用可能需硬编码相应的编码/解码逻辑。
  • CSV 没有任何模式,因此由应用来定义每行和每列的含义。如果应用变更添加了新行或新列,你必须手动处理该变更。CSV 也是一种相当模糊的格式(如果值包含逗号或换行符会怎样?)。尽管其转义规则已正式规定[9],但并非所有解析器都能正确实现它们。

尽管存在这些缺陷,JSON、XML 和 CSV 对于许多目的来说已经足够好了。它们很可能仍然流行,尤其是作为数据交换格式(即用于在组织之间发送数据)。在这些情况下,只要人们就格式达成一致,它通常就不那么重要。让不同组织就任何事情达成一致的困难,超过了大多数其他关注点。

JSON Schema

每当数据在系统之间交换或写入存储时,JSON Schema 已被广泛采用作为数据建模方式。你会在 Web 服务(参见第181页的“Web 服务”)中作为 OpenAPI Web 服务规范的一部分、在模式注册表(例如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry)中,以及在数据库中(例如 PostgreSQL 的 pg_jsonschema 验证器扩展和 MongoDB 的 $jsonSchema 验证器语法)找到 JSON Schema。

JSON Schema 规范提供了许多功能。模式包括标准原始类型,如 stringnumberintegerobjectarraybooleannull。但 JSON Schema 还提供了单独的验证规范,允许开发者在字段上叠加约束。例如,端口字段可能有最小值1和最大值65535。

JSON Schema 可以具有开放或封闭的内容模型。开放内容模型允许模式中未定义的任何字段以任何数据类型存在,而封闭内容模型仅允许显式定义的字段。当 additionalProperties 设置为 true(默认值)时,启用 JSON Schema 中的开放内容模型。因此,JSON Schema 通常定义了不被允许的内容(即任何已定义字段上的无效值),而不是允许的内容。

开放内容模型功能强大,但也可能很复杂。例如,假设你想定义一个从整数(例如 ID)到字符串的映射。JSON 没有允许整数键的映射或字典类型;JSON 对象始终使用字符串作为键。为满足你的需求,你可以用 JSON Schema 约束此类型,以便键只能包含数字,值只能为字符串,使用 patternPropertiesadditionalProperties,如示例 5-1 所示。

示例 5-1. 一个具有整数键和字符串值的 JSON Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "patternProperties": {
    "^[0-9]+$": {
      "type": "string"
    }
  },
  "additionalProperties": false
}

除了开放/封闭内容模型和验证器外,JSON Schema 还支持条件 if/else 模式逻辑、命名类型、对远程模式的引用等等。所有这些构成了一种非常强大的模式语言。这些特性也导致了笨拙的定义。解析远程模式、推理条件规则或以向前或向后兼容的方式演化模式可能具有挑战性[10, 11]。类似的问题也适用于 XML Schema[12]。

二进制编码

JSON 比 XML 更简洁,但与二进制格式相比,两者仍占用大量空间。这一观察导致了大量 JSON 二进制编码的开发(例如 MessagePack、CBOR、BSON、BJSON、UBJSON、BISON、Hessian 和 Smile),以及 XML 二进制编码(例如 WBXML 和 Fast Infoset)。这些格式已在各种细分领域被采用,因为它们更紧凑,有时解析速度更快,但没有任何一种像文本版本的 JSON 和 XML 那样被广泛采用[13]。

其中一些格式扩展了数据类型集(例如区分整数和浮点数,或添加对二进制字符串的支持),但除此之外,它们保持了 JSON/XML 数据模型不变。特别地,由于它们并未规定模式,因此需要在编码数据中包含所有对象字段名。也就是说,在对示例 5-2 中 JSON 文档进行二进制编码时,它们需要在某处包含字符串 userNamefavoriteNumberinterests

示例 5-2. 将在本章中用于多种二进制格式编码的一条记录

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

让我们来看一个 MessagePack 的例子,它是 JSON 的一种二进制编码。图 5-2 显示了如果将示例 5-2 中的 JSON 文档编码为 MessagePack 后得到的字节序列。

图 5-2. 使用 MessagePack 编码的记录(示例 5-2)

(图片:展示 MessagePack 编码的字节序列。描述:第一个字节 0x83 表示一个包含三个字段的对象;第二个字节 0xa8 表示一个8字节长的字符串;接下来是字段名 userName 的 ASCII 字节;然后是值 Martin 的编码等等。总长度为 66 字节。)

前几个字节如下:

  1. 第一个字节 0x83 表示接下来的内容是对象(最高四位 = 0x80),包含三个字段(最低四位 = 0x03)。(如果你想知道如果一个对象有超过15个字段会怎样——以至于字段数量不适合四位——那么它会得到一个不同的类型指示符,字段数量用两个或四个字节编码。)
  2. 第二个字节 0xa8 表示接下来的内容是字符串(最高四位 = 0xa0),长度为8字节(最低四位 = 0x08)。
  3. 接下来的八个字节是字段名 userName 的 ASCII 表示。由于之前已经指定了长度,因此无需任何标记来指示字符串结束(也无需转义)。
  4. 接下来的七个字节以前缀 0xa6 编码六字母字符串值 Martin,以此类推。

二进制编码长度为 66 字节,仅比文本 JSON 编码(去除空白后为 81 字节)略少。所有 JSON 的二进制编码在这方面都类似。不清楚这样小的空间缩减(以及可能的解析加速)是否值得牺牲人类可读性。

在接下来的章节中,我们将看到如何做得更好,将同样的记录编码成一半大小的字节数。

Protocol Buffers

Protocol Buffers(protobuf)是 Google 开发的一种二进制编码库。它与 Apache Thrift 类似(最初由 Facebook 开发[14]);本节关于 Protocol Buffers 的大部分内容也适用于 Thrift。

Protocol Buffers 要求任何要编码的数据都必须有一个模式。要对示例 5-2 中的数据进行编码,你需要在 Protocol Buffers 接口定义语言(IDL)中描述该模式,如下所示:

syntax = "proto3";
 
message Person {
    string user_name = 1;
    int64 favorite_number = 2;
    repeated string interests = 3;
}

Protocol Buffers 附带一个代码生成工具,它接受像此处展示的模式定义,并在各种编程语言中生成实现该模式的类。你的应用代码可以调用这些生成的代码来编码或解码符合模式的记录。与 JSON Schema 相比,模式语言非常简单;它定义了每条记录的字段及其类型,但不支持对字段可能值的其他限制。

使用 Protocol Buffers 编码器对示例 5-2 进行编码需要 33 字节,如图 5-3 所示[15]。与图 5-2 一样,每个字段都有一个类型注释(指示它是字符串、整数等),并在需要时带有长度指示(例如字符串的长度)。数据中出现的字符串(Martindaydreaminghacking)像之前一样以 ASCII(确切地说是 UTF-8)编码。

图 5-3. 使用 Protocol Buffers 编码的记录

(图片:展示 Protocol Buffers 编码的字节序列。描述:使用字段标签(1、2、3)代替字段名;字段类型和标签号被打包到一个字节中;整数 1337 用变长整数编码为两个字节;重复字段通过同一字段标签的多次出现来表示。总长度为 33 字节。)

与图 5-2 不同,此示例没有字段名(userNamefavoriteNumberinterests)。相反,编码数据包含字段标签,它们是数字(1、2 和 3)。这些是模式定义中出现的数字。字段标签就像字段的别名——它们是一种紧凑的方式来表示我们正在谈论的字段,而不必拼出字段名。

如你所见,Protocol Buffers 通过将字段类型和标签号打包到一个字节中,进一步节省了空间。它使用可变长整数:数字 1337 编码为两个字节,每个字节的最高位用于指示是否还有更多字节(最低有效七位存储在第一个字节中,以简化在读取字节时重建整数)。这意味着 –64 到 63 之间的数字编码为一个字节,–8,192 到 8,191 之间的数字编码为两个字节,以此类推。更大的数字使用更多字节。

Protocol Buffers 没有显式的列表或数组数据类型。相反,interests 字段上的 repeated 修饰符指示该字段包含一个值列表而不是单个值。在二进制编码中,列表元素简单地表示为同一字段标签在同一记录内的重复出现。

字段标签与模式演化

我们之前提到,模式不可避免地会随时间变化。我们将此称为模式演化。Protocol Buffers 如何在保持向后兼容和向前兼容的同时处理模式变化?

从示例中可以看出,一条编码记录只是其编码字段的简单拼接。每个字段由其标签号(示例 schema 中的数字 1、2、3)标识,并带有数据类型注释(例如 string 或 integer)。如果未设置某个字段的值,则直接在编码记录中省略该字段。由此可见,字段标签对编码数据的含义至关重要。你可以更改 schema 中字段的名称(因为编码数据从不引用字段名),但你不能更改字段的标签,否则所有现有编码数据都会失效。

你可以向 schema 添加新字段,前提是为每个字段分配一个新的标签号。如果旧代码(不知道你添加的新标签号)尝试读取新代码写入的数据,遇到带有它不认识的标签号的新字段时,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过多少字节以保留未知字段,从而避免图 5-1 所示的问题。这保持了向前兼容:旧代码可以读取由新代码写入的记录。

向后兼容性如何?只要每个字段具有唯一的标签号,新代码总是可以读取旧数据,因为标签号仍然具有相同的含义。如果在新的 schema 中添加了一个字段,而读取的旧数据中尚不包含该字段,则会用默认值填充(例如,如果字段类型是 string 则为空字符串,如果是数字则为 0)。

删除字段类似于添加字段,但向后兼容和向前兼容的关注点相反。你永远不能再使用相同的标签号,因为可能仍有一些数据写在某处包含旧标签号,而新代码必须忽略该字段。过去使用过的标签号可以在 schema 定义中保留,以确保不会被遗忘。

更改字段的数据类型呢?某些类型是可能的——请查看文档了解详情——但存在值被截断的风险。例如,假设你将一个 32 位整数改为 64 位整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用 0 填充任何缺失的位。但是,如果旧代码读取新代码写入的数据,旧代码仍使用 32 位变量来保存该值。如果解码后的 64 位值不适合 32 位,它将被截断。

Avro

Apache Avro 是另一种二进制编码格式,与 Protocol Buffers 相比有一些有趣的差异。它于 2009 年作为 Hadoop 的一个子项目启动,原因是 Protocol Buffers 不适合 Hadoop 的用例 [16]。

Avro 也使用 schema 来指定要编码的数据的结构。它有两种 schema 语言:一种(Avro IDL)专为人工编辑设计,另一种(基于 JSON)更容易机器读取。与 Protocol Buffers 一样,schema 语言只指定字段及其类型,不支持像 JSON Schema 那样的复杂验证规则。

用 Avro IDL 编写的示例 schema 可能如下所示:

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

该 schema 的等效 JSON 表示如下:

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

注意该 schema 没有标签号。如果我们使用这个 schema 对示例 5-2 的记录进行编码,Avro 二进制编码仅 32 字节——是我们见过的所有编码中最紧凑的。编码字节序列的分解如图 5-4 所示。

如果你检查字节序列,你会发现没有任何东西标识字段或其数据类型。编码只是将值连接在一起。字符串只是一个长度前缀后跟 UTF-8 字节,但编码数据中没有告诉你它是一个字符串。它同样可以是一个整数或其他完全不同的东西。整数使用可变长度编码。

要解析二进制数据,你按照 schema 中字段出现的顺序遍历字段,并使用 schema 来确定每个字段的数据类型。这意味着,只有当读取数据的代码使用与写入数据的代码完全相同的 schema 时,才能正确解码二进制数据。读取器和写入器之间的 schema 任何不匹配都将导致错误解码的数据。

图 5-4. 使用 Avro 编码的我们的记录

写入器模式与读取器模式

当应用程序想要编码一些数据(写入文件或数据库、通过网络发送等)时,它使用它所知道的任何版本的 schema——例如,编译到应用程序中的 schema。这称为写入器模式

为了解码某些数据(从文件或数据库读取、从网络接收等),应用程序使用两个 schema:写入器模式(与编码时使用的相同)和读取器模式(可能不同),如图 5-5 所示。读取器模式定义了应用程序代码期望的每个记录的字段及其类型。

如果读取器和写入器模式相同,解码就很容易。如果它们不同,Avro 通过比较两者并将数据从写入器模式转换为读取器模式来解决差异。

图 5-5. 在 Protocol Buffers 中,编码和解码可以使用不同版本的 schema。在 Avro 中,解码使用两个 schema:写入器模式必须与编码时使用的相同,但读取器模式可以是更旧或更新的版本。

Avro 规范 [17, 18] 精确定义了这种解析如何工作。如图 5-6 所示,如果写入器模式和读取器模式中的字段顺序不同也没有问题,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到了写入器模式中存在但读取器模式中没有的字段,则忽略它。如果读取数据的代码期望某个字段,但写入器模式中没有该名称的字段,则用读取器模式中声明的默认值填充。

图 5-6. Avro 读取器解析写入器模式和读取器模式之间的差异

模式演化规则

在 Avro 中,向前兼容意味着写入器可以使用比读取器更新的 schema 版本。相反,向后兼容意味着写入器可以使用比读取器更旧的 schema 版本。

为了保持兼容性,你只能添加或删除具有默认值的字段(如我们 Avro schema 中的字段 favoriteNumber)。例如,假设你添加了一个带默认值的字段,因此这个新字段存在于新 schema 中但不存在于旧 schema 中。当使用新 schema 的读取器读取用旧 schema 写入的记录时,缺失的字段会用默认值填充。

如果你添加一个没有默认值的字段,新读取器将无法读取旧写入器写入的数据,从而破坏向后兼容性。如果你删除一个没有默认值的字段,旧读取器将无法读取新写入器写入的数据,从而破坏向前兼容性。

在某些编程语言中,null 是任何变量的可接受默认值,但 Avro 中并非如此:如果你希望允许字段为 null,你必须使用联合类型。例如,union { null, long, string } field; 表示 field 可以是数字、字符串或 null。只有当 null 是联合的第一个分支时,你才能使用 null 作为默认值。这比默认所有内容都可空要冗长一些,但通过明确什么可以为 null、什么不可以,有助于防止错误 [19]。

更改字段的数据类型是可能的,前提是 Avro 能够转换该类型。更改字段的名称也是可能的,但稍微有点棘手。读取器模式可以为字段名称包含别名,因此它可以匹配旧写入器模式字段名与别名。这意味着更改字段名是向后兼容的,但不是向前兼容的。类似地,向联合类型添加一个分支是向后兼容的,但不是向前兼容的。

但写入器模式是什么?

我们忽略了一个重要问题:读取器如何知道用于编码特定数据的 schema?我们不能在每个记录中都包含整个 schema,因为 schema 可能比编码数据大得多,从而抵消二进制编码的所有空间节省。

答案取决于使用 Avro 的上下文。举几个例子:

包含大量记录的大文件 Avro 的一个常见用途是存储包含数百万条记录的大文件,所有这些记录都使用相同的 schema 编码。(我们将在第 10 章讨论这种情况。)

Avro 的使用上下文

答案取决于 Avro 的使用场景。以下是一些示例:

包含大量记录的大型文件

Avro 的一个常见用途是存储包含数百万条记录的大型文件,所有记录均使用相同的模式进行编码。(我们将在第 11 章讨论这种情况。)在这种情况下,该文件的写入者只需在文件开头包含一次模式即可。Avro 为此指定了一种文件格式(对象容器文件)。

包含独立写入记录的数据库

在数据库中,不同的记录可能在不同的时间点使用不同的模式写入——你不能假设所有记录都具有相同的模式。这种情况下最简单的解决方案是在每条编码记录的开头包含一个版本号,并在数据库中维护一个模式版本列表。读取者可以获取一条记录,提取版本号,然后从数据库中获取与该版本号对应的写入者模式。接着,它可以使用该模式解码记录的其余部分。例如,Confluent 的 Apache Kafka 模式注册表 [20] 和 LinkedIn 的 Espresso [21] 就是这样工作的。

通过网络连接发送记录

当两个进程通过双向网络连接进行通信时,它们可以在连接建立时协商模式版本,然后在该连接的整个生命周期中使用该模式。Avro RPC 协议(参见第 180 页的“通过服务的数据流:REST 和 RPC”)就是这样工作的。

TIP

在任何情况下,维护一个模式版本数据库都是有用的,因为它可以作为文档,并让你有机会检查模式兼容性 [22]。你可以使用一个简单的递增整数或模式的哈希值作为版本号。

动态生成的模式

与 Protocol Buffers 相比,Avro 方法的一个优势是模式不包含任何标签号。但为什么这很重要?在模式中保留几个数字有什么问题?

区别在于 Avro 对动态生成的模式更友好。例如,假设你有一个关系数据库,你想将其内容转储到文件中,并且希望使用二进制格式以避免前面提到的文本格式(JSON、CSV、XML)的问题。如果你使用 Avro,你可以相当容易地从关系模式生成 Avro 模式(采用我们之前看到的 JSON 表示形式),然后使用该模式对数据库内容进行编码,并将其全部转储到 Avro 对象容器文件中 [23]。你可以为每个数据库表生成一个记录模式,每个列成为该记录中的一个字段。数据库中的列名映射到 Avro 中的字段名。

现在,如果数据库模式发生变化(例如,一个表添加了一列并删除了一列),你可以仅从更新后的数据库模式生成新的 Avro 模式,并使用新的 Avro 模式导出数据。数据导出过程无需关注模式变化——它只需在每次运行时进行模式转换即可。任何读取新数据文件的人都会看到记录的字段发生了变化,但由于字段是通过名称标识的,更新后的写入者模式仍然可以与旧的读取者模式匹配。

相比之下,如果你为此目的使用 Protocol Buffers,字段标签很可能需要手动分配。每次数据库模式发生变化时,管理员都必须手动更新从数据库列名到字段标签的映射。(或许可以自动化此过程,但模式生成器必须非常小心,不要使用先前已使用的字段标签。)这种动态生成的模式本来就不是 Protocol Buffers 的设计目标,而 Avro 则是为此设计的。

模式的优点

如我们所见,Protocol Buffers 和 Avro 都使用模式来描述二进制编码格式。它们的模式语言比 XML Schema 或 JSON Schema 简单得多,后者支持更详细的验证规则(例如,“此字段的字符串值必须匹配此正则表达式”或“此字段的整数值必须在 0 到 100 之间”)。由于 Protocol Buffers 和 Avro 更易于实现和使用,它们获得了相当广泛的编程语言支持。

这些编码所基于的思想绝非新事物。例如,它们与 ASN.1 有很多共同之处,ASN.1 是一种模式定义语言,最早于 1984 年标准化 [24, 25]。它被用于定义各种网络协议,其二进制编码(DER)至今仍用于编码 SSL 证书(X.509),例如 [26]。ASN.1 使用标签号支持模式演化,类似于 Protocol Buffers [27]。然而,它也非常复杂且文档不全,因此 ASN.1 可能不是新应用的好选择。

许多数据系统也为它们的数据实现了某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,你可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于某个数据库,数据库供应商提供一个驱动程序(例如,使用 ODBC 或 JDBC API),该驱动程序将数据库网络协议中的响应解码为内存中的数据结构。

因此,我们可以看到,尽管 JSON、XML 和 CSV 等文本数据格式很普遍,但基于模式的二进制编码也是一个可行的选择。它们具有许多优点:

  • 它们可以比各种“二进制 JSON”变体更紧凑,因为它们可以从编码数据中省略字段名。
  • 模式是文档的一种有价值的形式,并且由于解码需要模式,你可以确保它是最新的(而手动维护的文档可能很容易与现实脱节)。
  • 维护模式数据库允许你在部署任何内容之前检查模式更改的前向和后向兼容性
  • 对于使用静态类型编程语言的用户来说,从模式生成代码的能力很有用,因为它可以在编译时进行类型检查。

SUMMARY

总之,模式演化允许与无模式/读取时模式 JSON 数据库(参见第 80 页的“文档模型中的模式灵活性”)相同类型的灵活性,同时为你的数据提供更好的保证和更好的工具化。尽管如此,建议将并发模式格式的数量保持在最低限度,以保持操作简单。

数据流模式

在本章开始时,我们说过,每当你想要将某些数据发送到另一个不共享内存的进程时——例如,当你想要通过网络发送数据或将其写入文件时——你需要将其编码为字节序列。然后我们讨论了用于此目的的各种编码。

我们讨论了前向和后向兼容性,这对于可演化性(通过允许你独立升级系统的一部分而不是一次更改所有内容来使更改变得容易)很重要。兼容性是编码数据的一个进程与解码数据的另一个进程之间的关系。

这是一个相当抽象的概念,因为数据可以通过多种方式从一个进程流向另一个进程。谁编码数据,谁解码数据?本章的剩余部分将探讨数据在进程之间流动的一些最常见方式,包括通过数据库、服务调用、工作流引擎和异步消息。

通过数据库的数据流

在数据库中,执行写入的进程对数据进行编码,执行读取的进程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,读取者只是同一进程的更新版本;在这种场景下,你可以将数据存储在数据库中视为向未来的自己发送消息。

WARNING

后向兼容性在这里显然是必需的,否则你未来的自己将无法解码你先前写入的内容。

不过,通常情况下,多个进程同时访问数据库是很常见的。这些进程可能是不同的应用程序或服务,也可能是同一服务的多个实例(为了可伸缩性或容错而并行运行)。无论哪种方式,在这种环境中,很可能一些访问数据库的进程正在运行较新的代码,而另一些正在运行较旧的代码——例如,在新版本进行滚动升级部署时,部分实例已更新,而其他实例尚未更新。

这意味着数据库中的一个值可能由新版本的代码写入,随后被仍在运行的旧版本代码读取。因此,数据库通常也需要前向兼容性。

不同时间写入的不同值

数据库通常允许在任何时间更新任何值。在同一个数据库中,你可能有一些值是5毫秒前写入的,另一些则是5年前写入的。

当你部署新版本的应用程序(至少是服务端应用)时,你可能会在几分钟内完全用新版本替换旧版本。但对于数据库内容来说,情况并非如此;5年前的数据仍然存在,并以原始编码存储,除非你在此之后显式地重写了它。这种观察有时被总结为数据比代码存活得更久

虽然将数据重写(迁移)为新模式是可能的,但对于大型数据集来说成本很高。因此,大多数数据库会推迟该操作,以异步和尽力而为的方式执行。例如,LSM树存储引擎(参见第118页的“日志结构存储”)会在压缩过程中使用最新格式重写数据。大多数关系数据库也允许简单的模式变更,例如添加一个具有空默认值的新列,而无需重写现有数据。当读取旧行时,数据库会为磁盘上编码数据中缺失的任何列填充空值。因此,模式演化使得整个数据库看起来像是用单一模式编码的,即使底层存储可能包含用模式的各种历史版本编码的记录。

更复杂的模式变更——例如,将单值属性改为多值,或将一些数据移动到单独的表中——仍然需要重写数据,通常是在应用层进行[28]。在此类迁移过程中保持前向和后向兼容性仍然是一个研究问题[29]。

归档存储

也许你会不时地获取数据库的快照——例如,用于备份或加载到数据仓库(参见第7页的“数据仓库”)。在这种情况下,数据转储通常使用最新的模式编码,即使源数据库中的原始编码包含来自不同时代的模式混合。由于你无论如何都在复制数据,你最好一致地编码数据的副本。

由于数据转储是一次性写入且之后不可变,像Avro对象容器文件这样的格式非常适合。这也是将数据编码为分析友好的列式格式(如Parquet)的好机会(参见第139页的“列压缩”)。

在第11章中,我们将更多地讨论在归档存储中使用数据。

通过服务的数据流:REST和RPC

当进程需要通过网络进行通信时,你可以通过几种方式安排这种通信。最常见的安排是拥有两种角色:客户端服务器。服务器通过网络暴露一个API,客户端可以连接到服务器以向该API发出请求。服务器暴露的API被称为服务

网络就是如此运作的:客户端(网络浏览器)向网络服务器发出请求,发送GET请求以下载HTML、CSS、JavaScript、图像等,并发送POST请求以向服务器提交数据。该API由一套标准化的协议和数据格式(HTTP、URL、SSL/TLS、HTML等)组成。由于网络浏览器、网络服务器和网站作者基本上都同意这些标准,你可以使用任何网络浏览器访问任何网站(至少在理论上是这样!)。

网络浏览器并不是唯一类型的客户端。例如,运行在移动设备和台式电脑上的原生应用通常与服务器通信,而在网络浏览器内运行的客户端JavaScript应用也可以发出HTTP请求。在这种情况下,服务器的响应通常不是用于人类显示的HTML,而是便于客户端应用代码进一步处理的数据(最常见的是JSON)。尽管HTTP可能被用作传输协议,但在此基础上实现的API是特定于应用的,客户端和服务器需要就该API的细节达成一致。

在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。然而,虽然数据库允许使用我们在第3章讨论的查询语言进行任意查询,但服务暴露的是应用特定的API,仅允许由服务的业务逻辑(应用代码)预定的输入和输出[30]。这种限制提供了一定程度的封装:服务可以对客户端能做什么和不能做什么施加细粒度的限制。

面向服务/微服务架构的一个关键设计目标是使应用更易于更改和维护,通过使服务能够独立部署和演化。一个常见原则是每个服务应由一个团队拥有,并且该团队应该能够频繁发布新版本的服务,而无需与其他团队协调。因此,我们应该预期服务器和客户端的新旧版本同时运行,因此服务器和客户端使用的数据编码必须在服务API的各个版本之间保持兼容。只要API保持兼容,团队就可以自由地以任何方式修改他们的系统;这一特性使开发人员更容易对数据、服务甚至整个系统进行内部迁移。

网络服务

当HTTP被用作与服务通信的底层协议时,它被称为网络服务。网络服务通常用于构建面向服务或微服务架构(前面在第21页的“微服务和无服务器”中讨论过)。这个术语可能有点用词不当,因为网络服务不仅在网络上使用,也在多种上下文中使用。例如:

  • 运行在用户设备上的客户端应用(例如,移动设备上的原生应用,或浏览器中的JavaScript网络应用)通过HTTP向服务发出请求。这些请求通常通过公共互联网传输。
  • 一个服务向同一组织拥有的另一个服务发出请求,通常位于同一私有网络内,作为面向服务/微服务架构的一部分。
  • 一个服务向不同组织拥有的服务发出请求,通常通过互联网。这用于组织后端系统之间的数据交换。此类别包括在线服务提供的公共API,例如信用卡处理系统,或用于共享用户数据访问的OAuth。

最流行的服务设计理念是REST,它建立在HTTP的原则之上[31, 32]。REST强调简单的数据格式,使用URL标识资源,并利用HTTP特性进行缓存控制、认证和内容协商。根据REST原则设计的API被称为RESTful

需要调用网络服务API的代码必须知道查询哪个HTTP端点,以及发送和期望接收哪种数据格式。即使服务采用RESTful设计原则,客户端也需要以某种方式找出这些细节。服务开发者通常使用IDL来定义和记录其服务的API端点和数据模型,并随着时间的推移演化它们。其他开发者随后可以使用该服务定义来确定如何查询服务。两种最流行的服务IDL是OpenAPI(也称为Swagger[33]),用于发送和接收JSON的网络服务,以及Protocol Buffers,用于gRPC服务。

开发者通常用JSON或YAML编写OpenAPI服务定义(参见示例5-3)。该服务定义允许开发者定义服务端点、文档、版本、数据模型等等。Protocol Buffers服务定义使用我们在第169页的“Protocol Buffers”中看到的IDL。

示例5-3. OpenAPI服务定义(YAML格式)

openapi: 3.0.0
info:
  title: Ping, Pong
  version: 1.0.0
servers:
  - url: http://localhost:8080
paths:
  /ping:
    get:
      summary: Given a ping, returns a pong message
      responses:
        '200':
          description: A pong
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Pong!

即使采用了设计理念和IDL,开发者仍然需要编写实现其服务API调用的代码。通常会采用一个服务框架,例如Spring Boot、FastAPI或gRPC,以简化这项工作。服务框架允许开发者专注于为每个API端点编写业务逻辑,而框架代码处理路由、指标、缓存、认证等。

示例5-4展示了示例5-3中定义的服务的一个Python实现。

示例5-4. 实现示例5-3定义的FastAPI服务

from fastapi import FastAPI
from pydantic import BaseModel
 
app = FastAPI(title="Ping, Pong", version="1.0.0")
 
class PongResponse(BaseModel):
    message: str = "Pong!"
 
@app.get("/ping", response_model=PongResponse,
         summary="Given a ping, returns a pong message")
async def ping():
    return PongResponse()

许多框架将服务定义和服务器代码耦合在一起。在某些情况下,例如流行的Python FastAPI框架,服务器是用代码编写的,而IDL是自动生成的。在其他情况下,例如gRPC,服务定义

数据流模式

远程过程调用(RPC)的问题

Web服务只是多年来一系列通过网络进行API请求的技术的最新体现,其中许多技术曾备受炒作,但却存在严重问题。Enterprise JavaBeans (EJB) 和 Java 的远程方法调用 (RMI) 仅限于 Java。分布式组件对象模型 (DCOM) 仅限于 Microsoft 平台。通用对象请求代理架构 (CORBA) 过于复杂,且不提供向后或向前兼容性 [34]。SOAP 和 WS-* Web 服务框架旨在提供跨供应商的互操作性,但也因复杂性和兼容性问题而饱受困扰 [35, 36, 37]。

所有这些都基于远程过程调用 (RPC) 的思想,该思想最早于 20 世纪 70 年代提出 [38]。RPC 模型试图使对远程网络服务的请求看起来与在同一进程中调用函数或方法相同(这种抽象称为位置透明性)。虽然这最初看起来很便捷,但这种方法存在根本性缺陷 [39, 40]。网络请求与本地函数调用有很大不同,原因如下:

  • 本地函数调用是可预测的,要么成功要么失败,这取决于你控制的参数。网络请求是不可预测的,原因完全不在你的控制范围内。例如,由于网络问题,请求或响应可能丢失,或者远程机器可能缓慢或不可用。网络问题很常见,因此应用程序必须提前考虑它们(例如,通过重试失败的请求)。
  • 本地函数调用要么返回结果,要么抛出异常,要么永不返回(因为进入无限循环或进程崩溃)。网络请求有另一种可能的结果:由于超时,它可能无结果返回。在这种情况下,你根本不知道发生了什么;如果你没有收到远程服务的响应,你无法知道请求是否已到达。(我们将在第9章更详细地讨论这个问题。)
  • 如果你重试失败的网络请求,前一个请求可能实际上已经通过,只是响应丢失了。在这种情况下,重试会导致操作被多次执行,除非你在协议中构建了去重(幂等性)机制 [41]。本地函数调用没有这个问题。(我们将在第12章更详细地讨论幂等性。)
  • 每次调用本地函数时,通常执行时间大致相同。网络请求比函数调用慢得多,而且其延迟变化极大:在好情况下可能不到一毫秒完成,但当网络拥堵或远程服务过载时,执行完全相同的事情可能需要很多秒。
  • 当你调用本地函数时,你可以高效地将引用(指针)传递给本地内存中的对象。当你发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列。如果参数是不可变的原始类型(如数字或短字符串)还好,但涉及大量数据和可变对象时,问题很快就会显现。
  • 客户端和服务可能用不同的编程语言实现,因此 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很糟糕,因为并非所有语言都有相同的类型——回想一下 JavaScript 在数字大于 2^53 时的问题(参见第165页的“JSON、XML 及其二进制变体”)。在单一语言编写的单一进程中不存在这个问题。

所有这些因素意味着,试图让远程服务看起来太像编程语言中的本地对象是没有意义的,因为它们本质上是不同的东西。REST 的部分吸引力在于它将网络上的状态转移视为一个独立于函数调用的过程。

负载均衡器、服务发现与服务网格

所有服务都通过网络进行通信。因此,客户端必须知道它要连接的服务地址——这个问题称为服务发现。最简单的方法是将客户端配置为连接服务运行的 IP 地址和端口。这种配置可以工作,但如果服务器离线、转移到新机器或过载,则必须手动重新配置客户端。

为了提供更高的可用性和可扩展性,通常会在多台机器上运行多个服务实例,其中任何一个都可以处理传入请求。将请求分散到这些实例上称为负载均衡 [42]。有许多负载均衡和服务发现解决方案可用:

  • 硬件负载均衡器:这些专用设备安装在数据中心中。它们允许客户端连接到一个主机和端口,传入的连接被路由到运行该服务的其中一个服务器。此类负载均衡器在连接到下游服务器时会检测网络故障,并将流量转移到其他服务器。

  • 软件负载均衡器(如 NGINX 和 HAProxy):它们的行为与硬件负载均衡器大致相同,但不需要专用设备,它们是可以在标准机器上安装的应用程序。

  • 域名服务 (DNS):这是当你打开网页时在互联网上解析域名的方式。它通过允许多个 IP 地址与单个域名相关联来支持负载均衡。然后可以将客户端配置为通过域名而不是 IP 地址连接到服务,客户端的网络层在建立连接时选择使用哪个 IP 地址。这种方法的一个缺点是 DNS 设计用于较长时间传播更改并缓存 DNS 条目。如果服务器频繁启动、停止或移动,客户端可能会看到过期的 IP 地址,而这些地址上不再有服务器运行。

  • 服务发现系统:这些系统使用集中式注册中心(例如 etcd 或 Apache ZooKeeper)而不是 DNS 来跟踪哪些服务端点可用(我们将在第437页的“协调服务”中回到这些系统)。当新的服务实例启动时,它通过声明其监听的主机和端口,以及相关的元数据(如分片所有权信息(参见第7章)、数据中心位置等)向服务发现系统注册自己。然后,该服务定期向发现系统发送心跳信号,以表明该服务仍然可用。

    当客户端希望连接到服务时,它首先查询发现系统以获取可用端点列表,然后直接连接到端点。与 DNS 相比,服务发现支持更动态的环境,其中服务实例频繁变化。发现系统还为客户端提供有关其连接到的服务的更多元数据,从而使客户端能够做出更智能的负载均衡决策。

  • 服务网格:这种复杂的负载均衡形式将软件负载均衡器和服务发现相结合。与运行在单独机器上的传统软件负载均衡器不同,服务网格负载均衡器通常作为进程内客户端库或作为客户端和服务器上的进程(或“边车”容器)部署。客户端应用程序连接到它们自己的本地服务负载均衡器,该负载均衡器连接到服务器的负载均衡器。从那里,连接被路由到本地服务器进程。

    虽然复杂,但这种拓扑结构具有优势。由于客户端和服务器完全通过本地连接路由,连接加密可以完全在负载均衡器级别处理。这使客户端和服务器不必处理 SSL 证书和 TLS 的复杂性。网格系统还提供复杂的可观测性。它们可以实时跟踪哪些服务正在相互调用、检测故障、跟踪流量负载等。

哪种解决方案合适取决于组织的需求。那些在非常动态的服务环境中运行,并配有像 Kubernetes 这样的编排器的组织,通常选择运行像 Istio 或 Linkerd 这样的服务网格。专门的 infrastructure(如数据库或消息系统)可能需要它们自己专用的负载均衡器。较简单的部署最好使用软件负载均衡器。

RPC 的数据编码与演化

对于可演化性而言,RPC 客户端和服务器能够独立更改和部署非常重要。与通过数据库流动的数据(如“通过数据库的数据流”第178页所述)相比,对于通过服务的数据流,我们可以做一个简化的假设:合理假设所有服务器将首先更新,然后所有客户端更新。因此,你只需要在请求上具有向后兼容性,在响应上具有向前兼容性。

RPC 方案的向后和向前兼容性属性继承自其所使用的任何编码:

  • gRPC (Protocol Buffers)Avro RPC 可以根据相应编码格式的兼容性规则进行演化。
  • RESTful API 最常用 JSON 作为响应格式,用 JSON 或 URI 编码/表单编码的请求参数作为请求格式。添加可选请求参数和向响应对象添加新字段通常被认为是保持兼容性的更改。

服务兼容性由于 RPC 通常用于跨组织边界的通信而变得更加困难,因此服务的提供者通常无法控制其客户端,也无法强制他们升级。因此,兼容性需要长期(可能是无限期)保持。如果需要破坏兼容性的更改,服务提供者经常最终需要同时维护多个版本的服务 API。

关于 API 版本控制应如何工作(即客户端如何指示它想使用哪个版本的 API [43])尚未达成一致。对于 RESTful API,常见的方法是在 URL 或 HTTP Accept 标头中使用版本号。对于使用 API 密钥识别特定客户端的服务,另一种选择是将客户端请求的 API 版本存储在服务器上,并允许通过单独的管理界面更新此版本选择 [44]。

持久化执行与工作流

根据定义,基于服务的架构有多个服务,每个服务负责应用程序的不同部分。考虑一个支付处理应用程序,它负责信用卡扣款并将资金存入银行账户。该系统可能包含多个不同服务,分别负责欺诈检测、信用卡集成、银行集成等。

在我们的示例中,处理单笔付款需要多次服务调用。支付处理器服务可能会调用欺诈检测服务来检查欺诈,调用信用卡服务来扣款,并调用银行服务来存入扣款资金,如图 5-7 所示。我们将这一系列步骤称为工作流,每个步骤是一个任务。工作流通常被定义为一张任务图。工作流定义可以用通用编程语言、领域特定语言(DSL)或标记语言(如业务流程执行语言(BPEL)[45])编写。

任务、活动和函数

不同的工作流引擎对任务使用不同的名称。例如,Temporal 使用术语活动。其他引擎将其称为持久化函数。尽管名称不同,但概念是相同的。

图5-7:使用业务流程模型和表示法(BPMN)表示的工作流,一种图形化表示法

工作流由工作流引擎运行(或执行)。工作流引擎决定每个任务在何时、在哪台机器上执行;如果任务失败(例如,任务运行时机器崩溃)该怎么办;允许多少个任务并行执行;等等。

工作流引擎通常由编排器执行器组成:编排器负责调度任务以便执行,执行器负责执行任务。当工作流被触发时,执行开始。如果用户定义了基于时间的调度(例如每小时执行一次),编排器会触发工作流本身。外部源(如 Web 服务,甚至是人工)也可以触发工作流执行。一旦触发,执行器被调用以运行任务。

有许多种工作流引擎,用于解决各种不同的用例。有些(如 Airflow、Dagster 和 Prefect)与数据系统集成,并编排 ETL 任务。其他(如 Camunda 和 Orkes)提供工作流的图形化表示法(例如图5-7中使用的 BPMN),以便非工程师更容易定义和执行工作流。还有一些(如 Temporal 和 Restate)提供持久化执行。

持久化执行框架已成为构建需要事务性的基于服务架构的流行方式。在我们的支付示例中,我们希望每笔支付恰好处理一次。工作流执行期间发生故障可能导致信用卡已扣款,但没有相应的银行存款。在基于服务的架构中,我们不能简单地将两个任务包装在数据库事务中。此外,我们可能正在与第三方支付网关交互,而我们对其控制有限。

持久化执行框架是一种为工作流提供恰好一次语义的方式。如果任务失败,框架将重新执行该任务,但会跳过该任务在失败前已成功完成的任何 RPC 调用或状态更改。它会假装进行调用,但返回先前调用的结果。这之所以可行,是因为持久化执行框架将所有 RPC 和状态更改记录到持久化存储(如预写日志)中 [46, 47]。例 5-5 展示了一个使用 Temporal 支持持久化执行的工作流定义片段。

例 5-5:图5-7中支付工作流的 Temporal 工作流定义片段

@workflow.defn
class PaymentWorkflow:
    @workflow.run
    async def run(self, payment: PaymentRequest) -> PaymentResult:
        is_fraud = await workflow.execute_activity(
            check_fraud,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        if is_fraud:
            return PaymentResultFraudulent
        credit_card_response = await workflow.execute_activity(
            debit_credit_card,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        # ...

像 Temporal 这样的框架并非没有挑战。外部服务(例如我们示例中的第三方支付网关)仍然必须提供幂等 API。开发者必须记住为这些 API 使用唯一 ID,以防止重复执行 [48]。并且由于持久化执行框架按顺序记录每个 RPC 调用,它们期望后续执行以相同顺序进行相同的 RPC 调用。这使得代码变更变得脆弱;仅仅通过重新排序函数调用就可能引入未定义行为 [49]。与其修改现有工作流的代码,更安全的做法是单独部署一个新版本的代码,这样现有工作流调用的重新执行继续使用旧版本,只有新调用使用新代码 [50]。

类似地,由于持久化执行框架期望确定性地重放所有代码(相同输入产生相同输出),非确定性代码(如调用随机数生成器或系统时钟)是有问题的 [49]。框架通常提供这类库函数的自身确定性实现,但你必须记住使用它们。一些框架还提供静态分析工具(如 Temporal 的 Workflow Check)来确定是否引入了非确定性行为。

使代码具有确定性是一个强大的想法,但要稳健地实现却很棘手。我们将在第九章回到这个话题。

事件驱动架构

在最后一节中,我们将简要介绍事件驱动架构,这是编码数据在进程之间流动的另一种方式。在此上下文中,请求称为事件消息。与 RPC 不同,发送方通常不会等待接收方处理事件。此外,事件通常不通过直接网络连接发送给接收方,而是通过称为消息代理(也称为事件代理消息队列面向消息的中间件)的中介,该中介临时存储消息 [51]。

与直接 RPC 相比,使用消息代理有几个优点:

  • 如果接收方不可用或过载,它可充当缓冲区,提高系统可靠性。
  • 它可以自动将消息重新投递给崩溃的进程,防止消息丢失。
  • 它避免了对服务发现的需求,因为发送方无需直接连接接收方的 IP 地址。
  • 它允许将同一条消息发送给多个接收方。
  • 它在逻辑上解耦了发送方和接收方(发送方只发布消息,不关心谁消费它们)。

通过消息代理的通信是异步的:发送方不等待消息投递,只是发送然后忘记它。然而,可以让发送方在单独的信道上等待响应,从而实现同步的类似 RPC 的模型。

消息代理

过去,消息代理领域由 TIBCO、IBM WebSphere 和 webMethods 等商业企业软件主导,后来 RabbitMQ、ActiveMQ、HornetQ、NATS、Redpanda 和 Apache Kafka 等开源实现变得流行。最近,Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub 等云服务得到采用。我们将在第十二章中更详细地比较它们。

具体的投递语义因实现和配置而异,但通常有两种最常用的消息分发模式:

  • 一个进程向命名的队列添加一条消息,然后队列的一个消费者接收该消息。如果有多个消费者,其中只有一个接收该消息。
  • 一个进程向命名的主题发布一条消息,然后代理将该消息投递给该主题的所有订阅者。如果有多个订阅者,它们都会收到该消息。

消息代理通常不强制任何特定的数据模型。消息只是一系列字节加上一些元数据,因此你可以使用任何编码格式。常见的方法是使用 Protocol Buffers、Avro 或 JSON,并在消息代理旁边部署一个模式注册表,以存储所有有效的模式版本并检查其兼容性 [20, 22]。AsyncAPI(一种基于消息的 OpenAPI 等价物)也可用于指定消息的模式。

消息代理在消息持久性方面有所不同。许多代理将消息写入磁盘,这样即使代理崩溃或需要重启,消息也不会丢失。与数据库不同,许多消息代理会在消息被消费后自动删除它们。然而,某些代理可以配置为无限期存储消息,如果你想要使用事件溯源(参见第 101 页的“事件溯源和 CQRS”),则需要这样做。

如果消费者将消息重新发布到另一个主题,则需要小心保留未知字段,以防止之前在与数据库相关的上下文中描述的问题(图5-1)。

分布式角色框架

Actor模型是单进程内并发的编程模型。它不是直接处理线程(以及与之相关的竞态条件、锁和死锁问题),而是将逻辑封装在Actor中。每个Actor通常代表一个客户端或实体。它可能有自己的本地状态(不与任何其他Actor共享),并通过发送和接收异步消息与其他Actor通信。消息传递不保证可靠;在某些错误场景下,消息会丢失。由于每个Actor一次只处理一条消息,因此无需担心线程问题,并且框架可以独立调度每个Actor。

在分布式Actor框架(如Akka、Orleans [52] 和 Erlang/OTP)中,该编程模型用于跨多个节点扩展应用程序。无论发送者和接收者位于同一节点还是不同节点,都使用相同的消息传递机制。如果它们位于不同节点,则消息会被透明地编码为字节序列,通过网络发送,并在另一端解码。

位置透明性在Actor模型中比在RPC中效果更好,因为Actor模型已经假设即使在同一进程内消息也可能丢失。虽然网络延迟通常高于同一进程内,但使用Actor模型时,本地通信和远程通信之间的根本性不匹配较小。

分布式Actor框架本质上是将消息代理和Actor编程模型集成到一个框架中。然而,如果要执行基于Actor的应用程序的滚动升级,仍然需要担心向前和向后兼容性,因为消息可能从运行新版本的节点发送到运行旧版本的节点,反之亦然。这可以通过使用本章讨论的编码之一来实现。

小结

在本章中,我们探讨了将数据结构转换为网络或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是影响应用程序的架构以及你演化它们的选项。

特别地,许多服务需要支持滚动升级,即新版本的服务逐渐部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不中断服务的情况下发布新版本的服务(从而鼓励频繁的小规模发布而非罕见的大规模发布),并降低部署风险(允许在影响大量用户之前检测和回滚有故障的发布)。这些属性对可演化性(即对应用程序进行更改的容易程度)非常有利。

在滚动升级期间,或出于其他各种原因,我们必须假设不同节点运行着不同版本的应用程序代码。因此,所有在系统中流动的数据都必须以一种提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码。

我们讨论了几种数据编码格式及其兼容性属性:

  • 特定于编程语言的编码:仅限于单一编程语言,且通常无法提供向前和向后兼容性。
  • 文本格式(如JSON、XML和CSV):使用广泛,其兼容性取决于使用方式。它们有可选的模式语言,有时有帮助,有时是障碍。这些格式在数据类型方面有些模糊,因此处理数字和二进制字符串等时需谨慎。
  • 二进制模式驱动格式(如Protocol Buffers和Avro):允许紧凑、高效的编码,具有明确定义的向前和向后兼容性语义。模式可用于静态类型语言中的文档和代码生成。然而,这些格式的缺点是需要解码后才能人类可读。

我们还讨论了几种数据流模式,说明了数据编码重要性的不同场景:

  • 数据库:写入数据库的进程编码数据,读取数据库的进程解码数据。
  • RPC和REST API:客户端编码请求,服务器解码请求并编码响应,客户端最后解码响应。
  • 事件驱动架构(使用消息代理或Actor):节点通过发送消息进行通信,消息由发送者编码并由接收者解码。

我们可以得出结论,只要稍加注意,向后/向前兼容性和滚动升级是相当容易实现的。愿你的应用程序演化迅速,部署频繁。

参考文献

[1] “CWE-502: Deserialization of Untrusted Data.” Common Weakness Enumeration, cwe.mitre.org, July 2006. Archived at perma.cc/26EU-UK9Y

[2] Steve Breen. “What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability.” foxglovesecurity.com, November 2015. Archived at perma.cc/9U97-UVVD

[3] Patrick McKenzie. “What the Rails Security Issue Means for Your Startup.” kalzumeus.com, January 2013. Archived at perma.cc/2MBJ-7PZ6

[4] Brian Goetz. “Towards Better Serialization.” openjdk.org, June 2019. Archived at perma.cc/UK6U-GQDE

[5] Eishay Smith. “jvm-serializers Wiki.” github.com, October 2023. Archived at perma.cc/PJP7-WCNG

[6] “XML Is a Poor Copy of S-Expressions.” wiki.c2.com, May 2013. Archived at perma.cc/7FAN-YBKL

[7] Julia Evans. “Examples of Floating Point Problems.” jvns.ca, January 2023. Archived at perma.cc/M57L-QKKW

[8] Matt Harris. “Snowflake: An Update and Some Very Important Information.” Email to Twitter Development Talk mailing list, October 2010. Archived at perma.cc/8UBV-MZ3D

[9] Yakov Shafranovich. “RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files.” IETF, October 2005.

[10] Andy Coates. “Evolving JSON Schemas—Part I.” creekservice.org, January 2024. Archived at perma.cc/MZW3-UA54

[11] Andy Coates. “Evolving JSON Schemas—Part II.” creekservice.org, January 2024. Archived at perma.cc/GT5H-WKZ5

[12] Pierre Genevès, Nabil Layaïda, and Vincent Quint. “Ensuring Query Compatibility with Evolving XML Schemas.” INRIA Technical Report 6711, November 2008. Archived at arxiv.org

[13] Tim Bray. “Bits on the Wire.” tbray.org, November 2019. Archived at perma.cc/3BT3-BQU3

[14] Mark Slee, Aditya Agarwal, and Marc Kwiatkowski. “Thrift: Scalable Cross-Language Services Implementation.” Facebook Technical Report, April 2007. Archived at perma.cc/22BS-TUFB

[15] Martin Kleppmann. “Schema Evolution in Avro, Protocol Buffers and Thrift.” martin.kleppmann.com, December 2012. Archived at perma.cc/E4R2-9RJT

[16] Doug Cutting et al. “[PROPOSAL] New Subproject: Avro.” Email thread on hadoop-general mailing list, lists.apache.org, April 2009. Archived at perma.cc/4A79-BMEB

[17] Apache Software Foundation. “Apache Avro 1.12.0 Specification.” avro.apache.org, August 2024. Archived at perma.cc/C36P-5EBQ

[18] Apache Software Foundation. “Avro Schemas as LL(1) CFG Definitions.” avro.apache.org, August 2024. Archived at perma.cc/JB44-EM9Q

[19] Tony Hoare. “Null References: The Billion Dollar Mistake.” At QCon London, March 2009.

[20] Confluent, Inc. “Schema Registry Overview.” docs.confluent.io, 2024. Archived at perma.cc/92C3-A9JA

[21] Aditya Auradkar and Tom Quiggle. “Introducing Espresso—LinkedIn’s Hot New Distributed Document Store.” engineering.linkedin.com, January 2015. Archived at perma.cc/FX4P-VW9T

[22] Jay Kreps. “Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2).” confluent.io, February 2015. Archived at perma.cc/8UA4-ZS5S

[23] Gwen Shapira. “The Problem of Managing Schemas.” oreilly.com, November 2014. Archived at perma.cc/BY8Q-RYV3

[24] John Larmouth. ASN.1 Complete. Morgan Kaufmann, 1999. ISBN: 9780122334351. Archived at perma.cc/GB7Y-XSXQ

[25] Burton S. Kaliski Jr. “A Layman’s Guide to a Subset of ASN.1, BER, and DER.” Technical Note, RSA Data Security, Inc., November 1993. Archived at perma.cc/2LMN-W9U8

[26] Jacob Hoffman-Andrews. “A Warm Welcome to ASN.1 and DER.” letsencrypt.org, April 2020. Archived at perma.cc/CYT2-GPQ8

[27] Lev Walkin. “Question: Extensibility and Dropping Fields.” lionet.info, September 2010. Archived at perma.cc/VX8E-NLH3

[28] Jacqueline Xu. “Online Migrations at Scale.” stripe.com, February 2017. Archived at perma.cc/X59W-DK7Y

[29] Geoffrey Litt, Peter van Hardenberg, and Orion Henry. “Project Cambria: Translate Your Data with Lenses.” Technical Report, October 2020. Archived at perma.cc/WA4V-VKDB

[30] Pat Helland. “Data on the Outside Versus Data on the Inside.” At 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005. Archived at perma.cc/GH56-WYZS

[31] Roy Thomas Fielding. “Architectural Styles and the Design of Network-Based Software Architectures.” 博士论文,加州大学尔湾分校,2000年。存档于 perma.cc/LWY9-7BPE
[32] Roy Thomas Fielding. “REST APIs Must Be Hypertext-Driven.” roy.gbiv.com,2008年10月。存档于 perma.cc/M2ZW-8ATG
[33] “OpenAPI Specification Version 3.1.0.” swagger.io,2021年2月。存档于 perma.cc/3S6S-K5M4
[34] Michi Henning. “The Rise and Fall of CORBA.” Communications of the ACM,第51卷,第8期,第52–57页,2008年8月。doi:10.1145/1378704.1378718
[35] Pete Lacey. “The S Stands for Simple.” harmful.cat-v.org,2006年11月。存档于 perma.cc/4PMK-Z9X7
[36] Stefan Tilkov. “Interview: Pete Lacey Criticizes Web Services.” infoq.com,2006年12月。存档于 perma.cc/JWF4-XY3P
[37] Tim Bray. “The Loyal WS-Opposition.” tbray.org,2004年9月。存档于 perma.cc/J5Q8-69Q2
[38] Andrew D. Birrell 和 Bruce Jay Nelson. “Implementing Remote Procedure Calls.” ACM Transactions on Computer Systems (TOCS),第2卷,第1期,第39–59页,1984年2月。doi:10.1145/2080.357392
[39] Jim Waldo, Geoff Wyant, Ann Wollrath 和 Sam Kendall. “A Note on Distributed Computing.” Sun Microsystems Laboratories, Inc., 技术报告 TR-94-29,1994年11月。存档于 perma.cc/8LRZ-BSZR
[40] Steve Vinoski. “Convenience over Correctness.” IEEE Internet Computing,第12卷,第4期,第89–92页,2008年7月。doi:10.1109/MIC.2008.75
[41] Brandur Leach. “Designing Robust and Predictable APIs with Idempotency.” stripe.com,2017年2月。存档于 perma.cc/JD22-XZQT
[42] Sam Rose. “Load Balancing.” samwho.dev,2023年4月。存档于 perma.cc/Q7BA-9AE2
[43] Troy Hunt. “Your API Versioning Is Wrong, Which Is Why I Decided to Do It 3 Different Wrong Ways.” troyhunt.com,2014年2月。存档于 perma.cc/9DSW-DGR5
[44] Brandur Leach. “APIs As Infrastructure: Future-Proofing Stripe with Versioning.” stripe.com,2017年8月。存档于 perma.cc/L63K-USFW
[45] OASIS Web Services Business Process Execution Language (WSBPEL) 技术委员会. “Web Services Business Process Execution Language Version 2.0.” docs.oasis-open.org,2007年4月。

摘要 | 195
[46] “Temporal. Temporal Service.” docs.temporal.io,2024年。存档于 perma.cc/32P3-CJ9V
[47] Stephan Ewen. “Why We Built Restate.” restate.dev,2023年8月。存档于 perma.cc/BJJ2-X75K
[48] Keith Tenzer 和 Joshua Smith. “Understanding Idempotency in Distributed Systems.” temporal.io,2024年2月。存档于 perma.cc/TY4U-EH3W
[49] “Temporal. Temporal Workflow.” docs.temporal.io,2024年。存档于 perma.cc/B5C5-Y396
[50] Jack Kleeman. “Solving Durable Execution’s Immutability Problem.” restate.dev,2024年2月。存档于 perma.cc/G55L-EYH5
[51] Srinath Perera. “Exploring Event-Driven Architecture: A Beginner’s Guide for Cloud Native Developers.” wso2.com,2023年8月。存档于 archive.org
[52] Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot 和 Jorgen Thelin. “Orleans: Distributed Virtual Actors for Programmability and Scalability.” Microsoft Research 技术报告 MSR-TR-2014-41,2014年3月。存档于 perma.cc/PD3U-WDMF