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