第四章:编码与演化
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即ALTER语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,读时模式(schema-on-read)(或 无模式(schemaless))数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合。
当数据格式(format)或模式(schema)发生变化时,通常需要对应用程序代码进行相应的更改。
- 对于 服务端(server-side) 应用程序,可能需要执行 滚动升级 (rolling upgrade) (也称为 阶段发布(staged rollout) ),一次将新版本部署到少数几个节点,检查新版本是否运行正常,然后逐渐部完所有的节点。这样无需中断服务即可部署新版本,为频繁发布提供了可行性,从而带来更好的可演化性。
- 对于 客户端(client-side) 应用程序,升不升级就要看用户的心情了。用户可能相当长一段时间里都不会去升级软件。
当新旧版本的代码切换时,需要做到双向兼容:
向后兼容 (backward compatibility)
新代码可以读旧数据。
向前兼容 (forward compatibility)
旧代码可以读新数据。
数据存储和通信格式:在Web服务中,具象状态传输(REST)和远程过程调用(RPC),以及消息传递系统(如Actor和消息队列)。
编码数据的格式
程序通常(至少)使用两种形式的数据:
- 在内存中,数据保存在对象,结构体,列表,数组,哈希表,树等中。 这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)。
- 如果要将数据写入文件,或通过网络发送,则必须将其 编码(encode) 为某种自包含的字节序列(例如,JSON文档)。 由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同。
从内存中表示到字节序列的转换称为 编码(Encoding) (也称为序列化(serialization)或编组(marshalling)),反过来称为解码(Decoding)(解析(Parsing),反序列化(deserialization),反编组( unmarshalling))。
语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持。但是它们也有一些深层次的问题:
- 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。并且很难将系统与其他组织的系统进行集成。
- 为了恢复相同对象类型的数据,解码过程需要实例化任意类的能力,这通常是安全问题的一个来源:如果攻击者可以让应用程序解码任意的字节序列,他们就能实例化任意的类,这会允许他们做可怕的事情,如远程执行任意代码。
- 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
- 效率(编码或解码所花费的CPU时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着。
除非临时使用,采用语言内置编码通常是一个坏主意。
JSON,XML和二进制变体
JSON,XML和CSV是文本格式,因此具有人类可读性。但是它们也有一些微妙的问题:
- 数字的编码多有歧义之处。XML和CSV不能区分数字和字符串。 JSON虽然区分字符串和数字,但不区分整数和浮点数,而且不能指定精度。
- 当处理大量数据时,这个问题更严重了。例如,大于$2^{53}$的整数不能在IEEE 754双精度浮点数中精确表示,因此在使用浮点数(例如JavaScript)的语言进行分析时,这些数字会变得不准确。
- CSV没有任何模式,因此应用程序需要定义每行和每列的含义。
二进制编码
JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事实导致大量二进制编码版本JSON & XML的出现。
由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称。
用于展示二进制编码的示例记录
{ "userName": "Martin", "favoriteNumber": 1337, "interests": ["daydreaming", "hacking"] }
MessagePack的例子,它是一个JSON的二进制编码:
- 第一个字节0x83表示接下来是3个字段(低四位= 0x03)的对象 object(高四位= 0x80)。 (如果想知道如果一个对象有15个以上的字段会发生什么情况,字段的数量塞不进4个bit里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。
- 第二个字节0xa8表示接下来是8字节长的字符串(最高四位= 0x08)。
- 接下来八个字节是ASCII字符串形式的字段名称userName。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。
- 接下来的七个字节对前缀为0xa6的六个字母的字符串值Martin进行编码,依此类推。
Thrift与Protocol Buffers
Apache Thrift 和Protocol Buffers(protobuf)是基于相同原理的二进制编码库。
在Thrift中对上面数据进行编码,可以使用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;
}
Thrift和Protocol Buffers每一个都带有一个代码生成工具,生成了以各种编程语言实现模式的类。
Thrift有两种不同的二进制编码格式,分别称为BinaryProtocol和CompactProtocol。
图 - 使用Thrift二进制协议编码的记录
每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串(“Martin”, “daydreaming”, “hacking”)也被编码为ASCII(或者说,UTF-8)。
Thrift CompactProtocol编码在语义上等同于BinaryProtocol。
图 - 使用Thrift压缩协议编码的记录
它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。
Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码。
图 - 使用Protobuf编码的记录
它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。
在前面所示的模式中,每个字段被标记为必需或可选,但是这对字段如何编码没有任何影响(二进制数据中没有任何字段指示是否需要字段)。所不同的是,如果未设置该字段,则所需的运行时检查将失败,这对于捕获错误非常有用。
字段标签和模式演变
模式不可避免地需要随着时间而改变。我们称之为模式演变。
每个字段由其标签号码(样本模式中的数字1,2,3)标识,并用数据类型(例如字符串或整数)注释。如果没有设置字段值,则简单地从编码记录中省略。
数据类型注释允许解析器确定需要跳过的字节数。这保持了前向兼容性:旧代码可以读取由新代码编写的记录。
向后兼容性:只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。
如果添加的新字段设置为必需的,那么新代码读取旧代码时,就会检查失败,因此,为了保持向后兼容性,在模式的初始部署之后 添加的每个字段必须是可选的或具有默认值。
删除一个字段就像添加一个字段,倒退和向前兼容性问题相反。这意味着只能删除一个可选的字段(必填字段永远不能删除),而且不能再次使用相同的标签号码。
数据类型和模式演变
假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。
Avro
Apache Avro 是另一种二进制编码格式。
Avro也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于人工编辑,一种(基于JSON),更易于机器读取。
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模式编码例子记录:
图 - 使用Avro编码的记录
编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。
如果读取数据的代码使用与写入数据的代码完全相同的模式,才能正确解码二进制数据。
作者模式与读者模式
当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,这被称为作者的模式。
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。
Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。
当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。
图 - 一个Avro Reader解决读写模式的差异
如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。
模式演变规则
为了保持兼容性,只能添加或删除具有默认值的字段。
在一些编程语言中,null是任何变量可以接受的默认值,但在Avro中并不是这样:如果要允许一个字段为null,则必须使用联合类型。union {null,long,string} 字段;表示该字段可以是数字或字符串,也可以是null。
Avro没有像Protocol Buffers和Thrift那样的optional和required标记(它有联合类型和默认值)。
读者的模式可以包含字段名称的别名,所以它可以匹配旧作家的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
作者模式到底是什么?
- 有很多记录的大文件Avro的一个常见用途 - 尤其是在Hadoop环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码。在这种情况下,该文件的作者可以在文件的开头只包含一次作者的模式。 Avro指定一个文件格式(对象容器文件)来做到这一点。
- 支持独立写入的记录的数据库在一个数据库中,不同的记录可能会在不同的时间点使用不同的作者的模式编写 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。读者可以获取记录,提取版本号,然后从数据库中获取该版本号的作者模式。使用该作者的模式,它可以解码记录的其余部分。
- 通过网络连接发送记录当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议如此工作。
动态生成的模式
一个关系数据库,想要把它的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON,CSV,SQL)的问题。使用Avro,可以很容易地从关系模式生成一个Avro模式,并使用该模式对数据库内容进行编码,并将其全部转储到Avro对象容器文件中。为每个数据库表生成一个记录模式,每个列成为该记录中的一个字段。数据库中的列名称映射到Avro中的字段名称。
如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的Avro模式,并在新的Avro模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的作者的模式仍然可以与旧的读者模式匹配。
如果使用Thrift或Protocol Buffers,则字段标记可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签。
代码生成和动态类型的语言
Thrift和Protobuf依赖于代码生成:在定义了模式之后,可以使用您选择的编程语言生成实现此模式的代码。
在动态类型编程语言(如JavaScript,Ruby或Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。
Avro为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了作者的模式),你可以简单地使用Avro库打开它,并以与查看JSON文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。这个属性特别适用于动态类型的数据处理语言。
模式的优点
Protocol Buffers,Thrift和Avro都使用模式来描述二进制编码格式。他们的模式语言比XML模式或者JSON模式简单得多,它支持更详细的验证规则。
大多数关系数据库都有一个网络协议,您可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用ODBC或JDBC API)。
基于模式的二进制编码有一些很好的属性:
- 它们可以比各种“二进制JSON”变体更紧凑,因为它们可以省略编码数据中的字段名称。
- 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
- 保留模式数据库允许您在部署任何内容之前检查模式更改的向前和向后兼容性。
- 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
数据流的类型
数据在进程之间流动的一些最常见的方式:
- 通过数据库
- 通过服务调用
- 通过异步消息传递
数据库中的数据流
在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。
数据库访问中,可能同时有多个应用程序或服务来访问数据库,有些可能会运行新代码,有些可能会运行旧代码,这意味着数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容,反之,也需要向后兼容。
在不同的时间写入不同的值
数据库通常允许任何时候更新任何值。
五年前的数据仍然存在于原始编码中,除非已经明确地重写了它。这种观察有时被总结为数据超出代码。
将数据重写(迁移)到一个新的模式在一个大数据集上执行是一个昂贵的事情,大多数数据库会尽量避免它。
大多数关系数据库都允许简单的模式更改,当添加一个默认值为空的新列时,不重写现有数据读取旧行,数据库将填充编码数据中缺少的任何列的空值在磁盘上。
归档存储
当为数据库创建快照时,不同时期的模式版本都将使用最新模式进行编码。
由于数据转储是一次写入的,而且以后是不可变的,所以Avro对象容器文件等格式非常适合。可以将数据编码为面向分析的列式格式,例如Parquet。
服务中的数据流:REST与RPC
网络进行通信最常见的两个角色:客户端和服务器。服务器通过网络公开API,并且客户端可以连接到服务器以向该API发出请求。服务器公开的API被称为服务。
Web工作方式:客户(Web浏览器)向Web服务器发出请求,使GET请求下载HTML,CSS,JavaScript,图像等,并向POST请求提交数据到服务器。
服务器本身可以是另一个服务的客户端。
大型应用程序按照功能区域分解为较小的服务,当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 面向服务的体系结构(service-oriented architecture,SOA) ,最近被改进和更名为 微服务架构 。
面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。
Web服务
当服务使用HTTP作为底层通信协议时,可称之为Web服务。
- 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax的JavaScript web应用程序)通过HTTP向服务发出请求。这些请求通常通过公共互联网进行。
- 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。 (支持这种用例的软件有时被称为 中间件(middleware) )
- 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API,或用于共享访问用户数据的OAuth。
有两种流行的Web服务方法:REST和SOAP。
REST不是一个协议,而是一个基于HTTP原则的设计哲学。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。根据REST原则设计的API称为RESTful。
SOAP是用于制作网络API请求的基于XML的协议。
SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。 WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次解码)访问远程服务。
REST风格的API倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如OpenAPI,也称为Swagger )可用于描述RESTful API并生成文档。
远程过程调用(RPC)的问题
Enterprise JavaBeans(EJB)和Java的远程方法调用(RMI)仅限于Java。分布式组件对象模型(DCOM)仅限于Microsoft平台。公共对象请求代理体系结构(CORBA)过于复杂,不提供前向或后向兼容性。
RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同。
网络请求与本地函数调用非常不同:
- 本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。
- 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。
- 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( 幂等(idempotence))机制。
- 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的。
- 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。
客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。 而REST的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实。
RPC的当前方向
Thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现,Finagle也使用Thrift,Rest.li使用JSON over HTTP。
Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。Futures还可以简化需要并行发出多项服务的情况,并将其结果合并。gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应。
使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。
RESTful API优点:对于实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),它是受支持的所有的主流编程语言和平台,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。
数据编码与RPC的演化
由于RPC经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务API。
对于RESTful API,常用的方法是在URL或HTTP Accept头中使用版本号。对于使用API密钥来标识特定客户端的服务,另一种选择是将客户端请求的API版本存储在服务器上,并允许通过单独的管理界面更新该版本选项。
消息传递中的数据流
RPC和数据库之间的异步消息传递系统。它们与RPC类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
与直接RPC相比,使用消息代理有几个优点:
- 如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
- 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
- 避免发件人需要知道收件人的IP地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
- 它允许将一条消息发送给多个收件人。
- 将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。
与RPC相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
消息掮客
消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给一个或多个消费者或订阅者到那个队列或主题。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。消费者本身可能会将消息发布到另一个主题上,或者发送给原始消息的发送者使用的回复队列(允许请求/响应数据流,类似于RPC)。
消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,可以使用任何编码格式。
分布式的Actor框架
Actor模型是单个进程中并发的编程模型。
逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
在分布式的行为者框架中,这个编程模型被用来跨越多个节点来扩展应用程序。
三个流行的分布式actor框架处理消息编码如下:
- 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似缓冲区的东西替代它,从而获得滚动升级的能力。
- Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集。 像Akka一样,可以使用自定义序列化插件。
- 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划。 一个新的实验性的maps数据类型可能使得这个数据类型在未来更容易。