【读书笔记】《数据密集型应用系统设计》第4章 数据编码与演化
引言
一切都在改变,一刻都没有停止 。
一-Heraclitus ,如柏拉图在Cratylus引用的那样(公元前360年)
在大多数情况下,更改应用程序功能时,也需要更改其存储的数据:可能需要捕获新的字段或记录类型,或者需要以新的方式呈现已有数据。
当数据格式或模式发生变化时,经常需要对应用程序代码进行相应的调整(例如,向记录中添加新字段,然后应用程序代码开始读取和写入该字段)。 然而,对于一个大型应用系统,代码更迭往往并非易事 :
-
对于服务器端应用程序,可能需要执行滚动升级(也被称为分阶段发布),每次将新版本部署到少数几个节点,检查新版本是否正常运行,然后逐步在所有节点上升级新的代码。这样新版本部署无需服务暂停,从而支持更频繁的版本发布和更好的演化。
-
对于客户端应用程序,只能寄望于用户,然而他们在一段时间内可能不会马上安装更新。
这意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。为了使系统继续顺利运行,需要保持双向的兼容性:
向后兼容
较新的代码可以读取由旧代码编写的数据。
向前兼容
较旧的代码可以读取由新代码编写的数据。
数据编码格式
程序通常使用(至少)两种不同的数据表示形式 :
1. 在内存中,数据保存在对象、结构体、列表、 数组、哈希表和树等结构中。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)。
2. 将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(如JSON文档)。由于指针对其他进程没有意义,所以这个字节序列表示看起来与内存中使用的数据结构大不一样。
语言特定的格式
许多编程语言都内置支持将内存中的对象编码为字节序列 。例如, Java有java.io.Serializable, Ruby有Marshal, Python有pickle等。
这些编码库使用起来非常方便,然而, 这里也有一些深层次的问题 :
-
编码通常与特定的编程语言绑定在一起, 而用另一种语言访问数据就非常困难 。
-
为了在相同的对象类型中恢复数据, 解码过程需要能够实例化任意的类。 这经常导致一些安全问题:如果攻击者可以让应用程序解码任意的字节序列, 那么它们可以实例化任意的类, 这通常意味着, 它们可以做些可怕的事情,比如远程执行任意代码。
-
在这些库中,多版本数据通常是次要的,主要目标是快速且简单地编码数据,所以它们经常忽略向前和向后兼容性等问题。
-
效率(编码或平码花费的CPU时间,以及编码结构的大小)通常也是次要的。例如,Java的内置序列化由于其糟糙的性能和腕肿的编码而广为诟病。
由于这些原因, 使用语言内置的编码方案通常不是个好主意,除非只是为了临时尝试。
JSON、XML与二进制变体
JSON、XML和CSV都是文本格式, 因此具有不错的可读性(尽管语法能容易引发争论)。除了表面的语法问题之外, 它们也有一些微妙的问题:
-
数字编码有很多模糊之处。 在XML和csv中,无法区分数字和碰巧由数字组成的字符串(除了引用外部模式)。 JSON 区分字符串和数字,但不区分整数和浮点数,并且不指定精度。
这在处理大数字时是一个问题, 大于253的整数在IEEE 754双精度浮点数中不能精确表示, 所以这些数字在使用浮点数(如JavaScript )的语言中进行分析时, 会变得不准确。 Twitter上有一个大于253的数字的例子, 它使用一个64位的数字来标识每条推文。 Twitter的API返回的JSON包含两次推特ID,一次是JSON数字,一次是十进制字符串,以解决JavaScript应用程序没有正确解析数字的问题。
-
JSON和XML对Unicode字符串(即人类可读文本)有很好的支持 ,但是它们不支持二进制字符串(没有字符编码的字节序列)。
-
XML和JSON都有可选的模式支持。这些模式语言相当强大, 因此学习和实现起来也比较复杂。XML模式的使用相当广泛, 但许多基于JSON的工具并不局限于使用模式。 由于数据(例如数字和二进制字符串)的正确解释取决于模式中的信息,因此不使用 XML/JSON 架构的应用程序可能不得不硬编码适当的编码/解码逻辑。
-
csv没有任何模式,因此应用程序需要定义每行和每列的含义。 如果应用程序更改添加新的行或列,则必须手动处理该更改。 csv也是一个相当模糊的格式(如果一个值包含逗号或换行符, 会发生什么)
尽管存在这些或那些缺陷, 但JSON 、 XML和csv已经可用于很多应用。 特别是作为数据交换格式( 即将数据从一个组织发送到另一个组织),它们非常受欢迎。 在这些情况下, 只要人们就格式本身达成一致,格式多么美观或者高效往往不太重要。 让不同的组织达成格式一致的难度通常超过了所有其他问题。
二进制编码
JSON不像XML那么冗长,但与二进制格式相比,两者仍然占用大量空间。这种观察导致开发了大量的二进制编码,用以支持JSON(举几个例子,如MessagePack 、 BSON、 BJSON、 UBJSON、 BISON和Smile )和XML。其中一些格式还扩展了数据类型集(例如,区分整数和浮点数,或者增加对二进制字符串的支持),但其他格式保持JSON/XML数据模型不变。特别是,由于它们没有规定模式,所以需要在编码数据时包含所有的对象字段名称。
来看一个MessagePack的例子,它是一种JSON的二进制编码 。图 4-1 展示了采用MessagePack对 示例4-1 的JSON文档进行编码所得到的字节序列。
前几个字节如下:
-
第一个字节0x83 , 表示接下来是包含三个字段 (最低四位 = 0X03 )的对象(最高四位=0x80) (如果想知道当对象的字段数超过15个,4bit已经无法容纳,会发生什么情况。结果是,它会得到一个不同的类型指示符,井且字段数被编码为两个或四个字节)。
-
第二个字节0xa8 , 表示接下来是八字节长的字符串(最高四位=0xa0 代表字符串,最低四位 = 0X08 代表长度)。
-
再往下的八字节是ASCII中的字段名称userName 。由于之前已经指出了快度,因此不需要任何标记来告诉字符串结束的位置(或任何转义)。
-
接下来的七宇节使用前缀0xa6对6个字母的字符串值Martin进行编码,依此类推。
二进制编码的长度为66字节,仅略小于文本JSON编码(去掉空格)占用的81字节。
Thrift与Protocol Buffers
Apache Thrift(来自Facebook)和 Protocol Buffers (来自Google) 是基于相同原理的两种二进制编码库。
Thrift和Protocol Buffers都需要模式来编码任意的数据 。为了用Thrift对示例4-1中的数据进行编码,可以使用Thrift接口定义语言( IDL )来描述模式,如下所示 :
struct Person {
1: required string username,
2: optional i64 favouriteNumber,
3: optional list<string> interests
}
Protocol Buffers的等价模式定义看起来非常相似 :
message Person {
required string user_name = 1;
optional int64 favourite_number = 2;
repeated string interests = 3;
}
Thrift和Protocol Buffers各有对应的代码生成工具,采用和上面类似的模式定义,并生成支持多种编程语言的类。 应用程序可以直接调用生成的代码来编码或解码该模式的记录。
Thrift有两种不同的二进制编码格式的,分别称为BinaryProtocol和CompactProtocol 。先来看看BinaryProtocol ,以这种格式编码 示例4-1 需要59字节,如图4-2所示
与图4-1类似, 每个字段都有一个类型注释,并且可以在需要时指定长度,与之前类 =似,数据中出现的字符串也被编码为ASCII。
与图4-1相比,最大的区别是没有字段名(userName、favoriteNumber和interest)。相反,编码数据包含数字类型的字段标签( 1、2和3 )。这些是模式定义中出现的数字。字段标签就像字段的别名,用来指示当前的字段,但更为紧凑,可以省去引用字段全名。
Thrift CompactProtocol编码在语义上等同于BinaryProtocol ,但如图4-3所示,它将相同的信息打包成只有34字节 。它通过将字段类型和标签号打包到单字节中,并使用可变长度整数来实现。对数字1337,不使用全部8字节,而是使用两个字节进行编码,每字节的最高位用来指示是否还有更多的字节。
最后, Protocol Buffs(只有一种二进制编码格式)对相同的数据进行编码,如图 4-4 所示。 它的位打包方式略有不同,但与Thrift的CompactProtocol非常相似。 Protocol Buffers只用33字节可以表示相同的记录。
需要注意的一个细节是,在前面所示的模式中 ,每个字段被标记为 required(必须)或optional (可选),但这对宇段如何编码没有任何影响(二进制数据中不会指示某字段是否必须)。区别在于,如果字段设置了required ,但字段未填充,则运行时检查将出现失败,这对于捕获错误非常有用。
字段标签和模式演化
从示例中可以看到,一条编码记录只是一组编码字段的拼接。每个字段由其标签号(示例模式中的数字1、2、3)标识,并使用数据类型(例如字符串或整数)进行注释。如果没有设置字段值,则将其从编码的记录中简单地忽略。不能随便更改宇段的标签,它会导致所有现有编码数据无效。
可以添加新的字段到模式,只要给每个字段一个新的标记号码。如果旧的代码(不知道添加的新标记号码)试图读取新代码写入的数据,包括一个它不能识别的标记号码中新的字段,则它可以简单地忽略该字段。实现时,通过数据类型的注释来通知解析器跳过特定的字节数。这样可以实现向前兼容性,即旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有一个唯一的标记号码,新的代码总是可以读取旧的数据,因为标记号码仍然具有相同的含义。唯一的细节是,如果添加一个新的字段, 则无法使其成为必需字段。如果要添加字段并将其设置为required,当新代码读取旧代码写人的数据,则该检查将失败,因为旧代码不会写入添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后添加的每个字段都必须是可选的或具有默认值。
删除字段就像添加字段一样,不过向后和向前兼容性问题相反。这意味着只能删除可选的字段(必填字段永远不能被删除),而且不能再次使用相同的标签号码(因为可能仍然有写入的数据包含旧的标签号码,而该字段必须被新代码忽略)。
数据类型和模式演化
如果改变宇段的数据类型呢?这是有可能的,但存在值会丢失精度或被截断的风险。
Protocol Buffers的一个奇怪的细节是,它没有列表或数组数据类型,而是有字段的重复标记( repeated ,这是必需和可选之外的第三个选项)。如图4-4所示,对于重复字段, 表示同一个宇段标签只是简单地多次出现在记录中。可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表 (取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
Avro
由于Thrift不适合Hadoop的用例,因此Avro在2009年作为Hadoop的子项目而启动。
Avro也使用模式来指定编码的数据结构。它有两种模式语言:一种(Avro IDL)用于人工编辑,另一种(基于JSON )更易于机器读取。
用 Avro IDL编写的示例模式如下所示:
该模式的等价JSON表示如下·
首先,请注意模式中没有标签编号。如果使用这个模式编码示例记录(示例4-1)Avro二进制编码只有32字节长,这是所见到的所有编码中最紧凑的。编码字节序列的分解如图 4-5 所示。
可以看到没有什么可以标识字段或数据类型。编码只是由连在一起的一些列值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节流编码数据中没有任何内容告诉你它是一个字符串。 它也可以是一个整数, 或者其他什么类型。整数使用可变长度编码(与Thrift的CompactProtocol 相同)进行编码。
为了解析二进制数据,按照它们出现在模式中的顺序遍历这些字段,然后直接采用模式告诉你每个字段的数据类型。 这意味着,只有当读取数据的代码使用与写入数据的代码完全相同的模式时,才能正确解码二进制数据。 读和写的模式如果有任何不匹配都将无法解码数据。
那么,如何支持模式演化?
写模式与读模式
有了Avro ,当应用程序想要对某些数据进行编码(例如将其写入文件或数据库,以及通过网络发送)时 ,它使用所知道的模式的任何版本来编码数据,例如,可以编译到应用程序中的模式。这被称为写模式。
当应用程序想要解码某些数据(例如从文件或数据库读取数据,或者从网络接收数据等)时, 它期望数据符合某个模式, 即读模式。这是应用程序代码所依赖的模式,代码可能是在应用程序的构建过程中基于模式而动态生成。
Avro的关键思想是,写模式和读模式不必是完全一模一样, 它们只需保持兼容。当数据被解码(读取)时, Avro库通过对比查看写模式和读模式并将数据从写模式转换为读模式来解决其差异。 Avro规范明确定义了这种解决方法的工作原理,如图4-6所示。
如果写模式和读模式的字段顺序不同,这也没有问题,因为模式解析通过字段名匹配字段。 如果读取数据的代码遇到出现在写模式但不在读模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是写模式不包含该名称的字段, 则使用在读模式中声明的默认值填充。
模式演化规则
为了保持兼容性,只能添加或删除具有默认值的字段。如果要添加一个没有默认值的字段,新的reader将无法读取旧的writer写的数据,因此将破坏向后兼容性。如果要删除没有默认值的字段,则reader将无法读取新writer写入的数据,因此将破坏向前兼容性。
在某些编程语言中,null是所有变量可以接受的默认值。但在Avro中并非如此:如果要允许字段为null ,则必须使用联合类型。例如,union{ null, long, string }字段; 表示该字段可以是数字、字符串或null。因此,Avro不像Protocol Buffers和Thrift那样具有可选和必需的标签(而是有联合类型和默认值)。
只要Avro可以转换类型,就可以改变宇段的数据类型。更改宇段的名称也是可能的,但有点棘手:reader的模式可以包含字段名称的别名, 因此它可以将旧writer模式字段名称与别名进行匹配。这意味着更改字段名称是向后兼容的(新代码兼容旧数据),但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
那么Writer模式又是什么
到目前为止,忽略了一个重要的问题:reader如何知道特定的数据采用哪个writer的模式编码的?
答案取决于Avro使用的上下文 。举几个例子:
有很多记录的大文件
Avro的一个常见用途,尤其是在Hadoop甘上下文中,是用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码。在这种情况下,该文件的writer可以仅在文件的开头包含writer的模式信息。 Avro通过指定一个文件格式(对象容器文件)来做到这一点。
具有单独写入记录的数据库
在数据库中,不同的记录可能在不同的时间点、使用不同的writer模式编写,不 能假设所有记录都具有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。 reader可以获取记录,提取版本号,然后从数据库中查询该版本号的writer模式。使用该writer模式,它可以解码记录的其余部分。
通过网络连接发送记录
当两个进程通过双向网络连接进行通信肘,他们可以在建立连接时协商模式版本,然后在连接的生命周期中使用该模式。这也是Avro RPC协议的基本原理。
在任何情况下,提供一个模式版本信息的数据库都非常有用,它可以充当一个说明文档来检查模式兼容性情况。至于版本号,可以使用简单的递增整数,也可以使用对模式的哈希。
动态生成的模式
与Protocol Buffers和Thrift相比, Avro方法的一个优点是不包含任何标签号。 为什么这很重要?在模式中保留一些数字有什么问题?
关键之处在于 Avro对动态生成的模式更友好。
现在,如果数据库模式发生变化(例如,表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的Avro模式,并用新的Avro模式导出数据。数据导出过程不需要关注模式的改变,每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的writer模式仍然可以与旧的reader模式匹配。
相比之下,如果使用Thrift或Protocol Buffers ,则可能必须手动分配字段标签:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射。
代码生成和动态类型语言
Thrift和Protocol Buffers依赖于代码生成:在定义了模式之后,可以使用选择的编程语言生成实现此模式的代码。这在Java、C++ 或C#等静态类型语言中很有用,因为它允许使用高效的内存结构来解码数据,并且在编写访问数据结构的程序时 ,支持在 IDE 中进行类型检查和自动完成。在动态类型编程语言中,如JavaScript 、Ruby或Python,因为没有编译时类型检查,生成代码没有太多意义。
Avro为静态类型编程语言提供了可选的代码生成,但是它也可以在不生成代码的情况下直接使用。 如果有一个对象容器文件(它嵌入了writer模式),可以简单地使用Avro库打开它,并用和查看JSON文件一样的方式查看数据。该文件是自描述的,它包含了所有必要的元数据。
模式的优点
我们看到 ,尽管JSON 、XML和csv等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。它们有许多不错的属性:
-
它们可以比各种“ 二进制JSON ”变体更紧凑,可以省略编码数据中的宇段名称。
-
模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
-
模式数据库允许在部署任何内容之前检查模式更改的向前和向后兼容性。
-
对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,它能够在编译时进行类型检查。
数据流模式
数据可以通过多种方式从一个进程流向另 一个进程 。 谁编码数据?谁解码数据?在本章的其余部分,将探讨一些最常见的进程间数据流动的方式。
基于数据库的数据流
在数据库中, 写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。可能只有一个进程访问数据库,在这种情况下, reader只是同一进程的较新版本,此时,可以认为向数据库中存储内容,就是给未来的自己发送消息。这种情况下,向后兼容性显然是必要的:否则未来的自己将无法解码以前写的东西。
一般而言,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,也可能只是同一服务的几个实例。这意味着数据库中的值可以由较新版本的代码写入,然后由仍在运行的旧版本代码读取。因此,数据库通常也需要向前兼容。
然而,还有一个额外的障碍。假设在记录模式中添加了一个字段,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道该新宇段)将读取、更新 记录井将其写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释。
之前讨论的编码格式支持未知字段的保存,但是有时候还需要注意应用程序层面的影响,如图 4-7所示。 例如,如果将数据库值解码为应用程序中的模型对象,然后重新编码这些模型对象,则在该转换过程中可能会丢失未知字段。
将数据重写(或迁移)为新模式当然是可能的,但在大型数据集上执行此操作代价不菲,因此很多数据库都尽可能避免此操作。
因此,模式模化支持整个数据库看起来像是采用单个模式编码,即使底层存储可能包含各个版本模式所编码的记录。
归档存储
或许你会不时地为数据库创建快照,例如用于备份或加载到数据仓库。在这种情况下,数据转储通常使用最新的模式进行编码,即使源数据库中的原始编码包含了不同时代的各种模式版本。由于无论如何都要复制数据,所以此时最好对数据副本进行统一的编码。
由于数据转储是一次写入的,而且以后不可改变,因此像Avro对象容器文件这样的格式非常适合。这也是很好的会,可以用分析友好的列存储对数据进行编码。
基于服务的数据流:REST和RPC
对于需要通过网络进行通信的进程 ,有多种不同的通信方式。最常见的是有两个角色:客户端和服务器。服务器通过网络公开API ,客户端可以连接到服务器以向该API发出请求。服务器公开的API称为服务。API包含一组标准的协议和数据格式( HTTP、URL、SSL/TLSH、HTML等 ) 。因为Web浏览器、Web服务器和网站作者大多同意这些标准 ,所以可以使用任何浏览器访问任何网站。
Web浏览器不是唯一的客户端类型。在非浏览器作为客户端的情况下,服务器的响应通常不是用于展示给用户的HTML ,而是便于客户端应用程序代码进一步处理的编码数据(如JSON )。虽然HTTP可以用作传输协议 ,但是在顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致 。
此外,服务器本身可以是另一项服务的客户端。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要另一个服务的某些功能或数据时,就会向另一个服务发出请求。即微服务架构。
在某些方面,服务类似于数据库 :它们通常允许客户端提交和查询数据。但服务公开了特定于应用程序的API ,它只允许由服务的业务逻辑 (应用程序代码)预先确定的输入和输出 。此限制提供了一定程度的封装:服务可以对客户端可以做什么和不能做什么施加细粒度的限制。
应该期望新旧版本的服务器和客户端同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容,这正是在本章所讨论的内容。
网络服务
当 HTTP 被用作与服务通信的底层协议时,它被称为Web服务。这可能有点用词不当,因为Web服务不仅在Web上使用,而且在几个不同的上下文中使用。例如:
-
运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax的JavaScript Web 应用程序),通过HTTP向服务发出请求。这些请求通常通过公共互联网进行。
-
一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。支持这种用例的软件有时被称为中间件。
-
一种服务向不同组织所拥有的服务提出请求,经常需通过互联网 。 这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API,或用于共享访问用户数据的OAuth 。
有两种流行的Web服务方告 :REST和SOAP 。它们在设计理念方面几乎是截然相反的。
REST不是一种协议,而是一个基于HTTP原则的设计理念。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制、身份验证和内容类型协商。根据REST原则所设计的API称为RESTful。
相比之下,SOAP是一种基于XML的协议,用于发出网络API请求的。虽然它最常用于HTTP ,但其目的是独立于HTTP ,并避免使用大多数HTTP功能。相反,它带有庞大而复杂的多种相关标准和新增的各种功能。
远程过程调用(RPC)的问题
RPC模型试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。虽然RPC起初看起来很方便,但这种方法在根本上是有缺陷的。网络请求与本地函数调用非常不同 :
-
本地函数调用是可预测的,并且成功或失败仅取决于控制的参数。网络请求是不可预测的:请求或响应可能由于网络问题而丢失,或者远程计算机可能速度慢或不可用,这些问题完全不在控制范围之内 。 网络问题很常见,因此必须有所准备,例如重试失败的请求。
-
本地函数调用要么返回一个结果,要么抛出一个异常,或者永远不会返回(因为进入无限循环或者进程崩愤)。网络请求有另一个可能的结果 : 由于超时,它返回时可能没有结果。在这种情况下,根本不知道发生了什么:如果没有收到来自远程服务的响应,无法知道请求是否成功。
-
如果重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非在协议中建立重复数据消除(幕等性)机制。本地函数调用则没有这样问题。
-
每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也有很大的变化: 情况好时,它可能会在不到1ms的时间内完成,但是当网络拥塞或者远程服务过载时,可能需要几秒钟的时间才能完成相同操作。
-
调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列。如果参数是像数字或字符串这样的基本类型,这没关系,但是对于较大的对象很快就会出现问题。
-
客户端和服务可以用不同的编程语言来实现,所以RPC框架必须将数据类型从一种语言转换成另一种语言。
REST的部分吸引力在于,它并不试图隐藏它是网络协议的事实(尽管这似乎并没有阻止人们在REST之上构建RPC库)。
RPC的发展方向
虽然有这些问题,但是RPC并没有消失。在本章提到的所有编码的基础上构建了各种RPC框架。
新一代的RPC框架更加明确了远程请求与本地函数调用不同的事实。例如, Finagle和Rest.Ii使用Futures (Promises)来封装可能失败的异步操作。 Futures还简化了需要并行请求多项服务的情况,并将其结果合并。 gRPC支持流,其中调用不仅包括一个请求和一个响应,还包括一段时间内一系列的请求和响应。
其中一些框架还提供了服务发现, 即允许客户端查询在哪个IP地址和端口号上获得特定的服务。
RESTful API还有其他一些显著的优点:它有利于实验和调试(只需使用Web浏览器或命令行工具curl 即可向它发出请求, 而无需任何代码生成或软件安装),支持所有的主流编程语言和平台,井且有一个庞大的工具生态系统(服务器、缓存、负载平衡器、代理、防火墙、监控、调试工具、测试工具等)。
RPC的数据编码和演化
对于演化性, 重要的是可以独立地更改和部署RPC客户端和服务器。此处可以做一个简化的假设 :假定所有的服务器都先被更新, 其次是所有的客户端。 因此, 只需要在请求上具有向后兼容性, 而在响应上具有向前兼容性。
关于API版本管理应该如何工作(即客户端如何指示它想要使用哪个版本的API)没有统一的方案。 对于RESTful API ,常用的方在是是在URL或HTTP Accept头中使用版本号。对于使用API密钥来标识特定客户端的服务,另一种选择是将客户端请求的API版本存储在服务器上, 并允许通过单独的管理接口更新该版本选项。
基于消息传递的数据流
在最后一节中,将简要介绍一下RPC和数据库之间的异步消息传递系统。 它们与RPC的相似之处在于,客户端的请求(通常称为消息)以低延迟传递到另一个进程。它们与数据库的相似之处在于,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列,或面向消息的中间件)的中介发送的,该中介会暂存消息。
与直接RPC相比,使用消息代理有以下几个优点 :
-
如果接收方不可用或过载,它可以充当缓冲区,从而提高系统的可靠性。
-
它可以自动将消息重新发送到崩溃的进程,从而防止消息丢失 。
-
它避免了发送方需要知道接收方的IP地址和端口号(这在虚拟机经常容易起起停停的云部署中特别有用)。
-
它支持将一条消息发送给多个接收方。
-
它在逻辑上将发送方与接收方分离(发送方只是发布消息,并不关心谁使用它们)。
然而,与RPC的差异在于,消息传递通信通常是单向的:发送方通常不期望收到对其消息的回复。
消息代理
通常情况下,消息代理的使用方式如下: 一个进程向指定的队列或主题发送消息,并且代理确保消息、被传递给队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题(因此可以将它们链接在一起),也可以发送到一个回复队列,该队列由原始消息发送者来消费(这样支持类似RPC的请求/响应数据流)。
消息代理通常不会强制任何特定的数据模型,消息只是包含一些元数据的字节序列,因此可以使用任何编码格式。如果编码是向后和向前兼容的,则可以最大程度灵活地独立更改发布者和消费者,并以任意顺序部署他们。
如果消费者重新发布消息到另 一个主题,则可能需要小心保留未知字段,防止未知字段被在处理逻辑里被吞掉。
分布式Actor框架
Actor模型是用于单个进程中并发的编程模型。逻辑被封装在Actor中,而不是直接处理线程(以及竞争条件、锁定和死锁的相关问题) 。每个Actor通常代表一个客户端或实体,它可能具有某些本地状态(不与其他任何Actor共享),并且它通过发送和接收异步消息与其他Actor通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个Actor一次只处理一条消息,因此不需要担心线程,每个Actor都可以由框架独立调度。
在分布式Actor框架中,这个编程模型被用来跨越多个节点来扩展应用程序。无论发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。 如果它们位于不同的节点上,则消息被透明地编码成字节序列,通过网络发送,并在另一端被解码。
相比RPC ,位置透明性在Actor模型中更有效,因为Actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高 ,但是在使 Actor模型时,本地和远程通信之间根本上的不匹配所发生的概率更小。
分布式的Actor框架实质上是将消息代理和Actor编程模型集成到单个框架中。但是,如果要对基于Actor的应用程序执行滚动明升级,则仍需担心向前和向后兼容性问题, 因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
浙公网安备 33010602011771号