应用程序不可避免地需要随时间而变化、调整。在大多数情况下,更改应用程序功能时,也需要更改其存储的数据:可能需要捕获新的字段或记录类型,或者需要以新的方式呈现已有数据。
当数据格式或模式发生变化时,在「数据模型」层面,不同的数据模型有不同的方法来应对这种变化:
- 关系数据库通常假设数据库中的所有数据都符合一种模式,这样在任何一个给定时间点都只有一个有效的模式
- 非关系数据库则不强制执行模式,包含了不同时间写入的新旧数据的混合体
在「应用程序」层面,数据格式或模式的变化需要应用程序代码进行相应的调整。然而,对于一个大型应用系统,代码更迭往往并非易事:
- 对于服务器端应用程序,可能需要执行「滚动升级」(rolling upgrade),每次将新版本部署到少数几个节点,检查新版本是否正常运行(无需暂停服务),然后逐步在所有节点上升级新的代码。
- 对于客户端应用程序,只能依赖用户安装更新(热更新或冷更新)
这意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。为了使系统继续顺利运行,需要保持双向的兼容性:
- 「向后兼容」(backward compatibility)较新的代码可以读取由旧代码编写的数据
- 「向前兼容」(forward compatibility)较旧的代码可以读取由新代码编写的数据
本章将介绍多种编码数据的格式,讨论不同的格式如何处理变化,以及如何支持新旧数据和新旧代码共存的系统。之后,还将讨论这些格式如何用于数据存储和通信场景。
1 数据编码格式
应用程序通常使用(至少)两种不同的数据表示形式:
- 在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)
- 将「数据写入文件」或通过「网络发送」时,必须将其编码为某种自包含的字节序列(如 JSON)。由于指针对其他进程没有意义,所以这个字节序列表示通常看起来与内存中使用的数据结构不大一样
因此,在这两种表示之间需要进行类型的转化,从内存中的表示到字节序列的转化称为「编码」(encoding)或「序列化」(serialization),相反的过程称为解码(decoding)或「反序列化」(deserialization)。当前存在许多不同的库和编码格式可供选择,下面进行简要的介绍。
1.1 语言特定的格式
许多编程语言都内置支持将内存中的对象编码为字节序列,例如 Java 的 java.io.Serializable
、Python 的 pickle
等,这些编码库使用起来非常方便,它们只需要很少的额外代码即可保存或回复内存中的对象。然而,其也存在一些深层次的问题:
- 编码通常与特定的编程语言绑定在一起,而用另一种语言访问数据就非常困难
- 为了在相同的对象类型中恢复数据,解码过程需要能够实例化任意的类,可能会导致一些安全问题
- 这些库的主要目标是快速且简单地编码数据,所以经常忽略向前和向后兼容性问题,同时效率也是次要的
由于这些原因,使用语言内置的编码方案通常不是个好主意。
1.2 JSON、XML 和二进制变体
1.2.1 JSON、XML 和 CSV
下面介绍可由不同编程语言编写和读取的标准化编码,其中最广为人知的编码是 「JSON」 和 「XML」,以及 「CSV」。三者都是文本格式,具有较好的可读性。除了表面的语法问题外,它们也有一些微妙的问题:
- 数字编码有很多模糊之处。XML 和 CSV 无法区分数字和碰巧由数字组成的字符串,JSON 不区分整数和浮点数,并且不指定精度
- JSON 和 XML 对 Unicode 字符串(即人类可读文本)有很好的支持,但是不支持二进制字符串(没有字符编码的字节序列)
- XML 和 JSON 都有可选的模式支持,这些模式语言相当强大,因此学习和实现起来也比较复杂
- CSV 没有任何模式,因此应用程序需要定义每行和每列的含义,如果应用程序更改添加新的行或列,则必须手动处理该更改
尽管存在一定的缺陷,但是 JSON、XML 和 CSV 作为数据交换格式仍然非常受欢迎。在大部分的场景下,只要就格式本身达成一致,格式的美观与高效往往不太重要。让不同的组织达成格式一致的难度通常超过了所有其他问题。
1.2.2 二进制编码
对于仅在组织内部使用的数据,可以考虑选择更紧凑或更快的解析格式,例如二进制格式。当前已经开发了大量的二进制编码,用以支持 JSON 与 XML 的转化,下面以 MessagePack 为例,它是一种 JSON 的二进制编码,样本记录如下(之后将都使用这条记录进行举例):
代码语言:javascript复制{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
由于 JSON 没有规定模式,所以需要在编码数据时包含所有的对象字段名称,下图展示了编码后所得到的的字节序列,从分解后的序列可以看到,每个实际的编码前都会有一个类型指示符,指示编码的类型与长度。最终得到的二进制编码长度为 66 字节,仅略小于「文本 JSON 编码」占用的 81 字节。
1.3 Thrift 与 Protocol Buffers
Apache Thrift 和 Protocol Buffers 是基于相同原理的两种二进制编码库,都需要「模式」(schema)来编码任意的数据。对于 Thrift,其使用「接口定义语言」(IDL)来描述模式:
代码语言:javascript复制struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests }
Protocol Buffers 也使用类似的模式定义方式:
代码语言:javascript复制message Person {
required string user_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
}
Thift 和 Protocol Buffers 各自有一套代码生成工具,基于上述模式定义,生成各类编程语言中的模式实现类,应用代码可以调用该代码来编码或解码模式。
具体来说,Thrift 有两种不同的二进制编码格式,分别称为 BinaryProtocol 以及 CompactProtocol,下图给出了 BinaryProtocol 对之前样例的编码,共使用 59 字节:
与上一节中的普通二进制编码类似,每个字段都有一个类型注释,并在需要时指定长度(例如字符串长度、列表项数)。字符串均被编码为常见格式(ASCII 或 UTF-8)。与之前最大的区别在于,编码中并没有包含字段名,而是数字类型的「字段标签」,其在模式中进行了定义,可以节省一定的编码量。
Thrift CompactProtocol 编码如下图所示,其将相同的信息打包成只有 34 字节,主要的节省点体现在:
- 将字段类型与标签号打包到单字节中
- 使用了整数的变长编码,对于数字 1337,不使用全部 8 字节,而是使用两个字节进行编码,每个字节的最高位用来指示是否还有更多的字节
最后,Protocol Buffers 只有一种编码格式,如下图所示(图中 1337 原编码的划分方式有问题)。它的位打包方式略有不同,但是与 CompactProtocol 非常相似,可以只用 33 字节表示相同的记录。
需要注意的是,在模式中定义的 required
与 optional
,对于字段的编码没有影响,如果设置了 required
,但字段未填充,运行时检查将出现失败,以体现模式的约束。
1.3.1 字段标签与模式演化
如之前所述,模式不可避免地需要随着时间而不断变化,这被称为「模式演化」(schema evolution)。从上面的编码案例中可以看出,一条编码记录是一组编码字段的拼接,每个字段由其「标签号」标识,并使用数据类型进行注释。字段标签对于编码数据的含义至关重要,编码永远不会直接引用字段名称。
针对基于字段标签的模式更改,Thrift 与 Protocol Buffers 通过如下方式来保持向后与向前兼容性:
- 「向前兼容性(旧代码兼容新代码数据)」:当「添加」新的字段到模式时,需要给每个新字段一个新的标签号,当旧代码读取新代码写入的数据时,对于无法识别的标签号,可以选择直接忽略(通过数据类型的注释来确定需要跳过的字节数量);而当「删除」字段时,只能删除可选的字段,以保证旧代码读取新代码数据时不会报错
- 「向后兼容性(新代码兼容旧代码数据)」:当「添加」新的字段到模式时,只要每个字段都有唯一的标签号,新代码总是可以读取旧代码的数据,因为标签号仍然具有相同的含义。需要注意的是,添加的新字段只能是可选字段(或具有默认值),否则新代码读取旧数据时会出现检查失败;而当删除字段时,不能再次使用已删除的相同标签号码,因为新代码读取旧代码的数据时需要忽略该标签号对应的字段)
1.3.2 数据类型与模式演化
另一方面,针对基于字段数据类型的模式更改,其不同点在于可能会存在字段值丢失精度或被截断的风险。例如将一个 32 位的整数变成一个 64 位的整数,新代码可以较容易地读取旧代码数据,用零填充缺失位;而旧代码读取新代码数据时,将仍然使用 32 位变量来保存该值(可能会被截断)。
对于 Protocol Buffers 来说,其并没有列表或数组数据类型,而是对这些字段提供 repeated
标记,其编码方式是同一个字段标签简单地重复多次(可以参照编码示意图)。这种方式可以支持将可选(单值)字段转化为重复(多值)字段,对于向后兼容性,读取旧数据的新代码会看到一个包含 0 个或 1 个元素的列表;而对于向前兼容性,读取新数据的旧代码只能看到列表的最后一个元素。
对于 Thrift 来说,其有专用的列表数据类型,使用列表元素的数据类型进行参数化。它不支持从单值到多值的模式转变,但是可以支持嵌套列表。
1.4 Avro
Apache Avro 是另一种二进制编码格式,其作为 Hadoop 的子项目,能够较好地与 Hadoop 兼容。Avro 同样使用模式来指定编码数据的结构,它有两种模式语言:
- 方便人工编辑的 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"}}
]
}
需要注意的是,模式中「没有标签号」。如果我们对之前的示例数据进行编码,所得到的 Avro 二进制编码只有 32 字节长,是所有编码中最紧凑的,其具体形式如下图所示:
从图中可以看出,编码中没有标识字段或数据类型,只是由连在一起的一些列值组成。一个字符串只是一个长度前缀加一个 UTF-8 字节流,并没有特别指明其是字符串。而整数则使用可变长度编码进行编码(与 Thrift 的 CompactProtocol 相同)。
为了解析二进制数据,需要按照其在模式中的「顺序」进行字段遍历,然后直接采用模式中指明的数据类型。这意味着只有当读取数据的代码使用与写入数据的代码完全相同的模式时,才能对二进制数据进行正确解码,任何不匹配都将导致解码失败。
1.4.1 写模式和读模式
当应用程序需要编码某些数据时(例如写入文件或通过网络发送),其使用所知道的模式的任意版本来进行编码,这被称为「写模式」(writer's schema);而当应用程序需要解码某些数据时(从文件读取或从网络接收),其期望数据满足某种模式,这被称为「读模式」(reader's schema)。
实际上,Avro 的关键思想在于:「写模式与读模式并不需要完全相同」,其只需要保持兼容。当数据被解码(读取)时,Avro 库会通过对比查看写模式与读模式并将数据从写模式转换为读模式来解决二者之间的差异,其工作原理如下图所示:
具体来说,如果写模式与读模式的字段顺序不同,可以通过字段名匹配字段;如果读取数据的代码遇到出现在写模式但是不在读模式中的字段,则选择忽略;如果读取数据的代码需要某个字段,但写模式中不包含,则使用读模式中声明的默认值填充。
1.4.2 模式演化规则
对 Avro 来说,向前兼容性(旧代码读取新数据)意味着将新版本的模式作为 writer,将旧版本的模式作为 reader;而向后兼容性(新代码读取旧数据)则意味着将新版本的模式作为 reader,将旧版本的模式作为 writer。为了保持兼容性,只能在模式中添加或删除具有「默认值」的字段。
具体来说,当添加了一个带有默认值的字段,使用新模式的 reader 读取旧模式写入的记录时,将为缺少的字段填充默认值(向后兼容性);而使用旧模式的 reader 读取新模式写入的记录时,将直接忽略该字段(向前兼容性)。如果添加了没有默认值的字段,向前与向后兼容性都会遭到破坏。
基于上述模式演化规则,与 Protocol Buffers 和 Thrift 不同,Avro 并没有可选(optional
)与必需(required
)的标签,而是使用了「联合类型」(union type)与「默认值」。例如,union{null, long, string}
表示该字段可以是数字、字符串或 null
,只有当 null
是联合的分支之一时,才可以使用它作为默认值。
另一方面,只要 Avro 支持转换类型,就可以改变模式中字段的「数据类型」,但是对于「字段名称」的改变,读模式可以包含字段名称的别名,从而支持向后兼容,但是不能向前兼容;类似地,向联合类型「添加分支」也是向后兼容,但是不能向前兼容。
1.4.3 写模式确认
到目前为止,还有一个重要问题需要确认:读模式如何知道特定数据是采用了哪个写模式进行编码的?这个问题的答案取决于 Avro 使用的上下文,下面给出几个例子:
- 「有很多记录的大文件」。在 Hadoop 中,会使用基于 Avro 编码的包含数百万条记录的大文件,所有记录都使用相同的模式进行编码,该文件会采用特定的格式(对象容器文件)。在这种情况下,写模式可以在文件的开头中包含一次即可。
- 「具有单独写入记录的数据库」。在数据库中,不同的记录可能在不同的时间点,使用不同的写模式进行编码。在这种情况下,最简单的解决方案是在每条编码记录的开头包含一个版本号,并在数据库中保留一个模块版本列表。reader 可以获取记录,提取版本号,然后从数据库中查询该版本号对应的写模式,使用该模式进行解码。
- 「通过网络连接发送记录」。当两个进程通过双向网络进行通信时,它们可以在建立连接时协商模式版本,并在连接的生命周期中使用该模式,这也是 Avro RPC 协议的基本原理。
1.4.4 动态生成的模式
与 Protocol Buffers 和 Thrift 相比,Avro 的优点在于不包含任何标签号,对于「动态生成」(dynamically generated)的模式更加友好。
举例来说,假设我们希望把一个关系型数据库的内容存储到一个文件中,并且希望用二进制格式来避免文本格式的问题(JSON、CSV、SQL)。如果使用 Avro,我们可以很容易地「根据关系模式生成 Avro 模式」,并使用该模式对数据库内容进行编码,然后将其全部转储到 Avro 对象容器文件中。我们可以为每一张数据库表生成对应的记录模式,而每个列成为该记录中的一个字段,数据库中的列名称映射为 Avro 中的字段名称。
现在,如果数据库模式发生变化(例如添加了一列或删除了一列),可以从更新的数据库模式生成新的 Avro 模式,并使用新的 Avro 模式导出数据,数据导出过程不需要关注模式的变更——可以在每次运行时简单地进行模式转换。由于字段是通过名称来标识的,更新后的写模式依然可以与旧的读模式相匹配(向前兼容,向后兼容同理)。
相比之下,如果使用基于标签号的 Thrift 或 Protocol Buffers,则需要手动分配字段标签。每当数据库模式更改时,管理员必须手动更新从数据库列名到字段标签的映射(自动化也可以实现,但需要注意标签号的不变性),相对来说会比较麻烦。
1.4.5 代码生成与动态类型语言
Thrift 与 Protocol Buffers 都依赖于代码生成:定义模式之后,可以使用所选编程语言生成实现此模式的代码,这种方式在「静态类型语言」(例如 Java、C 、C#)中比较有用,因为其允许使用高效的内存结构来解码数据,并且在编写访问数据结构的程序时,支持在 IDE 中进行类型检查与自动补全。
而对于诸如 JavaScript、Ruby、Python 这样的动态类型语言中,由于没有明确的编译步骤与编译时类型检查,这种代码生成的方式并没有太大意义。此外,对于动态生成的模式(例如 Avro),代码生成对于数据获取反而是不必要的障碍。
Avro 为静态类型语言提供了可选的代码生成,但是它也可以在不生成代码的情况下直接使用。如果有一个对象容器文件(内嵌写模式),可以简单地使用 Avro 库来打开它(相当于自动解码,编码同理),并直接查看其中的数据。文件是「自描述」(self-describing)的,包含了所有必要的元数据。
上述属性(不进行代码生成)与「动态类型数据处理语言」(例如 Apache Pig)结合使用时更加高效。在 Pig 中,我们可以直接打开一些 Avro 文件,分析其内容,并编写派生数据集以 Avro 格式输出文件(无需考虑模式)。
1.5 模式的优点
综上所述,Protocol Buffers、Thrift 与 Avro 都使用了模式来描述二进制编码格式,其模式语言要比 XML 模式或 JSON 模式简单得多,同时支持更加详细的校验规则。它们的实现与使用都非常简单,目前已经得到了非常广泛的编程语言支持。
许多数据库也实现了一些专有的二进制编码。大多数关系数据库都有网络协议,可以通过该协议向数据库发送查询并获取响应。这些协议通常用于特定的数据库,并且数据库供应商提供「驱动程序」(如 ODBC 或 JDBC API),将来自数据库的网络协议的响应解码为内存数据结构。
概括来说,基于模式的二进制编码主要具有以下这些优点:
- 可以比各种“二进制 JSON”变体更加紧凑,省略编码数据中的字段名称
- 模式是一种有价值的文档形式,由于解码时需要模式,所以可以保证其是最新的
- 维护一个模式的数据库可以允许在部署任何内容之前检查模式更改的向前与向后兼容性
- 对于静态类型编程语言的用户来说,从模式生成代码的能力可以帮助在编译时进行类型检查
总的来看,模式演化能够获得与无模式/读时模式的 JSON 数据库相同的灵活性,同时还提供了有关数据与工具方面的更好的保障。
2 数据流模式
在第一节中,我们介绍了将一些数据发送到非共享内存的另一个进程时(例如网络传输或写入文件),需要将数据「编码」为字节序列;然后,讨论了用于执行此操作的不同编码技术。
「兼容性」是执行数据编码进程与执行数据解码进程之间的关系。向前兼容性与向后兼容性对于可演化性来说非常重要,使得应用程序的更改更加容易。不同的编码技术通过不同的方式来保证程序的兼容性。
本节将讨论一些最常见的进程间数据流动的方式,包括:
- 通过数据库
- 通过服务调用
- 通过异步消息传递
2.1 基于数据库的数据流
在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。在这种场景下,前向兼容与后向兼容的必要性体现在:
- 后向兼容:未来的数据库需要对之前写入的内容进行解码(读取)
- 前向兼容:由于可能存在不同的进程同时访问数据库,某些进程可能运行较新的代码,某些则可能运行较旧的代码,此时由较新代码写入的值需要由仍在运行的旧版本代码读取
对于前向兼容,基于数据库的数据流存在一个额外障碍:如果在记录模式中添加了一个字段,新代码将该新字段的值写入数据库,此时如果旧代码需要读取、更新该记录,理想的行为是「保持新字段不变」,即使它无法解释。
在编码格式层面,上述障碍的影响不大,之前讨论的格式都支持未知字段的保存。而在应用程序层面,如果没有这方面的意识,在将数据库值解码为应用程序的模型对象,再重新编码模型对象的过程中,可能会丢失这些字段,如下图所示(实际上成熟的 ORM 框架都会考虑到这点):
2.1.1 不同时间写入不同值
数据库通常支持在任何时候更新任何值,这就导致某些数据可能使用的是很早之前的旧模式(原始编码),而某些数据使用的是新模式,这种现象有时被称为 data outlives code。在大型数据集上,将数据重写为新模式的操作代价不菲,很多数据库通常会避免此操作。
基于上述现象,大多数「关系型数据库」允许进行简单的模式更改,例如添加具有默认值为空的新列,而不重写现有数据(MySQL 经常会重写)。读取旧行时,数据库会为磁盘上编码数据缺失的所有列填充为空值。此外,某些「非关系型数据库」也支持模式的演化,例如 LinkedIn 的文档数据库 Espresso 使用 Avro 进行存储,支持 Avro 的模式演化规则。
总的来说,模式演化让整个数据库看起来像是采用单个模式编码,即使底层存储可能包含各个版本模式所编码的记录。
2.1.2 归档存储
另一方面,有时我们需要为数据库创建「快照」(snapshot),例如进行备份或是加载到数据仓库中。在这种情况下,数据转储通常会使用最新的模式进行编码,即便源数据库中的原始编码包含了不同时期的各种模式。对数据副本进行统一的编码更加有利于后续的操作。
在进行数据归档存储时,由于写入是一次性的且不可改变,像 Avro 对象容器文件这样的格式是非常适合的。同时,也可以考虑使用分析友好的「列存储」对数据进行重新编码。
2.2 基于服务的数据流:REST 和 RPC
对于需要通过网络进行通信的进程,最常见的通信方式包含两类角色:「客户端」(clients)和「服务器」(servers)。服务器通过网络公开 API(称为「服务」),客户端可以连接到服务器以向 API 发出请求。
具体来说,客户端可以是 「Web 浏览器」,也可以是「本地应用」,服务器的响应可以是直接用于「前端展示」的 HTML、CSS 等,也可以是便于客户端应用程序进一步处理的「编码数据」(如 JSON)。无论哪种形式,顶层实现的 API 都是特定于应用程序的,只允许由服务的业务逻辑预先确定的输入与输出,客户端和服务器需要就 API 的细节达成一致。此外,服务器本身也可以作为另一项服务的客户端(例如 web 应用服务器作为数据库的客户端)。
总的来看,这种将大型应用程序按照功能区域分解为较小的服务,通过发送请求交互的方式被称为面「向服务的体系结构」(SOA),最近更名为「微服务体系结构」。面向服务/微服务体系结构的一个关键设计目标是,通过使服务可独立部署和演化,让应用程序更易于更改和维护。为了让新旧版本的服务器和客户端同时运行,其使用的数据编码必须在不同版本的服务 API 之间兼容。
2.2.1 网络服务
当 HTTP 被用作与服务通信的底层协议时,其被称为 Web 服务。Web 服务的使用场景主要有以下几种:
- 运行在用户设备上的客户端应用程序,通过 HTTP 向服务发出请求
- 一种服务向同一组织拥有的另一项服务提出请求,支持这种用例的软件也被称为「中间件」
- 一种服务向不同组织所拥有的服务提出请求,包括由在线服务提供的公共 API,或用于共享访问用户数据的 OAuth
当前有两种流行的 Web 服务方法:「REST」 与 「SOAP」。它们在设计理念方面几乎是截然相反的,具体来说:
- REST 不是一种协议,而是一个基于 HTTP 原则的设计理念。其强调简单的数据格式,使用 URL 来标识资源,使用 HTTP 功能来进行缓存控制、身份验证和内容类型协商。根据 REST 原则所设计的 API 被称为 「RESTful」。
- SOAP 是一种基于 XML 的协议,用于发出网络 API 请求,其目的是独立于 HTTP 并避免使用大部分 HTTP 功能。SOAP Web 服务使用被称为 「WSDL」 的语言来描述,支持代码生成,对于静态类型编程语言非常有用。
总的来看,SOAP 带有庞大而复杂的多种相关标准,其消息通常过于复杂,严重依赖工具支持、代码生成与 IDE,集成 SOAP 服务相对困难;与 SOAP 相比,REST 已经越来越受欢迎,经常与微服务相关联,其倾向于更简单的方法,通常涉及较少的代码生成与自动化工具,可以使用 OpenAPI 规范(也被称为 Swagger)来描述 RESTful API 并帮助生成文档。
2.2.2 远程过程调用
20 世纪 70 年代以来,「远程过程调用」(RPC)的思想开始出现,其属于网络服务的一种技术,核心想法是试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同,这种抽象被称为「位置透明」(location transparency)。
虽然 RPC 最初看起来很方便,但是这种方法从根本上存在缺陷,即网络请求与本地函数调用是非常不同的,具体来说:
- 本地函数调用是可预测的(成功或失败仅取决于控制的参数),而网络请求是不可预测的(可能出现网络问题或远程机器问题)
- 本地函数调用要么返回结果(包括 void),要么抛出异常,或者永远不返回(进入无限循环或进程崩溃),而网络请求由于存在「超时」问题,可能在返回时没有结果,需要进行特殊处理
- 如果重试失败的网络请求,可能会存在之前的请求实际上已经完成,只是响应丢失的情况。这种情况下,重试将导致该操作被执行多次,除非在协议中建立重复数据消除(幂等性)机制
- 每次调用本地函数时,通常需要大致相同的时间来执行,而网络请求一般会比本地调用慢得多,同时执行时间受网络与远程机器的影响
- 调用本地函数时,可以高效地将引用(或指针)传递给本地内存中的对象,但是对于网络请求,所有参数需要被编码为可以通过网络发送的字节序列,对较大的对象来说可能会出现传输问题
- 客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言转换为另一种语言(不是所有语言都具有相同的类型)
总的来看,由于本质上的不同,远程服务调用看起来存在着很多问题,但是 RPC 并没有消失,本章提到的所有编码的基础上构建了各种 RPC 框架,新一代的 RPC 框架更加明确了远程请求与本地函数调用不同的事实,同时还提供了服务发现(在特定 ip 与端口号上获得特定服务)等新的特性。与 REST 相比,RPC 框架侧重于同一组织内多项服务之间的请求,通常发生在同一数据中心内。
RPC 的数据编码与演化
对于 RPC 框架来说,演化性主要体现在可以独立地更改和部署 RPC 客户端与服务器。与基于数据库的数据流相比,此处可以进行一个简化的假设:假定所有服务器都先被更新,其次是所有的客户端。因此,我们只需要在请求上(服务器)具有向后兼容性,在响应上(客户端)具有向前兼容性。
RPC 方案的向后与向前兼容性取决于其所使用的具体编码技术:
- Thrift、gRPC(Protocol Buffers)和 Avro RPC 可以根据各自编码格式的兼容性规则进行演化
- 在 SOAP 中,请求和响应是 XML 模式指定的,理论上可以支持演化
- RESTful API 通常使用 JSON 用于响应,而请求则采用 JSON 或 URI 编码/表单编码的请求参数等形式。为了保持兼容性,通常可考虑的更改包括添加可选的请求参数和在响应中添加新的字段
如果将 RPC 用于跨组织边界的通信,服务的兼容性会变得更加困难。为了长期保持兼容性,服务提供者往往会同时维护多个版本的服务 API。对于 API 版本的管理,常用的方法是在 URL 或 HTTP Accept
头中使用版本号,也可以将客户端请求的 API 版本(使用 API 密钥标识特定客户端)存储在服务器,通过单独的管理接口进行更新。
2.3 基于消息传递的数据流
在前两节中,已经讨论了两种数据流模式,其都是从一个进程到另一个进程:
- 以 REST 与 RPC 为代表的基于服务的数据流(一个进程通过网络向另一个进程发送请求,并期望尽快得到响应)
- 基于数据库的数据流(一个进程写入编码数据,另一个进程在未来某个时刻再次读取该数据)
本节将介绍介于 RPC 与数据库之间的「异步消息传递」系统。其与 RPC 的相似之处在于,客户端的请求(即消息)以低延迟传递到另一个进程;其与数据库的相似之处在于,不是通过直接的网络连接发送消息,而是通过称为「消息代理」(也称为消息队列、面向消息的中间件)的中介发送,该中介会暂存消息。
与直接 RPC 相比,消息代理具有以下优点:
- 如果接收方不可用或过载,可以充当缓冲区,提升系统可靠性
- 可以自动将消息重发给崩溃进程,防止消息丢失
- 避免了发送方需要知道接收方的 IP 地址与端口号
- 支持将一条消息发送给多个接收方
- 在逻辑上将发送方与接收方分离(发送方只需要发布消息)
而与 RPC 不同,消息传递的一个局限性在于其是「单向」的:发送方通常不期望收到对其消息的回复(即使有响应,也是在独立通道上异步完成的)
2.3.1 消息代理
常见的消息代理开源实现包括 RabbitMQ、ActiveMQ、HornetQ、Apache Kafka 等。通常情况下,消息代理的使用方式如下:
- 一个进程向指定的「队列」(queue)或「主题」(topic)发送消息,且代理确保消息被传递给队列或主题的一个或多个「消费者」(consumers)或「订阅者」(subscribers)
- 在同一个队列(或主题)上可以存在多个生产者与多个消费者,队列(或主题)只提供单向的数据流(但消费者可以将消息发送至其他队列)
消息代理通常不会强制任何特定的数据类型——消息只是包含一些元数据的字节序列,因此可以使用任何编码格式,如果编码是向后和向前兼容的,则可以最大程度灵活地独立更改发布者和消费者,并以任意顺序部署他们。
2.3.2 分布式 Actor 框架
「Actor 模型」是一种用于处理单个进程中并发的编程模型,逻辑被封装在 actor 中,而不是直接处理线程。每个 Actor 通常代表一个客户端或实体,可能具有某些本地状态,其通过发送和接收异步消息与其他 Actor 通信,且消息传送不被保证(可能存在丢失)。由于每个 Actor 一次只能处理一条消息,所以不需要担心线程,可以由框架独立调度。
对于「分布式 Actor 框架」,其被用来跨越多个节点扩展应用程序,无论发送方和接收方是否在同一个节点上,都使用相同的消息传递机制,消息被透明地编码为字节序列。相比 RPC,位置透明性在 Actor 模型中更为有效,因为其假定任何条件下消息都可能会丢失(这就使得单进程与多节点的差异性变小了)。
实际上,分布式 Actor 框架就是将消息代理与 Actor 编程模型集成到了单个框架中。而如果要对基于 Actor 的应用程序执行滚动升级,仍需要担心向前与向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。对于 Actor 模型的兼容性,三种主流的分布式 Actor 框架的处理方式如下:
- Akka 默认使用 Java 的内置序列化,不提供向前或向后兼容性,可以使用类似 Protocol Buffers 的东西进行替代,以获得滚动升级的能力
- Orleans 默认不支持滚动升级部署的自定义数据编码格式,部署新版本应用需要建立新的集群,将流量从旧集群导入新集群。其也可以像 Akka 一样使用自定义序列化插件
- Erlang OTP 很难对记录模式进行更改,滚动升级在技术上是可能的,但是需要仔细规划
3 小结
本章研究了将内存数据结构转换为网络或磁盘上字节流的多种方法。由于服务的滚动升级以及各种其他原因,很可能出现不同的节点运行不同版本应用代码的情况,因此,在系统内流动的所有数据都以提供「向后兼容性」和「向前兼容性」的方式进行编码显得非常重要。
本章首先讨论了多种数据编码格式及其兼容性情况:
- 编程语言特定的编码受语言限制,往往无法提供向前与向后兼容性
- JSON、XML 和 CSV 等文本格式十分普遍,其兼容性取决于如何使用它们
- 诸如 Thrift、Protocol Buffers 和 Avro 这样的二进制的模式驱动格式,支持使用清晰定义的向前和向后兼容性语义进行紧凑、高效的编码(人类不可读)
然后讨论了数据流的几种模型,说明了数据编码在不同场景下非常重要:
- 数据库:写入数据库的进程对数据进行编码,读取数据库的进程对数据进行解码
- RPC 与 REST API:客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
- 异步消息传递:使用消息代理或 actor,节点之间通过互相发送消息进行通信,消息由发送者编码并由接收者解码
最后,我们得出的结论是:只要稍加小心,向后/向前兼容性与滚动升级是完全可以实现的!