一切都在改变,一刻都没有停止
——Heaclitus,如柏拉图在Cratylus引用的那样(公元前360年)
当数据格式或模式发生变化时,经常需要对应用程序代码进行相应的调整(例如,向记录中添加新字段,然后应用程序代码开始读取和写入该字段),然而,对于一个大型应用系统,代码更迭往往不是简单的:
- 对于服务器端应用程序,可能需要执行滚动升级(也被称为分阶段分布),每次将新版本部署到少数的几个节点,检查新版本是否运行正常,然后逐步在所有节点上升级新的代码。这样新版本部署无需服务暂停,从而支持更频繁的版本发布和更好的演化(应该是指灰度测试吧)
- 对于客户端应用程序,只能寄望于用户,然而他们在一段时间内可能不会马上安装更新
这意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。为了使系统继续顺利运行,需要保持双向的兼容性
向后兼容:较新代码可以读取有旧代码编写的数据。
向前兼容:较旧的代码可以读取有新代码编写的数据
数据编码格式
程序通常使用两种不同的数据表现形式:
- 在内存中,数据保持在对象,结构体,列表,数组,哈希表和树等结构中。这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)
- 将数据写入文件或通过网络发送时,必须将其编码为某种字包含的字节序列(例如JSON文档)。由于指针对其他进程没有意义,所以这个字节序列表示看起来与内存中使用的数据结构不太一样
因此,在这两种表示之间需要进行类型的转化。从内存中的表示到字节序列的转化称为编码(序列化),相反过程称为解码(解析/反序列化)
序列化一词也用于事务处理,且意义完全不同,为了避免冲突,这里使用编码
语言特定的格式
许多编程语言多内置支持将内存中的对象编码为字节序列。例如,Java有java.io.Serializable,Ruby有Marshal,Python有pickle等
这些编码库使用起来很方便,它们只需要很少的额外代码即可保存或恢复内存中的对象。然而也有一些深层次的问题
- 编码通常与特定的编码语言绑定在一起,而用另一种语言访问数据就非常困难。如果用这种编码方式存储或传输数据,可能在很长一段时间内须使用当前的编程语言,并且不能将系统与其他组织(可能使用不同的语言)的系统方便地集成在一起
- 为了在相同的对象类型中恢复数据,解码过程需要能够实例化任意的类。这经常导致一些安全问题:如过攻击者可以让应用程序解码任意的字节序列,那么它们可以实例化任意的类,这通常意味着,它们可以做些可怕的事情,比如远程执行任意代码
- 在这些库中,多版本数据通常是次要的,主要目标是快速且简单地编码数据,所以它们经常忽略向前和向后兼容性等问题
- 效率(编码或解码花费的CPU时间,以及编码结构的大小)通常也是次要的。例如,Java的内置序列化由于其糟糕的性能和臃肿的编码而广为诟病
JSON、XML与二进制变体
JSON、XML和CSV都是文本格式,因此具有不错的可读性。除了表面的语法问题之外,它们也有一些微妙的问题:
- 数字编码有很多模糊之处。在XML和CSV中,无法区分数字和碰巧由数字组成的字符串(除了引用外部模式之外),JSON区分字符串和数字,但不区分整数和浮点数,并且不指定精度。这在处理大数字时是一个问题,大于2的53次方的整数在IEEE 745双精度浮点数中不能精确表示,所以这些数字在使用浮点数(如JS)的语言总进行分析时,会变得不准确。Twitter上有一个大于2的53次方的数字的例子,它使用一个64位的数字来标识每条推文。Twitter的API返回的JSON包含两次推特ID,一次是JSON数字,一次是十进制字符串,以解决JS应用程序没有正确解析数字的问题
- JSON和XML对Unicode字符串(即人类可读文本)有很好的支持,但是它们不支持二进制字符串(没有字符编码的字节序列)。二进制字符串是一个有用的功能,所以人们通过使用Base64将二进制数据编码为文本来解决这个限制。然后,模式可以表明该值应该被解释为Base64编码。虽然可行,但有点混乱,且数据大小增加了33%
- XML和JSON都有可选的模式支持。这些模式语言相当强大,因此学习和实现起来也比较复杂。XML模式的使用相当广泛,但许多基于JSON的工具并不局限于使用模式。由于数据(例如数字和二进制字符串)的正确解释取决于模式中的信息,因此不是用XML/JSON架构的应用程序可能不得不硬编码适当的编码/解码逻辑
- CSV没有任何模式,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加新的行或列,则必须手动处理该更改。CSV也是一个相当模糊的格式(如果一个值包含逗号或换行符,会发生什么)。尽管其转义规则已经被正式指定,但并不是所有的解析器都能正确的 实现它们
二进制编码
JSON不像XML那么冗长,但与二进制格式相比,两者仍然占用大量空间。这种观察导致开发了大量的二进制编码,用以支持JSON(如MessagePack、BSON、BJSON、UBJSON、BISON和Smile)和XML(如WBXML和Fast Infoset)
Thrift与Protocol Buffers
Apache Thrift和protocol Buffer(protobuf)是基于相同原理的两种二进制编码库。protobuf是由google开发并开源的,thrift是facebook开发并开源的
Thrift和Protocol Buffer都需要模式来编码任意的数据。(书中有示例)
Thrift和Protocol Buffers各有对应的代码生成工具,采用和上面类似的模式定义,并生成多种编程语言的类。应用程序可以直接调用生成的代码来编码或解码该模式的记录
Thrift有两种不同的二进制编码格式,分别为BinaryProtocol和CompactProtocol
BinaryProtocol:每个字段都有一个类型注释(用于指示它是否是字符串。整数、列表等)。并且可以在需要时指定长度(包括字符串长度、列表中的项数)。与MessagePack的区别是没有字段名称。但是编码数据包含数字类型的字段标签(1、2和3)。这些事模式定义中出现的数字。字段标签就像字读啊的别名,用来知识当前的字段,但更为紧凑,可以省去字段全名(书中有图,再读时需要继续看图理解)
CompactProtocol编码在语义上等同于BinaryProtocol,不同的是它将字段类型和标签号打包到单字节中,并使用变长度整数来实现数字编码,打包的信息比BinaryProtocol更短
如果在模式定义中对字段标记为required,但字段未填充,会出现运行时异常
字段标签和模式演化
Apache Thrift和protocol Buffer(protobuf)如何在保持向后和向前兼容性的同时应对模式的更改?
一条编码记录知识一组编码字段的拼接。每个字段由其标签号标识,并使用数据类型进行注释。如果没有设置字段值,则将其从编码的记录中简单的忽略。由此可见,字段标签对编码数据的含义至关重要,可以轻松更改模式中字段的名称,而编码永远不直接引用字段名称。但不能随便更改字段标签,它会导致现有编码数据无效
可以添加新的字段到模式中,只要给没个字段一个新的标记号码。如果旧的代码视图读取新代码写入的数据,包括一个它不能识别的标记号码中新的字段,则它可以简单地胡列盖子段。实现时,通过数据类型和注释来通知解析器跳过特定的字节数这样可以实现向前兼容性,即就带吗可以读取新代码编写的记录
向后兼容只要每个字段都有一个唯一的标记号码,新的代码总是可以读取旧的数据,因为标记号码仍然具有详情的含义。要注意的是新添加的字段不能是必填的
数据类型和模式演化
如果改变字段的数据类型,可能会使值丢失精度和被截断
Protocol Buffers没有列表或数组数据类型,而是有字段的重复标记(repeated)。对于重复字段,表示同一个字段标签知识简单地多次出现在记录中,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表。读取新数据的旧代码只能看到列表的最后一个元素
Avro
Apache Avro是另一种二进制编码格式,由于Thrift不适合Hadoop的用例,因此Avro在2009年作为Hadoop的子项目启动
Avro也使用模式来指定编码的数据结构。它有两种模式语言:一种(Avro IDL)用于人工编辑,另一种(基于JSON)更易于机器读取
Avro是目前所有编码中最紧凑的,它的模式中没有标签编号,它编码成的字节序列没有什么可以标识字段或数据类型。编码只是由连在一起的一些列值组成。一个字符串知识一个长度前缀,后跟UTF-8字节流,但编码数据中没有任何内容告诉你它是一个字符串。它也可以是一个整数,或者其他类型,整数可以使用可变长度编码进行编码
写模式与读模式
写模式:当应用程序想要对某些数据进行编码时,它所使用所知道的模式的任何版本来编译数据,例如,可以编译到应用程序中的模式
读模式:当应用程序想要解码某些数据时,它期望数据符合某个模式,即读模式
Avro的关键思想是,写模式和读模式不必一模一样,它们只需保持兼容。当数据被解码(读取)时,Avro库通过对比查看写模式和读模式并将数据从写模式转为读模式来解决差异
模式演化规则
使用Avro,向前兼容意味着可以使用新版本的模式作为writer,并将旧版本的模式作为reader。相反,向后兼容意味着可以用新版本的模式作为reader,并用旧版本的模式作为writer
为了保持兼容性,只能添加或删除具有默认值得字段,如果要添加一个没有默认值得字段,新的reader将无法读取旧的writer写的数据,因此将破坏向后兼容性。如果要删除没有默认值得字段,就reader将无法读取新writer写入的数据,将破坏向前兼容性
只要Avro可以转换类型,就可以改变字段的数据类型。更改字段名称也是可能的,但有点棘手:reader的模式可以包含字段名称的别名,因此它可以将就writer模式字段名称与别名进行匹配。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,想联合类型添加分支也是向后兼容的,但不能向前兼容
那么writer模式又是什么
reader如何知道特定的数据采用那个writer的模式编码的?
答案是取决于Avro的上下文。举几个列子:
1、有很多记录的大文件:
Avro的一个常见用途,尤其是在hadoop的上下文中,是用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码。这种情况下,该文件的writer可以尽在文件的开头包含writer的模式信息。Avro通过指定一个文件格式(对象容器文件)来做到这一点
2、具有单独写入记录的数据库
在数据库中,不同的记录可能在不同的时间点,使用不同的writer模式编写,不能假设所有的记录都具有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。reader可以获取记录,提取版本号,然后从数据库中查询该版本号的writer模式。使用该writer模式,它可以解码记录的其余部分。例如Espresso就是这么工作的
3、通过网络连接发送记录
当两个进程通过双向网络连接进行通信时,他们可以在建立连接时协商模式版本,然后在连接的生命周期中使用该模式。这也是Avro RPC协议的基本原理
动态生成的模式
与protocol Buffers和Thrift相比,Avro方法的一个优点是不包含任何标签号,为什么这么重要?在模式中保留一些数字有什么问题?
关键之处在于Avro对动态生成的模式更友好。例如,有一个关系型数据库,想要把它的内容转储到一个文件中,使用Avro可以很容易根据关系模式生成Avro模式,并使用该模式对数据库内容进行编码,然后将其全部转储到Avro对象容器文件中,可以为每个数据库的表生成对应的记录模式,而每个列成为该记录中的一个字段,数据库的列名称映射到Avro的字段名称
现在,如果数据库模式发生变化,则可以从更新的数据库模式生成新的Avro模式,并用新的Avro模式导出数据。导出数据过程不需要关注模式的改变,每次运行时都有简单地进行模式转换。然后读取新数据文件的人都会看到记录的字段已经改变了,但是由于字段是通过名字标识的,所以更新的writer模式仍然可以与旧的reader模式匹配
相比之下,如果使用protocol Buffers或Thrift,则可能必须手动分配字段标签:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射。这种动态生成的模式根本不是protocol Buffers和Thrift的设计目标,而是Avro的设计目标
代码生成与动态类型语言
Avro为静态编程语言提供了可选的代码生成,但是它也可以在不生成代码的情况下直接使用。如果有一个对象容器文件(它嵌入了writer模式),可以简单地使用Avro库打开它,并用和查看JSON文件一样的方式查看数据,该文件是自描述,它包含了所有必要的元数据
此属性与动态类型数据处理语言(Apache Pig)结合使用特别有用。在Apache Pig中,只需打开一些Avro文件,分析其内容,并编写派生数据集以Avro格式输出文件,而无需考虑模式
模式的优点
protocol Buffers、Thrift和Avro都使用了模式来描述二进制编码格式,它们的模式语言比XML模式或JSON模式更简单,它支持更详细的验证规则;而且实现更简单,使用更简单
基于二进制编码的优点:
- 它们可以比各种”二进制JSON“变体更紧凑,可以省略编码数据总的字段名称
- 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新(手动维护的文档可能很容易偏离现实)
- 模式数据库允许在部署任何内容之前检查模式更改的向前和向后兼容性
- 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,它能够在编译时进行类型检查
数据流模式
基于数据库的数据流
在数据库中,写入数据库的进程对数据进行编码,而读取数据库的进程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,reader只是同一个进程的较新版本,此时可以认为向数据库中存储内容,就是给未来的自己发送消息。
这时向后兼容是必要的,否则未来的自己将无法解码以前写的东西
一般而言,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,也可能只是同一个服务的几个实例。无论哪种情况,在应用程序正在改变的环境中,访问数据库的某些进程可能允许较新代码,某些进程可能运行较旧代码。例如,因为当前正在滚动升级中部署新版本,所以某些实例已经更新,而其他实例尚未更新
这意味着数据库中的值可能有较新版本的代码写入,然后由仍然运行的旧版本代码读取。因此数据库通常也需要向前兼容
然而,还有一个额外的障碍。假设在记录模式总添加一个字段,并较新代码将盖子段的值写入数据库。随后,旧版本代码(尚不知道该新字段)将读取、更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新字段不变,即使它无法解释
不同的时间写入不同的值
部署新版本应用程序时(至少是服务器端应用程序)时,可能会在几分钟内用新版本完全替换旧版本。数据库内容的情况并不是这样》5年前的数据仍然采用原始编码,除非已经明确重写了它,这种现象有时被总结为数据比代码更长久
将数据重写(或迁移)为新模式当然是可能的,但在大兴数据集上执行此操作代价不菲,因此很多数据库都尽可能避免此操作。大多数挂你数据库允许进行简单的模式更改,例如添加具有默认值为空的新列,而不重写现有数据。读取旧行时,数据库会为磁盘上边那数据缺失的所有列填充为空值。
归档存储
当为数据库创造快照,备份或加载到数据仓库中时,数据转储通常使用最新的模式进行编码,即使原数据库中的原始编码包含了不同时代的各种模式版本。由于无论如何都要复制数据,索引此时最好对数据副本进行统一的编码
由于数据转储是一次写入的,而且以后不可改变,因此像Avro对象容器文件这样格式非常合适。这也是很好的机会,可以用分析友好的列存储对数据编码
基于服务的数据流:REST和RPC
对于需要通过网络进行通信的进程,有多种不同的通信方式。最常见的是有两个角色:客户端和服务器。服务器通过网络公开API客户端可以连接到服务器以向API发出请求。服务器公开的API称为服务
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。然而,虽然数据库知识使用查询语言进行任何查询,但是服务公开了特定于应用程序的API,它只允许由服务的业务逻辑预先确定的输入和输出。此限制提供了一定程度的封装:服务可以对客户端可以做什么和不能做什么施加细粒度的限制
面向服务/微服务体系结构的一个关键设计木匾是,通过使服务可独立部署和演化,让应用程序更易于更改和维护。
网络服务
当HTTP被用作与服务通信的底层协议时,它被称为Web服务。这可能有点用词不当,因为Web服务不仅在Web上使用,而且在几个不同的上下文中使用。例如
- 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax的JS Web应用程序),通过HTTP向服务发出请求。这些请求通过公共互联网进行
- 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微服务架构的一部分。支持这种用例的软件有时候被称为中间件
- 一种服务想不同组织所拥有的服务提出请求,经常需要通过互联网。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务提供的公共API,或用于共享访问用户数据的OAuth
有两种流行的Web服务方法:REST和SOAP
REST不是一种协议,而是一个基于HTTP原则的设计理念。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商,与SOAP相比,REST已经越来越受欢迎,至少在夸组织服务集成背景下,并经常与微服务相关联。根据REST原则设计的API称为RESTful
SOAP是一种基于XML的协议,用于发出网络API请求。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能。相反它带有庞大而复杂的多种相关标准和新增各种功能
SOAP Web服务的API使用被称为WSDL来描述。WSDL支持代码生成,客户端可以使用本地类和方法调用来访问远程服务。
RESTful的API倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式如OpenAPI,也成为Swagger,可用于描述RESTful API并帮助生成文档
远程过程调用(RPC)的问题
网络请求于本地函数调用的不同:
- 本地函数的调用是可预测的,并成功或失败仅取决于控制的参数。网络请求是不可预测的:请求或响应可能由于网络问题而丢失,或者远程计算机可能速度慢或不可用,这些完全不在控制反问内,网络问题很常见,因此必须有啊偶准备,例如重试失败请求。
- 本地函数调用要么返回一个结果,要么抛出一个异常,或者永远不会返回(死循环)。网络请求有另一种可能结果:由于超时,它返回时可能没有结果。在这种情况下根本不知道发生了什么:如果没有收到来自远程服务的响应,无法知道请求成功与否。
- 如果重试失败的网络请求,可能会发生请求实际上已经完成,知识响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非在协议中建立重复数据消除(幂等性)机制。本地函数调用则没有这样问题
- 每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也会哟很大变化:情况好时,它可能会在不到1ms的时间内完成,但是当网络拥塞或者远程服务过载时,可能需奥几秒钟的时间才能完成相同操作
- 调用本地函数时,可以高效地将引用传递给本地内存中的对象。当发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列。如果参数是数字或者字符串这种基本类型是没有关系的,但是对于吊打的对象很快会出现问题
- 客户端和服务可以用不同的编程语言来实现,所以RPC框架必须将数据类型从一种语言转换成另一种语言。因为不是所有语言都具有相同的类型,所以最终可能会丑陋
RPC发展方向
新一代RPC框架更加明确了远程请求与本地函数调用不同的事实,例如Finagle和Rest.li使用Futures(Promises)来封装可能失败的异常操作。Futures还简化了需要并行请求多想服务的情况,将其结果合并。gRPC支持刘,其中调用不仅包括一个请求和响应,还包括一段时间内一系列的请求和响应
使用二进制编码格式的自定义RPC协议,可以实现比诸如REST上的JSON之类的通用协议更好的性能。但是RESTful API还有其他一些显著的优点:它有利于实验和调试,支持所有的额主流编程语言和平台,并有一个庞大的工具生态系统(服务器、缓存、负载平衡器、代理、防火墙、监控、调试工具、测试工具等)
RPC的数据编码和演化
对于演化性,重要的是可以独立的更改和部署RPC客户端和服务器
RPC方案的向后和向前兼容性属性取决于它所使用的具体编码技术
- Thrift,gRPC和Acro RPC可以根据各自的编码格式的兼容性规则进行演化
- 在SOAP中,请求和响应是用XML模式制定的,这些都是可以演化的,但有一些微妙的陷阱
- RESTful API通常JSON用于响应,而请求采用JSON或URI编码/表单编码的请求参数。为了保持兼容性,通常考虑的更改包括添加可选的请求参数和在响应中添加新的字段
如果RPC经常用于跨组织边界的通信,则服务的兼容性会变得更加困难,服务的提供者经常无法控制其客户,也不能强制他们升级。因此需要长期保持兼容性,也许是无限期的。不过不得不进行一些破坏性更改,则服务提供者往往会维护多个版本的服务API
关于API版本管理应该如何工作,没有统一方案。对于RESTful API,常用的方法是在URL或HTTP Accept头中使用版本号。对于API密钥来标识特定客户端的服务,另一种选择是将客户端请求的API版本存储到服务器上,并与徐通过单独管理接口更新版本选项
基于消息传递的数据流
与直接RPC相比,使用消息代理有以下几个优点
- 如果接收方不可用或过载,它可以充当缓冲区,从而提高系统的可靠性
- 它可以自动将消息重新发送到崩溃的进程,防止消息丢失
- 它避免了发送放需要知道接收方的IP地址和端口号
- 它支持将一条消息发送给多个接收方
- 它在逻辑上将发送方与接收方分离
然而,与RPC的差异在于,消息传递通常是单向的:发送方通常不期望收到对其消息的回复。进程可能发送一个响应,但这通常是在一个独立的通道上完成的,这种通信模式是异步的
消息代理
通常情况下,消息代理的使用方式如下:一个进程想指定的队列或主题的一个或多个消费者或订阅者。在同一主题下可以有多个生产者和消费者
主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题,也可以发送到一个恢复队列,该队里有原始信息发送者来消费
消息代理通常不会强制任何特定的数据模型,消息知识包含一些元数据的字节序列,因此可以使用任何编码格式。如果编码是向后和向前兼容的,则可以i最大程度灵活地独立更改发布者和消费者,并以任意顺序部署他们
分布式Actor框架
Actor模型是用于单个进程中并发的编程模型。逻辑被封装在Actor中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个Actor通常代表一个客户端或实体,它而能具有某些本地状态(不与其他任何Actor共享),并且它通过发送和接收异步消息与其他Actor通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个Actor一次只处理一条消息,因此不需要担心线程,每个Actor都可以由框架独立调度
在分布式Actor框架中,这个编程模型被用来跨越多个节点来扩展应用程序,无论发送方和接收方是在同一个节点还是不同节点上,都使用相同的消息传递机制,如果他们位于不同的节点上,则消息被透明的编码成字节序列,通过网络发送,并在另一端解码
相比RPC,位置透明性在Actor模型中更有效,因为Actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用Actor模型时,本地和远程通信之间根本上的不匹配所发生的概率更小
分布式的Actor框架实质上是将消息代理和Actor编程模型集成到单个框架中
三中流行的分布式Actor框架处理消息编码的方式如下:
- 默认情况下,Akka使用Java的内置序列化,它不提供向前或向后兼容性。但是可以使用类似Protocol Buffers的逗你替代它,从而获得滚动升级的能力
- 默认情况下,Orleans使用不支持滚动升级部署的自定义数据编码格式;要部署到新版本的应用程序,需要建立一个新的集群,将流量从旧集群导入到新集群,然后关闭旧集群。想Akka一样,也可以使用自定义序列化插件
- 在Erlang OTP中,很难对记录模式进行更改。滚动升级在技术上是可能的,但要求仔细规划。一些实验性的新型映射数据模型可能会使模式更改在将来变得更容易