DDIA第四章笔记

147 阅读16分钟

四.编码与演化

修改应用程序的功能也可能意味着需要更改其存储的数据:可能需要使用新的字段或记录方式,或者以新的方式展示现有的数据

当数据格式(format)或模式(schema)发生改变时,通常需要对应程序代码进行相应的更改,但在大型应用程序中,代码变更通常不会立即完成:

  • 对于服务端应用程序,可能需要执行滚动升级(rolling upgrade) (也称为阶段发布(staged rollout) ),一次将新版本部署到少数几个节点,检查新版本是否正常运行,然后逐渐部完所有的节点
  • 对于客户端应用程序,升级取决于用户,用户可能相当长一段时间不会去升级软件

新版本的代码以及新旧数据格式可能会在系统中同时共处。系统想要顺利运行,就需要保持双向兼容性

  • 向后兼容(backward compatibility):新代码可以读取由旧代码写入的数据
  • 向前兼容(forward compatibility):旧的代码可以读取由新的代码写入的数据

编码格式

  • JSON、XML、Protocol Buffers、Thrift和Avro

Web服务中的数据存储和通信

  • 表属性状态传递(REST)
  • 远程过程调用(RPC)
  • 消息传递系统(Actor和消息队列)

编码数据的格式

程序通常使用两种形式的数据

  1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等结构中。这些数据针对CPU的高效访问进行了优化(通常使用指针)
  2. 如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的字节序列(例如JSON)。由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同
  • 从内存中表示到字节序列的转换称为编码(encoding) ,也称为序列化(serialization)或编组(marshalling)
  • 反过来称为解码(decoding) ,也称为解析(paring),反序列化(deserialization),反编组(unmarshalling)

serialization和marshal的区别:marshal不仅传输对象的状态,而且会一起传输对象的方法

语言特定的格式

许多变成语言内建了将内存对象编码为字节序列的支持,这些编码库很方便,但也有一些问题

  • 与特定的编程语言绑定
  • 解码过程需要实例化任意类的能力,这通常是安全问题的一个来源
  • 数据版本控制是事后才考虑的,它们旨在快速简便地对数据进行编码,所以忽略了前向后向兼容性带来的麻烦
  • 效率问题

JSON、XML个二进制变体

一些微妙的问题

  • 数字编码有很多模糊之处。在XML和CSV中,无法区分数字和碰巧有数字组成的字符串(除了引用外部模式)。JSON虽然区分字符串与数字,但并不区分整数和浮点数,并且不能指定精度

    Twitter API 返回的 JSON 包含了两个推特 ID,一个是 JSON 数字,另一个是十进制字符串,以解决 JavaScript 程序中无法正确解析大数字的问题

  • JSON和XML对Unicode字符串有很好的支持但它们不支持二进制数据(即不带字符编码(character encoding) 的字节序列)。人们通常使用Base64将二进制数据编码为文本来绕过限制。其特有的模式标识着这个值当被解释为Base64编码的二进制数据。这种方案缺点是会增加三分之一的数据

  • XML和JSON都有可选的模式支持,这些模式语言相当强大,所以学习和实现起来都相当复杂。不使用XML/JSON模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码

  • CSV没有任何模式,因此每行每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。CSV也是一个相当模糊的格式

二进制编码

JSON比XML简洁,但与二进制格式想比还是太占空间。这导致了大量二进制版本JSON和XML的出现,但没有一个能像文本版本JSON和XML那样被采用

这些格式中的一些扩展了一组数据类型(例如:区分整数和浮点数或增加对二进制字符串的支持),另一方面,他们没有改变JSOM/XML的数据模型,特别由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称

image-20230817112528562.png

Thrift和Protocol Buffers

Thrift和Protocol BUffers都需要一个模式来编码任何数据,要在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有两种不同的二进制编码格式

1.BinaryProtocol

image-20230817112542808.png

  • 每个字段都有一个类型注解,还可以根据需要指定长度,出现在数据中的字符串被编码为ASCII
  • 没有字段名,编码数据包含字段标签,就像字段的别名,采用紧凑的方式而不必拼出字段名称

2.CompactPotocol

image-20230817112552146.png

  • 将相同信息打包成只有34个字节,它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。每个字节的最高位用来指示是否还有更多的字节

Protocol Buffers对相同的数据进行编码:

image-20230817115621896.png

字段设置为required与否对于编码没有任何影响,区别在于设置为required的字段如果没有被找到,则运行时检查将失败

字段标签和模式的演变

Thrift和Protocol Buffers如何处理模式更改,同时保持向后兼容性?

字段标记对编码数据的含义至关重要,你可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这回使所有现有的编码数据无效

  • 向前兼容性:可以添加新的字段到架构,只要给每个字段一个新的标签号码。如果旧代码视图读取新代码写入的数据,包含新的字段,其标签号不能之别,可以简单的忽略该字段,并根据数据类型的注释确定要跳过的字节数,使得旧代码可以读取新代码编写的记录
  • 向后兼容性:只要每个字段都有唯一的标签号码,新的代码总是可以读取旧的数据,唯一的细节是,不能将新添加的字段设置为必须,否则新代码读取旧代码写入的数据时,会因为旧代码不会写入新添加的必须字段而检查失败 为了保持向后兼容性,在模式初始部署之后添加的每个字段必须是可选的或具有默认值

删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性,只能删除可选的字段,不能删除必须的字段。并且不能再次使用相同的标签号码(因为可能仍然后旧数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)

数据类型和模式演变

改变字段的数据类型是可能的,但是可能导致值失去精度或被截短,例如32位整数和64位整数之间的转换

Protobuf没有列表或数组数据类型,而是用字段重复标记repeated

Thrift有专门的列表数据类型,这不允许Protobuf所做的从单值到多值的转变,但是具有支持嵌套列表的优点

Avro

Avro也使用模式来指定正在编码的数据的结构,有两种模式语言

  1. Avro IDL 用于人工编辑

    record Person {
        string                userName;
        union { null, long }  favoriteNumber = null;
        array<string>         interests;
    }
    
  2. 一种基于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二进制编码只有32个字节长

image-20230817163639786.png

编码只是由连载一起的值组成。一个字符串只是一个长度前缀,后跟UTF-8字节。但在被包含的数据中没有任何内容指出它是一个字符串,也可以是一个整数。

为了解析二进制数据,按照它们出现在模式中的顺序遍历这些字段,并使用模式来告诉每个字段的类型。这意味着读取数据的代码使用与写入数据的代码完全相同的模式才能正确解码二进制数据

Writer模式和Reader模式

Writer模式:编码数据时使用的模式

Reader模式:解码数据时使用的模式

Avro的关键思想是Writer模式和Reader模式不必是相同的,他们只需要兼容。Avro库通过并排查Writer模式和Reader模式并将数据从Writer模式转换到Reader模式来解决差异,具体由Avro规范规定

例如:Writer模式和Reader模式通过字段名匹配字段,如果读取的代码遇到Writer中有Reader中没有的字段则忽略它,如果Reader模式需要某个字段但是Writer没有则使用在Reader中声明的默认值填充

模式演变规则

为了保持兼容性,只能添加或删除具有默认值的字段

  • 如果要添加一个没有默认值的字段,新的Reader将无法读取旧Writer写的数据,破坏向后兼容性
  • 删除没有默认值的字段,旧的Reader将无法读取新Writer写入的数据,破坏向前兼容性

Writer模式到底是什么

对于一段特定的编码数据,Reader如何知道其Writer模式。如果将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而二进制节省的空间都是徒劳

答案取决于Avro使用的上下文

  • 有很多记录的大文件 很多大文件中,所有的记录都使用相同的模式进行编码,可以在文件的开头只包含一次Writer模式

  • 支持独立写入的记录数据库

    所有记录很难有相同的模式,最简单的方式是每个编码记录的开始处包含一个版本号,数据库中保留一个模式版本列表,Reader从记录中提取版本号从而获取Writer模式,例如Espresso

  • 通过网络连接发送记录

    两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后再连接的生命周期中使用该模式。例如Avro RPC

动态生成的模式

Avro方法的一个优点是架构不包含任何标签号码

Avro对动态生成的模式更为友善,假如你需要把一个关系数据库的内容转储到一个文件中,你可以很容易的从关系模式生成一个Avro模式,并用该模式进行编码,当关系数据库发生改变时,则可以生成新的Avro模式来导出数据。因为字段是通过名字来标识的,更新的Writer模式仍然可以与旧的Reader模式匹配

如果使用Thrit或Protocol Buffers的话字段标签可能必须手动分配,每次数据库模式更改时管理员都必须手动更新从数据库列名到字段标签的映射

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

Thrift和Protobuf依赖于代码生成,在定义了模式之后可以使用你选择的编程语言生成实现此模式的代码。在静态类型语言中,有助于将高效的内存中的数据结构用于解码数据,并且在编写访问数据结构的程序时允许在IDE中进行检查和自动补全

在动态类型编程语言中,生成代码没有太多意义,因为没有编译时的类型检查

Avro为金泰类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了 Writer 模式),你可以简单地使用 Avro 库打开它,并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。

模式的优点

Protocol Buffers、Thrift和Avro等比XML模式或者JSON模式简单的多,后者支持更详细的验证规则。由于前者实现起来更简单,使用起来也更简单,所以也发展到了支持相当广泛的编程语言

许多数据系统也为其数据实现了某种专有的二进制编码,例如:大多数数据库都有一个网络协议,可以通过该协议想数据库发送查询并获取相应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的相应解码为内存数据结构的驱动程序

基于模式的二进制编码的优点:

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

模式演化保持了与JSON数据库提供的无模式/读时模式相同的灵活性

数据流的类型

数据可以通过多种方式从一个流程流向另一个流程

数据在流程之间流动的一些常见的方式:

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

数据库中的数据流

几个不同的进程同时访问数据库是常见的,几个进程可能是不同应用程序或服务,也可能是几个相同的服务实例。无论那种方式都可能存在某些进程运行较新的代码,某些实例运行较旧的代码。所以显然数据库需要向前兼容和向后兼容

需要意识到一个问题是,加入旧版本代码读取并更新新代码写入的记录时,旧代码没有的新字段应该保持不变。许多编码格式支持未知字段的保存,但有时候需要在应用程序层面保持谨慎,要防止新字段的丢失

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

数据库中的值,有一些可能是五秒钟前写的,也有一些可能是五年前写的。

部署新应用程序可以在短时间将旧版本程序替换为新版本。但数据库不是如此,对五年前的数据,除非进行显式地重写,否则它仍然会以原始编码形式存在,这也被称作:数据的生命周期超出代码的生命周期

许多时候进行数据重写代价是昂贵的,所以许多数据库允许进行简单的模式更改来避免重写

模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录

归档存储

备份或转储数据库时可以对数据拷贝进行一致的编码

由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet

服务中的数据流:REST与RPC

最常见的通行是客户端和服务器的通信

服务器本身可以是另外一个服务的客户端,例如web应用服务器充当数据库的客户端,这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,当一个服务需要来自另外有一个服务的某些功能或数据时,会向另一个服务发出请求,这种某件应用程序的方式传统上被称为面向服务的体系结构(SOA) ,最近被改进和更名为微服务架构

面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。换句话说就是期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容