DDIA - 第4章 数据编码与演化

信息是激发创新的力量

        本章目标: 数据编码(序列化)方面,包括常见模式的演化历程。

第4章 数据编码与演化

        应用程序不可避免地需要随时间而变化、调整。在大多数情况下,更改应用程序功能时,也需要更改其存储的数据:可能需要捕获新的字段或记录类型,或者需要以新的方式呈现已有数据。
        对于一个大型应用系统,代码更迭往往并非易事:

  • 对于服务器端应用程序,可能需要执行滚动升级(也被称为分阶段发布),每次将新版本部署到少数几个节点,检查新版本是否正常运行,然后逐步在所有节点上升级新的代码。
  • 对于客户端应用程序,只能寄望于用户,然而他们在一段时间内可能不会马上安装更新。

        这意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。为了使系统继续顺利运行,需要保持双向的兼容性。

  • 向后兼容
            较新的代码可以读取由旧代码编写的数据
  • 向前兼容
            较旧的代码可以读取由新代码编写的数据

        向后兼容通常不难实现:作为新代码的作者,清楚旧代码所编写的数据格式,因此可以比较明确地处理这些旧数据(如果需要,只需保留旧的代码来读取旧的数据)。向前兼容可能会比较棘手,他需要旧代码忽略新版本的代码所做的添加。

1 数据编码格式

        程序通常使用(至少)两种不同的数据表示形式:

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

        因此,在这两种表示之间需要进行类型的转化。从内存中的表示到字节序列的转化称为编码(或序列化等),相反的过程称为解码(或解析,反序列化)。

1.1 语言特定的格式

        许多编程语言都内置支持将内存中的对象编码为字节序列。这些内置的编码库使用很方便,但是存在一些比较深层次的问题:

  • 编码通常与特定的编程语言绑定在一起,而用另一种语言访问数据就非常困难
  • 为了在相同的对象类型中恢复数据,解码过程需要能够实例化任何的类
  • 在这些库中,多版本数据通常是次要的,主要目标是快速且简单地编码数据,所以它们经常忽略向前和向后兼容性等问题
  • 效率(编码或解码花费的CPU时间,以及编码结构的大小)通常也是次要的。例如,Java的内置序列化由于其糟糕的性能和臃肿的编码而广为诟病

        由于这些原因,使用语言内置的编码方案通常不是个好主意,除非只是为了临时尝试。

1.2 JSON、XML与二进制变体

        目标转向可由不同编程语言编写和读取的标准化编码,显然JSON和XML是其中佼佼者。CSV是另一种流行的与语言无关的格式,尽管功能较弱。
        JSON、XML和CSV都是文本格式,因此具有不错的可读性(尽管语法容易引发争论)。除了表面的语法问题,它们也有一些微妙的问题:

  • 数字编码有很多模糊之处
  • JSON和XML对Unicode字符串(即人类可读文本)有很好的支持,但是它们不支持二进制字符串(没有字符编码的字节序列)
  • XML和JSON都有可选的模式支持
  • CSV没有任何模式,因此应用程序需要定义每行和每列的含义
1.2.1 二进制编码

        对于仅在组织内部使用的数据,使用最小公分母编码格式则较为顺畅。JSON不像XML那么冗长,但与二进制格式相比,两者仍然占用大量空间。这种观察导致开发了大量的二进制编码,用以支持JSON和XML。由于它们没有规定模式,所以需要在编码数据时包含所有的对象字段名称。
        JSON的所有二进制编码在编码长度方面是相似的。目前对于如此小的空间缩减(也许解析速度可以加快)是否值得失去可读性仍存有疑问。

1.3 Thrift与Protocol Buffers

        Apache Thrift 和 Protocol Buffers(protobuf)是基于相同原理的两种二进制编码库,两者都需要模式来编码任意的数据。两者都有着各自的接口定义语言来描述模式,都有着各自对应的代码生成工具,并生成支持多种编程语言的类。应用程序可以直接调用生成的代码来编码或解码该模式的记录。
        Thrift有两种不同的二进制编码格式,分别称为BinaryProtocol和CompactProtocol,对示例进行编码分别需要59字节和34字节。而Protocol Buffers对相同的数据进行编码只需要33字节。
        需要注意的一个细节是,每个字段被标记为required(必须)或optional(可选),但这对字段如何编码没有任何影响(二进制数据中不会指示某字段是否必须)。区别在于,如果字段设置了required,但字段未填充,则运行时检查将出现失败,这对于捕获错误非常有用。编码细节略。

1.3.1 字段标签和模式演化

        模式不可避免地需要随着时间而不断变化,称之为模式演化。那么这两个编码库是如何在保证向前兼容和向后兼容的同时应对模式更改呢?
        基础:一条编码记录只是一组编码字段的拼接。 每个字段由其标签号标识,并使用数据类型进行注释。如果没有是指字段值,则将其从编码的记录中简单地忽略。由此可以看出,字段标签(field tag)对编码数据的含义至关重要。可以轻松更改模式中字段的名称,而编码永远不直接引用字段名称。但不能随便更改字段的标签,它会导致所有现有编码数据无效。
        通过数据类型的注释来通知解析器跳过特定的字节数。这样可以实现向前兼容性,即旧代码可以读取由新代码编写的记录。为了保持向后兼容性,在模式的初始部署之后添加的每个字段都必须是可选的或具有默认值。删除字段就像添加字段一样,不过向后和向前兼容性问题相反。

1.3.2 数据类型和模式演化

        如果改变字段的数据类型呢?这会存在值丢失精度或被截断的风险。
        Protocol Buffers的一个奇怪的细节是,它没有列表或数组数据类型,而是有字段的重复标记(repeated,这是必需和可选之外的第三个选项)。对于重复字段,表示同一个字段标签只是简单地多次出现在记录中。可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
        Thrift有专用的列表数据类型,它使用列表元素的数据类型进行参数化。它不支持Protocol Buffers那样从单值到多值的改变,但是它具有支持嵌套列表的优点。

1.4 Avro

        Apache Avro是另一种二进制编码格式,它与Protocol Buffers和Thrift有着一些有趣的差异。由于Thrift不合适Hadoop的用例,因此Avro在2009年作为Hadoop的子项目而启动。
        Avro也使用模式来指定编码的数据结构。它有两种模式语言:一种(Avro IDL)用于人工编辑,另一种(基于JSON)更易于机器读取。
        首先,请注意模式中没有标签编号。如果使用这个模式编码示例记录,Avro二进制编码只有32字节长,这是所见到的所有编码中最近凑的。原因是它省略掉了数据类型字段和标识字段,编码只是有连在一起的一些列值组成。
        为了解析二进制数据,按照它们出现在模式中的顺序遍历这些字段,然后直接采用模式告诉你每个字段的数据类型。这意味着,只有当读取数据的代码使用与写入数据的代码完全相同的模式时,才能正确解码二进制数据。读和写的模式如果有任何不匹配都将无法解码数据。

1.4.1 写模式与读模式

        Avro如何支持模式演化?
        写模式: 可以编译到应用程序中的模式。
        读模式: 当应用程序想要解码某些数据时,它期望数据符合某个模式。
        Avro的关键思想是,写模式和读模式不必是完全一模一样,它们只需保持兼容。当数据被解码(读取)时,Avro库通过对比查看写模式和读模式并将数据从写模式转换为读模式来解决差异。Avro规范明确定义了这种解决方法的工作原理。
        如果写模式和读模式的字段顺序不同,这也没有问题,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在写模式但不在读模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是写模式不包含名称的字段,则使用读模式中声明的默认值填充。

1.4.2 模式演化规则

        使用Avro,向前兼容意味着可以将新版本的模式作为writer,并将旧版本的模式作为reader。相反,向后兼容意味着可以用新版本的模式作为reader,并用旧版本的模式作为writer。
        只要Avro可以转换类型,就可以改变字段的数据类型。更改字段的名称也是有可能的,但有点棘手:reader的模式可以包含字段名称的别名,因此它可以将旧writer模式字段名称与别名进行匹配。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容,但不能向前兼容。

1.4.3 那么writer模式又是什么?

        reader如何知道特定的数据采用哪个writer的模式编码的?在每个记录中都包含整个模式不太现实,因为模式有时甚至比编码数据还要大得多,这样二进制编码所节省的空间都变得没有意义。

  • 有很多记录的大文件
            该文件的writer可以仅在文件的开头包含writer的模式信息。Avro通过指定一个文件格式(对象容器文件)来做到这一点
  • 具有单独写入记录的数据库
            在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。reader可以获取记录,提取版本号,然后从数据库中查询该版本号的writer模式。使用该writer模式,它可以解码记录的其余部分
  • 通过网络连接发送记录
            当两个进程通过双向网络连接进行通信时,他们可以在建立连接时协商模式版本,然后再连接的生命周期中使用该模式。这也是Avro RPC协议的基本原理

        在任何情况下,提供一个模式版本信息的数据库都非常有用,它可以充当一个说明文档来检查模式兼容性情况。至于版本号,可以使用简单的递增整数,也可以使用对模式的哈希。

1.4.4 动态生成的模式

        与Protocol Buffers和Thrift相比,Avro方法的一个优点是不包含任何标签号码。为什么这很重要?在模式中保留一些数字有什么问题?
        关键之处在于Avro对动态生成的模式更友好。 数据库中的列名称映射到Avro中的字段名称。而Thrift或Protocol Buffers,则可能必须手动分配字段标签:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射(这可能会自动化,但模式生成器必须发非常小心,不要分配以前使用的字段标签)。这种动态生成的模式根部不是Thrift或Protocol Buffers的设计目标,而是Avro的设计目标。

1.4.5 代码生成和动态类型语言

        Thrift和Protocol Buffers依赖于代码生成:在定义了模式之后,可以使用选择的编程语言生成实现此模式的代码。这在Java、C++或C#等静态类型语言中很有用,因为它允许使用高效的内存结构来解码数据,并且在编写访问数据结构的程序时,支持再IDE中进行类型检查和自动完成。
        而在动态类型编程语言中,因为没有编译时类型检查,生成代码没有太多意义。
        一个对象容器文件(它嵌入了writer模式)是自描述的,它包含了所有必要的元数据。
        此属性与动态类型数据处理语言(如Apache Pig)结合使用时特别有用。再Apache Pig中,只需打开一些Avro文件,分析其内容,并编写派生数据集以Avro格式输出文件,而无需考虑模式。

1.5 模式的优点

        许多数据系统也实现了一些专有的二进制编码。尽管JSON、XML和CSV等文本格式非常普遍,但基于模式的二进制编码也是一个可行的选择。它们有许多不错的属性:

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

        总之,通过演化支持与无模式/读时模式的JSON数据库相同的灵活性,同时还提供了有关数据和工具方面更好的保障。

2 数据流模式

        每当将一些数据发送到非共享内存的另一个进程时,例如,当通过网络发送数据或者把它写入文件时,都需要将数据编码为字节序列。而向前和向后的兼容性对于可演化性来说非常重要,通过允许独立升级系统的不同部分,而不必一次改变所有,是更改更为容易。兼容性是执行编码的一个进储和执行解码的另一个进程之间的关系。
        接下来探讨一些最常见的进程间数据流动的方式:

  • 通过数据库
  • 通过服务调用
  • 通过异步消息传递

2.1 基于数据库的数据流

        在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。数据库也要具有向前兼容性(正在滚动升级中部署新版本)和向后兼容性(否则未来无法解析)。
        关于编码格式支持未知字段的保存,有时还需要注意应用程序层面的影响,在转换的过程中可能会丢失未知字段。解决这个问题并不算难,重要的是首先要有这方面的意识。

2.1.1 不同的时间写入不同的值

        数据库通常支持在任何时候更新任何值。这意味着在单个数据库中,可能有一些值是在5ms前写入的,而有些值是在5年前写入的。新部署的应用程序可能会在几分钟内完全替换旧版本,但数据库内容不是这样的,五年前的数据仍然采用原始编码,除非已经明确地重写了它。这种现象有时被总结为数据比代码更长久。
        当旧版本的应用程序更新新版本的程序所写入的数据时,需要小心,否则可能会丢失数据。 将数据重写(或迁移)为新模式是可能的,但对于大型数据集代价不菲。很多数据库支持简单的模式更改,或者支持Avro的模式演化规则。
        模式演化支持整个数据库看起来像是采用单个模式编码,即使底层存储可能包含各个版本模式所编码的记录。

2.1.2 归档存储

        或许你会不时地为数据库创建快照,例如用于备份或加载到数据仓库。在这种情况下,数据转储通常使用最新的模式进行编码,即使源数据库中的原始编码包含了不同时代的各种模式版本。由于无论如何都要复制数据,所以此时最好对数据副本进行统一的编码。
        由于数据转储是一次写入的,而且以后不可改变,因此像Avor对象容器文件这样的格式非常适合。这也是很好的机会,可以用分析友好的列存储(如Parquet)对数据进行编码。

2.2 基于服务的数据流:REST和RPC

        对于需要通过网络进行通信的进程,有多种不同的通信方式。最常见的是有两个角色:客户端和服务器。 服务器通过网络公开API,客户端可以连接到服务器以向该API发出请求。服务器公开的API称为服务。
        虽然HTTP可以用作传输协议,但是在顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。此外,服务器本身可以是另一项服务的客户端。微服务可以使服务可独立部署和演化,让应用程序更易于更改和维护,服务可以对客户端可以做什么和不能做什么施加细粒度的限制。
        期望新旧版本的服务器和客户端同时运行,因此服务器和哭护短使用的数据编码必须在不同版本的服务API之间兼容。

2.2.1 网络服务

        当HTTP被用作与服务通信的底层协议时,它被称为Web服务。
        有两种流行的Web服务方法:REST(更受欢迎)和SOAP
        REST不是一种协议,而是一个基于HTTP原则的设计理念。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制、身份验证和内容类型协商。根据REST原则所设计的API称为RESTRful。
        相比之下,SOAP是一种基于XML的协议,用于发送网络API请求。SOAP消息通常过于复杂,无法手动构建,SOAP用户严重依赖工具支持、代码生成和IDE。对于没有SOAP供应商支持的编程语言的用户来说,视图与SOAP服务集成非常困难。

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

        Web服务仅仅是通过网络发出API请求的一系列技术的最新体现。远程过程调用(Remote Procedure Call,RPC)的思想自20世纪70年代以来就一直存在。RPC模型试图使向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。虽然RPC起初看起来很方便,但这种方法在根本上是有缺陷的。网络请求与本地函数调用非常不同:

  • 本地函数调用是可预测的,网络请求是不可预测的
  • 本地函数调用要么返回一个结果,要么抛出一个异常,或者永远不会返回(因为进入无限循环或者进程崩溃)。网络请求有另一个可能的结果:由于超时,它返回时可能没有结果
  • 如果重试失败的网络请求,可能会发生请求实际上已经完成,只是相应丢失的情况(采用幂等性解决
  • 每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也有很大的变化
  • 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列
  • 客户端和服务可以用不同的编程语言来实现,所以RPC框架必须将数据类型从一种语言转称另一种语言

        所有这些因素意味着,尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为它们是根本不同的事情。REST的部分吸引力在于,它并不试图隐藏它是网络协议的事实(尽管这似乎并没有组织人们在REST上构建RPC库)

2.2.3 RPC的发展方向

        新一代的RPC框架更加明确了远程请求与本地函数调用不同的事实。使用二进制编码格式的自定义RPC协议,可以实现比诸如REST上的JSON之类的通用协议更好的性能。但是,RESTful API还有一些其他显著的优点:它有利于实验和调试,支持所有主流编程语言和平台,并且有一个庞大的工具生态系统。
        由于这些原因,REST似乎是公共API的主流风格。RPC框架主要侧重于同一组织内多项服务之间的请求,通常发生在同一数据中心内。

2.2.4 RPC的数据编码和演化

        对于演化性,重要的是可以独立地更改和部署RPC客户端和服务器。RPC方案的向后和向前兼容性取决于它所使用的具体编码技术。
        如果RPC经常跨越组织边界的通信,则服务的兼容性会变得更加困难。如果不得不破坏兼容性,则服务提供者往往会同时维护多个版本的服务API。关于API版本管理应该如何工作(即客户端如何指示它想要使用哪个版本的API)没有统一的方案。

2.3 基于消息传递的数据流

        我们一直在研究从一个进程到另一个进程不同的数据流编码方式,接下来简要介绍一下RPC和数据库之间的异步消息传递系统。它们与RPC的相似之处在于,客户端的请求(通常称为消息)以低延迟传递到另一个进程。它们与数据库的相似之处在于,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列,或面向消息的中间件)的中介发送的,该中介会暂存消息。
        与直接RPC相比,使用消息代理的优点如下:

  • 如果接收方不可用或过载,它可以充当缓冲区,从而提高系统的可靠性
  • 它可以自动将信息重新发送到崩溃的进程,从而防止消息丢失
  • 他避免了发送方需要知道接收方的IP地址和端口号(这在虚拟机经常容易起起停停的云部署中特别有用)
  • 它支持将一条消息发送给多个接收方
  • 他在逻辑上将发送方与接收方分离(发送方只是发布消息,并不关心谁使用它们)

        然而,与RPC的差异在于,消息传递通信通常是单向的:发送方通常不期望收到对其消息的回复。 进程可能发送一个响应,但这通常是在一个独立的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送然后忘记它。

2.3.1 消息代理

        详细的传递语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程向指定的队列或主题发送消息,并且代理确保消息被传递给队列或主题的一个或多个消费者或订阅者。 在同一主题上可以有许多生产者和许多消费者
        主题只提供单向数据流。消息代理通常不会强制任何特定的数据模型,消息只是包含一些元数据的字节序列,因此可以使用任何编码格式。

2.3.2 分布式Actor框架

        Actor模型是用于单个进程中并发的编程模型。逻辑被封装在Actor中,而不是直接处理线程(以及竞争条件、锁定和死锁的相关问题)。每个Actor通常代表一个客户端或实体,它可能具有某些本地状态(不于其他任何Actor共享),并且它通过发送和接收异步消息与其他Actor通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个Actor一次只处理一条消息,因此不需要担心线程,每个Actor都可以由框架独立调度。
        分布式的Actor框架实质上是将消息代理和Actor编程模型集成到单个框架中。
        在分布式Actor框架中,这个编程模型被用来跨越多个节点来扩展应用程序。无论发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们位于不同的节点上,则消息被透明地编码成字节序列,通过网络发送,并在另一端被解码。
        相比RPC,位置透明性在Actor模型中更有效,因为Actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能会比同一个进程中的延迟更高,但是在使用Actor模型时,本地和远程通信之间根本上的不匹配所发生的概率更小。

小结

        研究了将内存数据结构转换为网络或磁盘上字节流的多种方法,讨论了多种数据编码格式及其兼容性情况,讨论了数据流的几种模型,说明了数据编码在不同场景非常重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值