# DDIA 逐章精读（四）：编码和演进

第三章讲了存储引擎，本章继续下探，探讨编码相关问题。

所有涉及跨进程通信的地方，都需要对数据进行**编码**（**Encoding**），或者说**序列化**（**Serialization**）。因为持久化存储和网络传输都是面向字节流的。序列化本质上是一种“**降维**”操作，将内存中高维的数据结构降维成单维的字节流，于是底层硬件和相关协议，只需要处理一维信息即可。


编码主要涉及两方面问题：

1. 如何编码能够节省空间、提高性能。
2. 如何编码以适应数据的演化和兼容。

第一小节，以几种常见的编码工具（JSON，XML，Protocol Buffers 和 Avro）为例，逐一探讨了其如何进行编码、如何进行多版本兼容。这里引出了两个非常重要的概念：

1. 向后兼容 (backward compatibility)：当前代码可以读取旧版本代码写入的数据。
2. 向前兼容(forward compatibility)：当前代码可以读取新版本代码写入的数据。

> 翻译成中文后，很容易混淆，主要原因在于“后”的歧义性，到底指**身后**（过去），还是指**之后**（将来），私以为还不如翻译为，*兼容过去*和*兼容将来*。但为了习惯，后面行文仍然用向后/前兼容。
> 

其中，向后兼容比较常见，因为时间总是向前流逝，版本总是升级，那么升级之后的代码总要处理历史积压的数据，自然会产生向后兼容的问题。向前兼容比较少见，书中给出的例子是多实例滚动升级，但其持续时间也很短。

第二小节，结合几个具体的应用场景：数据库、服务和消息系统，来分别谈了相关数据流中涉及到的编码与演化。

# 数据编码的格式

![ddia4-encoding-decoding.png](img/ch04-encodec.png)

编码（Encoding）有多种称谓，如**序列化（serialization）**或 **编组（marshalling）**。对应的，解码（Decoding）也有多种别称，**解析（Parsing）**，**反序列化（deserialization）**，**反编组 (unmarshalling）。**

- 为什么内存中数据和外存、网络中的会有如此不同呢？
    
    在内存中，借助编译器，我们可以将内存解释为各种数据结构；但在文件系统和网络中，我们只能通过 seek\read 等几个有限的操作来流式的读取字节流。那 mmap 呢？
    
- 编码和序列化撞车了？
    
    在事务中，也有序列化相关的术语，所以这里专用编码，以避免歧义。
    
- 编码（encoding）和加密（**encryption**）？
    
    研究的范畴不太一样，编码是为了持久化或者传输，着重点在格式和演化；而加密是为了安全，着重点在于安全、防破解。
    

## 编程语言内置

很多编程语言内置了一些缺省的编码方法：

1. Java 有 `java.io.Serializable`
2. Ruby 有 `Marshal`
3. Python 有 `pickle`

如果你确定你的数据只会被某种特定的语言所读取，那么这种内置的编码方法很好用。比如深度学习研究员因为基本都用 Python，所以常会把数据以 [pickle](https://docs.python.org/zh-cn/3/library/pickle.html "pickle 官方文档") 的格式传来传去。

但这些编程语言内置的编码格式有以下缺点：

1. 和特定语言绑定
2. 安全问题
3. 兼容性支持不够
4. 效率不高

## JSON、XML 及其二进制变体

JSON，XML 和 CSV 属于常用的**文本编码**格式，其好处在于肉眼可读，坏处在于不够紧凑，占空间较多。

JSON 最初由 JavaScript 引入，因此在 Web Service 中用的较多，当然随着 web 的火热，现在成为了比较通用的编码格式，比如很多日志格式就是 JSON 的。

XML 比较古老了，比 JSON 冗余度还高，有时候配置文件中会用，但总体而言用的越来越少了。

CSV （以逗号\TAB、换行符分割）还算紧凑，但是表达能力有限。数据库表导出有时会用。

除了不够紧凑外，**文本编码（text encoding）** 还有以下缺点：

1. 对**数值类型支持不够**。CSV 和 XML 直接不支持，万物皆字符串。JSON 虽区分字符串和数值，但是不进一步区分细分数值类型。可以理解，毕竟文本编码嘛，主要还是面向字符串。
2. **对二进制数据支持不够**。支持 Unicode，但是对二进制串支持不够，可能会显示为乱码。虽然可以通过 Base64 编码来绕过，但有点做无用功的感觉。
3. **XML和 JSON 支持额外的模式**。模式会描述数据的类型，告诉你如何理解数据。配合这些模式语言，虽然可以让 XML 和 JSON 变得强大，但是大大增加了复杂度。
4. **CSV 没有任何模式**。

凡事讲究够用，很多场景下需要数据可读，并且不关心编码效率，那么这几种编码格式就够用了。

### 二进制编码

如果数据只被单一程序读取，不需要进行交换，不需要考虑易读性等问题。则可以用二进制编码，在数据量到达一定程度后，二进制编码所带来的空间节省、速度提高都很可观。

因此，JSON 有很多二进制变种：MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等。

对于下面例子，

```json
{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}
```

如果用 MessagePack 来编码，则为：

![ddia-4-messagepack-enc.png](img/ch04-fig01.png)

可以看出其基本编码策略为：使用类型，长度，bit 串，顺序编码，去掉无用的冒号、引号、花括号。

从而将 JSON 编码的 81 字节缩小到了 66 字节，微有提高。

## Thrift 和 Protocol Buffers

Thrift 最初由 Facebook，ProtoBuf 由 Google 在 07~08 年左右开源。他们都有对应的 RPC 框架和编解码工具。表达能力类似，语法也类似，在编码前都需要由接口定义语言（IDL）来描述模式：

```protobuf
struct Person {
    1: required string       userName,
    2: optional i64          favoriteNumber,
    3: optional list<string> interests
}
```

```protobuf
message Person {
    required string user_name       = 1;
    optional int64  favorite_number = 2;
    repeated string interests       = 3;
}
```

IDL 是编程语言无关的，可以利用相关代码生成工具，可以将上述 IDL 翻译为指定语言的代码。即，集成这些生成的代码，无论什么样的语言，都可以使用同样的格式编解码。

这也是不同 service 可以使用不同编码语言，且能够互相通信的基础。

此外，Thrift 还支持多种不同的编码格式，常用的有：Binary、Compact、JSON。可以让用户自行在：编码速度、占用空间、可读性方便进行取舍。

![ddia4-thrift-binary-enc.png](img/ch04-fig02.png)

可以看出其特点：

1. **使用 field tag 编码**。field tag 其实蕴含了字段**类型**和**名字**。
2. 使用类型、tag、长度、bit 数组的顺序编码。

![ddia4-thrift-compact-enc.png](img/ch04-fig03.png)

相比 Binary Protocol，Compact Protocol 由以下优化：

1. filed tag 只记录 delta。
2. 从而将 field tag 和 type 压缩到一个字节中。
3. 对数字使用变长编码和[Zigzag编码](img/ch04-fig04.png)。

ProtoBuf 与 Thrift Compact Protocol 编码方式很类似，也用了变长编码和 Zigzag 编码。但 ProtoBuf 对于数组的处理与 Thrift 显著不同，使用了 repeated 前缀而非真数组，好处后面说。

![ddia4-pb-enc.png](https://s2.loli.net/2022/04/16/amgw7pUeodGhisb.png)

### 字段标号和模式演变

**模式**，即有哪些字段，字段分别为什么类型。

随着时间的推移，业务总会发生变化，我们也不可避免的**增删字段**，**修改字段类型**，即**模式演变**。

在模式发生改变后，需要：

1. **向后兼容**：新的代码，在处理新的增量数据格式的同时，也得处理旧的存量数据。
2. **向前兼容**：旧的代码，如果遇到新的数据格式，不能 crash。
- ProtoBuf 和 Thrift 是怎么解决这两个问题的呢？
    
    **字段标号** + **限定符**（optional、required）
    
    向后兼容：新加的字段需为 optional。这样在解析旧数据时，才不会出现字段缺失的情况。
    
    向前兼容：字段标号不能修改，只能追加。这样旧代码在看到不认识的标号时，省略即可。
    

### 数据类型和模式演变

修改数据类型比较麻烦：只能够在相容类型中进行修改。

如不能将字符串修改为整形，但是可以在整形内修改： 32 bit 到 64 bit 整形。

ProtoBuf 没有列表类型，而有一个 repeated 类型。其好处在于**兼容数组类型**的同时，支持将可选（optional）**单值字段**，修改为**多值字段**。修改后，旧代码在看到新的多值字段时，只会使用最后一个元素。

Thrift 列表类型虽然没这个灵活性，但是可以**嵌套**呀。

## Avro

Apache Avro 是 Apache Hadoop 的一个子项目，专门为数据密集型场景设计，对模式演变支持的很好。支持 **Avro IDL** 和 **JSON** 两种模式语言，前者适合人工编辑，后者适合机器读取。

```protobuf
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 只用了 32 个字节，为什么呢？
    
    他没有编入类型。
    

![ddia4-avro-enc.png](img/ch04-fig05.png)

因此，Avro 必须配合模式定义来解析，如 Client-Server 在通信的握手阶段会先交换数据模式。

### 写入模式和读取模式

- 没有字段标号，Avro 如何支持模式演进呢？
    
    答案是**显式的**使用两种模式。
    

即，在对数据进行编码（写入文件或者进行传输）时，使用模式 A，称为**写入模式**（writer schema）；在对数据进行解码（从文件或者网络读取）时，使用模式 B，称为**读取模式**（reader schema），而两者不必相同，只需兼容。

也就是说，只要模式在演进时，是**兼容**的，那么 Avro 就能够处理向后兼容和向前兼容。

**向后兼容**：新代码读取旧数据。即读取时首先得到旧数据的写入模式（即旧模式），然后将其与读取模式（即新模式）对比，得到转换映射，即可拿着此映射去解析旧数据。

**向前兼容**：旧代码读取新数据。原理类似，只不过是需要得到一个逆向映射。

在由写入模式到读取模式建立映射时有一些规则：

1. **使用字段名来进行匹配**。因此写入模式和读取模式字段名顺序不一样无所谓。
2. **忽略多出的字段**。
3. **对缺少字段填默认值**。

![ddia-4avro-map.png](img/ch04-fig06.png)

### 模式演化规则

- 那么如何保证写入模式的兼容呢？
    1. 在增删字段时，只能添加或删除具有默认值的字段。
    2. 在更改字段类型时，需要 Avro 支持相应的类型转换。

Avro 没有像 ProtoBuf、Thrift 那样的 optional 和 required 限定符，是通过 union 的方式，里指定默认值，甚至多种类型：

```c
union {null, long, string} field;
```

**注**：默认值必须是联合的第一个分支的类型。

更改字段名和在 union 中添加类型，都是向后兼容，但是不能向前兼容的，想想为什么？

### 如何从编码中获取写入模式

对于一段给定的 Avro 编码数据，Reader 如何从其中获得其对应的写入模式？

这取决于不同的应用场景。

- **所有数据条目同构的大文件**
    
    典型的就是 Hadoop 生态中。如果一个大文件所有记录都使用相同模式编码，则在文件头包含一次写入模式即可。
    
- **支持模式变更的数据库表**
    
    由于数据库表允许模式修改，其中的行可能写入于不同模式阶段。对于这种情况，可以在编码时额外记录一个模式版本号（比如自增），然后在某个地方存储所有的模式版本。
    
    解码时，通过版本去查询对应的写入模式即可。
    
- **网络中发送数据**
    
    在两个进程通信的握手阶段，交换写入模式。比如在一个 session 开始时交换模式，然后在整个 session 生命周期内都用此模式。
    

### 动态生成数据中的模式

Avro 没有使用字段标号的一个好处是，不需要手动维护字段标号到字段名的映射，这对于动态生成的数据模式很友好。

书中给的例子是对数据库做导出备份，注意和数据库本身使用 Avro 编码不是一个范畴，此处是指导出的数据使用 Avro 编码。

在数据库表模式发生改变前后，Avro 只需要在导出时依据当时的模式，做相应的转换，生成相应的模式数据即可。但如果使用 PB，则需要自己处理多个备份文件中，字段标号到字段名称的映射关系。其本质在于，Avro 的数据模式可以和数据存在一块，但是 ProtoBuf 的数据模式只能体现在生成的代码中，需要手动维护新旧版本备份数据与PB 生成的代码间的映射。

### 代码生成和动态语言

Thrift 和 Protobuf 会依据语言无关的 IDL 定义的模式，生成给定语言的编解码的代码。这对静态语言很有用，因为它允许利用 IDE 和编译器进行类型检查，并且能够提高编解码效率。

**上述思路本质上在于，将模式内化到了生成的代码中。**

但对于动态语言，或者说解释型语言，如 JavaScript、Ruby 或 Python，由于没有了编译期检查，生成代码的意义没那么大，反而会有一定的冗余。这时 Avro 这种支持不生成代码的框架就节省一些，它可以将模式写入数据文件，读取时利用 Avro 进行动态解析即可。

## 模式的优点

**模式的本质是显式类型约束，即，先有模式，才能有数据。**

相比于没有任何类型约束的文本编码 JSON，XML 和 CSV，Protocol Buffers，Thrift 和 Avro 这些基于显式定义二进制编码优点有：

1. 省去字段名，从而更加紧凑。
2. 模式是数据的注释或者文档，并且总是最新的。
3. 数据模式允许不读取数据，仅比对模式来做低成本的兼容性检查。
4. 对于静态类型来说，可以利用代码生成做编译时的类型检查。

模式演化 vs 读时模式

# 几种数据流模型

数据可以以很多种形式从一个系统流向另一个系统，但不变的是，流动时都需要编码与解码。

在数据流动时，会涉及编解码双方模式匹配问题，上一小节已经讨论，本小节主要探讨几种进程间典型的数据流方式：

1. 通过数据库
2. 通过服务调用
3. 通过异步消息传递

## 经由数据库的数据流

访问数据库的程序，可能：

1. **只由同一个进程访问**。则数据库可以理解为该进程向将来发送数据的中介。
2. **由多个进程访问**。则多个进程可能有的是旧版本，有的是新版本，此时数据库需要考虑向前和向后兼容的问题。

还有一种比较棘手的情况：在某个时刻，你给一个表增加了一个字段，较新的代码写入带有该字段的行，之后又被较旧的代码覆盖成缺少该字段的行。这时候就会出现一个问题：我们更新了一个字段 A，更新完后，却发现字段 B 没了。

![ddia-4-update-by-old.png](img/ch04-fig07.png)

### 不同时间写入的数据

对于应用程序，可能很短时间就可以由旧版本替换为新版本。但是对于数据，旧版本的代码写入的数据量，经年累月，可能很大。在变更了模式之后，由于这些旧模式的数据量很大，全部更新对齐到新版本的代价很高。

这种情况我们称之为：**数据的生命周期超过了其对应代码的生命周期**。

在读取时，数据库一般会对缺少对应列的旧数据：

1. 填充新版本字段的**默认值**（default value）
2. 如果没有默认值则填充**空值**（nullable）

后返回给用户。一般来说，在更改模式时（比如 alter table），数据库不允许增加既没有默认值、也不允许为空的列。

### 存储归档

有时候需要对数据库做备份到外存。在做备份（或者说快照）时，虽然会有不同时间点生成的数据，但通常会将各种版本数据转化、对齐到最新版本。毕竟，总是要全盘拷贝数据，那就顺便做下转换好了。

之前也提到了，对于这种场景，生成的是一次性的不可变的备份或者快照数据，使用 Avro 比较合适。此时也是一个很好地契机，可以将数据按需要的格式输出，比如面向分析的按列存储格式：[Parquet](https://parquet.apache.org/docs/file-format/ "Parquet")。

## 经由服务的数据流：REST 和 RPC

通过网络通信时，通常涉及两种角色：服务器（server）和客户端（client）。

通常来说，暴露于公网的多为 HTTP 服务，而 RPC 服务常在内部使用。

服务器也可以同时是客户端：

1. 作为客户端访问数据库。
2. 作为客户端访问其他服务。

对于后者，是因为我们常把一个大的服务拆成一组功能独立、相对解耦的服务，这就是 **面向服务的架构（service-oriented architecture，SOA）**，或者最近比较火的**微服务架构（micro-services architecture）**。这两者有一些不同，但这里不再展开。

服务在某种程度上和数据库类似：允许客户端以某种方式存储和查询数据。但不同的是，数据库通常提供某种灵活的查询语言，而服务只能提供相对死板的 API。

### web 服务

当服务使用 HTTP 作为通信协议时，我们通常将其称为 **web 服务**。但其并不局限于 web，还包括：

1. 用户终端（如移动终端）通过 HTTP 向服务器请求。
2. 同组织内的一个服务向另一个服务发送 HTTP 请求（微服务架构，其中的一些组件有时被称为中间件）。
3. 不同组织的服务进行数据交换。一般要通过某种手段进行验证，比如 OAuth。

有两种设计 HTTP API 的方法：REST 和 SOAP。

1. **REST 并不是一种协议，而是一种设计哲学**。它强调简单的 API 格式，使用 URL 来标识资源，使用 HTTP 的动作（GET、POST、PUT、DELETE ）来对资源进行增删改查。由于其简洁风格，越来越受欢迎。
2. SOAP 是基于 XML 的协议。虽然使用 HTTP，但目的在于独立于 HTTP。现在提的比较少了。

### RPC 面临的问题

RPC 想让调用远端服务像调用本地（同进程中）函数一样自然，虽然设想比较好、现在用的也比较多，但也存在一些问题：

1. 本地函数调用要么成功、要么不成功。但是 RPC 由于经过网络，可能会有各种复杂情况，比如请求丢失、响应丢失、hang 住以至于超时等等。因此，可能需要重试。
2. 如果重试，需要考虑**幂等性**问题。因为上一次的请求可能已经到达了服务端，只是请求没有成功返回。那么多次调用远端函数，就要保证不会造成额外副作用。
3. 远端调用延迟不可用，受网络影响较大。
4. 客户端与服务端使用的编程语言可能不同，但如果有些类型不是两种语言都有，就会出一些问题。

REST 相比 RPC 的好处在于，它不试图隐去网络，更为显式，让使用者不易忽视网络的影响。

### RPC 当前方向

尽管有上述问题，但其实在工程中，大部分情况下，上述情况都在容忍范围内：

1. 比如局域网的网络通常比较快速、可控。
2. 多次调用，使用幂等性来解决。
3. 跨语言，可以使用 RPC 框架的 IDL 来解决。

但 RPC 程序需要考虑上面提到的极端情况，否则可能会偶然出一个很难预料的 BUG。

另外，基于二进制编码的 RPC 通常比基于 HTTP 服务效率更高。但 HTTP 服务，或者更具体一点，RESTful API 的好处在于，生态好、有大量的工具支持。而 RPC 的 API 通常和 RPC 框架生成的代码高度相关，因此很难在不同组织中无痛交换和升级。

因此，如本节开头所说：暴露于公网的多为 HTTP 服务，而 RPC 服务常在内部使用。

### 数据编码和 RPC 的演化

通过服务的数据流通常可以假设：所有的服务器先更新，然后服务端再更新。因此，只需要在请求里考虑后向兼容性，在响应中考虑前向兼容性：

1. Thrift、gRPC（Protobuf）和 Avro RPC 可以根据编码格式的兼容性规则进行演变。
2. RESTful API 通常使用 JSON 作为请求响应的格式，JSON 比较容易添加新的字段来进行演进和兼容。
3. SOAP 按下不表。

对于 RPC，服务的兼容性比较困难，因为一旦 RPC 服务的 SDK 提供出去之后，你就无法对其生命周期进行控制：总有用户因为各种原因，不会进行主动升级。因此可能需要长期保持兼容性，或者提前通知和不断预告，或者维护多个版本 SDK 并逐渐对早期版本进行淘汰。

对于 RESTful API，常用的兼容方法是，将版本号做到 URL 或者 HTTP 请求头中。

## 经由消息传递的数据流

前面研究了编码解码的不同方式：

1. 数据库：一个进程写入（编码），将来一个进程读取（解码）
2. RPC 和 REST：一个进程通过网络（发送前会编码）向另一个进程发送请求（收到后会解码）并同步等待响应。

本节研究介于数据库和 RPC 间的**异步消息系统**：一个存储（消息 broker、消息队列来临时存储消息）+ 两次 RPC（生产者一次，消费者一次）。

与 RPC 相比，使用消息队列的优点：

1. 如果消费者暂时不可用，可以充当暂存系统。
2. 当消费者宕机重启后，自动地重新发送消息。
3. 生产者不必知道消费者 IP 和端口。
4. 能将一条消息发送给多个消费者。
5. 将生产者和消费者解耦。

### 消息队列

书中用的是**消息代理**（Message Broker），但另一个名字，消息队列，可能更为大家熟知，因此，本小节之后行文都用消息队列。

过去，消息队列为大厂所垄断。但近年来，开源的消息队列越来越多，可以适应不同场景，如 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 等等。

消息队列的**送达保证**因实现和配置而异，包括：

1. **最少一次 （at-least-once）**：同一条数据可能会送达多次给消费者。
2. **最多一次（at-most-once）**：同一条数据最多会送达一次给消费者，有可能丢失。
3. **严格一次（exactly-once）**：同一条数据保证会送达一次，且最多一次给消费者。

消息队列的逻辑抽象叫做 **Queue** 或者 **Topic**，常用的消费方式两种：

1. 多个消费者互斥消费一个 Topic
2. 每个消费者独占一个 Topic

**注**：我们有时会区分这两个概念：将点对点的互斥消费称为 Queue，多点对多点的发布订阅称为 Topic，但这并不通用，或者说没有形成共识。

一个 Topic 提供一个单向数据流，但可以组合多个 Topic，形成复杂的数据流拓扑。

消息队列通常是面向**字节数组**的，因此你可以将消息按任意格式进行编码。如果编码是前后向兼容的，同一个主题的消息格式，便可以进行灵活演进。

### 分布式的 Actor 框架

**Actor 模型**是一种基于消息传递的并发编程模型。 Actor 通常是由状态（State）、行为（Behavior）和信箱（MailBox，可以认为是一个消息队列）三部分组成：

1. 状态：Actor 中包含的状态信息。
2. 行为：Actor 中对状态的计算逻辑。
3. 信箱：Actor 接受到的消息缓存地。

由于  Actor 和外界交互都是通过消息，因此本身可以并行的，且不需要加锁。

分布式的 Actor 框架，本质上是将消息队列和 actor 编程模型集成到一块。自然，在 Actor 滚动升级是，也需要考虑前后向兼容问题。
