第 5 章 编码与演进
万物变迁,无物不动。
——以弗所的赫拉克利特,柏拉图《克拉底鲁篇》(公元前 360 年)
应用必然会随时间变化。新产品上线、对用户需求有了更深的理解,或是业务环境发生变化时,特性会被增加或修改。第 2 章我们引入了可演化性的概念:我们应力求构建易于适应变化的系统(参见"可演化性:让变更易于进行",第 55 页)。
多数情况下,应用功能的变化也需要对其存储的数据做相应改动。也许要捕获新字段或新记录类型,又或者已有数据需要以新方式呈现。
第 3 章讨论的数据模型对这种变化各有不同应对方式。关系数据库一般假设所有数据都符合同一个模式。该模式可以改变(通过模式迁移,即 ALTER 语句),但任一时刻只有一个模式生效。相反,读时模式("无模式")数据库不强制模式,因此数据库中可能同时存在写于不同时期、混合在一起的旧新数据格式(参见"文档模型中的模式灵活性",第 80 页)。
数据格式或模式变化时,常常需要相应改动应用代码(例如向记录加新字段,应用代码开始读写该字段)。然而在大型应用中,代码改动并非瞬时完成,原因有很多。例如:
- 服务端应用中你可能想做滚动升级(也叫分阶段发布)——一次只把新版本部署到几个节点,监控运行是否正常,再逐步推到所有节点。这让新版本能在不停服的情况下部署,从而鼓励更频繁的发布与更好的可演化性。
- 对于客户端应用,你只能任由用户选择——他们可能很久都不更新。
这意味着代码的新旧版本与数据格式的新旧版本可能同时共存于系统中。要让系统继续顺畅运行,你需要在两个方向上保持兼容:
向后兼容(Backward compatibility) :确保较新的代码能读较老代码写出的数据。
向前兼容(Forward compatibility) :确保较老的代码能读较新代码写出的数据。
在 API 语境下:如果你想让较老的客户端能成功调用较新的服务,请求需向后兼容、响应需向前兼容。要让较新的客户端能调用较老的服务,请求需向前兼容、响应需向后兼容。
向后兼容通常不难做到。作为较新代码的作者,你知道较老代码所写数据的格式,可以显式处理它(必要时干脆保留旧代码用以读旧数据)。向前兼容更棘手——它要求较老代码忽略较新代码所做的添加。
向前兼容的另一项挑战见图 5-1。假设你向某记录模式新增一个字段,较新代码创建一条含此字段的记录并存进数据库;之后较老版本的代码(不知道新字段)读取该记录、修改、再写回。这种情形下,我们希望旧代码即便无法解释新字段,也能保留它不受影响。但若该记录被解码为不显式保留未知字段的模型对象,数据可能丢失,如下所示。

图 5-1. 当较老应用更新先前由较新应用写入的数据时,若不小心数据可能丢失
图示文字描述: 图示左侧 DB 中含
{"userName":"Martin","favoriteNumber":1337,"interests":["hacking"],"photoURL":"http://..."}(较新代码写入,含新 photoURL 字段);上方旧版本代码读出后只解码到含 userName/favoriteNumber/interests 三字段的 Person 对象(不知 photoURL);旧代码做setFavoriteNumber(42)后再写回 DB,结果{"userName":"Martin","favoriteNumber":42,"interests":["hacking"]},photoURL 丢失。
本章我们考察若干数据编码格式,包括 JSON、XML、Protocol Buffers 与 Avro。我们将特别关注它们如何处理模式变化,以及如何支持新旧数据与代码并存的系统。然后讨论这些格式如何被用于数据存储与通信:在数据库、Web 服务、REST API、远程过程调用(RPC)、工作流引擎,以及 actor 与消息队列等事件驱动系统中。
编码数据的格式
程序通常用(至少)两种表示来处理数据:
- 在内存中,数据被放在对象、结构体、列表、数组、哈希表、树等数据结构里。这些数据结构针对 CPU 的高效访问与操控做了优化(通常使用指针)。
- 当你想把数据写到文件或经网络发送时,必须把它编码成某种自包含的字节序列(例如一份 JSON 文档)。由于指针对其他进程没有意义,这种字节序列表示通常与内存中所用的数据结构差别很大。
因此我们需要在两种表示之间做某种翻译。从内存表示到字节序列叫编码(encoding)(也叫序列化(serialization)或 marshaling),反过来叫解码(decoding)(即解析(parsing)、反序列化或 unmarshaling)。
术语冲突
不幸的是,serialization 一词也用于事务的语境(参见第 8 章),意思完全不同。为避免词义重载,本书坚持使用 encoding,尽管 serialization 或许更常见。
有时不需要编码/解码——例如数据库直接对从磁盘加载的压缩数据进行操作(如"查询执行:编译与向量化",第 142 页所讨论)。也存在 Cap'n Proto 与 FlatBuffers 这样的*零拷贝(zero-copy)*数据格式——它们被设计为既可在运行时使用,也可在磁盘/网络上使用,无需显式的转换步骤。
但多数系统需要在内存对象与扁平字节序列之间转换。由于这是一个常见问题,存在数不胜数的库与编码格式。下面我们做一个简短综览。
语言专属格式
许多编程语言内建支持把内存对象编码为字节序列。例如 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]。X 上就出现过大于 2^53 的数字示例:它用 64 位数字标识每条帖子。其 API 返回的 JSON 中帖子 ID 出现两次:一次作为 JSON 数字,一次作为十进制字符串,以绕开 JavaScript 应用错误解析数字的问题 [8]。
JSON 与 XML 对 Unicode 字符串(即人类可读文本)支持得很好,但不支持二进制字符串(无字符编码的字节序列)。二进制字符串是个有用的特性,所以人们用 Base64 把二进制数据编码成文本来绕过这个限制。然后用模式说明该值应被解释为 Base64 编码。这能用,但有点 hacky,并使数据大小增加约三分之一。
XML Schema 与 JSON Schema 都很强大,因此学习与实现都相当复杂。由于数据的正确解释(如数字与二进制字符串)依赖模式信息,未使用 XML/JSON Schema 的应用可能需要硬编码合适的编/解码逻辑。
CSV 没有任何模式,因此每行每列的含义由应用来定义。如果应用变化时加了一行或一列,你得手动处理。CSV 也是个相当模糊的格式(如果一个值含逗号或换行怎么办?)。其转义规则虽有正式规范 [9],但并非所有解析器都正确实现。
尽管有这些缺陷,JSON、XML 与 CSV 在很多用途上够用。它们很可能仍会流行,尤其是作为数据交换格式(即把数据从一个组织发到另一个组织)。在这种场景下,只要各方对格式达成一致,格式好不好看、效率高不高常常没那么重要。让不同组织对任何事达成一致的难度,通常远大于其他考虑。
JSON Schema
JSON Schema 已被广泛采用,作为系统间数据交换或写入存储时为数据建模的方式。你能在 Web 服务(参见"Web 服务",第 181 页)中见到 JSON Schema,它是 OpenAPI Web 服务规范的一部分;在 Confluent 的 Schema Registry 与 Red Hat 的 Apicurio Registry 等模式注册中心中也是;在 PostgreSQL 的 pg_jsonschema 验证扩展、MongoDB 的 $jsonSchema 验证语法等数据库中也是。
JSON Schema 规范提供若干特性。模式包含 string、number、integer、object、array、boolean 与 null 等标准原始类型。但 JSON Schema 也提供独立的验证规范,让开发者在字段上叠加约束。例如 port 字段最小值为 1、最大值为 65,535。
JSON Schema 可以是开放或封闭内容模型。开放内容模型允许模式中未定义的任何字段以任意数据类型存在,而封闭内容模型只允许显式定义的字段。开放内容模型在 JSON Schema 中由 additionalProperties 设为 true 来开启,这是默认值。因此 JSON Schema 通常定义"什么不被允许"(即对已定义字段的无效值),而非"允许什么"。
开放内容模型很强大,但可能很复杂。例如假设你想定义一个从整数(如 IDs)到字符串的映射。JSON 没有允许整数键的映射或字典类型;JSON 对象始终以字符串为键。要满足你的需求,你可以用 JSON Schema 约束这种类型,让键只能含数字、值只能是字符串,使用 patternProperties 与 additionalProperties,如示例 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 文档的二进制编码中,必须把字符串 userName、favoriteNumber、interests 也包含进去。
示例 5-2. 一条本章会用多种二进制格式编码的记录
{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}我们看一个 MessagePack 例子(JSON 的一种二进制编码)。图 5-2 给出用 MessagePack 编码示例 5-2 后的字节序列。

图 5-2. 我们的记录(示例 5-2)用 MessagePack 编码
图示文字描述: 图示上半部 66 字节的字节序列,下半部"Breakdown"逐字节解释:第一字节
0x83表示有 3 项的对象;下一个0xa8表示长 8 字节的字符串 "userName";接着是字符串 "Martin"(前缀0xa6);0xae是 14 字节字符串 "favoriteNumber";0xcd 0x05 0x39表示 16 位整数 1337;0xa9是 9 字节字符串 "interests";0x92表示 2 项数组;0xab11 字节字符串 "daydreaming";0xa77 字节字符串 "hacking"。
前几字节如下:
- 第一字节
0x83表示后面是一个含三个字段的对象(高 4 位 =0x80,低 4 位 =0x03)。(如果对象超过 15 个字段、4 位放不下,则使用不同的类型指示,并把字段数编码到 2 或 4 字节里。) - 第二字节
0xa8表示后面是一个 8 字节长的字符串(高 4 位 =0xa0,低 4 位 =0x08)。 - 接下来 8 字节是 ASCII 的字段名
userName。由于长度先前已指明,就不需要标记串结尾(也无需转义)。 - 接下来 7 字节编码 6 字符的字符串值
Martin,前缀0xa6,依此类推。
二进制编码长 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 一样,每个字段都有类型标注(指明它是字符串、整数等等),以及长度指示(如字符串长度)。出现在数据里的字符串(Martin、daydreaming、hacking)以 ASCII(确切说是 UTF-8)编码,与之前一样。

图 5-3. 我们的记录用 Protocol Buffers 编码
图示文字描述: 图示 33 字节的字节序列,下半逐字段细化:field tag=1 type 2 (string),长度 6,"Martin";field tag=2 type 0 (varint),varint 编码 1337(两字节 b9 0a → 二进制 1011 1001 0000 1010 → 解出 1337);field tag=3 type 2 (string),长度 11,"daydreaming";field tag=3 type 2 (string),长度 7,"hacking"。
与图 5-2 不同,本例中编码的数据没有字段名(userName、favoriteNumber、interests)。取而代之的是,编码数据里含字段标签(field tags)——也就是数字(1、2、3)。这正是模式定义中的那些数字。字段标签就像字段的别名——它们以一种紧凑方式指明我们说的是哪个字段,无需把字段名拼出来。
如你所见,Protocol Buffers 把字段类型与标签号打包进单字节,进一步节省空间。它使用变长整数:数字 1337 用两字节编码,每字节最高位指示是否还有更多字节(最低 7 位放在第一字节以简化阅读时的整数重建)。这意味着 −64 到 63 之间的数字用一字节,−8192 到 8191 用两字节,更大的数字用更多字节。
Protocol Buffers 没有显式的列表或数组数据类型。interests 字段上的 repeated 修饰符表明该字段包含一个值列表,而非单一值。在二进制编码中,列表元素以同一字段标签在同一记录内重复出现的方式来表示。
字段标签与模式演化
我们之前说过,模式不可避免地需要随时间变化。这叫模式演化(schema evolution)。Protocol Buffers 如何在保持向后与向前兼容的同时处理模式变化?
如示例所示,被编码的记录就是其编码字段的连接。每个字段由其标签号识别(示例模式中的 1、2、3),并附有数据类型注释(如字符串或整数)。如果某字段值未设置,它会从编码记录中被直接省略。由此可见字段标签对编码数据的含义至关重要。你可以改模式中字段的名字——因为编码数据从不引用字段名——但不能改字段的标签,否则会让所有已存在的编码数据失效。
你可以向模式新增字段,前提是给每个字段一个新的标签号。如果旧代码(不知道你新加的标签号)尝试读由新代码写出的数据——其中包含一个它不识别的标签号——它可以直接忽略该字段。数据类型注释让解析器能确定要跳多少字节,从而保留未知字段,避免图 5-1 那样的问题。这就维持了向前兼容:旧代码能读由新代码写出的记录。
向后兼容呢?只要每个字段都有唯一的标签号,新代码就总能读旧数据,因为标签号仍然具有同样的含义。如果你在新模式中加了一个字段,读旧数据时该字段不存在,会用默认值填充(如字符串型的空字符串、数字型的 0)。
删除字段类似于新增,只是向后/向前兼容的考虑对调:你绝不能再用同一个标签号——因为可能有数据写在某处仍含旧标签号,新代码必须忽略该字段。模式定义中过去用过的标签号可以被保留,以确保它们不会被遗忘。
改变字段的数据类型呢?某些类型可以——细节看文档——但风险是值会被截断。例如把 32 位整数改成 64 位整数。新代码很容易能读旧代码写的数据——解析器会把缺失位补 0。但若旧代码读新代码写的数据,旧代码仍用 32 位变量保存值。如果解码出的 64 位值放不下 32 位,就会被截断。
Avro
Apache Avro 是另一种二进制编码格式,与 Protocol Buffers 有一些有趣的差异。它始于 2009 年,作为 Hadoop 的子项目,原因是 Protocol Buffers 不太适合 Hadoop 的用例 [16]。
Avro 也用模式来指定被编码数据的结构。它有两种模式语言:一种(Avro IDL)面向人工编辑,另一种(基于 JSON)更易被机器读取。和 Protocol Buffers 一样,其模式语言只指定字段及其类型,并不支持像 JSON Schema 那样的复杂校验规则。
写成 Avro IDL,我们例子的模式可能像这样:
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}该模式的等价 JSON 表示如下:
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}注意模式中没有标签号。如果用此模式编码我们的记录(示例 5-2),Avro 二进制编码只有 32 字节——是我们看到的所有编码中最紧凑的。编码字节序列的细节如图 5-4 所示。
如果你查看字节序列,会发现没有任何东西标识字段或其类型。编码就由值连接而成。一个字符串只是长度前缀加上 UTF-8 字节,编码数据中没有任何东西告诉你它是字符串。它同样可以是整数或别的什么。整数用变长编码。
要解析二进制数据,按字段在模式中出现的顺序遍历,并用模式确定每个字段的数据类型。这意味着只有当读数据的代码使用与写数据时完全相同的模式时,才能正确解码。读者与写者的模式之间的任何不一致都会导致错误解码。

图 5-4. 我们的记录用 Avro 编码
图示文字描述: 图示 32 字节序列;细化:长度 6 的 "Martin";union 分支 1(long,非 null),编码值 1337;2 项数组;长度 11 的 "daydreaming";长度 7 的 "hacking";以及表示数组结束的 0x00。
那么 Avro 如何支持模式演化?
写者模式与读者模式
当应用想编码数据时(写到文件或数据库、通过网络发送等等),它使用所了解的某个版本的模式——例如编译进应用的模式。这就是写者模式(writer's schema)。
要解码数据(从文件或数据库读出、从网络接收等等),应用使用两个模式:写者模式(与编码所用模式相同),以及读者模式(reader's schema)——它可能不同。如图 5-5 所示。读者模式定义了应用代码所期望的每条记录的字段及其类型。
如果读者与写者模式相同,解码很容易。如果不同,Avro 会通过比较二者并把数据从写者模式翻译到读者模式来解决差异。

图 5-5. 在 Protocol Buffers 中,编码与解码可用不同版本的模式。在 Avro 中,解码使用两份模式:写者模式须与编码所用相同,但读者模式可以是较旧或较新
图示文字描述: 图示上半 Protocol Buffers:Objects → Encoding 用 Writer's schema → Bytes → Decoding 用 Reader's schema → Objects;下半 Avro:Objects → Encoding 用 Writer's schema → Bytes → Decoding 同时用 Writer's & Reader's schema → Objects。
Avro 规范 [17, 18] 精确定义了这种解析如何工作。如图 5-6 所示,若写者模式与读者模式中字段顺序不同,并无问题——模式解析按字段名匹配字段。如果读数据的代码遇到一个出现在写者模式里但不在读者模式中的字段,就忽略它。如果读数据的代码期望某字段而写者模式不含该字段,则用读者模式中该字段的默认值填充。

图 5-6. Avro 读者解析写者模式与读者模式之间的差异
图示文字描述: 图示左侧 Person 的写者模式列出(string userName, union {null,long} favoriteNumber, array<string> interests, string photoURL);右侧 Person 的读者模式列出(long userID, union {null,int} favoriteNumber, string userName, array<string> interests);箭头连接 userName↔userName, favoriteNumber↔favoriteNumber, interests↔interests,photoURL 在读者中无对应(被忽略),userID 在写者中无对应(用默认值)。
模式演化规则
在 Avro 里,向前兼容意味着写者使用比读者更新的模式版本;反之,向后兼容意味着写者使用比读者更旧的模式版本。
要保持兼容性,你只能添加或删除带有默认值的字段(如我们 Avro 模式中的 favoriteNumber)。例如,假设你新增一个有默认值的字段,于是新模式有该字段而旧模式没有。当用新模式的读者读用旧模式写的记录时,缺失字段用默认值填充。
如果新加一个没有默认值的字段,新读者将无法读旧写者写的数据,因此会破坏向后兼容。如果删除一个没有默认值的字段,旧读者将无法读新写者写的数据,因此会破坏向前兼容。
某些编程语言里 null 是任何变量都可接受的默认值,但 Avro 不是这样:要让字段允许为 null,你必须使用联合类型(union type)。例如 union { null, long, string } field; 表示 field 可以是数字、字符串或 null。仅当 null 是联合的第一个分支时,才能把它作为默认值。这比"默认所有都可空"啰嗦一些,但有助于通过显式地表明"什么可以为 null、什么不可以"来防 bug [19]。
字段的数据类型可以更改,前提是 Avro 能转换该类型。改字段名也行,但有些棘手——读者模式可以为字段名提供别名(aliases),这样它能把旧写者模式里的字段名与新别名匹配起来。这意味着改字段名是向后兼容的,但不向前兼容。同样,向联合类型新增分支是向后兼容但不向前兼容。
但写者模式从何而来?
我们略过了一个重要问题:读者怎么知道某段数据是用何种模式编码的?我们不能在每条记录里都把整个模式包含进去——因为模式可能远比编码数据大,从而抵消二进制编码带来的空间节省。
答案取决于使用 Avro 的语境。举几个例子:
含大量记录的大文件 :Avro 的常见用途是存放含数百万条记录的大文件,全部以同一模式编码(这种情况我们将在第 11 章讨论)。这种情况下,该文件的写者只需在文件开头包含一次模式即可。Avro 指定了一种文件格式(对象容器文件)来做这件事。
单独写入记录的数据库 :在数据库中,不同记录可能在不同时间用不同模式写入——你不能假设所有记录都有同样模式。此时最简单的做法是:在每条编码记录开头包含一个版本号,并在数据库中保留模式版本列表。读者取出一条记录、提取版本号,再用该版本号从数据库取写者模式,然后用此模式解码记录其余部分。Confluent 的 Apache Kafka 模式注册中心 [20] 与 LinkedIn 的 Espresso [21] 就是这样工作的。
经网络连接发送记录 :当两个进程通过双向网络连接通信时,它们可以在连接建立时协商模式版本,并在连接生命期内使用该模式。Avro RPC 协议(参见"通过服务的数据流:REST 与 RPC",第 180 页)就是这样工作的。
无论哪种情形,拥有一份模式版本数据库都很有用——它既是文档,也让你有机会检查模式的兼容性 [22]。版本号可以是简单的递增整数或模式哈希。
动态生成的模式
Avro 相比 Protocol Buffers 的一项优势是模式不含标签号。但为什么这一点重要?模式里多放几个数字有什么问题?
差别在于: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 有许多共同点——一种 1984 年首次标准化的模式定义语言 [24, 25]。它被用于定义各种网络协议,其二进制编码(DER)至今被用于编码 SSL 证书(X.509)等 [26]。ASN.1 也用类似 Protocol Buffers 的标签号来支持模式演化 [27]。然而它也非常复杂、文档糟糕,因此 ASN.1 大概不是新应用的良好选择。
许多数据系统也为其数据实现了某种专有的二进制编码。例如多数关系数据库都有可向数据库发查询并取回响应的网络协议。这些协议通常专属于某具体数据库,数据库厂商提供驱动(如使用 ODBC 或 JDBC API)把响应从数据库网络协议解码成内存中的数据结构。
可见,尽管 JSON、XML、CSV 等文本数据格式很普遍,基于模式的二进制编码也是可行的选项。它们具有几个良好属性:
- 它们可以比各种"二进制 JSON"变种紧凑得多——因为可以省略编码数据中的字段名。
- 模式是一种有价值的文档形式,又因解码时需要它,你可以确信它是最新的(手动维护的文档很容易与现实脱节)。
- 保留一份模式数据库让你能在部署任何东西之前检查模式变更的向前/向后兼容。
- 对静态类型语言的用户而言,能从模式生成代码很有用——它在编译时就启用类型检查。
总之,模式演化提供了与无模式/读时模式 JSON 数据库类似的灵活性(参见"文档模型中的模式灵活性",第 80 页),同时又能在数据上提供更好的保障与更好的工具。但仍建议把同时存在的模式格式数量保持在最低,以让运维更简单。
数据流模式
本章开头我们说过:每当你想把数据发给与你不共享内存的另一个进程——比如想把数据通过网络发送或写到文件——你都需要把它编码成字节序列。随后我们讨论了多种实现这一点的编码格式。
我们谈了向前与向后兼容,它们对可演化性很重要(让你能独立升级系统的各部分,而非一次性改动一切)。兼容性是一个进程"编码数据"与另一个进程"解码数据"之间的关系。
这是一个相当抽象的想法——数据可以从一个进程流向另一个进程的方式有许多。谁编码数据,谁解码?本章余下部分我们将探索数据在进程间流动的几种最常见方式:通过数据库、通过服务调用、通过工作流引擎,以及通过异步消息。
通过数据库的数据流
在数据库中,写数据的进程负责编码数据,读数据的进程负责解码。可能只有一个进程访问数据库,这种情况下读者只是该进程的一个稍后版本——这种场景下,可以把数据库存储看作"给未来的自己发消息"。这里向后兼容显然必要——否则未来的自己将无法解码你之前写下的内容。
通常会有几个进程同时访问数据库。这些进程可能是不同的应用或服务,也可能仅仅是同一服务的多个实例(为可扩展性或容错而并行运行)。无论哪种,在这种环境里访问数据库的某些进程很可能跑着较新的代码,而另一些跑着较旧的代码——例如因为新版本正在做滚动升级,所以一些实例已被更新而其他还没。
这意味着数据库中的某个值可能由较新版本的代码写入,随后被仍在运行的较老代码读到。因此数据库通常也需要向前兼容。
不同时间写入的不同值
数据库通常允许任何值在任何时间被更新。在同一数据库内,你可能既有 5 毫秒前写入的值,也有 5 年前写入的值。
当你部署应用新版本时(至少对服务端应用而言),可能在几分钟内就把旧版本完全替换为新版本。但数据库内容并非如此:5 年前的数据仍然以原编码存在那儿,除非你之后显式重写过。这一点常被概括为数据比代码活得久(data outlives code)。
虽然把数据"迁移(migrating)"到新模式当然可行,但在大数据集上代价高。所以多数数据库会推迟该操作,以异步、尽力而为的方式来做。例如 LSM 树存储引擎(参见"日志结构存储",第 118 页)会在压实期间把数据用最新格式重写。多数关系数据库也允许做简单的模式更改——如加一新列、默认 NULL——而无需重写已有数据。当读到旧行时,数据库会为编码到磁盘上时缺少的列填 NULL。模式演化因此让整个数据库看起来像用单一模式编码,即便底层存储可能含有用各历史模式编码的记录。
更复杂的模式更改——例如把单值属性改为多值,或把某些数据移到独立表——仍要求数据被重写,常在应用层完成 [28]。要在这种迁移中保持向前/向后兼容仍是研究问题 [29]。
归档存储
也许你不时给数据库做快照——比如为备份或加载到数据仓库(参见"数据仓库",第 7 页)。此时数据转储通常会用最新模式编码,即便源数据库的原始编码包含来自不同年代的混合模式版本。既然要复制数据,不如把数据副本统一编码。
由于数据转储是一次性写出且之后不变,Avro 对象容器文件等格式很合适。这也是用 Parquet 等列式分析友好格式编码的好机会(参见"列压缩",第 139 页)。第 11 章会更多讨论归档存储中数据的使用。
通过服务的数据流:REST 与 RPC
当你需要让若干进程经网络通信时,这种通信可以有几种安排方式。最常见的是有两类角色:客户端与服务端。服务端通过网络暴露 API,客户端可以连到服务端向该 API 发请求。服务端暴露的 API 称为服务(service)。
Web 就是这样工作的:客户端(Web 浏览器)向 Web 服务器发请求,用 GET 请求下载 HTML、CSS、JavaScript、图像等,用 POST 请求向服务器提交数据。该 API 由一组标准化协议与数据格式(HTTP、URL、SSL/TLS、HTML 等)构成。由于 Web 浏览器、Web 服务器与网站作者大多已就这些标准达成一致,你能用任何浏览器访问任何网站(至少理论上!)。
Web 浏览器并非唯一的客户端类型。例如,跑在移动设备或桌面电脑上的原生 App 也常与服务器对话;运行在浏览器中的客户端 JavaScript 应用也可发 HTTP 请求。这种情况下,服务器响应通常不是用于直接显示给人的 HTML,而是以某种便于客户端应用代码进一步处理的编码(最常是 JSON)。虽然 HTTP 可以作为传输协议,但其上实现的 API 是应用专属的,客户端与服务端需要就该 API 的细节达成一致。
在某种程度上,服务类似数据库:它们通常允许客户端提交并查询数据。但数据库允许用第 3 章讨论的查询语言发任意查询,而服务则暴露应用专属的 API:只允许由服务的业务逻辑(应用代码)预先定义的输入与输出 [30]。这种限制提供了一定程度的封装:服务可对客户端能做与不能做的事设细粒度的限制。
面向服务架构/微服务架构的关键设计目标,是通过让服务可独立部署与演化,让应用更易于修改与维护。一项常见原则是每个服务由一个团队拥有,该团队应能频繁发布服务的新版本,无需与其他团队协调。因此我们应当预期:服务端与客户端的新旧版本会同时运行,所以服务端与客户端所使用的数据编码必须在不同版本之间兼容该服务 API。只要 API 仍然兼容,团队就可以以任何方式自由修改自己的系统;这一性质让开发者更容易做内部数据、服务、乃至整个系统的迁移。
Web 服务
当 HTTP 被用作服务通信的底层协议时,它被称为 Web 服务(web service)。Web 服务常被用于构建面向服务/微服务架构(前面"微服务与无服务器",第 21 页中讨论过)。这一术语或许略有不当——因为 Web 服务不只用于网络,也用于多种场景。例如:
- 用户设备上跑的客户端应用(如移动设备上的原生 App,或浏览器中的 JavaScript Web 应用)通过 HTTP 向服务发请求。这些请求通常经由公网。
- 一项服务向同一组织所拥有的另一项服务发请求,二者通常位于同一私有网络内,作为面向服务/微服务架构的一部分。
- 一项服务向不同组织所拥有的服务发请求,通常经由互联网。这用于组织间后端系统的数据交换。这一类别包括在线服务提供的公共 API,如信用卡处理系统,或用于共享访问用户数据的 OAuth。
最流行的服务设计哲学是 REST,它建立在 HTTP 的原则之上 [31, 32]。REST 强调简单数据格式、用 URL 标识资源、使用 HTTP 缓存控制、认证、内容类型协商等特性。按 REST 原则设计的 API 称为 RESTful。
要调用 Web 服务 API 的代码必须知道:要查询哪个 HTTP 端点、要发送什么数据格式、在响应中预期什么。即便服务采用了 RESTful 设计原则,客户端仍需要某种方式发现这些细节。服务开发者通常使用一种 IDL 来定义并文档化其服务的 API 端点与数据模型,并让它们随时间演化。其他开发者随后可借助服务定义判断如何查询该服务。两种最流行的服务 IDL 是 OpenAPI(也叫 Swagger [33],用于发送与接收 JSON 的 Web 服务)与 Protocol Buffers(用于 gRPC 服务)。
开发者通常以 JSON 或 YAML 写 OpenAPI 服务定义(参见示例 5-3)。服务定义让开发者能定义服务端点、文档、版本、数据模型等等。Protocol Buffers 服务定义使用我们在"Protocol Buffers"(第 169 页)中看到的 IDL。
示例 5-3. 一段以 YAML 写的 OpenAPI 服务定义
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 调用的代码。常常会用一个服务框架(service framework)——如 Spring Boot、FastAPI 或 gRPC——来简化这项工作。服务框架让开发者专注于写每个 API 端点的业务逻辑,由框架代码处理路由、指标、缓存、认证等。示例 5-4 展示了用 Python 实现示例 5-3 所定义服务。
示例 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——服务定义先写好,再生成服务端代码骨架。两种做法都允许开发者从服务定义生成多种语言的客户端库与 SDK。除了代码生成,Swagger 等 IDL 工具还能生成文档、验证模式变更的兼容性,并提供图形界面(GUI)让开发者查询并测试服务。
远程过程调用的问题
Web 服务只是一长串"用于通过网络发 API 请求的技术"中最新的化身——其中许多曾被大肆吹捧但都有严重问题。Enterprise JavaBeans(EJB)与 Java 的 Remote Method Invocation(RMI)只限于 Java。Distributed Component Object Model(DCOM)只限于 Microsoft 平台。Common Object Request Broker Architecture(CORBA)过度复杂、不提供向后/向前兼容 [34]。SOAP 与 WS-* Web 服务框架旨在跨厂商互操作,但同样饱受复杂与兼容性问题困扰 [35, 36, 37]。
它们都基于远程过程调用(RPC)的想法——这一概念早在 1970 年代就被提出 [38]。RPC 模型试图让"向远端网络服务发请求"看起来与"同一进程内调用一个函数或方法"一样(这种抽象称为位置透明性,location transparency)。虽然乍看方便,但这种做法从根本上有缺陷 [39, 40]。出于种种原因,网络请求与本地函数调用差异巨大:
- 本地函数调用是可预测的,根据你能控制的参数要么成功、要么失败。网络请求则不可预测:请求或响应可能因网络问题丢失,远端机器可能变慢或不可用,原因完全不在你掌控之中。网络问题很常见,因此应用必须对此有所预期(如重试失败请求)。
- 本地函数调用要么返回结果、要么抛异常、要么永不返回(因进入无限循环或进程崩溃)。网络请求还有另一种可能结局:可能因超时而无结果返回。这种情况下你根本不知道发生了什么;如果没有从远端服务收到响应,你无从得知请求是否到达。(第 9 章会更详细讨论该问题。)
- 如果你重试失败的网络请求,可能发生这种情况:上次请求其实成功了、只是响应丢失。这种情况下,重试会让动作被执行多次,除非你在协议里建立去重机制(幂等性,idempotence)[41]。本地函数调用没有这一问题。(第 12 章会更多讨论幂等。)
- 调用本地函数时,每次大致都用相同的时间执行。网络请求比函数调用慢得多,且其延迟波动也极大:好的时候可能不到一毫秒,但当网络拥塞或远端服务过载时,做同样的事可能要花几秒。
- 调用本地函数时,你能高效地把对内存对象的引用(指针)传过去。而网络请求时,所有这些参数都需要被编码成可经网络发送的字节序列。当参数是数字或短字符串等不可变原始类型时还好,对较大、可变的对象就很快变得棘手。
- 客户端与服务端可能用不同的编程语言实现,所以 RPC 框架必须把数据类型从一种语言翻译到另一种。结果可能不太美观——因为并非所有语言都有相同的类型——回想 JavaScript 处理大于 2^53 数字时的麻烦(参见"JSON、XML 与二进制变种",第 165 页)。这种问题在单语言写的单进程里并不存在。
所有这些因素都意味着:试图把远端服务弄得太像编程语言里的本地对象没有意义——它从根本上就是另一种东西。REST 的部分吸引力恰在于它把"通过网络做状态转移"视作不同于函数调用的过程。
负载均衡器、服务发现与服务网格
所有服务都通过网络通信。因此客户端必须知道它要连接的服务的地址——这一问题被称为服务发现(service discovery)。最简单的做法是把客户端配置成连到该服务运行的 IP 地址与端口。这种配置能用,但若服务端宕机、被迁到新机器或过载,客户端必须手动重新配置。
为提供更高的可用性与可扩展性,通常一项服务的多个实例运行在多台机器上,每台都能处理到来的请求。把请求分散到这些实例叫负载均衡(load balancing) [42]。多种负载均衡与服务发现方案可供选择:
硬件负载均衡器 :这类专用设备装在数据中心。让客户端连到一个主机与端口,进来的连接被路由到运行该服务的某台服务器。这种负载均衡器在连接下游服务器时检测网络故障,并在出现服务器失败时把流量切走。
软件负载均衡器(如 NGINX 与 HAProxy) :行为大致与硬件负载均衡器相同,但不需要特殊设备——它们是可装在标准机器上的应用。
域名服务(DNS) :当你打开网页时,它就解析互联网上的域名。它通过允许同一域名关联多个 IP 地址来支持负载均衡。客户端可被配置成通过域名而非 IP 地址连服务,客户端的网络层选哪个 IP 用于建立连接。这种做法的一项缺点是:DNS 在设计上面向长期传播变更,并缓存 DNS 条目。如果服务器频繁启动、停止或迁移,客户端可能看到失效的、不再有服务运行的 IP 地址。
服务发现系统 :用 etcd 或 Apache ZooKeeper 等中心化注册表(而非 DNS)来跟踪可用的服务端点(我们将在"协调服务",第 437 页中回到这些系统)。新服务实例启动时向服务发现系统注册,声明它在哪个主机与端口监听,连同所属分片(参见第 7 章)、数据中心位置等相关元数据。然后该服务定期向发现系统发送心跳信号,以表明它仍可用。
当客户端希望连服务时,先查发现系统取可用端点列表,再直连端点。相比 DNS,服务发现支持更动态的环境,服务实例可频繁变化。发现系统也能提供更多客户端所连服务的元数据,让客户端能做更聪明的负载均衡决策。
服务网格(Service meshes) :这种复杂形式的负载均衡把软件负载均衡器与服务发现结合在一起。与跑在独立机器上的传统软件负载均衡器不同,服务网格负载均衡器通常作为进程内客户端库部署,或在客户端与服务端两侧以"sidecar"容器进程的形式部署。客户端应用连到自己本地的服务负载均衡器,由此再连到服务端的负载均衡器,再从那里把连接路由到本地的服务进程。
这种拓扑虽然复杂,却有其优势。由于客户端与服务端完全通过本地连接路由,连接加密可全部在负载均衡器层处理。这能让客户端与服务端不必处理 SSL 证书与 TLS 的复杂性。网格系统还提供复杂的可观测性。它们能实时追踪哪些服务在彼此调用、检测故障、追踪流量负载等等。
哪种方案合适取决于组织需求。运行于非常动态的服务环境、用 Kubernetes 等编排器的组织,常选择运行 Istio、Linkerd 等服务网格。数据库、消息系统等专门基础设施可能需要自己专用的负载均衡器。更简单的部署最适合用软件负载均衡器。
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 key 标识具体客户端的服务,另一种选择是在服务端存放客户端请求的 API 版本,并允许通过单独的管理界面更新版本选择 [44]。
持久执行与工作流
根据定义,基于服务的架构有多个服务,各自负责应用的不同部分。考虑一个支付处理应用——它从信用卡扣款并把资金存入银行账户。该系统大概会有不同的服务负责欺诈检测、信用卡集成、银行集成等。
处理我们示例中的单笔支付需要多次服务调用。支付处理服务可能调用欺诈检测服务检查欺诈,调用信用卡服务扣信用卡,调用银行服务把扣到的资金入账,如图 5-7 所示。我们把这一系列步骤称为工作流(workflow),每一步是一个任务(task)。工作流通常被定义为任务的图。工作流定义可以写在通用编程语言、领域专属语言(DSL),或像 Business Process Execution Language(BPEL)这样的标记语言里 [45]。
任务、活动与函数
不同工作流引擎对任务采用不同的名字。例如 Temporal 用术语活动(activity)。其他的把任务叫持久函数(durable functions)。名字虽不同,概念相同。

图 5-7. 用 BPMN(一种图形记法)表达的工作流
图示文字描述: 图示从 "Payment requested" 起,经"Compute fraud risk score"判断 Fraud risk 分支:Low risk 进入"Debit credit card",再到 Authorized 判断:成功则"Credit bank account"到达"Transaction complete";High risk 或 Declined 则到 "Transaction rejected"。
工作流由*工作流引擎(workflow engine)*运行或执行。工作流引擎决定每个任务何时、在哪台机器上运行,任务失败时怎么办(如任务运行中机器崩溃),允许多少任务并行执行等等。
工作流引擎通常由编排器与执行器组成:编排器负责调度待执行的任务,执行器负责执行任务。当工作流被触发时执行开始。如果用户定义了基于时间的调度(如每小时执行),编排器自身就会触发工作流。Web 服务等外部源乃至人也能触发工作流执行。一旦触发,执行器就被调用以运行任务。
工作流引擎种类繁多,应对各种用例。一些(如 Airflow、Dagster、Prefect)与数据系统集成、协调 ETL 任务。另一些(如 Camunda 与 Orkes)为工作流提供图形记法(如图 5-7 中所用 BPMN),让非工程师也能更易定义并执行工作流。还有一些(如 Temporal 与 Restate)提供持久执行(durable execution)。
持久执行框架已成为构建需要事务性的服务架构的流行方式。在我们的支付例子中,希望每笔支付恰好处理一次。工作流执行中失败可能让信用卡被扣却没对应的银行账户存款。在服务架构中,我们不能简单地把两个任务包在一个数据库事务里。而且,我们可能要与对其控制有限的第三方支付网关交互。
持久执行框架是为工作流提供恰好一次语义的一种方式。如果某个任务失败,框架会重新执行该任务,但跳过任务在失败之前已成功做完的任何 RPC 调用或状态变更。它会假装做了调用,但返回上次调用的结果。这之所以可行,是因为持久执行框架把所有 RPC 与状态变更记录到 WAL 这样的持久存储中 [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——以判断是否引入了非确定性行为。
让代码具有确定性是个强大的思想,但要稳健做到很棘手。我们将在第 9 章重回此话题。
事件驱动架构
最后这一节我们简要看看事件驱动架构(event-driven architectures)——它是编码后的数据从一个进程流到另一个进程的另一种方式。在此语境下,一次请求被称为事件(event)或消息(message)。与 RPC 不同,发送方通常不等接收方处理事件。此外,事件通常并不通过直接网络连接发给接收方,而是经一个被称为消息代理(message broker)的中间件(也叫事件代理、消息队列或面向消息的中间件)——它临时存储消息 [51]。
相比直接 RPC,使用消息代理有几大优势:
- 它能在接收方不可用或过载时充当缓冲,提高系统可靠性。
- 它能自动把消息重新投递给已崩溃的进程,防止消息丢失。
- 它免去了服务发现的需要——发送方无需直连接收方的 IP。
- 它允许同一消息被发给多个接收方。
- 它在逻辑上把发送方与接收方解耦(发送方只发布消息,不关心谁来消费)。
通过消息代理通信是异步的:发送方不等消息被投递,只发出后就不再过问。但通过让发送方在单独通道上等响应,也可实现类 RPC 的同步模型。
消息代理
过去消息代理领域被 TIBCO、IBM WebSphere、webMethods 等商业企业软件主导,后来 RabbitMQ、ActiveMQ、HornetQ、NATS、Redpanda、Apache Kafka 等开源实现流行起来。最近,Amazon Kinesis、Azure Service Bus、Google Cloud Pub/Sub 等云服务也被采纳。我们将在第 12 章更详细比较它们。
具体投递语义因实现与配置而异,但总体上有两种最常用的消息分发模式:
- 一个进程把消息加到具名队列中,该队列的消费者接收消息。如果有多个消费者,其中之一收到。
- 一个进程把消息发布到具名话题(topic),代理把它投递给该话题的所有订阅者。如果有多个订阅者,所有人都收到。
消息代理通常不强制特定的数据模型。一条消息只是带些元数据的字节序列,所以你可以用任何编码格式。常见做法是使用 Protocol Buffers、Avro 或 JSON,并把模式注册中心与消息代理一起部署,存放所有有效模式版本并检查兼容性 [20, 22]。AsyncAPI——OpenAPI 在消息领域的对应物——也可用于指定消息模式。
消息代理在消息持久性上各有不同。许多代理把消息写到磁盘,这样在消息代理崩溃或需要重启时不丢消息。与数据库不同,许多消息代理在消息被消费后会自动删除消息。然而某些代理可被配置为无限期存放消息——如果你想做事件溯源(参见"事件溯源与 CQRS",第 101 页),就需要这样。
如果消费者把消息再发布到另一个话题,要小心保留未知字段,以防出现之前在数据库语境下讨论过的图 5-1 那种问题。
分布式 Actor 框架
Actor 模型是一种单进程内并发的编程模型。与其直接处理线程(以及与之相伴的竞态、加锁、死锁问题),逻辑被封装在 actor 中。每个 actor 通常代表一个客户端或实体。它可能有一些本地状态(不与任何其他 actor 共享),并通过发送和接收异步消息与其他 actor 通信。消息投递不保证;某些错误情形下消息会丢。由于每个 actor 一次只处理一条消息,它不必担心线程,每个 actor 可由框架独立调度。
在 Akka、Orleans [52]、Erlang/OTP 等分布式 Actor 框架中,这种编程模型被用于跨多节点扩展应用。无论发送方与接收方在同一节点还是不同节点,都使用同样的消息传递机制。如果它们在不同节点,消息会被透明地编码成字节序列、经网络发送、在另一侧解码。
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. 归档于 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. 归档于 perma.cc/9U97-UVVD
[3] Patrick McKenzie. "What the Rails Security Issue Means for Your Startup." kalzumeus.com, January 2013. 归档于 perma.cc/2MBJ-7PZ6
[4] Brian Goetz. "Towards Better Serialization." openjdk.org, June 2019. 归档于 perma.cc/UK6U-GQDE
[5] Eishay Smith. "jvm-serializers Wiki." github.com, October 2023. 归档于 perma.cc/PJP7-WCNG
[6] "XML Is a Poor Copy of S-Expressions." wiki.c2.com, May 2013. 归档于 perma.cc/7FAN-YBKL
[7] Julia Evans. "Examples of Floating Point Problems." jvns.ca, January 2023. 归档于 perma.cc/M57L-QKKW
[8] Matt Harris. "Snowflake: An Update and Some Very Important Information." Email to Twitter Development Talk mailing list, October 2010. 归档于 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. 归档于 perma.cc/MZW3-UA54
[11] Andy Coates. "Evolving JSON Schemas—Part II." creekservice.org, January 2024. 归档于 perma.cc/GT5H-WKZ5
[12] Pierre Genevès, Nabil Layaïda, Vincent Quint. "Ensuring Query Compatibility with Evolving XML Schemas." INRIA Technical Report 6711, November 2008. 归档于 arxiv.org
[13] Tim Bray. "Bits on the Wire." tbray.org, November 2019. 归档于 perma.cc/3BT3-BQU3
[14] Mark Slee, Aditya Agarwal, Marc Kwiatkowski. "Thrift: Scalable Cross-Language Services Implementation." Facebook Technical Report, April 2007. 归档于 perma.cc/22BS-TUFB
[15] Martin Kleppmann. "Schema Evolution in Avro, Protocol Buffers and Thrift." martin.kleppmann.com, December 2012. 归档于 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. 归档于 perma.cc/4A79-BMEB
[17] Apache Software Foundation. "Apache Avro 1.12.0 Specification." avro.apache.org, August 2024. 归档于 perma.cc/C36P-5EBQ
[18] Apache Software Foundation. "Avro Schemas as LL(1) CFG Definitions." avro.apache.org, August 2024. 归档于 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. 归档于 perma.cc/92C3-A9JA
[21] Aditya Auradkar, Tom Quiggle. "Introducing Espresso—LinkedIn's Hot New Distributed Document Store." engineering.linkedin.com, January 2015. 归档于 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. 归档于 perma.cc/8UA4-ZS5S
[23] Gwen Shapira. "The Problem of Managing Schemas." oreilly.com, November 2014. 归档于 perma.cc/BY8Q-RYV3
[24] John Larmouth. ASN.1 Complete. Morgan Kaufmann, 1999. ISBN: 9780122334351. 归档于 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. 归档于 perma.cc/2LMN-W9U8
[26] Jacob Hoffman-Andrews. "A Warm Welcome to ASN.1 and DER." letsencrypt.org, April 2020. 归档于 perma.cc/CYT2-GPQ8
[27] Lev Walkin. "Question: Extensibility and Dropping Fields." lionet.info, September 2010. 归档于 perma.cc/VX8E-NLH3
[28] Jacqueline Xu. "Online Migrations at Scale." stripe.com, February 2017. 归档于 perma.cc/X59W-DK7Y
[29] Geoffrey Litt, Peter van Hardenberg, Orion Henry. "Project Cambria: Translate Your Data with Lenses." Technical Report, October 2020. 归档于 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. 归档于 perma.cc/GH56-WYZS
[31] Roy Thomas Fielding. "Architectural Styles and the Design of Network-Based Software Architectures." PhD thesis, University of California, Irvine, 2000. 归档于 perma.cc/LWY9-7BPE
[32] Roy Thomas Fielding. "REST APIs Must Be Hypertext-Driven." roy.gbiv.com, October 2008. 归档于 perma.cc/M2ZW-8ATG
[33] "OpenAPI Specification Version 3.1.0." swagger.io, February 2021. 归档于 perma.cc/3S6S-K5M4
[34] Michi Henning. "The Rise and Fall of CORBA." Communications of the ACM, volume 51, issue 8, pages 52–57, August 2008.
[35] Pete Lacey. "The S Stands for Simple." harmful.cat-v.org, November 2006. 归档于 perma.cc/4PMK-Z9X7
[36] Stefan Tilkov. "Interview: Pete Lacey Criticizes Web Services." infoq.com, December 2006. 归档于 perma.cc/JWF4-XY3P
[37] Tim Bray. "The Loyal WS-Opposition." tbray.org, September 2004. 归档于 perma.cc/J5Q8-69Q2
[38] Andrew D. Birrell, Bruce Jay Nelson. "Implementing Remote Procedure Calls." ACM Transactions on Computer Systems, volume 2, issue 1, pages 39–59, February 1984.
[39] Jim Waldo, Geoff Wyant, Ann Wollrath, Sam Kendall. "A Note on Distributed Computing." Sun Microsystems Laboratories, Technical Report TR-94-29, November 1994. 归档于 perma.cc/8LRZ-BSZR
[40] Steve Vinoski. "Convenience over Correctness." IEEE Internet Computing, volume 12, issue 4, pages 89–92, July 2008.
[41] Brandur Leach. "Designing Robust and Predictable APIs with Idempotency." stripe.com, February 2017. 归档于 perma.cc/JD22-XZQT
[42] Sam Rose. "Load Balancing." samwho.dev, April 2023. 归档于 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, February 2014. 归档于 perma.cc/9DSW-DGR5
[44] Brandur Leach. "APIs As Infrastructure: Future-Proofing Stripe with Versioning." stripe.com, August 2017. 归档于 perma.cc/L63K-USFW
[45] OASIS Web Services Business Process Execution Language (WSBPEL) Technical Committee. "Web Services Business Process Execution Language Version 2.0." docs.oasis-open.org, April 2007.
[46] "Temporal. Temporal Service." docs.temporal.io, 2024. 归档于 perma.cc/32P3-CJ9V
[47] Stephan Ewen. "Why We Built Restate." restate.dev, August 2023. 归档于 perma.cc/BJJ2-X75K
[48] Keith Tenzer, Joshua Smith. "Understanding Idempotency in Distributed Systems." temporal.io, February 2024. 归档于 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, February 2024. 归档于 perma.cc/G55L-EYH5
[51] Srinath Perera. "Exploring Event-Driven Architecture: A Beginner's Guide for Cloud Native Developers." wso2.com, August 2023. 归档于 archive.org
[52] Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, Jorgen Thelin. "Orleans: Distributed Virtual Actors for Programmability and Scalability." Microsoft Research Technical Report MSR-TR-2014-41, March 2014. 归档于 perma.cc/PD3U-WDMF