mirror of
https://github.com/Vonng/ddia.git
synced 2025-03-06 15:40:11 +08:00
update text spacing with pangu and some manual adjustments
This commit is contained in:
parent
11429ddb4e
commit
7f7bcad5b4
316
ch4.md
316
ch4.md
@ -1,28 +1,28 @@
|
||||
# 第四章:编码与演化
|
||||
# 第四章:编码与演化
|
||||
|
||||

|
||||
|
||||
> 唯变所适
|
||||
>
|
||||
> —— 以弗所的赫拉克利特,为柏拉图所引(公元前360年)
|
||||
> —— 以弗所的赫拉克利特,为柏拉图所引(公元前 360 年)
|
||||
>
|
||||
|
||||
-------------------
|
||||
|
||||
[TOC]
|
||||
|
||||
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着**功能(feature)** 的增增改改。[第一章](ch1.md)介绍了**可演化性(evolvability)** 的概念:应该尽力构建能灵活适应变化的系统(请参阅“[可演化性:拥抱变化](ch1.md#可演化性:拥抱变化)”)。
|
||||
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着 **功能(feature)** 的增增改改。[第一章](ch1.md) 介绍了 **可演化性(evolvability)** 的概念:应该尽力构建能灵活适应变化的系统(请参阅 “[可演化性:拥抱变化](ch1.md#可演化性:拥抱变化)”)。
|
||||
|
||||
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
|
||||
|
||||
我们在[第二章](ch2.md)讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即`ALTER`语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式**(schema-on-read,或**无模式**,即schemaless)数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合(请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)” )。
|
||||
我们在 [第二章](ch2.md) 讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即 `ALTER` 语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式**(schema-on-read,或 **无模式**,即 schemaless)数据库不会强制一个模式,因此数据库可以包含在不同时间写入的新老数据格式的混合(请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)” )。
|
||||
|
||||
当数据**格式(format)** 或**模式(schema)** 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:
|
||||
当数据 **格式(format)** 或 **模式(schema)** 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:
|
||||
|
||||
* 对于 **服务端(server-side)** 应用程序,可能需要执行 **滚动升级 (rolling upgrade)** (也称为 **阶段发布(staged rollout)** ),一次将新版本部署到少数几个节点,检查新版本是否运行正常,然后逐渐部完所有的节点。这样无需中断服务即可部署新版本,为频繁发布提供了可行性,从而带来更好的可演化性。
|
||||
* 对于 **客户端(client-side)** 应用程序,升不升级就要看用户的心情了。用户可能相当长一段时间里都不会去升级软件。
|
||||
|
||||
这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行,就需要保持**双向兼容性**:
|
||||
这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行,就需要保持 **双向兼容性**:
|
||||
|
||||
* 向后兼容 (backward compatibility)
|
||||
|
||||
@ -36,63 +36,63 @@
|
||||
|
||||
向前兼容性可能会更棘手,因为旧版的程序需要忽略新版数据格式中新增的部分。
|
||||
|
||||
本章中将介绍几种编码数据的格式,包括 JSON,XML,Protocol Buffers,Thrift和Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信:在Web服务中,**表述性状态传递(REST)** 和**远程过程调用(RPC)**,以及**消息传递系统**(如Actor和消息队列)。
|
||||
本章中将介绍几种编码数据的格式,包括 JSON、XML、Protocol Buffers、Thrift 和 Avro。尤其将关注这些格式如何应对模式变化,以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信:在 Web 服务中,**表述性状态传递(REST)** 和 **远程过程调用(RPC)**,以及 **消息传递系统**(如 Actor 和消息队列)。
|
||||
|
||||
## 编码数据的格式
|
||||
|
||||
程序通常(至少)使用两种形式的数据:
|
||||
|
||||
1. 在内存中,数据保存在对象,结构体,列表,数组,散列表,树等中。 这些数据结构针对CPU的高效访问和操作进行了优化(通常使用指针)。
|
||||
2. 如果要将数据写入文件,或通过网络发送,则必须将其 **编码(encode)** 为某种自包含的字节序列(例如,JSON文档)。 由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同[^i]。
|
||||
1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等中。 这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
|
||||
2. 如果要将数据写入文件,或通过网络发送,则必须将其 **编码(encode)** 为某种自包含的字节序列(例如,JSON 文档)。 由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同 [^i]。
|
||||
|
||||
[^i]: 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如“[列压缩](ch4.md#列压缩)”中所述)。
|
||||
[^i]: 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如 “[列压缩](ch4.md#列压缩)” 中所述)。
|
||||
|
||||
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为 **编码(Encoding)** (也称为**序列化(serialization)** 或**编组(marshalling)**),反过来称为**解码(Decoding)**[^ii](**解析(Parsing)**,**反序列化(deserialization)**,**反编组( unmarshalling)**)[^译i]。
|
||||
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为 **编码(Encoding)** (也称为 **序列化(serialization)** 或 **编组(marshalling)**),反过来称为 **解码(Decoding)**[^ii](**解析(Parsing)**,**反序列化(deserialization)**,**反编组 (unmarshalling)**)[^译i]。
|
||||
|
||||
[^ii]: 请注意,**编码(encode)** 与 **加密(encryption)** 无关。 本书不讨论加密。
|
||||
[^译i]: Marshal与Serialization的区别:Marshal不仅传输对象的状态,而且会一起传输对象的方法(相关代码)。
|
||||
[^译i]: Marshal 与 Serialization 的区别:Marshal 不仅传输对象的状态,而且会一起传输对象的方法(相关代码)。
|
||||
|
||||
> #### 术语冲突
|
||||
> 不幸的是,在[第七章](ch7.md): **事务(Transaction)** 的上下文里,**序列化(Serialization)** 这个术语也出现了,而且具有完全不同的含义。尽管序列化可能是更常见的术语,为了避免术语重载,本书中坚持使用 **编码(Encoding)** 表达此含义。
|
||||
> 不幸的是,在 [第七章](ch7.md): **事务(Transaction)** 的上下文里,**序列化(Serialization)** 这个术语也出现了,而且具有完全不同的含义。尽管序列化可能是更常见的术语,为了避免术语重载,本书中坚持使用 **编码(Encoding)** 表达此含义。
|
||||
|
||||
这是一个常见的问题,因而有许多库和编码格式可供选择。 首先让我们概览一下。
|
||||
|
||||
### 语言特定的格式
|
||||
|
||||
许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java有`java.io.Serializable` 【1】,Ruby有`Marshal`【2】,Python有`pickle`【3】等等。许多第三方库也存在,例如`Kryo for Java` 【4】。
|
||||
许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java 有 `java.io.Serializable` 【1】,Ruby 有 `Marshal`【2】,Python 有 `pickle`【3】等等。许多第三方库也存在,例如 `Kryo for Java` 【4】。
|
||||
|
||||
这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一些深层次的问题:
|
||||
|
||||
* 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。并且很难将系统与其他组织的系统(可能用的是不同的语言)进行集成。
|
||||
* 为了恢复相同对象类型的数据,解码过程需要**实例化任意类**的能力,这通常是安全问题的一个来源【5】:如果攻击者可以让应用程序解码任意的字节序列,他们就能实例化任意的类,这会允许他们做可怕的事情,如远程执行任意代码【6,7】。
|
||||
* 为了恢复相同对象类型的数据,解码过程需要 **实例化任意类** 的能力,这通常是安全问题的一个来源【5】:如果攻击者可以让应用程序解码任意的字节序列,他们就能实例化任意的类,这会允许他们做可怕的事情,如远程执行任意代码【6,7】。
|
||||
* 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
|
||||
* 效率(编码或解码所花费的CPU时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着【8】。
|
||||
* 效率(编码或解码所花费的 CPU 时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着【8】。
|
||||
|
||||
因此,除非临时使用,采用语言内置编码通常是一个坏主意。
|
||||
|
||||
### JSON、XML和二进制变体
|
||||
|
||||
当我们谈到可以被多种编程语言读写的标准编码时,JSON和XML是最显眼的角逐者。它们广为人知,广受支持,也“广受憎恶”。 XML经常收到批评:过于冗长与且过份复杂【9】。 JSON的流行则主要源于(通过成为JavaScript的一个子集)Web浏览器的内置支持,以及相对于XML的简单性。 CSV是另一种流行的与语言无关的格式,尽管其功能相对较弱。
|
||||
当我们谈到可以被多种编程语言读写的标准编码时,JSON 和 XML 是最显眼的角逐者。它们广为人知,广受支持,也 “广受憎恶”。 XML 经常收到批评:过于冗长与且过份复杂【9】。 JSON 的流行则主要源于(通过成为 JavaScript 的一个子集)Web 浏览器的内置支持,以及相对于 XML 的简单性。 CSV 是另一种流行的与语言无关的格式,尽管其功能相对较弱。
|
||||
|
||||
JSON,XML和CSV属于文本格式,因此具有人类可读性(尽管它们的语法是一个热门争议话题)。除了表面的语法问题之外,它们也存在一些微妙的问题:
|
||||
JSON,XML 和 CSV 属于文本格式,因此具有人类可读性(尽管它们的语法是一个热门争议话题)。除了表面的语法问题之外,它们也存在一些微妙的问题:
|
||||
|
||||
* **数值(numbers)** 的编码多有歧义之处。XML和CSV不能区分数字和字符串(除非引用一个外部模式)。 JSON虽然区分字符串与数值,但不区分整数和浮点数,而且不能指定精度。
|
||||
* 当处理更大的数值时,这个问题显得尤为严重。例如大于$2^{53}$的整数无法使用IEEE 754双精度浮点数精确表示,因此在使用浮点数(例如JavaScript)的语言进行分析时,这些数字会变得不准确。 Twitter有一个关于大于$2^{53}$的数字的例子,它使用64位整数来标识每条推文。 Twitter API返回的JSON包含了两种推特ID,一种是JSON数值,另一种是十进制字符串,以避免JavaScript程序无法正确解析数字的问题【10】。
|
||||
* JSON和XML对Unicode字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 **字符编码(character encoding)** 的字节序列)。二进制串是很有用的功能,人们通过使用Base64将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为Base64编码的二进制数据。这种方案虽然管用,但比较Hacky,并且会增加三分之一的数据大小。
|
||||
* XML 【11】和 JSON 【12】都有可选的模式支持。这些模式语言相当强大,所以学习和实现起来都相当复杂。 XML模式的使用相当普遍,但许多基于JSON的工具才不会去折腾模式。对数据的正确解读(例如区分数值与二进制串)取决于模式中的信息,因此不使用XML/JSON模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码。
|
||||
* CSV没有任何模式,因此每行和每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。 CSV也是一个相当模糊的格式(如果一个值包含逗号或换行符,会发生什么?)。尽管其转义规则已经被正式指定【13】,但并不是所有的解析器都正确的实现了标准。
|
||||
* **数值(numbers)** 的编码多有歧义之处。XML 和 CSV 不能区分数字和字符串(除非引用一个外部模式)。 JSON 虽然区分字符串与数值,但不区分整数和浮点数,而且不能指定精度。
|
||||
* 当处理更大的数值时,这个问题显得尤为严重。例如大于 $2^{53}$ 的整数无法使用 IEEE 754 双精度浮点数精确表示,因此在使用浮点数(例如 JavaScript)的语言进行分析时,这些数字会变得不准确。 Twitter 有一个关于大于 $2^{53}$ 的数字的例子,它使用 64 位整数来标识每条推文。 Twitter API 返回的 JSON 包含了两种推特 ID,一种是 JSON 数值,另一种是十进制字符串,以避免 JavaScript 程序无法正确解析数字的问题【10】。
|
||||
* JSON 和 XML 对 Unicode 字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 **字符编码 (character encoding)** 的字节序列)。二进制串是很有用的功能,人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为 Base64 编码的二进制数据。这种方案虽然管用,但比较 Hacky,并且会增加三分之一的数据大小。
|
||||
* XML 【11】和 JSON 【12】都有可选的模式支持。这些模式语言相当强大,所以学习和实现起来都相当复杂。 XML 模式的使用相当普遍,但许多基于 JSON 的工具才不会去折腾模式。对数据的正确解读(例如区分数值与二进制串)取决于模式中的信息,因此不使用 XML/JSON 模式的应用程序可能需要对相应的编码 / 解码逻辑进行硬编码。
|
||||
* CSV 没有任何模式,因此每行和每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列,那么这种变更必须通过手工处理。 CSV 也是一个相当模糊的格式(如果一个值包含逗号或换行符,会发生什么?)。尽管其转义规则已经被正式指定【13】,但并不是所有的解析器都正确的实现了标准。
|
||||
|
||||
尽管存在这些缺陷,但JSON,XML和CSV对很多需求来说已经足够好了。它们很可能会继续流行下去,特别是作为数据交换格式来说(即将数据从一个组织发送到另一个组织)。在这种情况下,只要人们对格式是什么意见一致,格式有多美观或者效率有多高效就无所谓了。让不同的组织就这些东西达成一致的难度超过了绝大多数问题。
|
||||
尽管存在这些缺陷,但 JSON、XML 和 CSV 对很多需求来说已经足够好了。它们很可能会继续流行下去,特别是作为数据交换格式来说(即将数据从一个组织发送到另一个组织)。在这种情况下,只要人们对格式是什么意见一致,格式有多美观或者效率有多高效就无所谓了。让不同的组织就这些东西达成一致的难度超过了绝大多数问题。
|
||||
|
||||
#### 二进制编码
|
||||
|
||||
对于仅在组织内部使用的数据,使用最小公约数式的编码格式压力较小。例如,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计;但一旦达到TB级别,数据格式的选型就会产生巨大的影响。
|
||||
对于仅在组织内部使用的数据,使用最小公约数式的编码格式压力较小。例如,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计;但一旦达到 TB 级别,数据格式的选型就会产生巨大的影响。
|
||||
|
||||
JSON比XML简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等) 和 XML(例如WBXML和Fast Infoset)的出现。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版JSON和XML那样被广泛采用。
|
||||
JSON 比 XML 简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本 JSON(MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML(例如 WBXML 和 Fast Infoset)的出现。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版 JSON 和 XML 那样被广泛采用。
|
||||
|
||||
这些格式中的一些扩展了一组数据类型(例如,区分整数和浮点数,或者增加对二进制字符串的支持),另一方面,它们没有改变JSON / XML的数据模型。特别是由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称。也就是说,在[例4-1]()中的JSON文档的二进制编码中,需要在某处包含字符串`userName`,`favoriteNumber`和`interests`。
|
||||
这些格式中的一些扩展了一组数据类型(例如,区分整数和浮点数,或者增加对二进制字符串的支持),另一方面,它们没有改变 JSON / XML 的数据模型。特别是由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称。也就是说,在 [例 4-1]() 中的 JSON 文档的二进制编码中,需要在某处包含字符串 `userName`,`favoriteNumber` 和 `interests`。
|
||||
|
||||
**例4-1 本章中用于展示二进制编码的示例记录**
|
||||
**例 4-1 本章中用于展示二进制编码的示例记录**
|
||||
|
||||
```json
|
||||
{
|
||||
@ -102,26 +102,26 @@ JSON比XML简洁,但与二进制格式相比还是太占空间。这一事实
|
||||
}
|
||||
```
|
||||
|
||||
我们来看一个MessagePack的例子,它是一个JSON的二进制编码。图4-1显示了如果使用MessagePack 【14】对[例4-1]()中的JSON文档进行编码,则得到的字节序列。前几个字节如下:
|
||||
我们来看一个 MessagePack 的例子,它是一个 JSON 的二进制编码。图 4-1 显示了如果使用 MessagePack 【14】对 [例 4-1]() 中的 JSON 文档进行编码,则得到的字节序列。前几个字节如下:
|
||||
|
||||
1. 第一个字节`0x83`表示接下来是**3**个字段(低四位= `0x03`)的**对象 object**(高四位= `0x80`)。 (如果想知道如果一个对象有15个以上的字段会发生什么情况,字段的数量塞不进4个bit里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。
|
||||
2. 第二个字节`0xa8`表示接下来是**8**字节长(低四位=0x08)的字符串(高四位= 0x0a)。
|
||||
3. 接下来八个字节是ASCII字符串形式的字段名称`userName`。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。
|
||||
4. 接下来的七个字节对前缀为`0xa6`的六个字母的字符串值`Martin`进行编码,依此类推。
|
||||
1. 第一个字节 `0x83` 表示接下来是 **3** 个字段(低四位 = `0x03`)的 **对象 object**(高四位 = `0x80`)。 (如果想知道如果一个对象有 15 个以上的字段会发生什么情况,字段的数量塞不进 4 个 bit 里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。
|
||||
2. 第二个字节 `0xa8` 表示接下来是 **8** 字节长(低四位 = `0x08`)的字符串(高四位 = `0x0a`)。
|
||||
3. 接下来八个字节是 ASCII 字符串形式的字段名称 `userName`。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。
|
||||
4. 接下来的七个字节对前缀为 `0xa6` 的六个字母的字符串值 `Martin` 进行编码,依此类推。
|
||||
|
||||
二进制编码长度为66个字节,仅略小于文本JSON编码所取的81个字节(删除了空白)。所有的JSON的二进制编码在这方面是相似的。空间节省了一丁点(以及解析加速)是否能弥补可读性的损失,谁也说不准。
|
||||
二进制编码长度为 66 个字节,仅略小于文本 JSON 编码所取的 81 个字节(删除了空白)。所有的 JSON 的二进制编码在这方面是相似的。空间节省了一丁点(以及解析加速)是否能弥补可读性的损失,谁也说不准。
|
||||
|
||||
在下面的章节中,能达到比这好得多的结果,只用32个字节对相同的记录进行编码。
|
||||
在下面的章节中,能达到比这好得多的结果,只用 32 个字节对相同的记录进行编码。
|
||||
|
||||
|
||||

|
||||
|
||||
**图4-1 使用MessagePack编码的记录(例4-1)**
|
||||
**图 4-1 使用 MessagePack 编码的记录(例 4-1)**
|
||||
|
||||
### Thrift与Protocol Buffers
|
||||
|
||||
Apache Thrift 【15】和Protocol Buffers(protobuf)【16】是基于相同原理的二进制编码库。 Protocol Buffers最初是在Google开发的,Thrift最初是在Facebook开发的,并且在2007~2008年都是开源的【17】。
|
||||
Thrift和Protocol Buffers都需要一个模式来编码任何数据。要在Thrift的[例4-1]()中对数据进行编码,可以使用Thrift **接口定义语言(IDL)** 来描述模式,如下所示:
|
||||
Apache Thrift 【15】和 Protocol Buffers(protobuf)【16】是基于相同原理的二进制编码库。 Protocol Buffers 最初是在 Google 开发的,Thrift 最初是在 Facebook 开发的,并且在 2007~2008 年都是开源的【17】。
|
||||
Thrift 和 Protocol Buffers 都需要一个模式来编码任何数据。要在 Thrift 的 [例 4-1]() 中对数据进行编码,可以使用 Thrift **接口定义语言(IDL)** 来描述模式,如下所示:
|
||||
|
||||
```c
|
||||
struct Person {
|
||||
@ -131,7 +131,7 @@ struct Person {
|
||||
}
|
||||
```
|
||||
|
||||
Protocol Buffers的等效模式定义看起来非常相似:
|
||||
Protocol Buffers 的等效模式定义看起来非常相似:
|
||||
|
||||
```protobuf
|
||||
message Person {
|
||||
@ -141,38 +141,38 @@ message Person {
|
||||
}
|
||||
```
|
||||
|
||||
Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。你的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。
|
||||
用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](img/fig4-2.png)所示【19】。
|
||||
Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。你的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。
|
||||
用这个模式编码的数据是什么样的?令人困惑的是,Thrift 有两种不同的二进制编码格式 [^iii],分别称为 BinaryProtocol 和 CompactProtocol。先来看看 BinaryProtocol。使用这种格式的编码来编码 [例 4-1]() 中的消息只需要 59 个字节,如 [图 4-2](img/fig4-2.png) 所示【19】。
|
||||
|
||||

|
||||
|
||||
**图4-2 使用Thrift二进制协议编码的记录**
|
||||
**图 4-2 使用 Thrift 二进制协议编码的记录**
|
||||
|
||||
[^iii]: 实际上,Thrift有三种二进制协议:BinaryProtocol、CompactProtocol和DenseProtocol,尽管DenseProtocol只支持C ++实现,所以不算作跨语言【18】。 除此之外,它还有两种不同的基于JSON的编码格式【19】。 真逗!
|
||||
[^iii]: 实际上,Thrift 有三种二进制协议:BinaryProtocol、CompactProtocol 和 DenseProtocol,尽管 DenseProtocol 只支持 C ++ 实现,所以不算作跨语言【18】。 除此之外,它还有两种不同的基于 JSON 的编码格式【19】。 真逗!
|
||||
|
||||
与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。
|
||||
与 [图 4-1](Img/fig4-1.png) 类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串 `(“Martin”, “daydreaming”, “hacking”)` 也被编码为 ASCII(或者说,UTF-8),与之前类似。
|
||||
|
||||
与[图4-1](img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interests)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。
|
||||
与 [图 4-1](img/fig4-1.png) 相比,最大的区别是没有字段名 `(userName, favoriteNumber, interests)`。相反,编码数据包含字段标签,它们是数字 `(1, 2 和 3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。
|
||||
|
||||
Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。
|
||||
Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol,但是如 [图 4-3](img/fig4-3.png) 所示,它只将相同的信息打包成只有 34 个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字 1337 不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着 - 64 到 63 之间的数字被编码为一个字节,-8192 和 8191 之间的数字以两个字节编码,等等。较大的数字使用更多的字节。
|
||||
|
||||

|
||||
|
||||
**图4-3 使用Thrift压缩协议编码的记录**
|
||||
**图 4-3 使用 Thrift 压缩协议编码的记录**
|
||||
|
||||
最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。
|
||||
最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如 [图 4-4](img/fig4-4.png) 所示。 它的打包方式稍有不同,但与 Thrift 的 CompactProtocol 非常相似。 Protobuf 将同样的记录塞进了 33 个字节中。
|
||||
|
||||

|
||||
|
||||
**图4-4 使用Protobuf编码的记录**
|
||||
**图 4-4 使用 Protobuf 编码的记录**
|
||||
|
||||
需要注意的一个细节:在前面所示的模式中,每个字段被标记为必需或可选,但是这对字段如何编码没有任何影响(二进制数据中没有任何字段指示某字段是否必须)。区别在于,如果字段设置为 `required`,但未设置该字段,则所需的运行时检查将失败,这对于捕获错误非常有用。
|
||||
|
||||
#### 字段标签和模式演变
|
||||
|
||||
我们之前说过,模式不可避免地需要随着时间而改变。我们称之为模式演变。 Thrift和Protocol Buffers如何处理模式更改,同时保持向后兼容性?
|
||||
我们之前说过,模式不可避免地需要随着时间而改变。我们称之为模式演变。 Thrift 和 Protocol Buffers 如何处理模式更改,同时保持向后兼容性?
|
||||
|
||||
从示例中可以看出,编码的记录就是其编码字段的拼接。每个字段由其标签号码(样本模式中的数字1,2,3)标识,并用数据类型(例如字符串或整数)注释。如果没有设置字段值,则简单地从编码记录中省略。从中可以看到,字段标记对编码数据的含义至关重要。你可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这会使所有现有的编码数据无效。
|
||||
从示例中可以看出,编码的记录就是其编码字段的拼接。每个字段由其标签号码(样本模式中的数字 1,2,3)标识,并用数据类型(例如字符串或整数)注释。如果没有设置字段值,则简单地从编码记录中省略。从中可以看到,字段标记对编码数据的含义至关重要。你可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这会使所有现有的编码数据无效。
|
||||
|
||||
你可以添加新的字段到架构,只要你给每个字段一个新的标签号码。如果旧的代码(不知道你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
|
||||
|
||||
@ -182,19 +182,19 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4
|
||||
|
||||
#### 数据类型和模式演变
|
||||
|
||||
如何改变字段的数据类型?这也许是可能的——详细信息请查阅相关的文档——但是有一个风险,值将失去精度或被截断。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。
|
||||
如何改变字段的数据类型?这也许是可能的 —— 详细信息请查阅相关的文档 —— 但是有一个风险,值将失去精度或被截断。例如,假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位,则它将被截断。
|
||||
|
||||
Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(`repeated`,这是除必需和可选之外的第三个选项)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
|
||||
Protobuf 的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(`repeated`,这是除必需和可选之外的第三个选项)。如 [图 4-4](img/fig4-4.png) 所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
|
||||
|
||||
Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的演变,但是它具有支持嵌套列表的优点。
|
||||
Thrift 有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许 Protocol Buffers 所做的从单值到多值的演变,但是它具有支持嵌套列表的优点。
|
||||
|
||||
### Avro
|
||||
|
||||
Apache Avro 【20】是另一种二进制编码格式,与Protocol Buffers和Thrift有着有趣的不同。 它是作为Hadoop的一个子项目在2009年开始的,因为Thrift不适合Hadoop的用例【21】。
|
||||
Apache Avro 【20】是另一种二进制编码格式,与 Protocol Buffers 和 Thrift 有着有趣的不同。 它是作为 Hadoop 的一个子项目在 2009 年开始的,因为 Thrift 不适合 Hadoop 的用例【21】。
|
||||
|
||||
Avro也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于人工编辑,一种(基于JSON)更易于机器读取。
|
||||
Avro 也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于人工编辑,一种(基于 JSON)更易于机器读取。
|
||||
|
||||
我们用Avro IDL编写的示例模式可能如下所示:
|
||||
我们用 Avro IDL 编写的示例模式可能如下所示:
|
||||
|
||||
```c
|
||||
record Person {
|
||||
@ -204,7 +204,7 @@ record Person {
|
||||
}
|
||||
```
|
||||
|
||||
等价的JSON表示:
|
||||
等价的 JSON 表示:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -218,102 +218,102 @@ record Person {
|
||||
}
|
||||
```
|
||||
|
||||
首先,请注意模式中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。
|
||||
首先,请注意模式中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例 4-1]()),Avro 二进制编码只有 32 个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如 [图 4-5](img/fig4-5.png) 所示。
|
||||
|
||||
如果你检查字节序列,你可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。
|
||||
如果你检查字节序列,你可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟 UTF-8 字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与 Thrift 的 CompactProtocol 相同)进行编码。
|
||||
|
||||

|
||||
|
||||
**图4-5 使用Avro编码的记录**
|
||||
**图 4-5 使用 Avro 编码的记录**
|
||||
|
||||
为了解析二进制数据,你按照它们出现在模式中的顺序遍历这些字段,并使用模式来告诉你每个字段的数据类型。这意味着如果读取数据的代码使用与写入数据的代码完全相同的模式,才能正确解码二进制数据。Reader和Writer之间的模式不匹配意味着错误地解码数据。
|
||||
为了解析二进制数据,你按照它们出现在模式中的顺序遍历这些字段,并使用模式来告诉你每个字段的数据类型。这意味着如果读取数据的代码使用与写入数据的代码完全相同的模式,才能正确解码二进制数据。Reader 和 Writer 之间的模式不匹配意味着错误地解码数据。
|
||||
|
||||
那么,Avro如何支持模式演变呢?
|
||||
那么,Avro 如何支持模式演变呢?
|
||||
|
||||
#### Writer模式与Reader模式
|
||||
|
||||
有了Avro,当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式编码数据,例如,模式可能被编译到应用程序中。这被称为Writer模式。
|
||||
有了 Avro,当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式编码数据,例如,模式可能被编译到应用程序中。这被称为 Writer 模式。
|
||||
|
||||
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是Reader模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能已经从该模式生成。
|
||||
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是 Reader 模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能已经从该模式生成。
|
||||
|
||||
Avro的关键思想是Writer模式和Reader模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看Writer模式和Reader模式并将数据从Writer模式转换到Reader模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。
|
||||
Avro 的关键思想是 Writer 模式和 Reader 模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro 库通过并排查看 Writer 模式和 Reader 模式并将数据从 Writer 模式转换到 Reader 模式来解决差异。 Avro 规范【20】确切地定义了这种解析的工作原理,如 [图 4-6](img/fig4-6.png) 所示。
|
||||
|
||||
例如,如果Writer模式和Reader模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在Writer模式中但不在Reader模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是Writer模式不包含该名称的字段,则使用在Reader模式中声明的默认值填充。
|
||||
例如,如果 Writer 模式和 Reader 模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在 Writer 模式中但不在 Reader 模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是 Writer 模式不包含该名称的字段,则使用在 Reader 模式中声明的默认值填充。
|
||||
|
||||

|
||||
|
||||
**图4-6 一个Avro Reader解决读写模式的差异**
|
||||
**图 4-6 一个 Avro Reader 解决读写模式的差异**
|
||||
|
||||
#### 模式演变规则
|
||||
|
||||
使用Avro,向前兼容性意味着你可以将新版本的模式作为Writer,并将旧版本的模式作为Reader。相反,向后兼容意味着你可以有一个作为Reader的新版本模式和作为Writer的旧版本模式。
|
||||
使用 Avro,向前兼容性意味着你可以将新版本的模式作为 Writer,并将旧版本的模式作为 Reader。相反,向后兼容意味着你可以有一个作为 Reader 的新版本模式和作为 Writer 的旧版本模式。
|
||||
|
||||
为了保持兼容性,你只能添加或删除具有默认值的字段(我们的Avro模式中的字段`favoriteNumber`的默认值为`null`)。例如,假设你添加了一个有默认值的字段,这个新的字段将存在于新模式而不是旧模式中。当使用新模式的Reader读取使用旧模式写入的记录时,将为缺少的字段填充默认值。
|
||||
为了保持兼容性,你只能添加或删除具有默认值的字段(我们的 Avro 模式中的字段 `favoriteNumber` 的默认值为 `null`)。例如,假设你添加了一个有默认值的字段,这个新的字段将存在于新模式而不是旧模式中。当使用新模式的 Reader 读取使用旧模式写入的记录时,将为缺少的字段填充默认值。
|
||||
|
||||
如果你要添加一个没有默认值的字段,新的Reader将无法读取旧Writer写的数据,所以你会破坏向后兼容性。如果你要删除没有默认值的字段,旧的Reader将无法读取新Writer写入的数据,因此你会打破向前兼容性。在一些编程语言中,null是任何变量可以接受的默认值,但在Avro中并不是这样:如果要允许一个字段为`null`,则必须使用联合类型。例如,`union {null, long, string} field;`表示field可以是数字或字符串,也可以是`null`。如果要将null作为默认值,则它必须是union的分支之一[^iv]。这样的写法比默认情况下就允许任何变量是`null`显得更加冗长,但是通过明确什么可以和什么不可以是`null`,有助于防止出错【22】。
|
||||
如果你要添加一个没有默认值的字段,新的 Reader 将无法读取旧 Writer 写的数据,所以你会破坏向后兼容性。如果你要删除没有默认值的字段,旧的 Reader 将无法读取新 Writer 写入的数据,因此你会打破向前兼容性。在一些编程语言中,null 是任何变量可以接受的默认值,但在 Avro 中并不是这样:如果要允许一个字段为 `null`,则必须使用联合类型。例如,`union {null, long, string} field;` 表示 field 可以是数字或字符串,也可以是 `null`。如果要将 null 作为默认值,则它必须是 union 的分支之一 [^iv]。这样的写法比默认情况下就允许任何变量是 `null` 显得更加冗长,但是通过明确什么可以和什么不可以是 `null`,有助于防止出错【22】。
|
||||
|
||||
[^iv]: 确切地说,默认值必须是联合的第一个分支的类型,尽管这是Avro的特定限制,而不是联合类型的一般特征。
|
||||
[^iv]: 确切地说,默认值必须是联合的第一个分支的类型,尽管这是 Avro 的特定限制,而不是联合类型的一般特征。
|
||||
|
||||
因此,Avro没有像Protocol Buffers和Thrift那样的`optional`和`required`标记(但它有联合类型和默认值)。
|
||||
因此,Avro 没有像 Protocol Buffers 和 Thrift 那样的 `optional` 和 `required` 标记(但它有联合类型和默认值)。
|
||||
|
||||
只要Avro可以支持相应的类型转换,就可以改变字段的数据类型。更改字段的名称也是可能的,但有点棘手:Reader模式可以包含字段名称的别名,所以它可以匹配旧Writer的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
|
||||
只要 Avro 可以支持相应的类型转换,就可以改变字段的数据类型。更改字段的名称也是可能的,但有点棘手:Reader 模式可以包含字段名称的别名,所以它可以匹配旧 Writer 的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
|
||||
|
||||
#### 但Writer模式到底是什么?
|
||||
|
||||
到目前为止,我们一直跳过了一个重要的问题:对于一段特定的编码数据,Reader如何知道其Writer模式?我们不能只将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而使二进制编码节省的所有空间都是徒劳的。
|
||||
到目前为止,我们一直跳过了一个重要的问题:对于一段特定的编码数据,Reader 如何知道其 Writer 模式?我们不能只将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而使二进制编码节省的所有空间都是徒劳的。
|
||||
|
||||
答案取决于Avro使用的上下文。举几个例子:
|
||||
答案取决于 Avro 使用的上下文。举几个例子:
|
||||
|
||||
* 有很多记录的大文件
|
||||
|
||||
Avro的一个常见用途 - 尤其是在Hadoop环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码(我们将在[第十章](ch10.md)讨论这种情况)。在这种情况下,该文件的作者可以在文件的开头只包含一次Writer模式。 Avro指定了一个文件格式(对象容器文件)来做到这一点。
|
||||
Avro 的一个常见用途 - 尤其是在 Hadoop 环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码(我们将在 [第十章](ch10.md) 讨论这种情况)。在这种情况下,该文件的作者可以在文件的开头只包含一次 Writer 模式。 Avro 指定了一个文件格式(对象容器文件)来做到这一点。
|
||||
|
||||
* 支持独立写入的记录的数据库
|
||||
|
||||
在一个数据库中,不同的记录可能会在不同的时间点使用不同的Writer模式来写入 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。Reader可以获取记录,提取版本号,然后从数据库中获取该版本号的Writer模式。使用该Writer模式,它可以解码记录的其余部分(例如Espresso 【23】就是这样工作的)。
|
||||
在一个数据库中,不同的记录可能会在不同的时间点使用不同的 Writer 模式来写入 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号,并在数据库中保留一个模式版本列表。Reader 可以获取记录,提取版本号,然后从数据库中获取该版本号的 Writer 模式。使用该 Writer 模式,它可以解码记录的其余部分(例如 Espresso 【23】就是这样工作的)。
|
||||
|
||||
* 通过网络连接发送记录
|
||||
|
||||
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议(请参阅“[服务中的数据流:REST与RPC](#服务中的数据流:REST与RPC)”)就是这样工作的。
|
||||
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC 协议(请参阅 “[服务中的数据流:REST 与 RPC](#服务中的数据流:REST与RPC)”)就是这样工作的。
|
||||
|
||||
具有模式版本的数据库在任何情况下都是非常有用的,因为它充当文档并为你提供了检查模式兼容性的机会【24】。作为版本号,你可以使用一个简单的递增整数,或者你可以使用模式的散列。
|
||||
|
||||
#### 动态生成的模式
|
||||
|
||||
与Protocol Buffers和Thrift相比,Avro方法的一个优点是架构不包含任何标签号码。但为什么这很重要?在模式中保留一些数字有什么问题?
|
||||
与 Protocol Buffers 和 Thrift 相比,Avro 方法的一个优点是架构不包含任何标签号码。但为什么这很重要?在模式中保留一些数字有什么问题?
|
||||
|
||||
不同之处在于Avro对动态生成的模式更友善。例如,假如你有一个关系数据库,你想要把它的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON,CSV,SQL)的问题。如果你使用Avro,你可以很容易地从关系模式生成一个Avro模式(在我们之前看到的JSON表示中),并使用该模式对数据库内容进行编码,并将其全部转储到Avro对象容器文件【25】中。你为每个数据库表生成一个记录模式,每个列成为该记录中的一个字段。数据库中的列名称映射到Avro中的字段名称。
|
||||
不同之处在于 Avro 对动态生成的模式更友善。例如,假如你有一个关系数据库,你想要把它的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON,CSV,SQL)的问题。如果你使用 Avro,你可以很容易地从关系模式生成一个 Avro 模式(在我们之前看到的 JSON 表示中),并使用该模式对数据库内容进行编码,并将其全部转储到 Avro 对象容器文件【25】中。你为每个数据库表生成一个记录模式,每个列成为该记录中的一个字段。数据库中的列名称映射到 Avro 中的字段名称。
|
||||
|
||||
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的Avro模式,并在新的Avro模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的Writer模式仍然可以与旧的Reader模式匹配。
|
||||
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的 Avro 模式,并在新的 Avro 模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的 Writer 模式仍然可以与旧的 Reader 模式匹配。
|
||||
|
||||
相比之下,如果你为此使用Thrift或Protocol Buffers,则字段标签可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射(这可能会自动化,但模式生成器必须非常小心,不要分配以前使用的字段标签)。这种动态生成的模式根本不是Thrift或Protocol Buffers的设计目标,而是Avro的。
|
||||
相比之下,如果你为此使用 Thrift 或 Protocol Buffers,则字段标签可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射(这可能会自动化,但模式生成器必须非常小心,不要分配以前使用的字段标签)。这种动态生成的模式根本不是 Thrift 或 Protocol Buffers 的设计目标,而是 Avro 的。
|
||||
|
||||
#### 代码生成和动态类型的语言
|
||||
|
||||
Thrift和Protobuf依赖于代码生成:在定义了模式之后,可以使用你选择的编程语言生成实现此模式的代码。这在Java、C++或C#等静态类型语言中很有用,因为它允许将高效的内存中结构用于解码的数据,并且在编写访问数据结构的程序时允许在IDE中进行类型检查和自动完成。
|
||||
Thrift 和 Protobuf 依赖于代码生成:在定义了模式之后,可以使用你选择的编程语言生成实现此模式的代码。这在 Java、C++ 或 C# 等静态类型语言中很有用,因为它允许将高效的内存中结构用于解码的数据,并且在编写访问数据结构的程序时允许在 IDE 中进行类型检查和自动完成。
|
||||
|
||||
在动态类型编程语言(如JavaScript、Ruby或Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。代码生成在这些语言中经常被忽视,因为它们避免了显式的编译步骤。而且,对于动态生成的模式(例如从数据库表生成的Avro模式),代码生成对获取数据是一个不必要的障碍。
|
||||
在动态类型编程语言(如 JavaScript、Ruby 或 Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。代码生成在这些语言中经常被忽视,因为它们避免了显式的编译步骤。而且,对于动态生成的模式(例如从数据库表生成的 Avro 模式),代码生成对获取数据是一个不必要的障碍。
|
||||
|
||||
Avro为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了Writer模式),你可以简单地使用Avro库打开它,并以与查看JSON文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
|
||||
Avro 为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了 Writer 模式),你可以简单地使用 Avro 库打开它,并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
|
||||
|
||||
这个属性特别适用于动态类型的数据处理语言如Apache Pig 【26】。在Pig中,你可以打开一些Avro文件,开始分析它们,并编写派生数据集以Avro格式输出文件,而无需考虑模式。
|
||||
这个属性特别适用于动态类型的数据处理语言如 Apache Pig 【26】。在 Pig 中,你可以打开一些 Avro 文件,开始分析它们,并编写派生数据集以 Avro 格式输出文件,而无需考虑模式。
|
||||
|
||||
### 模式的优点
|
||||
|
||||
正如我们所看到的,Protocol Buffers,Thrift和Avro都使用模式来描述二进制编码格式。他们的模式语言比XML模式或者JSON模式简单得多,而后者支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配”或“该字段的整数值必须在0和100之间“)。由于Protocol Buffers,Thrift和Avro实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。
|
||||
正如我们所看到的,Protocol Buffers,Thrift 和 Avro 都使用模式来描述二进制编码格式。他们的模式语言比 XML 模式或者 JSON 模式简单得多,而后者支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配” 或 “该字段的整数值必须在 0 和 100 之间 “)。由于 Protocol Buffers,Thrift 和 Avro 实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。
|
||||
|
||||
这些编码所基于的想法绝不是新的。例如,它们与ASN.1有很多相似之处,它是1984年首次被标准化的模式定义语言【27】。它被用来定义各种网络协议,例如其二进制编码(DER)仍然被用于编码SSL证书(X.509)【28】。 ASN.1支持使用标签号码的模式演进,类似于Protocol Buffers和Thrift 【29】。然而,它也非常复杂,而且没有好的配套文档,所以ASN.1可能不是新应用程序的好选择。
|
||||
这些编码所基于的想法绝不是新的。例如,它们与 ASN.1 有很多相似之处,它是 1984 年首次被标准化的模式定义语言【27】。它被用来定义各种网络协议,例如其二进制编码(DER)仍然被用于编码 SSL 证书(X.509)【28】。 ASN.1 支持使用标签号码的模式演进,类似于 Protocol Buffers 和 Thrift 【29】。然而,它也非常复杂,而且没有好的配套文档,所以 ASN.1 可能不是新应用程序的好选择。
|
||||
|
||||
许多数据系统也为其数据实现了某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,你可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用ODBC或JDBC API)。
|
||||
许多数据系统也为其数据实现了某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,你可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用 ODBC 或 JDBC API)。
|
||||
|
||||
所以,我们可以看到,尽管JSON,XML和CSV等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。他们有一些很好的属性:
|
||||
所以,我们可以看到,尽管 JSON,XML 和 CSV 等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。他们有一些很好的属性:
|
||||
|
||||
* 它们可以比各种“二进制JSON”变体更紧凑,因为它们可以省略编码数据中的字段名称。
|
||||
* 它们可以比各种 “二进制 JSON” 变体更紧凑,因为它们可以省略编码数据中的字段名称。
|
||||
* 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
|
||||
* 维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
|
||||
* 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
|
||||
|
||||
总而言之,模式进化允许与JSON数据库提供的无模式/读时模式相同的灵活性(请参阅“[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”),同时还可以更好地保证数据和更好的工具。
|
||||
总而言之,模式进化允许与 JSON 数据库提供的无模式 / 读时模式相同的灵活性(请参阅 “[文档模型中的模式灵活性](ch2.md#文档模型中的模式灵活性)”),同时还可以更好地保证数据和更好的工具。
|
||||
|
||||
|
||||
## 数据流的类型
|
||||
@ -324,9 +324,9 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
|
||||
|
||||
* 通过数据库(请参阅“[数据库中的数据流](#数据库中的数据流)”)
|
||||
* 通过服务调用(请参阅“[服务中的数据流:REST与RPC](#服务中的数据流:REST与RPC)”)
|
||||
* 通过异步消息传递(请参阅“[消息传递中的数据流](#消息传递中的数据流)”)
|
||||
* 通过数据库(请参阅 “[数据库中的数据流](#数据库中的数据流)”)
|
||||
* 通过服务调用(请参阅 “[服务中的数据流:REST 与 RPC](#服务中的数据流:REST与RPC)”)
|
||||
* 通过异步消息传递(请参阅 “[消息传递中的数据流](#消息传递中的数据流)”)
|
||||
|
||||
|
||||
### 数据库中的数据流
|
||||
@ -341,11 +341,11 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
但是,还有一个额外的障碍。假设你将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的字段不变,即使它不能被解释。
|
||||
|
||||
前面讨论的编码格式支持未知字段的保存,但是有时候需要在应用程序层面保持谨慎,如图4-7所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
|
||||
前面讨论的编码格式支持未知字段的保存,但是有时候需要在应用程序层面保持谨慎,如图 4-7 所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
|
||||
|
||||

|
||||
|
||||
**图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。**
|
||||
**图 4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。**
|
||||
|
||||
#### 在不同的时间写入不同的值
|
||||
|
||||
@ -353,147 +353,147 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它
|
||||
|
||||
在部署应用程序的新版本时,也许用不了几分钟就可以将所有的旧版本替换为新版本(至少服务器端应用程序是这样的)。但数据库内容并非如此:对于五年前的数据来说,除非对其进行显式重写,否则它仍然会以原始编码形式存在。这种现象有时被概括为:数据的生命周期超出代码的生命周期。
|
||||
|
||||
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据[^v]。读取旧行时,对于磁盘上的编码数据缺少的任何列,数据库将填充空值。 LinkedIn的文档数据库Espresso使用Avro存储,允许它使用Avro的模式演变规则【23】。
|
||||
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据 [^v]。读取旧行时,对于磁盘上的编码数据缺少的任何列,数据库将填充空值。 LinkedIn 的文档数据库 Espresso 使用 Avro 存储,允许它使用 Avro 的模式演变规则【23】。
|
||||
|
||||
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
|
||||
|
||||
[^v]: 除了MySQL,即使并非真的必要,它也经常会重写整个表,正如“[文档模型中的模式灵活性](ch3.md#文档模型中的模式灵活性)”中所提到的。
|
||||
[^v]: 除了 MySQL,即使并非真的必要,它也经常会重写整个表,正如 “[文档模型中的模式灵活性](ch3.md#文档模型中的模式灵活性)” 中所提到的。
|
||||
|
||||
|
||||
#### 归档存储
|
||||
|
||||
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库(请参阅“[数据仓库](ch3.md#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
|
||||
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库(请参阅 “[数据仓库](ch3.md#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
|
||||
|
||||
由于数据转储是一次写入的,而且以后是不可变的,所以Avro对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如Parquet(请参阅“[列压缩](ch3.md#列压缩)”)。
|
||||
由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet(请参阅 “[列压缩](ch3.md#列压缩)”)。
|
||||
|
||||
在[第十章](ch10.md)中,我们将详细讨论使用档案存储中的数据。
|
||||
在 [第十章](ch10.md) 中,我们将详细讨论使用档案存储中的数据。
|
||||
|
||||
|
||||
### 服务中的数据流:REST与RPC
|
||||
|
||||
当你需要通过网络进行通信的进程时,安排该通信的方式有几种。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开API,并且客户端可以连接到服务器以向该API发出请求。服务器公开的API被称为服务。
|
||||
当你需要通过网络进行通信的进程时,安排该通信的方式有几种。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开 API,并且客户端可以连接到服务器以向该 API 发出请求。服务器公开的 API 被称为服务。
|
||||
|
||||
Web以这种方式工作:客户(Web浏览器)向Web服务器发出请求,通过GET请求下载HTML,CSS,JavaScript,图像等,并通过POST请求提交数据到服务器。 API包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML等)。由于网络浏览器,网络服务器和网站作者大多同意这些标准,你可以使用任何网络浏览器访问任何网站(至少在理论上!)。
|
||||
Web 以这种方式工作:客户(Web 浏览器)向 Web 服务器发出请求,通过 GET 请求下载 HTML、CSS、JavaScript、图像等,并通过 POST 请求提交数据到服务器。 API 包含一组标准的协议和数据格式(HTTP、URL、SSL/TLS、HTML 等)。由于网络浏览器、网络服务器和网站作者大多同意这些标准,你可以使用任何网络浏览器访问任何网站(至少在理论上!)。
|
||||
|
||||
Web浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在Web浏览器内运行的客户端JavaScript应用程序可以使用XMLHttpRequest成为HTTP客户端(该技术被称为Ajax 【30】)。在这种情况下,服务器的响应通常不是用于显示给人的HTML,而是便于客户端应用程序代码进一步处理的编码数据(如JSON)。尽管HTTP可能被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。
|
||||
Web 浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在 Web 浏览器内运行的客户端 JavaScript 应用程序可以使用 XMLHttpRequest 成为 HTTP 客户端(该技术被称为 Ajax 【30】)。在这种情况下,服务器的响应通常不是用于显示给人的 HTML,而是便于客户端应用程序代码进一步处理的编码数据(如 JSON)。尽管 HTTP 可能被用作传输协议,但顶层实现的 API 是特定于应用程序的,客户端和服务器需要就该 API 的细节达成一致。
|
||||
|
||||
此外,服务器本身可以是另一个服务的客户端(例如,典型的Web应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为**面向服务的体系结构(service-oriented architecture,SOA)**,最近被改进和更名为**微服务架构**【31,32】。
|
||||
此外,服务器本身可以是另一个服务的客户端(例如,典型的 Web 应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 **面向服务的体系结构(service-oriented architecture,SOA)**,最近被改进和更名为 **微服务架构**【31,32】。
|
||||
|
||||
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在[第二章](ch2.md)中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出【33】。这种限制提供了一定程度的封装:服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
|
||||
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在 [第二章](ch2.md) 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的 API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出【33】。这种限制提供了一定程度的封装:服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
|
||||
|
||||
面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容——这正是我们在本章所一直在谈论的。
|
||||
面向服务 / 微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务 API 之间兼容 —— 这正是我们在本章所一直在谈论的。
|
||||
|
||||
#### Web服务
|
||||
|
||||
**当服务使用HTTP作为底层通信协议时,可称之为Web服务**。这可能是一个小错误,因为Web服务不仅在Web上使用,而且在几个不同的环境中使用。例如:
|
||||
**当服务使用 HTTP 作为底层通信协议时,可称之为 Web 服务**。这可能是一个小错误,因为 Web 服务不仅在 Web 上使用,而且在几个不同的环境中使用。例如:
|
||||
|
||||
1. 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax的JavaScript web应用程序)通过HTTP向服务发出请求。这些请求通常通过公共互联网进行。
|
||||
2. 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。 (支持这种用例的软件有时被称为 **中间件(middleware)** )
|
||||
3. 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API,或用于共享访问用户数据的OAuth。
|
||||
1. 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用 Ajax 的 JavaScript web 应用程序)通过 HTTP 向服务发出请求。这些请求通常通过公共互联网进行。
|
||||
2. 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务 / 微型架构的一部分。 (支持这种用例的软件有时被称为 **中间件(middleware)** )
|
||||
3. 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共 API,或用于共享访问用户数据的 OAuth。
|
||||
|
||||
有两种流行的Web服务方法:REST和SOAP。他们在哲学方面几乎是截然相反的,往往也是各自支持者之间的激烈辩论的主题[^vi]。
|
||||
有两种流行的 Web 服务方法:REST 和 SOAP。他们在哲学方面几乎是截然相反的,往往也是各自支持者之间的激烈辩论的主题 [^vi]。
|
||||
|
||||
[^vi]: 即使在每个阵营内也有很多争论。 例如,**HATEOAS(超媒体作为应用程序状态的引擎)** 就经常引发讨论【35】。
|
||||
|
||||
REST不是一个协议,而是一个基于HTTP原则的设计哲学【34,35】。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,至少在跨组织服务集成的背景下【36】,并经常与微服务相关【31】。根据REST原则设计的API称为RESTful。
|
||||
REST 不是一个协议,而是一个基于 HTTP 原则的设计哲学【34,35】。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制,身份验证和内容类型协商。与 SOAP 相比,REST 已经越来越受欢迎,至少在跨组织服务集成的背景下【36】,并经常与微服务相关【31】。根据 REST 原则设计的 API 称为 RESTful。
|
||||
|
||||
相比之下,SOAP是用于制作网络API请求的基于XML的协议[^vii]。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能。相反,它带有庞大而复杂的多种相关标准(Web服务框架,称为`WS-*`),它们增加了各种功能【37】。
|
||||
相比之下,SOAP 是用于制作网络 API 请求的基于 XML 的协议 [^vii]。虽然它最常用于 HTTP,但其目的是独立于 HTTP,并避免使用大多数 HTTP 功能。相反,它带有庞大而复杂的多种相关标准(Web 服务框架,称为 `WS-*`),它们增加了各种功能【37】。
|
||||
|
||||
[^vii]: 尽管首字母缩写词相似,SOAP并不是SOA的要求。 SOAP是一种特殊的技术,而SOA是构建系统的一般方法。
|
||||
[^vii]: 尽管首字母缩写词相似,SOAP 并不是 SOA 的要求。 SOAP 是一种特殊的技术,而 SOA 是构建系统的一般方法。
|
||||
|
||||
SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。 WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(请参阅“[代码生成和动态类型的语言](#代码生成和动态类型的语言)”)。
|
||||
SOAP Web 服务的 API 使用称为 Web 服务描述语言(WSDL)的基于 XML 的语言来描述。 WSDL 支持代码生成,客户端可以使用本地类和方法调用(编码为 XML 消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(请参阅 “[代码生成和动态类型的语言](#代码生成和动态类型的语言)”)。
|
||||
|
||||
由于WSDL的设计不是人类可读的,而且由于SOAP消息通常因为过于复杂而无法手动构建,所以SOAP的用户在很大程度上依赖于工具支持,代码生成和IDE【38】。对于SOAP供应商不支持的编程语言的用户来说,与SOAP服务的集成是困难的。
|
||||
由于 WSDL 的设计不是人类可读的,而且由于 SOAP 消息通常因为过于复杂而无法手动构建,所以 SOAP 的用户在很大程度上依赖于工具支持,代码生成和 IDE【38】。对于 SOAP 供应商不支持的编程语言的用户来说,与 SOAP 服务的集成是困难的。
|
||||
|
||||
尽管SOAP及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题【39】。由于所有这些原因,尽管许多大型企业仍然使用SOAP,但在大多数小公司中已经不再受到青睐。
|
||||
尽管 SOAP 及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题【39】。由于所有这些原因,尽管许多大型企业仍然使用 SOAP,但在大多数小公司中已经不再受到青睐。
|
||||
|
||||
REST风格的API倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如OpenAPI,也称为Swagger 【40】)可用于描述RESTful API并生成文档。
|
||||
REST 风格的 API 倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如 OpenAPI,也称为 Swagger 【40】)可用于描述 RESTful API 并生成文档。
|
||||
|
||||
#### 远程过程调用(RPC)的问题
|
||||
|
||||
Web服务仅仅是通过网络进行API请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。 Enterprise JavaBeans(EJB)和Java的**远程方法调用(RMI)** 仅限于Java。**分布式组件对象模型(DCOM)** 仅限于Microsoft平台。**公共对象请求代理体系结构(CORBA)** 过于复杂,不提供前向或后向兼容性【41】。
|
||||
Web 服务仅仅是通过网络进行 API 请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。 Enterprise JavaBeans(EJB)和 Java 的 **远程方法调用(RMI)** 仅限于 Java。**分布式组件对象模型(DCOM)** 仅限于 Microsoft 平台。**公共对象请求代理体系结构(CORBA)** 过于复杂,不提供前向或后向兼容性【41】。
|
||||
|
||||
所有这些都是基于 **远程过程调用(RPC)** 的思想,该过程调用自20世纪70年代以来一直存在【42】。 RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同:
|
||||
所有这些都是基于 **远程过程调用(RPC)** 的思想,该过程调用自 20 世纪 70 年代以来一直存在【42】。 RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管 RPC 起初看起来很方便,但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同:
|
||||
|
||||
* 本地函数调用是可预测的,并且成功或失败仅取决于受你控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在你的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
|
||||
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过(我们将在[第八章](ch8.md)更详细地讨论这个问题)。
|
||||
* 如果你重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非你在协议中引入去重机制(**幂等**,即idempotence)。本地函数调用没有这个问题。 (在[第十一章](ch11.md)更详细地讨论幂等性)
|
||||
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过(我们将在 [第八章](ch8.md) 更详细地讨论这个问题)。
|
||||
* 如果你重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非你在协议中引入去重机制(**幂等**,即 idempotence)。本地函数调用没有这个问题。 (在 [第十一章](ch11.md) 更详细地讨论幂等性)
|
||||
* 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:好的时候它可能会在不到一毫秒的时间内完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完成一样的东西。
|
||||
* 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会变成问题。
|
||||
|
||||
- 客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 —— 例如回想一下JavaScript的数字大于$2^{53}$的问题(请参阅“[JSON、XML和二进制变体](#JSON、XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。
|
||||
- 客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 —— 例如回想一下 JavaScript 的数字大于 $2^{53}$ 的问题(请参阅 “[JSON、XML 和二进制变体](#JSON、XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。
|
||||
|
||||
所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为这是一个根本不同的事情。 REST的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实(尽管这似乎并没有阻止人们在REST之上构建RPC库)。
|
||||
所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为这是一个根本不同的事情。 REST 的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实(尽管这似乎并没有阻止人们在 REST 之上构建 RPC 库)。
|
||||
|
||||
#### RPC的当前方向
|
||||
|
||||
尽管有这样那样的问题,RPC不会消失。在本章提到的所有编码的基础上构建了各种RPC框架:例如,Thrift和Avro带有RPC支持,gRPC是使用Protocol Buffers的RPC实现,Finagle也使用Thrift,Rest.li使用JSON over HTTP。
|
||||
尽管有这样那样的问题,RPC 不会消失。在本章提到的所有编码的基础上构建了各种 RPC 框架:例如,Thrift 和 Avro 带有 RPC 支持,gRPC 是使用 Protocol Buffers 的 RPC 实现,Finagle 也使用 Thrift,Rest.li 使用 JSON over HTTP。
|
||||
|
||||
这种新一代的RPC框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle和Rest.li 使用futures(promises)来封装可能失败的异步操作。`Futures`还可以简化需要并行发出多项服务并将其结果合并的情况【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间的一系列请求和响应【46】。
|
||||
这种新一代的 RPC 框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle 和 Rest.li 使用 futures(promises)来封装可能失败的异步操作。`Futures` 还可以简化需要并行发出多项服务并将其结果合并的情况【45】。 gRPC 支持流,其中一个调用不仅包括一个请求和一个响应,还可以是随时间的一系列请求和响应【46】。
|
||||
|
||||
其中一些框架还提供服务发现,即允许客户端找出在哪个IP地址和端口号上可以找到特定的服务。我们将在“[请求路由](ch6.md#请求路由)”中回到这个主题。
|
||||
其中一些框架还提供服务发现,即允许客户端找出在哪个 IP 地址和端口号上可以找到特定的服务。我们将在 “[请求路由](ch6.md#请求路由)” 中回到这个主题。
|
||||
|
||||
使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但是,RESTful API还有其他一些显著的优点:方便实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),能被所有主流的编程语言和平台所支持,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。
|
||||
使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是,RESTful API 还有其他一些显著的优点:方便实验和调试(只需使用 Web 浏览器或命令行工具 curl,无需任何代码生成或软件安装即可向其请求),能被所有主流的编程语言和平台所支持,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。
|
||||
|
||||
由于这些原因,REST似乎是公共API的主要风格。 RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
|
||||
由于这些原因,REST 似乎是公共 API 的主要风格。 RPC 框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
|
||||
|
||||
#### 数据编码与RPC的演化
|
||||
|
||||
对于可演化性,重要的是可以独立更改和部署RPC客户端和服务器。与通过数据库流动的数据相比(如上一节所述),我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,你只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。
|
||||
对于可演化性,重要的是可以独立更改和部署 RPC 客户端和服务器。与通过数据库流动的数据相比(如上一节所述),我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,你只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。
|
||||
|
||||
RPC方案的前后向兼容性属性从它使用的编码方式中继承:
|
||||
RPC 方案的前后向兼容性属性从它使用的编码方式中继承:
|
||||
|
||||
* Thrift,gRPC(Protobuf)和Avro RPC可以根据相应编码格式的兼容性规则进行演变。
|
||||
* 在SOAP中,请求和响应是使用XML模式指定的。这些可以演变,但有一些微妙的陷阱【47】。
|
||||
* RESTful API通常使用JSON(没有正式指定的模式)用于响应,以及用于请求的JSON或URI编码/表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。
|
||||
* Thrift、gRPC(Protobuf)和 Avro RPC 可以根据相应编码格式的兼容性规则进行演变。
|
||||
* 在 SOAP 中,请求和响应是使用 XML 模式指定的。这些可以演变,但有一些微妙的陷阱【47】。
|
||||
* RESTful API 通常使用 JSON(没有正式指定的模式)用于响应,以及用于请求的 JSON 或 URI 编码 / 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。
|
||||
|
||||
由于RPC经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务API。
|
||||
由于 RPC 经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务 API。
|
||||
|
||||
关于API版本化应该如何工作(即,客户端如何指示它想要使用哪个版本的API)没有一致意见【48】)。对于RESTful API,常用的方法是在URL或HTTP Accept头中使用版本号。对于使用API密钥来标识特定客户端的服务,另一种选择是将客户端请求的API版本存储在服务器上,并允许通过单独的管理界面更新该版本选项【49】。
|
||||
关于 API 版本化应该如何工作(即,客户端如何指示它想要使用哪个版本的 API)没有一致意见【48】)。对于 RESTful API,常用的方法是在 URL 或 HTTP Accept 头中使用版本号。对于使用 API 密钥来标识特定客户端的服务,另一种选择是将客户端请求的 API 版本存储在服务器上,并允许通过单独的管理界面更新该版本选项【49】。
|
||||
|
||||
### 消息传递中的数据流
|
||||
|
||||
我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了REST和RPC(其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应)以及数据库(一个进程写入编码数据,另一个进程在将来再次读取)。
|
||||
我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了 REST 和 RPC(其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应)以及数据库(一个进程写入编码数据,另一个进程在将来再次读取)。
|
||||
|
||||
在最后一节中,我们将简要介绍一下RPC和数据库之间的异步消息传递系统。它们与RPC类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
|
||||
在最后一节中,我们将简要介绍一下 RPC 和数据库之间的异步消息传递系统。它们与 RPC 类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
|
||||
|
||||
与直接RPC相比,使用消息代理有几个优点:
|
||||
与直接 RPC 相比,使用消息代理有几个优点:
|
||||
|
||||
* 如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
|
||||
* 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
|
||||
* 避免发件人需要知道收件人的IP地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
|
||||
* 避免发件人需要知道收件人的 IP 地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
|
||||
* 它允许将一条消息发送给多个收件人。
|
||||
* 将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。
|
||||
|
||||
然而,与RPC相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
|
||||
然而,与 RPC 相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
|
||||
|
||||
#### 消息代理
|
||||
|
||||
过去,**消息代理(Message Broker)** 主要是TIBCO、IBM WebSphere和webMethods等公司的商业软件的秀场。最近像RabbitMQ、ActiveMQ、HornetQ、NATS和Apache Kafka这样的开源实现已经流行起来。我们将在[第十一章](ch11.md)中对它们进行更详细的比较。
|
||||
过去,**消息代理(Message Broker)** 主要是 TIBCO、IBM WebSphere 和 webMethods 等公司的商业软件的秀场。最近像 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 这样的开源实现已经流行起来。我们将在 [第十一章](ch11.md) 中对它们进行更详细的比较。
|
||||
|
||||
详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
|
||||
|
||||
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在[第十一章](ch11.md)中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求/响应数据流,类似于RPC)。
|
||||
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在 [第十一章](ch11.md) 中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求 / 响应数据流,类似于 RPC)。
|
||||
|
||||
消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。如果编码是向后和向前兼容的,你可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
|
||||
|
||||
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。
|
||||
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图 4-7](img/fig4-7.png))。
|
||||
|
||||
#### 分布式的Actor框架
|
||||
|
||||
Actor模型是单个进程中并发的编程模型。逻辑被封装在actor中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个actor通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
|
||||
Actor 模型是单个进程中并发的编程模型。逻辑被封装在 actor 中,而不是直接处理线程(以及竞争条件、锁定和死锁的相关问题)。每个 actor 通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
|
||||
|
||||
在分布式Actor框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
|
||||
在分布式 Actor 框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
|
||||
|
||||
位置透明在actor模型中比在RPC中效果更好,因为actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用actor模型时,本地和远程通信之间的基本不匹配是较少的。
|
||||
位置透明在 actor 模型中比在 RPC 中效果更好,因为 actor 模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用 actor 模型时,本地和远程通信之间的基本不匹配是较少的。
|
||||
|
||||
分布式的Actor框架实质上是将消息代理和actor编程模型集成到一个框架中。但是,如果要执行基于actor的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
|
||||
分布式的 Actor 框架实质上是将消息代理和 actor 编程模型集成到一个框架中。但是,如果要执行基于 actor 的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
|
||||
|
||||
三个流行的分布式actor框架处理消息编码如下:
|
||||
三个流行的分布式 actor 框架处理消息编码如下:
|
||||
|
||||
* 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似Prototol Buffers的东西替代它,从而获得滚动升级的能力【50】。
|
||||
* Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,你需要设置一个新的集群,将流量从旧集群迁移到新集群,然后关闭旧集群【51,52】。 像Akka一样,可以使用自定义序列化插件。
|
||||
* 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划【53】。 一个新的实验性的`maps`数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易【54】。
|
||||
* 默认情况下,Akka 使用 Java 的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似 Prototol Buffers 的东西替代它,从而获得滚动升级的能力【50】。
|
||||
* Orleans 默认使用不支持滚动升级部署的自定义数据编码格式;要部署新版本的应用程序,你需要设置一个新的集群,将流量从旧集群迁移到新集群,然后关闭旧集群【51,52】。 像 Akka 一样,可以使用自定义序列化插件。
|
||||
* 在 Erlang OTP 中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划【53】。 一个新的实验性的 `maps` 数据类型(2014 年在 Erlang R17 中引入的类似于 JSON 的结构)可能使得这个数据类型在未来更容易【54】。
|
||||
|
||||
|
||||
## 本章小结
|
||||
@ -507,13 +507,13 @@ Actor模型是单个进程中并发的编程模型。逻辑被封装在actor中
|
||||
我们讨论了几种数据编码格式及其兼容性属性:
|
||||
|
||||
* 编程语言特定的编码仅限于单一编程语言,并且往往无法提供前向和后向兼容性。
|
||||
* JSON、XML和CSV等文本格式非常普遍,其兼容性取决于你如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
|
||||
* 像Thrift、Protocol Buffers和Avro这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
|
||||
* JSON、XML 和 CSV 等文本格式非常普遍,其兼容性取决于你如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
|
||||
* 像 Thrift、Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
|
||||
|
||||
我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:
|
||||
|
||||
* 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
|
||||
* RPC和REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
|
||||
* RPC 和 REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
|
||||
* 异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码
|
||||
|
||||
我们可以小心地得出这样的结论:前向兼容性和滚动升级在某种程度上是可以实现的。愿你的应用程序的演变迅速、敏捷部署。
|
||||
|
392
ch5.md
392
ch5.md
@ -1,74 +1,74 @@
|
||||
# 第五章:复制
|
||||
# 第五章:复制
|
||||
|
||||

|
||||
|
||||
> 与可能出错的东西比,'不可能'出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。
|
||||
> 与可能出错的东西比,“不可能”出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。
|
||||
>
|
||||
> —— 道格拉斯·亚当斯(1992)
|
||||
> —— 道格拉斯・亚当斯(1992)
|
||||
|
||||
------
|
||||
|
||||
[TOC]
|
||||
|
||||
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
|
||||
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在 [第二部分](part-ii.md) 的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
|
||||
|
||||
* 使得数据与用户在地理上接近(从而减少延迟)
|
||||
* 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
|
||||
* 伸缩可以接受读请求的机器数量(从而提高读取吞吐量)
|
||||
|
||||
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在[第六章](ch6.md)中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
|
||||
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在 [第六章](ch6.md) 中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
|
||||
|
||||
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的**变更(change)**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者(single leader)**,**多领导者(multi leader)** 和**无领导者(leaderless)**。几乎所有分布式数据库都使用这三种方法之一。
|
||||
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 **变更(change)**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者(single leader)**,**多领导者(multi leader)** 和 **无领导者(leaderless)**。几乎所有分布式数据库都使用这三种方法之一。
|
||||
|
||||
在复制时需要进行许多权衡:例如,使用同步复制还是异步复制?如何处理失败的副本?这些通常是数据库中的配置选项,细节因数据库而异,但原理在许多不同的实现中都类似。本章会讨论这些决策的后果。
|
||||
|
||||
数据库的复制算得上是老生常谈了 ——70年代研究得出的基本原则至今没有太大变化【1】,因为网络的基本约束仍保持不变。然而在研究之外,许多开发人员仍然假设一个数据库只有一个节点。分布式数据库变为主流只是最近发生的事。许多程序员都是这一领域的新手,因此对于诸如 **最终一致性(eventual consistency)** 等问题存在许多误解。在“[复制延迟问题](#复制延迟问题)”一节,我们将更加精确地了解最终的一致性,并讨论诸如 **读己之写(read-your-writes)** 和 **单调读(monotonic read)** 保证等内容。
|
||||
数据库的复制算得上是老生常谈了 ——70 年代研究得出的基本原则至今没有太大变化【1】,因为网络的基本约束仍保持不变。然而在研究之外,许多开发人员仍然假设一个数据库只有一个节点。分布式数据库变为主流只是最近发生的事。许多程序员都是这一领域的新手,因此对于诸如 **最终一致性(eventual consistency)** 等问题存在许多误解。在 “[复制延迟问题](#复制延迟问题)” 一节,我们将更加精确地了解最终的一致性,并讨论诸如 **读己之写(read-your-writes)** 和 **单调读(monotonic read)** 保证等内容。
|
||||
|
||||
## 领导者与追随者
|
||||
|
||||
存储数据库副本的每个节点称为 **副本(replica)** 。当存在多个副本时,会不可避免的出现一个问题:如何确保所有数据都落在了所有的副本上?
|
||||
|
||||
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制(leader-based replication)** (也称 **主动/被动(active/passive)** 或 **主/从(master/slave)** 复制),如[图5-1](#fig5-1.png)所示。它的工作原理如下:
|
||||
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制(leader-based replication)** (也称 **主动 / 被动(active/passive)** 或 **主 / 从(master/slave)** 复制),如 [图 5-1](#fig5-1.png) 所示。它的工作原理如下:
|
||||
|
||||
1. 副本之一被指定为 **领导者(leader)**,也称为 **主库(master|primary)** 。当客户端要向数据库写入时,它必须将请求发送给**领导者**,领导者会将新数据写入其本地存储。
|
||||
2. 其他副本被称为**追随者(followers)**,亦称为**只读副本(read replicas)**,**从库(slaves)**,**备库( secondaries)**,**热备(hot-standby)**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为**复制日志(replication log)** 记录或**变更流(change stream)**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
|
||||
1. 副本之一被指定为 **领导者(leader)**,也称为 **主库(master|primary)** 。当客户端要向数据库写入时,它必须将请求发送给 **领导者**,领导者会将新数据写入其本地存储。
|
||||
2. 其他副本被称为 **追随者(followers)**,亦称为 **只读副本(read replicas)**,**从库(slaves)**,**备库( secondaries)**,**热备(hot-standby)**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 **复制日志(replication log)** 记录或 **变更流(change stream)**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
|
||||
3. 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。 但只有领导者才能接受写操作(从客户端的角度来看从库都是只读的)。
|
||||
|
||||
[^i]: 不同的人对 **热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)** 指的是能接受客户端读请求的副本。而 **温备(warm standby)** 只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。
|
||||
[^i]: 不同的人对 **热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在 PostgreSQL 中,**热备(hot standby)** 指的是能接受客户端读请求的副本。而 **温备(warm standby)** 只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。
|
||||
|
||||

|
||||
**图5-1 基于领导者(主-从)的复制**
|
||||
**图 5-1 基于领导者 (主 - 从) 的复制**
|
||||
|
||||
这种复制模式是许多关系数据库的内置功能,如PostgreSQL(从9.0版本开始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库,包括MongoDB,RethinkDB和Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如DRBD这样的块复制设备也与之类似。
|
||||
这种复制模式是许多关系数据库的内置功能,如 PostgreSQL(从 9.0 版本开始),MySQL,Oracle Data Guard 【2】和 SQL Server 的 AlwaysOn 可用性组【3】。 它也被用于一些非关系数据库,包括 MongoDB,RethinkDB 和 Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像 Kafka 【5】和 RabbitMQ 高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如 DRBD 这样的块复制设备也与之类似。
|
||||
|
||||
### 同步复制与异步复制
|
||||
|
||||
复制系统的一个重要细节是:复制是 **同步(synchronously)** 发生还是 **异步(asynchronously)** 发生。 (在关系型数据库中这通常是一个配置项,其他系统通常硬编码为其中一个)。
|
||||
|
||||
想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。
|
||||
想象 [图 5-1](fig5-1.png) 中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。
|
||||
|
||||
[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。
|
||||
[图 5-2](img/fig5-2.png) 显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。
|
||||
|
||||

|
||||
**图5-2 基于领导者的复制:一个同步从库和一个异步从库**
|
||||
**图 5-2 基于领导者的复制:一个同步从库和一个异步从库**
|
||||
|
||||
在[图5-2](img/fig5-2.png)的示例中,从库1的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库1的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的:主库发送消息,但不等待从库的响应。
|
||||
在 [图 5-2](img/fig5-2.png) 的示例中,从库 1 的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者 2 的复制是异步的:主库发送消息,但不等待从库的响应。
|
||||
|
||||
在这幅图中,从库2处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快:大多数数据库系统能在一秒向从库应用变更,但它们不能提供复制用时的保证。有些情况下,从库可能落后主库几分钟或更久;例如:从库正在从故障中恢复,系统在最大容量附近运行,或者如果节点间存在网络问题。
|
||||
在这幅图中,从库 2 处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快:大多数数据库系统能在一秒向从库应用变更,但它们不能提供复制用时的保证。有些情况下,从库可能落后主库几分钟或更久;例如:从库正在从故障中恢复,系统在最大容量附近运行,或者如果节点间存在网络问题。
|
||||
|
||||
同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
|
||||
|
||||
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中**一个**跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 **半同步(semi-synchronous)**【7】。
|
||||
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中 **一个** 跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 **半同步(semi-synchronous)**【7】。
|
||||
|
||||
通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证 **持久(Durable)** 。 然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
|
||||
|
||||
弱化的持久性可能听起来像是一个坏的折衷,然而异步复制已经被广泛使用了,特别当有很多追随者,或追随者异地分布时。 稍后将在“[复制延迟问题](#复制延迟问题)”中回到这个问题。
|
||||
弱化的持久性可能听起来像是一个坏的折衷,然而异步复制已经被广泛使用了,特别当有很多追随者,或追随者异地分布时。 稍后将在 “[复制延迟问题](#复制延迟问题)” 中回到这个问题。
|
||||
|
||||
> ### 关于复制的研究
|
||||
>
|
||||
> 对于异步复制系统而言,主库故障时有可能丢失数据。这可能是一个严重的问题,因此研究人员仍在研究不丢数据但仍能提供良好性能和可用性的复制方法。 例如,**链式复制**【8,9】]是同步复制的一种变体,已经在一些系统(如Microsoft Azure存储【10,11】)中成功实现。
|
||||
> 对于异步复制系统而言,主库故障时有可能丢失数据。这可能是一个严重的问题,因此研究人员仍在研究不丢数据但仍能提供良好性能和可用性的复制方法。 例如,**链式复制**【8,9】] 是同步复制的一种变体,已经在一些系统(如 Microsoft Azure 存储【10,11】)中成功实现。
|
||||
>
|
||||
> 复制的一致性与**共识**(consensus,使几个节点就某个值达成一致)之间有着密切的联系,[第九章](ch9.md)将详细地探讨这一领域的理论。本章主要讨论实践中数据库常用的简单复制形式。
|
||||
> 复制的一致性与 **共识**(consensus,使几个节点就某个值达成一致)之间有着密切的联系,[第九章](ch9.md) 将详细地探讨这一领域的理论。本章主要讨论实践中数据库常用的简单复制形式。
|
||||
>
|
||||
|
||||
### 设置新从库
|
||||
@ -79,9 +79,9 @@
|
||||
|
||||
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。幸运的是,拉起新的从库通常并不需要停机。从概念上讲,过程如下所示:
|
||||
|
||||
1. 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如MySQL的innobackupex 【12】。
|
||||
1. 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如 MySQL 的 innobackupex 【12】。
|
||||
2. 将快照复制到新的从库节点。
|
||||
3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为 **日志序列号(log sequence number, LSN)**,MySQL将其称为 **二进制日志坐标(binlog coordinates)**。
|
||||
3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL 将其称为 **日志序列号(log sequence number, LSN)**,MySQL 将其称为 **二进制日志坐标(binlog coordinates)**。
|
||||
4. 当从库处理完快照之后积压的数据变更,我们说它 **赶上(caught up)** 了主库。现在它可以继续处理主库产生的数据变化了。
|
||||
|
||||
建立从库的实际步骤因数据库而异。在某些系统中,这个过程是完全自动化的,而在另外一些系统中,它可能是一个需要由管理员手动执行的,有点神秘的多步骤工作流。
|
||||
@ -98,21 +98,21 @@
|
||||
|
||||
#### 主库失效:故障切换
|
||||
|
||||
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为**故障切换(failover)**。
|
||||
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为 **故障切换(failover)**。
|
||||
|
||||
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动故障切换过程通常由以下步骤组成:
|
||||
|
||||
1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时(Timeout)** :节点频繁地相互来回传递消息,并且如果一个节点在一段时间内(例如30秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
|
||||
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的**控制器节点(controller node)** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个**共识**问题,将在[第九章](ch9.md)详细讨论。
|
||||
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在“[请求路由](ch6.md#请求路由)”中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
|
||||
1. 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时(Timeout)** :节点频繁地相互来回传递消息,并且如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
|
||||
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 **控制器节点(controller node)** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个 **共识** 问题,将在 [第九章](ch9.md) 详细讨论。
|
||||
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在 “[请求路由](ch6.md#请求路由)” 中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
|
||||
|
||||
故障切换会出现很多大麻烦:
|
||||
|
||||
* 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
|
||||
|
||||
* 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在GitHub 【13】的一场事故中,一个过时的MySQL从库被提升为主库。数据库使用自增ID作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的ID作为主键。这些主键也在Redis中使用,主键重用使得MySQL和Redis中数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
|
||||
* 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 GitHub 【13】的一场事故中,一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用,主键重用使得 MySQL 和 Redis 中数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
|
||||
|
||||
* 发生某些故障时(见[第八章](ch8.md))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂(split brain)**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅“[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点[^ii],但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
|
||||
* 发生某些故障时(见 [第八章](ch8.md))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂 (split brain)**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅 “[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 [^ii],但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
|
||||
|
||||
[^ii]: 这种机制称为 **屏蔽(fencing)**,充满感情的术语是:**爆彼之头(Shoot The Other Node In The Head, STONITH)**。
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
|
||||
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
|
||||
|
||||
节点故障、不可靠的网络、对副本一致性,持久性,可用性和延迟的权衡 ,这些问题实际上是分布式系统中的基本问题。[第八章](ch8.md)和[第九章](ch9.md)将更深入地讨论它们。
|
||||
节点故障、不可靠的网络、对副本一致性,持久性,可用性和延迟的权衡 ,这些问题实际上是分布式系统中的基本问题。[第八章](ch8.md) 和 [第九章](ch9.md) 将更深入地讨论它们。
|
||||
|
||||
### 复制日志的实现
|
||||
|
||||
@ -128,32 +128,32 @@
|
||||
|
||||
#### 基于语句的复制
|
||||
|
||||
在最简单的情况下,主库记录下它执行的每个写入请求(**语句**,即statement)并将该语句日志发送给其从库。对于关系数据库来说,这意味着每个`INSERT`、`UPDATE`或`DELETE`语句都被转发给每个从库,每个从库解析并执行该SQL语句,就像从客户端收到一样。
|
||||
在最简单的情况下,主库记录下它执行的每个写入请求(**语句**,即 statement)并将该语句日志发送给其从库。对于关系数据库来说,这意味着每个 `INSERT`、`UPDATE` 或 `DELETE` 语句都被转发给每个从库,每个从库解析并执行该 SQL 语句,就像从客户端收到一样。
|
||||
|
||||
虽然听上去很合理,但有很多问题会搞砸这种复制方式:
|
||||
|
||||
* 任何调用 **非确定性函数(nondeterministic)** 的语句,可能会在每个副本上生成不同的值。例如,使用`NOW()`获取当前日期时间,或使用`RAND()`获取一个随机数。
|
||||
* 如果语句使用了**自增列(auto increment)**,或者依赖于数据库中的现有数据(例如,`UPDATE ... WHERE <某些条件>`),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
|
||||
* 有副作用的语句(例如,触发器,存储过程,用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定的。
|
||||
* 任何调用 **非确定性函数(nondeterministic)** 的语句,可能会在每个副本上生成不同的值。例如,使用 `NOW()` 获取当前日期时间,或使用 `RAND()` 获取一个随机数。
|
||||
* 如果语句使用了 **自增列(auto increment)**,或者依赖于数据库中的现有数据(例如,`UPDATE ... WHERE <某些条件>`),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
|
||||
* 有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定的。
|
||||
|
||||
的确有办法绕开这些问题 ——例如,当语句被记录时,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
|
||||
的确有办法绕开这些问题 —— 例如,当语句被记录时,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
|
||||
|
||||
基于语句的复制在5.1版本前的MySQL中使用。因为它相当紧凑,现在有时候也还在用。但现在在默认情况下,如果语句中存在任何不确定性,MySQL会切换到基于行的复制(稍后讨论)。 VoltDB使用了基于语句的复制,但要求事务必须是确定性的,以此来保证安全【15】。
|
||||
基于语句的复制在 5.1 版本前的 MySQL 中使用。因为它相当紧凑,现在有时候也还在用。但现在在默认情况下,如果语句中存在任何不确定性,MySQL 会切换到基于行的复制(稍后讨论)。 VoltDB 使用了基于语句的复制,但要求事务必须是确定性的,以此来保证安全【15】。
|
||||
|
||||
#### 传输预写式日志(WAL)
|
||||
|
||||
在[第三章](ch3.md)中,我们讨论了存储引擎如何在磁盘上表示数据,并且我们发现,通常写操作都是追加到日志中:
|
||||
在 [第三章](ch3.md) 中,我们讨论了存储引擎如何在磁盘上表示数据,并且我们发现,通常写操作都是追加到日志中:
|
||||
|
||||
* 对于日志结构存储引擎(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
|
||||
* 对于覆写单个磁盘块的[B树](ch3.md#B树),每次修改都会先写入 **预写式日志(Write Ahead Log, WAL)**,以便崩溃后索引可以恢复到一个一致的状态。
|
||||
* 对于日志结构存储引擎(请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
|
||||
* 对于覆写单个磁盘块的 [B 树](ch3.md#B树),每次修改都会先写入 **预写式日志(Write Ahead Log, WAL)**,以便崩溃后索引可以恢复到一个一致的状态。
|
||||
|
||||
在任何一种情况下,日志都是包含所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给其从库。
|
||||
|
||||
当从库应用这个日志时,它会建立和主库一模一样数据结构的副本。
|
||||
|
||||
PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层:WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。
|
||||
PostgreSQL 和 Oracle 等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。
|
||||
|
||||
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换,使升级后的节点之一成为新的主库,从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输WAL经常出现这种情况),则此类升级需要停机。
|
||||
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换,使升级后的节点之一成为新的主库,从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输 WAL 经常出现这种情况),则此类升级需要停机。
|
||||
|
||||
#### 逻辑日志复制(基于行)
|
||||
|
||||
@ -165,36 +165,36 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
* 对于删除的行,日志包含足够的信息来唯一标识已删除的行。通常是主键,但是如果表上没有主键,则需要记录所有列的旧值。
|
||||
* 对于更新的行,日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
|
||||
|
||||
修改多行的事务会生成多个这样的日志记录,后面跟着一条记录,指出事务已经提交。 MySQL的二进制日志(当配置为使用基于行的复制时)使用这种方法【17】。
|
||||
修改多行的事务会生成多个这样的日志记录,后面跟着一条记录,指出事务已经提交。 MySQL 的二进制日志(当配置为使用基于行的复制时)使用这种方法【17】。
|
||||
|
||||
由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。
|
||||
|
||||
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统,这一点很有用,例如复制到数据仓库进行离线分析,或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获(change data capture)**,[第十一章](ch11.md)将重新讲到它。
|
||||
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统,这一点很有用,例如复制到数据仓库进行离线分析,或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获(change data capture)**,[第十一章](ch11.md) 将重新讲到它。
|
||||
|
||||
#### 基于触发器的复制
|
||||
|
||||
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果你只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果你需要冲突解决逻辑(请参阅“[处理写入冲突](#处理写入冲突)”),则可能需要将复制移动到应用程序层。
|
||||
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果你只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果你需要冲突解决逻辑(请参阅 “[处理写入冲突](#处理写入冲突)”),则可能需要将复制移动到应用程序层。
|
||||
|
||||
一些工具,如Oracle Golden Gate 【19】,可以通过读取数据库日志,使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
|
||||
一些工具,如 Oracle Golden Gate 【19】,可以通过读取数据库日志,使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
|
||||
|
||||
触发器允许你注册在数据库系统中发生数据更改(写入事务)时自动执行的自定义应用程序代码。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上任何业务逻辑处理,会后将数据变更复制到另一个系统去。例如,Databus for Oracle 【20】和Bucardo for Postgres 【21】就是这样工作的。
|
||||
触发器允许你注册在数据库系统中发生数据更改(写入事务)时自动执行的自定义应用程序代码。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上任何业务逻辑处理,会后将数据变更复制到另一个系统去。例如,Databus for Oracle 【20】和 Bucardo for Postgres 【21】就是这样工作的。
|
||||
|
||||
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库的内置复制更容易出错,也有很多限制。然而由于其灵活性,仍然是很有用的。
|
||||
|
||||
|
||||
## 复制延迟问题
|
||||
|
||||
容忍节点故障只是需要复制的一个原因。正如在[第二部分](part-ii.md)的介绍中提到的,另一个原因是可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
|
||||
容忍节点故障只是需要复制的一个原因。正如在 [第二部分](part-ii.md) 的介绍中提到的,另一个原因是可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
|
||||
|
||||
基于主库的复制要求所有写入都由单个节点处理,但只读查询可以由任何副本处理。所以对于读多写少的场景(Web上的常见模式),一个有吸引力的选择是创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许向最近的副本发送读请求。
|
||||
基于主库的复制要求所有写入都由单个节点处理,但只读查询可以由任何副本处理。所以对于读多写少的场景(Web 上的常见模式),一个有吸引力的选择是创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许向最近的副本发送读请求。
|
||||
|
||||
在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制——如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。
|
||||
在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制 —— 如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。
|
||||
|
||||
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性(eventual consistency)**[^iii]【22,23】
|
||||
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性(eventual consistency)**[^iii]【22,23】
|
||||
|
||||
[^iii]: 道格拉斯·特里(Douglas Terry)等人创造了术语最终一致性。 【24】 并经由Werner Vogels 【22】推广,成为许多NoSQL项目的口号。 然而,不只有NoSQL数据库是最终一致的:关系型数据库中的异步复制追随者也有相同的特性。
|
||||
[^iii]: 道格拉斯・特里(Douglas Terry)等人创造了术语最终一致性。 【24】 并经由 Werner Vogels 【22】推广,成为许多 NoSQL 项目的口号。 然而,不只有 NoSQL 数据库是最终一致的:关系型数据库中的异步复制追随者也有相同的特性。
|
||||
|
||||
“最终”一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,**复制延迟(replication lag)**,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至几分钟。
|
||||
“最终” 一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,**复制延迟(replication lag)**,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至几分钟。
|
||||
|
||||
因为滞后时间太长引入的不一致性,可不仅是一个理论问题,更是应用设计中会遇到的真实问题。本节将重点介绍三个由复制延迟问题的例子,并简述解决这些问题的一些方法。
|
||||
|
||||
@ -202,27 +202,27 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给领导者,但是当用户查看数据时,可以从追随者读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。
|
||||
|
||||
但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。
|
||||
但对于异步复制,问题就来了。如 [图 5-3](fig5-3.png) 所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。
|
||||
|
||||

|
||||
|
||||
**图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常**
|
||||
**图 5-3 用户写入后从旧副本中读取数据。需要写后读 (read-after-write) 的一致性来防止这种异常**
|
||||
|
||||
在这种情况下,我们需要 **读写一致性(read-after-write consistency)**,也称为 **读己之写一致性(read-your-writes consistency)**【24】。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺:其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。
|
||||
|
||||
如何在基于领导者的复制系统中实现读后一致性?有各种可能的技术,这里说一些:
|
||||
|
||||
* 读用户**可能已经修改过**的内容时,都从主库读;这就要求有一些方法,不用实际查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则是:从主库读取用户自己的档案,在从库读取其他用户的档案。
|
||||
* 读用户 **可能已经修改过** 的内容时,都从主库读;这就要求有一些方法,不用实际查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则是:从主库读取用户自己的档案,在从库读取其他用户的档案。
|
||||
|
||||
* 如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(扩容读就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止对任意比主库滞后超过一分钟的从库发出查询。
|
||||
|
||||
* 客户端可以记住最近一次写入的时间戳,系统需要确保从库为该用户提供任何查询时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读,或者等待从库追赶上来。
|
||||
|
||||
时间戳可以是逻辑时间戳(指示写入顺序的东西,例如日志序列号)或实际系统时钟(在这种情况下,时钟同步变得至关重要;请参阅“[不可靠的时钟](ch8.md#不可靠的时钟)”)。
|
||||
时间戳可以是逻辑时间戳(指示写入顺序的东西,例如日志序列号)或实际系统时钟(在这种情况下,时钟同步变得至关重要;请参阅 “[不可靠的时钟](ch8.md#不可靠的时钟)”)。
|
||||
|
||||
* 如果你的副本分布在多个数据中心(出于可用性目的与用户尽量在地理上接近),则会增加复杂性。任何需要由领导者提供服务的请求都必须路由到包含主库的数据中心。
|
||||
|
||||
另一种复杂的情况是:如果同一个用户从多个设备请求服务,例如桌面浏览器和移动APP。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在某个设备上输入了一些信息,然后在另一个设备上查看,则应该看到他们刚输入的信息。
|
||||
另一种复杂的情况是:如果同一个用户从多个设备请求服务,例如桌面浏览器和移动 APP。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在某个设备上输入了一些信息,然后在另一个设备上查看,则应该看到他们刚输入的信息。
|
||||
|
||||
在这种情况下,还有一些需要考虑的问题:
|
||||
|
||||
@ -234,57 +234,57 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流(moving backward in time)**。
|
||||
|
||||
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的)。第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。
|
||||
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图 5-4](img/fig5-4.png) 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的)。第一个查询返回最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,那么对于用户 2345,就很让人头大了。
|
||||
|
||||

|
||||
|
||||
**图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。**
|
||||
**图 5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。**
|
||||
|
||||
**单调读(Monotonic reads)**【23】保证这种异常不会发生。这是一个比 **强一致性(strong consistency)** 更弱,但比 **最终一致性(eventual consistency)** 更强的保证。当读取数据时,你可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退,即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。
|
||||
|
||||
实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户ID的散列来选择副本,而不是随机选择副本。但是,如果该副本失败,用户的查询将需要重新路由到另一个副本。
|
||||
实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本失败,用户的查询将需要重新路由到另一个副本。
|
||||
|
||||
|
||||
### 一致前缀读
|
||||
|
||||
第三个复制延迟例子违反了因果律。 想象一下Poons先生和Cake夫人之间的以下简短对话:
|
||||
第三个复制延迟例子违反了因果律。 想象一下 Poons 先生和 Cake 夫人之间的以下简短对话:
|
||||
|
||||
*Mr. Poons*
|
||||
> Mrs. Cake,你能看到多远的未来?
|
||||
|
||||
*Mrs. Cake*
|
||||
> 通常约十秒钟,Mr. Poons.
|
||||
> 通常约十秒钟,Mr. Poons.
|
||||
|
||||
这两句话之间有因果关系:Cake夫人听到了Poons先生的问题并回答了这个问题。
|
||||
这两句话之间有因果关系:Cake 夫人听到了 Poons 先生的问题并回答了这个问题。
|
||||
|
||||
现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容:
|
||||
现在,想象第三个人正在通过从库来听这个对话。 Cake 夫人说的内容是从一个延迟很低的从库读取的,但 Poons 先生所说的内容,从库的延迟要大的多(见 [图 5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容:
|
||||
|
||||
*Mrs. Cake*
|
||||
> 通常约十秒钟,Mr. Poons.
|
||||
> 通常约十秒钟,Mr. Poons.
|
||||
|
||||
*Mr. Poons*
|
||||
> Mrs. Cake,你能看到多远的未来?
|
||||
|
||||
对于观察者来说,看起来好像Cake夫人在Poons先生提问前就回答了这个问题。
|
||||
对于观察者来说,看起来好像 Cake 夫人在 Poons 先生提问前就回答了这个问题。
|
||||
这种超能力让人印象深刻,但也会把人搞糊涂。【25】。
|
||||
|
||||

|
||||
|
||||
**图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。**
|
||||
**图 5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。**
|
||||
|
||||
防止这种异常,需要另一种类型的保证:**一致前缀读(consistent prefix reads)**【23】。 这个保证说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。
|
||||
|
||||
这是**分区(partitioned)** 或 **分片(sharded)** 数据库中的一个特殊问题,将在[第六章](ch6.md)中讨论。如果数据库总是以相同的顺序应用写入,则读取总是会看到一致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在**全局写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
|
||||
这是 **分区(partitioned)** 或 **分片(sharded)** 数据库中的一个特殊问题,将在 [第六章](ch6.md) 中讨论。如果数据库总是以相同的顺序应用写入,则读取总是会看到一致的前缀,所以这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在 **全局写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
|
||||
|
||||
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在“[“此前发生”的关系和并发](#“此前发生”的关系和并发)”一节中返回这个主题。
|
||||
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在 “[“此前发生” 的关系和并发](#“此前发生”的关系和并发)” 一节中返回这个主题。
|
||||
|
||||
### 复制延迟的解决方案
|
||||
|
||||
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是“没问题”,那很好。但如果结果对于用户来说是不好体验,那么设计系统来提供更强的保证是很重要的,例如**写后读**。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
|
||||
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是 “没问题”,那很好。但如果结果对于用户来说是不好体验,那么设计系统来提供更强的保证是很重要的,例如 **写后读**。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
|
||||
|
||||
如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过主库进行某种读取。但在应用程序代码中处理这些问题是复杂的,容易出错。
|
||||
|
||||
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库“做了正确的事情”,那该多好呀。这就是 **事务(transaction)** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。
|
||||
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库 “做了正确的事情”,那该多好呀。这就是 **事务(transaction)** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。
|
||||
|
||||
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务。声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。第七章和第九章将回到事务的话题,并讨论一些替代机制。
|
||||
|
||||
@ -293,11 +293,11 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
本章到目前为止,我们只考虑使用单个领导者的复制架构。 虽然这是一种常见的方法,但也有一些有趣的选择。
|
||||
|
||||
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它[^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
|
||||
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它 [^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
|
||||
|
||||
[^iv]: 如果数据库被分区(见[第六章](ch6.md)),每个分区都有一个领导。 不同的分区可能在不同的节点上有其领导者,但是每个分区必须有一个领导者节点。
|
||||
[^iv]: 如果数据库被分区(见 [第六章](ch6.md)),每个分区都有一个领导。 不同的分区可能在不同的节点上有其领导者,但是每个分区必须有一个领导者节点。
|
||||
|
||||
基于领导者的复制模型的自然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为**多领导者配置**(也称多主、多活复制)。 在这种情况下,每个领导者同时扮演其他领导者的追随者。
|
||||
基于领导者的复制模型的自然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为 **多领导者配置**(也称多主、多活复制)。 在这种情况下,每个领导者同时扮演其他领导者的追随者。
|
||||
|
||||
### 多主复制的应用场景
|
||||
|
||||
@ -307,11 +307,11 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。
|
||||
|
||||
多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
|
||||
多领导者配置中可以在每个数据中心都有主库。 [图 5-6](img/fig5-6.png) 展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
|
||||
|
||||

|
||||
|
||||
**图5-6 跨多个数据中心的多主复制**
|
||||
**图 5-6 跨多个数据中心的多主复制**
|
||||
|
||||
我们来比较一下在运维多个数据中心时,单主和多主的适应情况。
|
||||
|
||||
@ -327,9 +327,9 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对这数据中心间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。
|
||||
|
||||
有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于MySQL的Tungsten Replicator 【26】,用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。
|
||||
有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于 MySQL 的 Tungsten Replicator 【26】,用于 PostgreSQL 的 BDR【27】以及用于 Oracle 的 GoldenGate 【19】。
|
||||
|
||||
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“冲突解决(conflict resolution)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。
|
||||
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如 [图 5-6](img/fig5-6.png) 中 “冲突解决(conflict resolution)”)。本书将在 “[处理写入冲突](#处理写入冲突)” 中详细讨论这个问题。
|
||||
|
||||
由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免【28】。
|
||||
|
||||
@ -341,13 +341,13 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
在这种情况下,每个设备都有一个充当领导者的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。
|
||||
|
||||
从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个“数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。
|
||||
从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多活配好是多么困难的一件事。
|
||||
|
||||
有一些工具旨在使这种多领导者配置更容易。例如,CouchDB就是为这种操作模式而设计的【29】。
|
||||
有一些工具旨在使这种多领导者配置更容易。例如,CouchDB 就是为这种操作模式而设计的【29】。
|
||||
|
||||
#### 协同编辑
|
||||
|
||||
实时协作编辑应用程序允许多个人同时编辑文档。例如,Etherpad 【30】和Google Docs 【31】允许多人同时编辑文本文档或电子表格(该算法在“[自动冲突解决](#自动冲突解决)”中简要讨论)。我们通常不会将协作式编辑视为数据库复制问题,但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(Web浏览器或客户端应用程序中的文档状态),并异步复制到服务器和编辑同一文档的任何其他用户。
|
||||
实时协作编辑应用程序允许多个人同时编辑文档。例如,Etherpad 【30】和 Google Docs 【31】允许多人同时编辑文本文档或电子表格(该算法在 “[自动冲突解决](#自动冲突解决)” 中简要讨论)。我们通常不会将协作式编辑视为数据库复制问题,但与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本(Web 浏览器或客户端应用程序中的文档状态),并异步复制到服务器和编辑同一文档的任何其他用户。
|
||||
|
||||
如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
|
||||
|
||||
@ -357,11 +357,11 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
|
||||
|
||||
例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。
|
||||
例如,考虑一个由两个用户同时编辑的维基页面,如 [图 5-7](img/fig5-7.png) 所示。用户 1 将页面的标题从 A 更改为 B,并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。
|
||||
|
||||

|
||||
|
||||
**图5-7 两个主库同时更新同一记录引起的写入冲突**
|
||||
**图 5-7 两个主库同时更新同一记录引起的写入冲突**
|
||||
|
||||
#### 同步与异步冲突检测
|
||||
|
||||
@ -373,23 +373,23 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个领导者,那么冲突就不会发生。由于许多的多领导者复制实现在处理冲突时处理得相当不好,避免冲突是一个经常推荐的方法【34】。
|
||||
|
||||
例如,在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的领导者进行读写。不同的用户可能有不同的“家庭”数据中心(可能根据用户的地理位置选择),但从任何用户的角度来看,配置基本上都是单一的领导者。
|
||||
例如,在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的领导者进行读写。不同的用户可能有不同的 “家庭” 数据中心(可能根据用户的地理位置选择),但从任何用户的角度来看,配置基本上都是单一的领导者。
|
||||
|
||||
但是,有时你可能需要更改指定的记录的主库——可能是因为一个数据中心出现故障,你需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近不同的数据中心。在这种情况下,冲突避免会中断,你必须处理不同主库同时写入的可能性。
|
||||
但是,有时你可能需要更改指定的记录的主库 —— 可能是因为一个数据中心出现故障,你需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近不同的数据中心。在这种情况下,冲突避免会中断,你必须处理不同主库同时写入的可能性。
|
||||
|
||||
#### 收敛至一致的状态
|
||||
|
||||
单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
|
||||
|
||||
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。
|
||||
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在 [图 5-7](img/fig5-7.png) 中,在主库 1 中标题首先更新为 B 而后更新为 C;在主库 2 中,首先更新为 C,然后更新为 B。两个顺序都不是 “更正确” 的。
|
||||
|
||||
如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库1的C和主库2的B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种 **收敛(convergent)** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
|
||||
如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库 1 的 C 和主库 2 的 B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种 **收敛(convergent)** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
|
||||
|
||||
实现冲突合并解决有多种途径:
|
||||
|
||||
* 给每个写入一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为**最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在本章末尾的[检测并发写入](#检测并发写入)更详细地讨论LWW。
|
||||
* 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
|
||||
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中,合并的标题可能类似于“B/C”)。
|
||||
* 给每个写入一个唯一的 ID(例如,一个时间戳,一个长的随机数,一个 UUID 或者一个键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为 **最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在本章末尾的 [检测并发写入](#检测并发写入) 更详细地讨论 LWW。
|
||||
* 为每个副本分配一个唯一的 ID,ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
|
||||
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在 [图 5-7](img/fig5-7.png) 中,合并的标题可能类似于 “B/C”)。
|
||||
* 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
|
||||
|
||||
|
||||
@ -399,13 +399,13 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
* 写时执行
|
||||
|
||||
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo允许你为此编写一段Perl代码。这个处理程序通常不能提示用户——它在后台进程中运行,并且必须快速执行。
|
||||
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行,并且必须快速执行。
|
||||
|
||||
* 读时执行
|
||||
|
||||
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突,并将结果写回数据库。例如,CouchDB以这种方式工作。
|
||||
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突,并将结果写回数据库。例如,CouchDB 以这种方式工作。
|
||||
|
||||
请注意,冲突解决通常适用于单个行或文档层面,而不是整个事务【36】。因此,如果你有一个事务会原子性地进行几次不同的写入(请参阅[第七章](ch7.md),对于冲突解决而言,每个写入仍需分开单独考虑。
|
||||
请注意,冲突解决通常适用于单个行或文档层面,而不是整个事务【36】。因此,如果你有一个事务会原子性地进行几次不同的写入(请参阅 [第七章](ch7.md),对于冲突解决而言,每个写入仍需分开单独考虑。
|
||||
|
||||
|
||||
> #### 自动冲突解决
|
||||
@ -414,9 +414,9 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
>
|
||||
> 已经有一些有趣的研究来自动解决由于数据修改引起的冲突。有几行研究值得一提:
|
||||
>
|
||||
> * **无冲突复制数据类型(Conflict-free replicated datatypes,CRDT)**【32,38】是可以由多个用户同时编辑的集合,映射,有序列表,计数器等的一系列数据结构,它们以合理的方式自动解决冲突。一些CRDT已经在Riak 2.0中实现【39,40】。
|
||||
> * **可合并的持久数据结构(Mergeable persistent data structures)**【41】显式跟踪历史记录,类似于Git版本控制系统,并使用三向合并功能(而CRDT使用双向合并)。
|
||||
> * **可执行的转换(operational transformation)**[42]是Etherpad 【30】和Google Docs 【31】等合作编辑应用背后的冲突解决算法。它是专为同时编辑项目的有序列表而设计的,例如构成文本文档的字符列表。
|
||||
> * **无冲突复制数据类型(Conflict-free replicated datatypes,CRDT)**【32,38】是可以由多个用户同时编辑的集合,映射,有序列表,计数器等的一系列数据结构,它们以合理的方式自动解决冲突。一些 CRDT 已经在 Riak 2.0 中实现【39,40】。
|
||||
> * **可合并的持久数据结构(Mergeable persistent data structures)**【41】显式跟踪历史记录,类似于 Git 版本控制系统,并使用三向合并功能(而 CRDT 使用双向合并)。
|
||||
> * **可执行的转换(operational transformation)**[42] 是 Etherpad 【30】和 Google Docs 【31】等合作编辑应用背后的冲突解决算法。它是专为同时编辑项目的有序列表而设计的,例如构成文本文档的字符列表。
|
||||
>
|
||||
> 这些算法在数据库中的实现还很年轻,但很可能将来它们将被集成到更多的复制数据系统中。自动冲突解决方案可以使应用程序处理多领导者数据同步更为简单。
|
||||
>
|
||||
@ -424,51 +424,51 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
#### 什么是冲突?
|
||||
|
||||
有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
|
||||
有些冲突是显而易见的。在 [图 5-7](img/fig5-7.png) 的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
|
||||
|
||||
其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。
|
||||
|
||||
现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在[第七章](ch7.md)中看到更多的冲突示例,在[第十二章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
|
||||
现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在 [第七章](ch7.md) 中看到更多的冲突示例,在 [第十二章](ch12.md) 中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
|
||||
|
||||
|
||||
### 多主复制拓扑
|
||||
|
||||
**复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如[图5-7](img/fig5-7.png)所示,只有一个合理的拓扑结构:领导者1必须把他所有的写到领导者2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图5-8](img/fig5-8.png)举例说明了一些例子。
|
||||
**复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如 [图 5-7](img/fig5-7.png) 所示,只有一个合理的拓扑结构:领导者 1 必须把他所有的写到领导者 2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图 5-8](img/fig5-8.png) 举例说明了一些例子。
|
||||
|
||||

|
||||
|
||||
**图5-8 三个可以设置多领导者复制的示例拓扑。**
|
||||
**图 5-8 三个可以设置多领导者复制的示例拓扑。**
|
||||
|
||||
最普遍的拓扑是全部到全部([图5-8 (c)](img/fig5-8.png)),其中每个领导者将其写入每个其他领导。但是,也会使用更多受限制的拓扑:例如,默认情况下,MySQL仅支持**环形拓扑(circular topology)**【34】,其中每个节点接收来自一个节点的写入,并将这些写入(加上自己的任何写入)转发给另一个节点。另一种流行的拓扑结构具有星形的形状[^v]。一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
|
||||
最普遍的拓扑是全部到全部([图 5-8 (c)](img/fig5-8.png)),其中每个领导者将其写入每个其他领导。但是,也会使用更多受限制的拓扑:例如,默认情况下,MySQL 仅支持 **环形拓扑(circular topology)**【34】,其中每个节点接收来自一个节点的写入,并将这些写入(加上自己的任何写入)转发给另一个节点。另一种流行的拓扑结构具有星形的形状 [^v]。一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
|
||||
|
||||
[^v]: 不要与星型模式混淆(请参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
|
||||
[^v]: 不要与星型模式混淆(请参阅 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
|
||||
|
||||
在环形和星形拓扑中,写入可能需要在到达所有副本之前通过多个节点。因此,节点需要转发从其他节点收到的数据更改。为了防止无限复制循环,每个节点被赋予一个唯一的标识符,并且在复制日志中,每个写入都被标记了所有已经过的节点的标识符【43】。当一个节点收到用自己的标识符标记的数据更改时,该数据更改将被忽略,因为节点知道它已经被处理过。
|
||||
|
||||
环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
|
||||
|
||||
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。
|
||||
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能 “超过” 其他复制消息,如 [图 5-9](img/fig5-9.png) 所示。
|
||||
|
||||

|
||||
|
||||
**图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。**
|
||||
**图 5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。**
|
||||
|
||||
在[图5-9](img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。
|
||||
在 [图 5-9](img/fig5-9.png) 中,客户端 A 向主库 1 的表中插入一行,客户端 B 在主库 3 上更新该行。然而,主库 2 可以以不同的顺序接收写入:它可以首先接收更新(从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。
|
||||
|
||||
这是一个因果关系的问题,类似于我们在“[一致前缀读](#一致前缀读)”中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,以便在主库2处正确地排序这些事件(见[第八章](ch8.md))。
|
||||
这是一个因果关系的问题,类似于我们在 “[一致前缀读](#一致前缀读)” 中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,以便在主库 2 处正确地排序这些事件(见 [第八章](ch8.md))。
|
||||
|
||||
要正确排序这些事件,可以使用一种称为 **版本向量(version vectors)** 的技术,本章稍后将讨论这种技术(请参阅“[检测并发写入](#检测并发写入)”)。然而,冲突检测技术在许多多领导者复制系统中执行得不好。例如,在撰写本文时,PostgreSQL BDR不提供写入的因果排序【27】,而Tungsten Replicator for MySQL甚至不尝试检测冲突【34】。
|
||||
要正确排序这些事件,可以使用一种称为 **版本向量(version vectors)** 的技术,本章稍后将讨论这种技术(请参阅 “[检测并发写入](#检测并发写入)”)。然而,冲突检测技术在许多多领导者复制系统中执行得不好。例如,在撰写本文时,PostgreSQL BDR 不提供写入的因果排序【27】,而 Tungsten Replicator for MySQL 甚至不尝试检测冲突【34】。
|
||||
|
||||
如果你正在使用具有多领导者复制功能的系统,那么应该了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它确实提供了你认为具有的保证。
|
||||
|
||||
|
||||
## 无主复制
|
||||
|
||||
我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
|
||||
我们在本章到目前为止所讨论的复制方法 —— 单主复制、多主复制 —— 都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
|
||||
|
||||
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的(leaderless)**【1,44】,但是在关系数据库主导的时代,这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后,它再一次成为数据库的一种时尚架构【37】。 Riak,Cassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储,所以这类数据库也被称为*Dynamo风格*。
|
||||
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是 **无领导的(leaderless)**【1,44】,但是在关系数据库主导的时代,这个想法几乎已被忘却。在亚马逊将其用于其内部的 Dynamo 系统 [^vi] 之后,它再一次成为数据库的一种时尚架构【37】。 Riak,Cassandra 和 Voldemort 是由 Dynamo 启发的无领导复制模型的开源数据存储,所以这类数据库也被称为 *Dynamo 风格*。
|
||||
|
||||
[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是,AWS提供了一个名为DynamoDB的托管数据库产品,它使用了完全不同的体系结构:它基于单领导者复制。
|
||||
[^vi]: Dynamo 不适用于 Amazon 以外的用户。 令人困惑的是,AWS 提供了一个名为 DynamoDB 的托管数据库产品,它使用了完全不同的体系结构:它基于单领导者复制。
|
||||
|
||||
在一些无领导者的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,一个 **协调者(coordinator)** 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
|
||||
|
||||
@ -476,84 +476,84 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(请参阅「[处理节点宕机](#处理节点宕机)」)。
|
||||
|
||||
另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
|
||||
另一方面,在无领导配置中,故障切换不存在。[图 5-10](img/fig5-10.png) 显示了发生了什么事情:客户端(用户 1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
|
||||
|
||||

|
||||
|
||||
**图5-10 法定写入,法定读取,并在节点中断后读修复。**
|
||||
**图 5-10 法定写入,法定读取,并在节点中断后读修复。**
|
||||
|
||||
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果你从该节点读取数据,则可能会将陈旧(过时)值视为响应。
|
||||
|
||||
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新(请参阅“[检测并发写入](#检测并发写入)”)。
|
||||
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新(请参阅 “[检测并发写入](#检测并发写入)”)。
|
||||
|
||||
#### 读修复和反熵
|
||||
|
||||
复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入?
|
||||
|
||||
在Dynamo风格的数据存储中经常使用两种机制:
|
||||
|
||||
在 Dynamo 风格的数据存储中经常使用两种机制:
|
||||
|
||||
* 读修复(Read repair)
|
||||
|
||||
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
|
||||
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在 [图 5-10](img/fig5-10.png) 中,用户 2345 获得了来自副本 3 的版本 6 值和来自副本 1 和 2 的版本 7 值。客户端发现副本 3 具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
|
||||
|
||||
* 反熵过程(Anti-entropy process)
|
||||
|
||||
此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
|
||||
|
||||
并不是所有的系统都实现了这两个,例如,Voldemort目前没有反熵过程。请注意,如果没有反熵过程,某些副本中很少读取的值可能会丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
|
||||
并不是所有的系统都实现了这两个,例如,Voldemort 目前没有反熵过程。请注意,如果没有反熵过程,某些副本中很少读取的值可能会丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
|
||||
|
||||
#### 读写的法定人数
|
||||
|
||||
在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写成功?
|
||||
在 [图 5-10](img/fig5-10.png) 的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写成功?
|
||||
|
||||
如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。
|
||||
|
||||
更一般地说,如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3,w = 2,r = 2$)。只要$w + r> n$,我们期望在读取时获得最新的值,因为r个读取中至少有一个节点是最新的。遵循这些r值,w值的读写称为**法定人数(quorum)**[^vii]的读和写【44】。你可以认为,r和w是有效读写所需的最低票数。
|
||||
更一般地说,如果有 n 个副本,每个写入必须由 w 节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 r 个节点。 (在我们的例子中,$n = 3,w = 2,r = 2$)。只要 $w + r> n$,我们期望在读取时获得最新的值,因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值,w 值的读写称为 **法定人数(quorum)**[^vii] 的读和写【44】。你可以认为,r 和 w 是有效读写所需的最低票数。
|
||||
|
||||
[^vii]: 有时候这种法定人数被称为严格的法定人数,相对“宽松的法定人数”而言(见“[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”)
|
||||
[^vii]: 有时候这种法定人数被称为严格的法定人数,相对 “宽松的法定人数” 而言(见 “[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”)
|
||||
|
||||
在Dynamo风格的数据库中,参数n,w和r通常是可配置的。一个常见的选择是使n为奇数(通常为3或5)并设置 $w = r =(n + 1)/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
|
||||
在 Dynamo 风格的数据库中,参数 n,w 和 r 通常是可配置的。一个常见的选择是使 n 为奇数(通常为 3 或 5)并设置 $w = r =(n + 1)/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置 $w = n$ 和 $r = 1$ 的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
|
||||
|
||||
> 集群中可能有多于n的节点。(集群的机器数可能多于副本数目),但是任何给定的值只能存储在n个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在[第六章](ch6.md)继续讨论分区。
|
||||
> 集群中可能有多于 n 的节点。(集群的机器数可能多于副本数目),但是任何给定的值只能存储在 n 个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在 [第六章](ch6.md) 继续讨论分区。
|
||||
>
|
||||
|
||||
法定人数条件$w + r> n$允许系统容忍不可用的节点,如下所示:
|
||||
法定人数条件 $w + r> n$ 允许系统容忍不可用的节点,如下所示:
|
||||
|
||||
* 如果$w <n$,如果节点不可用,我们仍然可以处理写入。
|
||||
* 如果$r <n$,如果节点不可用,我们仍然可以处理读取。
|
||||
* 对于$n = 3,w = 2,r = 2$,我们可以容忍一个不可用的节点。
|
||||
* 对于$n = 5,w = 3,r = 3$,我们可以容忍两个不可用的节点。 这个案例如[图5-11](img/fig5-11.png)所示。
|
||||
* 通常,读取和写入操作始终并行发送到所有n个副本。 参数w和r决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
|
||||
* 如果 $w <n$,如果节点不可用,我们仍然可以处理写入。
|
||||
* 如果 $r <n$,如果节点不可用,我们仍然可以处理读取。
|
||||
* 对于 $n = 3,w = 2,r = 2$,我们可以容忍一个不可用的节点。
|
||||
* 对于 $n = 5,w = 3,r = 3$,我们可以容忍两个不可用的节点。 这个案例如 [图 5-11](img/fig5-11.png) 所示。
|
||||
* 通常,读取和写入操作始终并行发送到所有 n 个副本。 参数 w 和 r 决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
|
||||
|
||||

|
||||
|
||||
**图5-11 如果$w + r > n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入**
|
||||
**图 5-11 如果 $w + r > n$,读取 r 个副本,至少有一个 r 副本必然包含了最近的成功写入**
|
||||
|
||||
如果少于所需的w或r节点可用,则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入),因为节点关闭(崩溃,关闭电源),由于客户端和服务器节点之间的网络中断,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
|
||||
如果少于所需的 w 或 r 节点可用,则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入),因为节点关闭(崩溃,关闭电源),由于客户端和服务器节点之间的网络中断,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
|
||||
|
||||
|
||||
### 法定人数一致性的局限性
|
||||
|
||||
如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个键的读取都能返回最近写入的值。情况就是这样,因为你写入的节点集合和你读取的节点集合必须重叠。也就是说,你读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。
|
||||
如果你有 n 个副本,并且你选择 w 和 r,使得 $w + r> n$,你通常可以期望每个键的读取都能返回最近写入的值。情况就是这样,因为你写入的节点集合和你读取的节点集合必须重叠。也就是说,你读取的节点中必须至少有一个具有最新值的节点(如 [图 5-11](img/fig5-11.png) 所示)。
|
||||
|
||||
通常,r和w被选为多数(超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。
|
||||
通常,r 和 w 被选为多数(超过 $n/2$ )节点,因为这确保了 $w + r> n$,同时仍然容忍多达 $n/2$ 个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。
|
||||
|
||||
你也可以将w和r设置为较小的数字,以使$w + r≤n$(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到n个节点,但操作成功只需要少量的成功响应。
|
||||
你也可以将 w 和 r 设置为较小的数字,以使 $w + r≤n$(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到 n 个节点,但操作成功只需要少量的成功响应。
|
||||
|
||||
较小的w和r更有可能会读取过时的数据,因为你的读取更有可能不包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于w或r时,数据库才分别变得不可用于写入或读取。
|
||||
较小的 w 和 r 更有可能会读取过时的数据,因为你的读取更有可能不包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于 w 或 r 时,数据库才分别变得不可用于写入或读取。
|
||||
|
||||
但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
|
||||
但是,即使在 $w + r> n$ 的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
|
||||
|
||||
* 如果使用宽松的法定人数(见“[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”),w个写入和r个读取落在完全不同的节点上,因此r节点和w之间不再保证有重叠节点【46】。
|
||||
* 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅“[处理写入冲突](#处理写入冲突)”)。如果根据时间戳(最后写入胜利)挑选出一个胜者,则由于时钟偏差【35】,写入可能会丢失。我们将在“[检测并发写入](#检测并发写入)”继续讨论此话题。
|
||||
* 如果使用宽松的法定人数(见 “[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”),w 个写入和 r 个读取落在完全不同的节点上,因此 r 节点和 w 之间不再保证有重叠节点【46】。
|
||||
* 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅 “[处理写入冲突](#处理写入冲突)”)。如果根据时间戳(最后写入胜利)挑选出一个胜者,则由于时钟偏差【35】,写入可能会丢失。我们将在 “[检测并发写入](#检测并发写入)” 继续讨论此话题。
|
||||
* 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值。
|
||||
* 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于w个副本上写入成功。所以整体判定写入失败,但整体写入失败并没有在写入成功的副本上回滚。这意味着如果一个写入虽然报告失败,后续的读取仍然可能会读取这次失败写入的值【47】。
|
||||
* 如果携带新值的节点失败,需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复,则存储新值的副本数可能会低于w,从而打破法定人数条件。
|
||||
* 即使一切工作正常,有时也会不幸地出现关于**时序(timing)** 的边缘情况,我们将在“[线性一致性和法定人数](ch9.md#线性一致性和法定人数)”中看到这点。
|
||||
* 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于 w 个副本上写入成功。所以整体判定写入失败,但整体写入失败并没有在写入成功的副本上回滚。这意味着如果一个写入虽然报告失败,后续的读取仍然可能会读取这次失败写入的值【47】。
|
||||
* 如果携带新值的节点失败,需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复,则存储新值的副本数可能会低于 w,从而打破法定人数条件。
|
||||
* 即使一切工作正常,有时也会不幸地出现关于 **时序(timing)** 的边缘情况,我们将在 “[线性一致性和法定人数](ch9.md#线性一致性和法定人数)” 中看到这点。
|
||||
|
||||
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取陈旧值的概率,但把它们当成绝对的保证是不明智的。
|
||||
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数 w 和 r 来调整读取陈旧值的概率,但把它们当成绝对的保证是不明智的。
|
||||
|
||||
尤其是,因为通常没有得到“[复制延迟问题](#复制延迟问题)”中讨论的保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要**事务**或**共识**。我们将在[第七章](ch7.md)和[第九章](ch9.md)回到这些话题。
|
||||
尤其是,因为通常没有得到 “[复制延迟问题](#复制延迟问题)” 中讨论的保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要 **事务** 或 **共识**。我们将在 [第七章](ch7.md) 和 [第九章](ch9.md) 回到这些话题。
|
||||
|
||||
#### 监控陈旧度
|
||||
|
||||
@ -563,42 +563,42 @@ PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志
|
||||
|
||||
然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
|
||||
|
||||
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数n,w和r来预测陈旧读取的预期百分比【48】。不幸的是,这还不是很常见的做法,但是将陈旧测量值包含在数据库的度量标准集中是一件好事。虽然最终一致性是一种有意模糊的保证,但是从可操作性角度来说,能够量化“最终”也是很重要的。
|
||||
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数 n,w 和 r 来预测陈旧读取的预期百分比【48】。不幸的是,这还不是很常见的做法,但是将陈旧测量值包含在数据库的度量标准集中是一件好事。虽然最终一致性是一种有意模糊的保证,但是从可操作性角度来说,能够量化 “最终” 也是很重要的。
|
||||
|
||||
### 宽松的法定人数与提示移交
|
||||
|
||||
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢,因为请求不必等待所有n个节点响应——当w或r节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说,这些特性使无主复制的数据库很有吸引力。
|
||||
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢,因为请求不必等待所有 n 个节点响应 —— 当 w 或 r 节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说,这些特性使无主复制的数据库很有吸引力。
|
||||
|
||||
然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端来看,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于w或r,因此客户端不再能达到法定人数。
|
||||
然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端来看,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于 w 或 r,因此客户端不再能达到法定人数。
|
||||
|
||||
在一个大型的集群中(节点数量明显多于n个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定值的法定人数。在这种情况下,数据库设计人员需要权衡一下:
|
||||
在一个大型的集群中(节点数量明显多于 n 个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定值的法定人数。在这种情况下,数据库设计人员需要权衡一下:
|
||||
|
||||
* 对于所有无法达到w或r节点法定人数的请求,是否返回错误是更好的?
|
||||
* 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的n个节点上?
|
||||
* 对于所有无法达到 w 或 r 节点法定人数的请求,是否返回错误是更好的?
|
||||
* 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的 n 个节点上?
|
||||
|
||||
后者被认为是一个**宽松的法定人数(sloppy quorum)**【37】:写和读仍然需要w和r成功的响应,但这些响应可能来自不在指定的n个“主”节点中的其它节点。比方说,如果你把自己锁在房子外面,你可能会敲开邻居的门,问你是否可以暂时呆在沙发上。
|
||||
后者被认为是一个 **宽松的法定人数(sloppy quorum)**【37】:写和读仍然需要 w 和 r 成功的响应,但这些响应可能来自不在指定的 n 个 “主” 节点中的其它节点。比方说,如果你把自己锁在房子外面,你可能会敲开邻居的门,问你是否可以暂时呆在沙发上。
|
||||
|
||||
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“主”节点。这就是所谓的**提示移交(hinted handoff)**(一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家)。
|
||||
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的 “主” 节点。这就是所谓的 **提示移交(hinted handoff)**(一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家)。
|
||||
|
||||
宽松的法定人数对写入可用性的提高特别有用:只要有任何w节点可用,数据库就可以接受写入。然而,这意味着即使当$w + r> n$时,也不能确定读取某个键的最新值,因为最新的值可能已经临时写入了n之外的某些节点【47】。
|
||||
宽松的法定人数对写入可用性的提高特别有用:只要有任何 w 节点可用,数据库就可以接受写入。然而,这意味着即使当 $w + r> n$ 时,也不能确定读取某个键的最新值,因为最新的值可能已经临时写入了 n 之外的某些节点【47】。
|
||||
|
||||
因此,在传统意义上,一个宽松的法定人数实际上不是一个法定人数。这只是一个保证,即数据存储在w节点的地方。但不能保证r节点的读取,直到提示移交已经完成。
|
||||
因此,在传统意义上,一个宽松的法定人数实际上不是一个法定人数。这只是一个保证,即数据存储在 w 节点的地方。但不能保证 r 节点的读取,直到提示移交已经完成。
|
||||
|
||||
在所有常见的Dynamo实现中,宽松的法定人数是可选的。在Riak中,它们默认是启用的,而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。
|
||||
在所有常见的 Dynamo 实现中,宽松的法定人数是可选的。在 Riak 中,它们默认是启用的,而在 Cassandra 和 Voldemort 中它们默认是禁用的【46,49,50】。
|
||||
|
||||
#### 运维多个数据中心
|
||||
|
||||
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅“[多主复制](#多主复制)”)。无主复制也适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
|
||||
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅 “[多主复制](#多主复制)”)。无主复制也适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
|
||||
|
||||
Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持:副本的数量n包括所有数据中心的节点,在配置中,你可以指定每个数据中心中你想拥有的副本的数量。无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生,尽管配置有一定的灵活性【50,51】。
|
||||
Cassandra 和 Voldemort 在正常的无主模型中实现了他们的多数据中心支持:副本的数量 n 包括所有数据中心的节点,在配置中,你可以指定每个数据中心中你想拥有的副本的数量。无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生,尽管配置有一定的灵活性【50,51】。
|
||||
|
||||
Riak将客户端和数据库节点之间的所有通信保持在一个数据中心本地,因此n描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生,其风格类似于多领导者复制【52】。
|
||||
Riak 将客户端和数据库节点之间的所有通信保持在一个数据中心本地,因此 n 描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生,其风格类似于多领导者复制【52】。
|
||||
|
||||
### 检测并发写入
|
||||
|
||||
Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(请参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**提示移交**期间也可能会产生冲突。
|
||||
Dynamo 风格的数据库允许多个客户端同时写入相同的 Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(请参阅 “[处理写入冲突](#处理写入冲突)”),但在 Dynamo 样式的数据库中,在 **读修复** 或 **提示移交** 期间也可能会产生冲突。
|
||||
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X:
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图 5-12](img/fig5-12.png) 显示了两个客户机 A 和 B 同时写入三节点数据存储区中的键 X:
|
||||
|
||||
* 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。
|
||||
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
|
||||
@ -606,72 +606,72 @@ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意
|
||||
|
||||

|
||||
|
||||
**图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。**
|
||||
**图 5-12 并发写入 Dynamo 风格的数据存储:没有明确定义的顺序。**
|
||||
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如 [图 5-12](img/fig5-12.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B,而其他节点认为值是 A 。
|
||||
|
||||
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
|
||||
|
||||
在“[处理写入冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
|
||||
在 “[处理写入冲突](#处理写入冲突)” 一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
|
||||
|
||||
#### 最后写入胜利(丢弃并发写入)
|
||||
|
||||
实现最终融合的一种方法是声明每个副本只需要存储最 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
|
||||
实现最终融合的一种方法是声明每个副本只需要存储最 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是 “最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
|
||||
|
||||
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是 **并发(concurrent)** 的,所以它们的顺序是不确定的。
|
||||
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在 [图 5-12](img/fig5-12.png) 的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说 “发生” 是没有意义的:我们说写入是 **并发(concurrent)** 的,所以它们的顺序是不确定的。
|
||||
|
||||
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最 **“最近”** 的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。
|
||||
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最 **“最近”** 的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利(LWW, last write wins)**,是 Cassandra 【53】唯一支持的冲突解决方法,也是 Riak 【35】中的一个可选特征。
|
||||
|
||||
LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一个Key有多个并发写入,即使它们报告给客户端的都是成功(因为它们被写入 w 个副本),也只有一个写入将存活,而其他写入将被静默丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
|
||||
LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同一个 Key 有多个并发写入,即使它们报告给客户端的都是成功(因为它们被写入 w 个副本),也只有一个写入将存活,而其他写入将被静默丢弃。此外,LWW 甚至可能会删除不是并发的写入,我们将在的 “[有序事件的时间戳](ch8.md# 有序事件的时间戳)” 中讨论。
|
||||
|
||||
有一些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW是解决冲突的一个很烂的选择。
|
||||
有一些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW 是解决冲突的一个很烂的选择。
|
||||
|
||||
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个键进行并发更新。例如,Cassandra推荐使用的方法是使用UUID作为键,从而为每个写操作提供一个唯一的键【53】。
|
||||
与 LWW 一起使用数据库的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个键进行并发更新。例如,Cassandra 推荐使用的方法是使用 UUID 作为键,从而为每个写操作提供一个唯一的键【53】。
|
||||
|
||||
#### “此前发生”的关系和并发
|
||||
|
||||
我们如何判断两个操作是否是并发的?为了建立一个直觉,让我们看看一些例子:
|
||||
|
||||
* 在[图5-9](fig5-9.png)中,两个写入不是并发的:A的插入发生在B的递增之前,因为B递增的值是A插入的值。换句话说,B的操作建立在A的操作上,所以B的操作必须有后来发生。我们也可以说B是 **因果依赖(causally dependent)** 于A。
|
||||
* 另一方面,[图5-12](fig5-12.png)中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在执行操作同样的键。因此,操作之间不存在因果关系。
|
||||
* 在 [图 5-9](fig5-9.png) 中,两个写入不是并发的:A 的插入发生在 B 的递增之前,因为 B 递增的值是 A 插入的值。换句话说,B 的操作建立在 A 的操作上,所以 B 的操作必须有后来发生。我们也可以说 B 是 **因果依赖(causally dependent)** 于 A。
|
||||
* 另一方面,[图 5-12](fig5-12.png) 中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在执行操作同样的键。因此,操作之间不存在因果关系。
|
||||
|
||||
如果操作B了解操作A,或者依赖于A,或者以某种方式构建于操作A之上,则操作A在另一个操作B之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上,我们可以简单地说,如果两个操作都不在另一个之前发生,那么两个操作是并发的(即,两个操作都不知道另一个)【54】。
|
||||
如果操作 B 了解操作 A,或者依赖于 A,或者以某种方式构建于操作 A 之上,则操作 A 在另一个操作 B 之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上,我们可以简单地说,如果两个操作都不在另一个之前发生,那么两个操作是并发的(即,两个操作都不知道另一个)【54】。
|
||||
|
||||
因此,只要有两个操作A和B,就有三种可能性:A在B之前发生,或者B在A之前发生,或者A和B并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖较早的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
|
||||
因此,只要有两个操作 A 和 B,就有三种可能性:A 在 B 之前发生,或者 B 在 A 之前发生,或者 A 和 B 并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖较早的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
|
||||
|
||||
|
||||
> #### 并发性,时间和相对性
|
||||
>
|
||||
> 如果两个操作 **“同时”** 发生,似乎应该称为并发——但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否**同时**发生的,这个问题我们将在[第八章](ch8.md)中详细讨论。
|
||||
> 如果两个操作 **“同时”** 发生,似乎应该称为并发 —— 但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否 **同时** 发生的,这个问题我们将在 [第八章](ch8.md) 中详细讨论。
|
||||
>
|
||||
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作**并发**,而不管它们发生的物理时间。人们有时把这个原理和狭义相对论的物理学联系起来【54】,它引入了信息不能比光速更快的思想。因此,如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间,那么这两个事件不可能相互影响。
|
||||
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作 **并发**,而不管它们发生的物理时间。人们有时把这个原理和狭义相对论的物理学联系起来【54】,它引入了信息不能比光速更快的思想。因此,如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间,那么这两个事件不可能相互影响。
|
||||
>
|
||||
> 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是**并行的**。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,但仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
|
||||
> 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是 **并行的**。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,但仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
|
||||
|
||||
|
||||
#### 捕获"此前发生"关系
|
||||
|
||||
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无领导者数据库。
|
||||
|
||||
[图5-13](img/fig5-13.png)显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
|
||||
[图 5-13](img/fig5-13.png) 显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
|
||||
|
||||
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号1,最后将值与版本号一起回送给客户端。
|
||||
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值**都**返回给客户端 2 ,并附上版本号 2 。
|
||||
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是**并发**的。因此,服务器将版本 3 分配给[牛奶,面粉],覆盖版本1值[牛奶],但保留版本 2 的值[蛋],并将所有的值返回给客户端 1 。
|
||||
4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿]
|
||||
5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。
|
||||
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号 1,最后将值与版本号一起回送给客户端。
|
||||
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值 **都** 返回给客户端 2 ,并附上版本号 2 。
|
||||
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道 [牛奶,面粉] 的写入取代了 [牛奶] 的先前值,但与 [鸡蛋] 的值是 **并发** 的。因此,服务器将版本 3 分配给 [牛奶,面粉],覆盖版本 1 值 [牛奶],但保留版本 2 的值 [蛋],并将所有的值返回给客户端 1 。
|
||||
4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值 [牛奶] 和 [蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉] **并发**,所以剩下的两个是 v3 [牛奶,面粉],和 v4:[鸡蛋,牛奶,火腿]
|
||||
5. 最后,客户端 1 想要加培根。它以前在 v3 中从服务器接收 [牛奶,面粉] 和 [鸡蛋],所以它合并这些,添加培根,并将最终值 [牛奶,面粉,鸡蛋,培根] 连同版本号 v3 发往服务器。这会覆盖 v3 [牛奶,面粉](请注意 [鸡蛋] 已经在最后一步被覆盖),但与 v4 [鸡蛋,牛奶,火腿] 并发,所以服务器保留这两个并发值。
|
||||
|
||||

|
||||
|
||||
**图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。**
|
||||
**图 5-13 捕获两个客户端之间的因果关系,同时编辑购物车。**
|
||||
|
||||
[图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。
|
||||
[图 5-13](img/fig5-13.png) 中的操作之间的数据流如 [图 5-14](img/fig5-14.png) 所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。
|
||||
|
||||

|
||||
|
||||
**图5-14 图5-13中的因果依赖关系图。**
|
||||
**图 5-14 图 5-13 中的因果依赖关系图。**
|
||||
|
||||
请注意,服务器可以通过查看版本号来确定两个操作是否是并发的——它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下:
|
||||
请注意,服务器可以通过查看版本号来确定两个操作是否是并发的 —— 它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下:
|
||||
|
||||
* 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
|
||||
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
|
||||
@ -682,31 +682,31 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
|
||||
#### 合并同时写入的值
|
||||
|
||||
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:客户端随后必须通过合并并发写入的值来进行清理。 Riak称这些并发值为**兄弟(siblings)**。
|
||||
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:客户端随后必须通过合并并发写入的值来进行清理。 Riak 称这些并发值为 **兄弟(siblings)**。
|
||||
|
||||
合并并发值,本质上是与多领导者复制中的冲突解决问题相同,我们先前讨论过(请参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)来选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中额外做些更聪明的事情。
|
||||
合并并发值,本质上是与多领导者复制中的冲突解决问题相同,我们先前讨论过(请参阅 “[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)来选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中额外做些更聪明的事情。
|
||||
|
||||
以购物车为例,一种合理的合并值的方法就是做并集。在[图5-14](img/fig5-14.png)中,最后的合并结果是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋同时出现在两个并发值里,即使他们每个只被写过一次。合并的值可以是[牛奶,面粉,鸡蛋,培根,火腿],他们没有重复。
|
||||
以购物车为例,一种合理的合并值的方法就是做并集。在 [图 5-14](img/fig5-14.png) 中,最后的合并结果是 [牛奶,面粉,鸡蛋,熏肉] 和 [鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋同时出现在两个并发值里,即使他们每个只被写过一次。合并的值可以是 [牛奶,面粉,鸡蛋,培根,火腿],他们没有重复。
|
||||
|
||||
然而,如果你想让人们也可以从他们的购物车中**删除**东西,而不是仅仅添加东西,那么把并发值做并集可能不会产生正确的结果:如果你合并了两个客户端的购物车,并且只在其中一个客户端里面删掉了它,那么被删除的项目会重新出现在这两个客户端的交集结果中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有适当版本号的标记,在合并兄弟时表明该项目已被删除。这种删除标记被称为**墓碑(tombstone)**(我们之前在“[散列索引”](ch3.md#散列索引)中的日志压缩的上下文中看到了墓碑)。
|
||||
然而,如果你想让人们也可以从他们的购物车中 **删除** 东西,而不是仅仅添加东西,那么把并发值做并集可能不会产生正确的结果:如果你合并了两个客户端的购物车,并且只在其中一个客户端里面删掉了它,那么被删除的项目会重新出现在这两个客户端的交集结果中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有适当版本号的标记,在合并兄弟时表明该项目已被删除。这种删除标记被称为 **墓碑(tombstone)**(我们之前在 “[散列索引”](ch3.md#散列索引) 中的日志压缩的上下文中看到了墓碑)。
|
||||
|
||||
因为在应用程序代码中做合并是复杂且易出错,所以有一些数据结构被设计出来用于自动执行这种合并,如“[自动冲突解决](#自动冲突解决)”中讨论的。例如,Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并,包括保留删除。
|
||||
因为在应用程序代码中做合并是复杂且易出错,所以有一些数据结构被设计出来用于自动执行这种合并,如 “[自动冲突解决](#自动冲突解决)” 中讨论的。例如,Riak 的数据类型支持使用称为 CRDT 的数据结构家族【38,39,55】可以以合理的方式自动合并,包括保留删除。
|
||||
|
||||
#### 版本向量
|
||||
|
||||
[图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改?
|
||||
[图 5-13](img/fig5-13.png) 中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改?
|
||||
|
||||
[图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,还需要在**每个副本**以及**每个键**使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及保留哪些并发值。
|
||||
[图 5-13](img/fig5-13.png) 使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,还需要在 **每个副本** 以及 **每个键** 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及保留哪些并发值。
|
||||
|
||||
所有副本的版本号集合称为**版本向量(version vector)**【56】。这个想法的一些变体正在被使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**虚线版本向量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。
|
||||
所有副本的版本号集合称为 **版本向量(version vector)**【56】。这个想法的一些变体正在被使用,但最有趣的可能是在 Riak 2.0 【58,59】中使用的 **虚线版本向量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。
|
||||
|
||||
与[图5-13](img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。(Riak将版本向量编码为一个字符串,并称其为**因果上下文**,即causal context)。版本向量允许数据库区分覆盖写入和并发写入。
|
||||
与 [图 5-13](img/fig5-13.png) 中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。(Riak 将版本向量编码为一个字符串,并称其为 **因果上下文**,即 causal context)。版本向量允许数据库区分覆盖写入和并发写入。
|
||||
|
||||
另外,就像在单个副本中的情况一样,应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据,但只要能正确合并就不会丢失数据。
|
||||
|
||||
> #### 版本向量和向量时钟
|
||||
>
|
||||
> 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙——细节请参阅参考资料【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。
|
||||
> 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙 —— 细节请参阅参考资料【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。
|
||||
>
|
||||
|
||||
## 本章小结
|
||||
@ -751,7 +751,7 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制滞后增加和服务器故障时会发生什么,这一点很重要。如果一个领导者失败了,并且你提升了一个异步更新的追随者成为新的领导者,那么最近提交的数据可能会丢失。
|
||||
|
||||
我们研究了一些可能由复制滞后引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型:
|
||||
|
||||
|
||||
* 写后读
|
||||
|
||||
用户应该总是看到自己提交的数据。
|
||||
|
181
ch6.md
181
ch6.md
@ -1,4 +1,4 @@
|
||||
# 第六章:分区
|
||||
# 第六章:分区
|
||||
|
||||

|
||||
|
||||
@ -11,43 +11,44 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在[第五章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区(partitions)**,也称为**分片(sharding)**[^i]。
|
||||
在 [第五章](ch5.md) 中,我们讨论了复制 —— 即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行 **分区(partitions)**,也称为 **分片(sharding)**[^i]。
|
||||
|
||||
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区(network partitions, netsplits)** 无关,这是节点之间网络故障的一种。我们将在[第八章](ch8.md)讨论这些错误。
|
||||
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区(network partitions, netsplits)** 无关,这是节点之间网络故障的一种。我们将在 [第八章](ch8.md) 讨论这些错误。
|
||||
|
||||
> #### 术语澄清
|
||||
>
|
||||
> 上文中的**分区(partition)**,在MongoDB,Elasticsearch和Solr Cloud中被称为**分片(shard)**,在HBase中称之为**区域(Region)**,Bigtable中则是 **表块(tablet)**,Cassandra和Riak中是**虚节点(vnode)**,Couchbase中叫做**虚桶(vBucket)**。但是**分区(partitioning)** 是最约定俗成的叫法。
|
||||
> 上文中的 **分区 (partition)**,在 MongoDB,Elasticsearch 和 Solr Cloud 中被称为 **分片 (shard)**,在 HBase 中称之为 **区域 (Region)**,Bigtable 中则是 **表块(tablet)**,Cassandra 和 Riak 中是 **虚节点(vnode)**,Couchbase 中叫做 **虚桶 (vBucket)**。但是 **分区 (partitioning)** 是最约定俗成的叫法。
|
||||
>
|
||||
|
||||
通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。
|
||||
|
||||
分区主要是为了**可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅[第二部分](part-ii.md)关于[无共享架构](part-ii.md#无共享架构)的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
|
||||
分区主要是为了 **可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅 [第二部分](part-ii.md) 关于 [无共享架构](part-ii.md#无共享架构) 的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
|
||||
|
||||
对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。
|
||||
|
||||
分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出,最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(请参阅“[事务处理还是分析](ch3.md#事务处理还是分析)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
|
||||
分区数据库在 20 世纪 80 年代由 Teradata 和 NonStop SQL【1】等产品率先推出,最近因为 NoSQL 数据库和基于 Hadoop 的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(请参阅 “[事务处理还是分析](ch3.md#事务处理还是分析)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
|
||||
|
||||
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[分区再平衡(rebalancing)](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
|
||||
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论 [分区再平衡(rebalancing)](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
|
||||
|
||||
## 分区与复制
|
||||
|
||||
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
|
||||
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
|
||||
|
||||
一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1](img/fig6-1.png)所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。
|
||||
我们在[第五章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
|
||||
一个节点可能存储多个分区。如果使用主从复制模型,则分区和复制的组合如 [图 6-1](img/fig6-1.png) 所示。每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。
|
||||
|
||||
我们在 [第五章](ch5.md) 讨论的关于数据库复制的所有内容同样适用于分区的复制。大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
|
||||
|
||||

|
||||
|
||||
**图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。**
|
||||
**图 6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。**
|
||||
|
||||
## 键值数据的分区
|
||||
|
||||
假设你有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢?
|
||||
|
||||
分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量(暂时忽略复制)。
|
||||
分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上 10 个节点应该能够处理 10 倍的数据量和 10 倍的单个节点的读写吞吐量(暂时忽略复制)。
|
||||
|
||||
如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为**偏斜(skew)**。数据偏斜的存在使分区效率下降很多。在极端的情况下,所有的负载可能压在一个分区上,其余9个节点空闲的,瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为**热点(hot spot)**。
|
||||
如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为 **偏斜(skew)**。数据偏斜的存在使分区效率下降很多。在极端的情况下,所有的负载可能压在一个分区上,其余 9 个节点空闲的,瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为 **热点(hot spot)**。
|
||||
|
||||
避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。
|
||||
|
||||
@ -55,19 +56,19 @@
|
||||
|
||||
### 根据键的范围分区
|
||||
|
||||
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图6-2](img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果你还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
|
||||
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图 6-2](img/fig6-2.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果你还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
|
||||
|
||||

|
||||
|
||||
**图6-2 印刷版百科全书按照关键字范围进行分区**
|
||||
**图 6-2 印刷版百科全书按照关键字范围进行分区**
|
||||
|
||||
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2](img/fig6-2.png)中,第1卷包含以A和B开头的单词,但第12卷则包含以T,U,V,X,Y和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
|
||||
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在 [图 6-2](img/fig6-2.png) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷则包含以 T、U、V、X、Y 和 Z 开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
|
||||
|
||||
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[分区再平衡](#分区再平衡)”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略,以及其开源等价物HBase 【2, 3】,RethinkDB和2.4版本之前的MongoDB 【4】。
|
||||
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在 “[分区再平衡](#分区再平衡)” 中更详细地讨论分区边界的选择)。 Bigtable 使用了这种分区策略,以及其开源等价物 HBase 【2, 3】,RethinkDB 和 2.4 版本之前的 MongoDB 【4】。
|
||||
|
||||
在每个分区中,我们可以按照一定的顺序保存键(请参阅“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,你可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅“[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
|
||||
在每个分区中,我们可以按照一定的顺序保存键(请参阅 “[SSTables 和 LSM 树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,你可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅 “[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
|
||||
|
||||
然而,Key Range分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是,由于我们在测量发生时将数据从传感器写入数据库,因此所有写入操作都会转到同一个分区(即今天的分区),这样分区可能会因写入而过载,而其他分区则处于空闲状态【5】。
|
||||
然而,Key Range 分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是,由于我们在测量发生时将数据从传感器写入数据库,因此所有写入操作都会转到同一个分区(即今天的分区),这样分区可能会因写入而过载,而其他分区则处于空闲状态【5】。
|
||||
|
||||
为了避免传感器数据库中的这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进行分区。 假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在一个时间范围内获取多个传感器的值时,你需要为每个传感器名称执行一个单独的范围查询。
|
||||
|
||||
@ -75,39 +76,39 @@
|
||||
|
||||
由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
|
||||
|
||||
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入,它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似,它们的散列也会均匀分布在这个数字范围内。
|
||||
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个 32 位散列函数,无论何时给定一个新的字符串输入,它将返回一个 0 到 $2^{32}$ -1 之间的 “随机” 数。即使输入的字符串非常相似,它们的散列也会均匀分布在这个数字范围内。
|
||||
|
||||
出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于散列表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。
|
||||
出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra 和 MongoDB 使用 MD5,Voldemort 使用 Fowler-Noll-Vo 函数。许多编程语言都有内置的简单哈希函数(它们用于散列表),但是它们可能不适合分区:例如,在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。
|
||||
|
||||
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。
|
||||
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如 [图 6-3](img/fig6-3.png) 所示。
|
||||
|
||||

|
||||
|
||||
**图6-3 按哈希键分区**
|
||||
**图 6-3 按哈希键分区**
|
||||
|
||||
这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希**,即consistent hashing)。
|
||||
这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为 **一致性哈希**,即 consistent hashing)。
|
||||
|
||||
> #### 一致性哈希
|
||||
>
|
||||
> 一致性哈希由Karger等人定义。【7】 用于跨互联网级别的缓存系统,例如CDN中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界(partition boundaries)** 来避免中央控制或分布式共识的需要。 请注意,这里的一致性与复制一致性(请参阅[第五章](ch5.md))或ACID一致性(请参阅[第七章](ch7.md))无关,而只是描述了一种重新平衡(reblancing)的特定方法。
|
||||
> 一致性哈希由 Karger 等人定义。【7】 用于跨互联网级别的缓存系统,例如 CDN 中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界(partition boundaries)** 来避免中央控制或分布式共识的需要。 请注意,这里的一致性与复制一致性(请参阅 [第五章](ch5.md))或 ACID 一致性(请参阅 [第七章](ch7.md))无关,而只是描述了一种重新平衡(reblancing)的特定方法。
|
||||
>
|
||||
> 正如我们将在“[分区再平衡](#分区再平衡)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区(hash partitioning)**。
|
||||
> 正如我们将在 “[分区再平衡](#分区再平衡)” 中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为 **散列分区(hash partitioning)**。
|
||||
|
||||
不幸的是,通过使用键散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中,所以它们之间的顺序就丢失了。在MongoDB中,如果你使用了基于散列的分区模式,则任何范围查询都必须发送到所有分区【4】。Riak【9】、Couchbase 【10】或Voldemort不支持主键上的范围查询。
|
||||
不幸的是,通过使用键散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中,所以它们之间的顺序就丢失了。在 MongoDB 中,如果你使用了基于散列的分区模式,则任何范围查询都必须发送到所有分区【4】。Riak【9】、Couchbase 【10】或 Voldemort 不支持主键上的范围查询。
|
||||
|
||||
Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据,而其他列则被用作Casssandra的SSTables中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表,但如果第一列已经指定了固定值,则可以对该键的其他列执行有效的范围扫描。
|
||||
Cassandra 采取了折衷的策略【11, 12, 13】。 Cassandra 中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据,而其他列则被用作 Casssandra 的 SSTables 中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表,但如果第一列已经指定了固定值,则可以对该键的其他列执行有效的范围扫描。
|
||||
|
||||
组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为`(user_id, update_timestamp)`,那么你可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
|
||||
组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为 `(user_id, update_timestamp)`,那么你可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
|
||||
|
||||
### 负载偏斜与热点消除
|
||||
|
||||
如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。
|
||||
|
||||
这种场景也许并不常见,但并非闻所未闻:例如,在社交媒体网站上,一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入(键可能是名人的用户ID,或者人们正在评论的动作的ID)。哈希策略不起作用,因为两个相同ID的哈希值仍然是相同的。
|
||||
这种场景也许并不常见,但并非闻所未闻:例如,在社交媒体网站上,一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入(键可能是名人的用户 ID,或者人们正在评论的动作的 ID)。哈希策略不起作用,因为两个相同 ID 的哈希值仍然是相同的。
|
||||
|
||||
如今,大多数数据系统无法自动补偿这种高度偏斜的负载,因此应用程序有责任减少偏斜。例如,如果一个主键被认为是非常火爆的,一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中。
|
||||
如今,大多数数据系统无法自动补偿这种高度偏斜的负载,因此应用程序有责任减少偏斜。例如,如果一个主键被认为是非常火爆的,一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为 100 种不同的主键,从而存储在不同的分区中。
|
||||
|
||||
然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有100个主键分布中读取数据并将其合并。此技术还需要额外的记录:只需要对少量热点附加随机数;对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此,你还需要一些方法来跟踪哪些键需要被分割。
|
||||
然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有 100 个主键分布中读取数据并将其合并。此技术还需要额外的记录:只需要对少量热点附加随机数;对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此,你还需要一些方法来跟踪哪些键需要被分割。
|
||||
|
||||
也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,你需要自己来权衡。
|
||||
|
||||
@ -117,132 +118,132 @@ Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使
|
||||
|
||||
到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。
|
||||
|
||||
如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构](ch3.md#其他索引结构)”)。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户123的所有操作,查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。
|
||||
如果涉及次级索引,情况会变得更加复杂(参考 “[其他索引结构](ch3.md#其他索引结构)”)。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户 123 的所有操作,查找包含词语 `hogwash` 的所有文章,查找所有颜色为红色的车辆等等。
|
||||
|
||||
次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如HBase和Volde-mort)为了减少实现的复杂度而放弃了次级索引,但是一些(如Riak)已经开始添加它们,因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。
|
||||
次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如 HBase 和 Volde-mort)为了减少实现的复杂度而放弃了次级索引,但是一些(如 Riak)已经开始添加它们,因为它们对于数据模型实在是太有用了。并且次级索引也是 Solr 和 Elasticsearch 等搜索服务器的基石。
|
||||
|
||||
次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:**基于文档的分区(document-based)** 和**基于关键词(term-based)的分区**。
|
||||
次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:**基于文档的分区(document-based)** 和 **基于关键词(term-based)的分区**。
|
||||
|
||||
### 基于文档的次级索引进行分区
|
||||
|
||||
假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。
|
||||
假设你正在经营一个销售二手车的网站(如 [图 6-4](img/fig6-4.png) 所示)。 每个列表都有一个唯一的 ID—— 称之为文档 ID—— 并且用文档 ID 对数据库进行分区(例如,分区 0 中的 ID 0 到 499,分区 1 中的 ID 500 到 999 等)。
|
||||
|
||||
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果你声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。
|
||||
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是 **字段(field)**,关系数据库中这些是 **列(column)** )。 如果你声明了索引,则数据库可以自动执行索引 [^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目 `color:red` 的文档 ID 列表中。
|
||||
|
||||
[^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现次级索引。 如果沿着这条路线走下去,请万分小心,确保你的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅“[多对象事务的需求](ch7.md#多对象事务的需求)”。
|
||||
[^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档 ID 的映射来实现次级索引。 如果沿着这条路线走下去,请万分小心,确保你的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅 “[多对象事务的需求](ch7.md#多对象事务的需求)”。
|
||||
|
||||

|
||||
|
||||
**图6-4 基于文档的次级索引进行分区**
|
||||
**图 6-4 基于文档的次级索引进行分区**
|
||||
|
||||
在这种索引方法中,每个分区是完全独立的:每个分区维护自己的次级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时你需要写入数据库(添加,删除或更新文档),只需处理包含你正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引**(而不是将在下一节中描述的**全局索引**)。
|
||||
在这种索引方法中,每个分区是完全独立的:每个分区维护自己的次级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时你需要写入数据库(添加,删除或更新文档),只需处理包含你正在编写的文档 ID 的分区即可。出于这个原因,**文档分区索引** 也被称为 **本地索引**(而不是将在下一节中描述的 **全局索引**)。
|
||||
|
||||
但是,从文档分区索引中读取需要注意:除非你对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
|
||||
但是,从文档分区索引中读取需要注意:除非你对文档 ID 做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在 [图 6-4](img/fig6-4.png) 中,红色汽车出现在分区 0 和分区 1 中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
|
||||
|
||||
|
||||
这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(请参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议你构建一个能从单个分区提供次级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个次级索引时(例如同时需要按颜色和制造商查询)。
|
||||
这种查询分区数据库的方法有时被称为 **分散 / 聚集(scatter/gather)**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散 / 聚集也容易导致尾部延迟放大(请参阅 “[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和 VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议你构建一个能从单个分区提供次级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个次级索引时(例如同时需要按颜色和制造商查询)。
|
||||
|
||||
|
||||
### 基于关键词(Term)的次级索引进行分区
|
||||
|
||||
我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
|
||||
我们可以构建一个覆盖所有分区数据的 **全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
|
||||
|
||||
[图6-5](img/fig6-5.png)描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。
|
||||
[图 6-5](img/fig6-5.png) 描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从 `a` 到 `r` 的颜色在分区 0 中,`s` 到 `z` 的在分区 1。汽车制造商的索引也与之类似(分区边界在 `f` 和 `h` 之间)。
|
||||
|
||||

|
||||
|
||||
**图6-5 基于关键词对次级索引进行分区**
|
||||
**图 6-5 基于关键词对次级索引进行分区**
|
||||
|
||||
我们将这种索引称为**关键词分区(term-partitioned)**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词(Term)** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
|
||||
我们将这种索引称为 **关键词分区(term-partitioned)**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词(Term)** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
|
||||
|
||||
和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
|
||||
和之前一样,我们可以通过 **关键词** 本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
|
||||
|
||||
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要**分散/收集**所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
|
||||
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要 **分散 / 收集** 所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
|
||||
|
||||
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅[第七章](ch7.md)和[第九章](ch9.md))。
|
||||
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅 [第七章](ch7.md) 和 [第九章](ch9.md))。
|
||||
|
||||
在实践中,对全局次级索引的更新通常是**异步**的(也就是说,如果在写入之后不久读取索引,刚才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB声称在正常情况下,其全局次级索引会在不到一秒的时间内更新,但在基础架构出现故障的情况下可能会有延迟【20】。
|
||||
在实践中,对全局次级索引的更新通常是 **异步** 的(也就是说,如果在写入之后不久读取索引,刚才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB 声称在正常情况下,其全局次级索引会在不到一秒的时间内更新,但在基础架构出现故障的情况下可能会有延迟【20】。
|
||||
|
||||
全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库,它允许你在本地和全局索引之间进行选择【22】。我们将在[第十二章](ch12.md)中继续关键词分区次级索引实现的话题。
|
||||
全局关键词分区索引的其他用途包括 Riak 的搜索功能【21】和 Oracle 数据仓库,它允许你在本地和全局索引之间进行选择【22】。我们将在 [第十二章](ch12.md) 中继续关键词分区次级索引实现的话题。
|
||||
|
||||
## 分区再平衡
|
||||
|
||||
随着时间的推移,数据库会有各种变化:
|
||||
|
||||
* 查询吞吐量增加,所以你想要添加更多的CPU来处理负载。
|
||||
* 数据集大小增加,所以你想添加更多的磁盘和RAM来存储它。
|
||||
* 查询吞吐量增加,所以你想要添加更多的 CPU 来处理负载。
|
||||
* 数据集大小增加,所以你想添加更多的磁盘和 RAM 来存储它。
|
||||
* 机器出现故障,其他机器需要接管故障机器的责任。
|
||||
|
||||
所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程称为**再平衡(rebalancing)**。
|
||||
所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程称为 **再平衡(rebalancing)**。
|
||||
|
||||
无论使用哪种分区方案,再平衡通常都要满足一些最低要求:
|
||||
|
||||
* 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共享。
|
||||
* 再平衡发生时,数据库应该继续接受读取和写入。
|
||||
* 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。
|
||||
* 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘 I/O 负载。
|
||||
|
||||
|
||||
### 再平衡策略
|
||||
|
||||
有几种不同的分区分配方法【23】,让我们依次简要讨论一下。
|
||||
有几种不同的分区分配方法【23】,让我们依次简要讨论一下。
|
||||
|
||||
#### 反面教材:hash mod N
|
||||
|
||||
我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key)<b_0$,则将键分配给分区0,如果$b_0 ≤ hash(key) <b_1$,则分配给分区1)
|
||||
我们在前面说过([图 6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果 $0 ≤ hash(key)< b_0$,则将键分配给分区 0,如果 $b_0 ≤ hash(key) < b_1$,则分配给分区 1)
|
||||
|
||||
也许你想知道为什么我们不使用 ***取模(mod)***(许多编程语言中的%运算符)。例如,`hash(key) mod 10`会返回一个介于0和9之间的数字(如果我们将散列写为十进制数,散列模10将是最后一个数字)。如果我们有10个节点,编号为0到9,这似乎是将每个键分配给一个节点的简单方法。
|
||||
也许你想知道为什么我们不使用 ***取模(mod)***(许多编程语言中的 % 运算符)。例如,`hash(key) mod 10` 会返回一个介于 0 和 9 之间的数字(如果我们将散列写为十进制数,散列模 10 将是最后一个数字)。如果我们有 10 个节点,编号为 0 到 9,这似乎是将每个键分配给一个节点的简单方法。
|
||||
|
||||
模N($mod N$)方法的问题是,如果节点数量N发生变化,大多数键将需要从一个节点移动到另一个节点。例如,假设$hash(key)=123456$。如果最初有10个节点,那么这个键一开始放在节点6上(因为$123456\ mod\ 10 = 6$)。当你增长到11个节点时,键需要移动到节点3($123456\ mod\ 11 = 3$),当你增长到12个节点时,需要移动到节点0($123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。
|
||||
模 N($mod N$)方法的问题是,如果节点数量 N 发生变化,大多数键将需要从一个节点移动到另一个节点。例如,假设 $hash(key)=123456$。如果最初有 10 个节点,那么这个键一开始放在节点 6 上(因为 $123456\ mod\ 10 = 6$)。当你增长到 11 个节点时,键需要移动到节点 3($123456\ mod\ 11 = 3$),当你增长到 12 个节点时,需要移动到节点 0($123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。
|
||||
|
||||
我们需要一种只移动必需数据的方法。
|
||||
|
||||
#### 固定数量的分区
|
||||
|
||||
幸运的是,有一个相当简单的解决方案:创建比节点更多的分区,并为每个节点分配多个分区。例如,运行在10个节点的集群上的数据库可能会从一开始就被拆分为1,000个分区,因此大约有100个分区被分配给每个节点。
|
||||
幸运的是,有一个相当简单的解决方案:创建比节点更多的分区,并为每个节点分配多个分区。例如,运行在 10 个节点的集群上的数据库可能会从一开始就被拆分为 1,000 个分区,因此大约有 100 个分区被分配给每个节点。
|
||||
|
||||
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中**窃取**一些分区,直到分区再次公平分配。这个过程如[图6-6](img/fig6-6.png)所示。如果从集群中删除一个节点,则会发生相反的情况。
|
||||
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中 **窃取** 一些分区,直到分区再次公平分配。这个过程如 [图 6-6](img/fig6-6.png) 所示。如果从集群中删除一个节点,则会发生相反的情况。
|
||||
|
||||
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
|
||||
|
||||

|
||||
|
||||
**图6-6 将新节点添加到每个节点具有多个分区的数据库集群。**
|
||||
**图 6-6 将新节点添加到每个节点具有多个分区的数据库集群。**
|
||||
|
||||
原则上,你甚至可以解决集群中的硬件不匹配问题:通过为更强大的节点分配更多的分区,可以强制这些节点承载更多的负载。在Riak 【15】,Elasticsearch 【24】,Couchbase 【10】和Voldemort 【25】中使用了这种再平衡的方法。
|
||||
原则上,你甚至可以解决集群中的硬件不匹配问题:通过为更强大的节点分配更多的分区,可以强制这些节点承载更多的负载。在 Riak 【15】、Elasticsearch 【24】、Couchbase 【10】和 Voldemort 【25】中使用了这种再平衡的方法。
|
||||
|
||||
在这种配置中,分区的数量通常在数据库第一次建立时确定,之后不会改变。虽然原则上可以分割和合并分区(请参阅下一节),但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是你可以拥有的最大节点数量,所以你需要选择足够多的分区以适应未来的增长。但是,每个分区也有管理开销,所以选择太大的数字会适得其反。
|
||||
|
||||
如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
|
||||
如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小 “恰到好处” 的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
|
||||
|
||||
#### 动态分区
|
||||
|
||||
对于使用键范围分区的数据库(请参阅“[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。
|
||||
对于使用键范围分区的数据库(请参阅 “[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。
|
||||
|
||||
出于这个原因,按键的范围进行分区的数据库(如HBase和RethinkDB)会动态创建分区。当分区增长到超过配置的大小时(在HBase上,默认值是10GB),会被分成两个分区,每个分区约占一半的数据【26】。与之相反,如果大量数据被删除并且分区缩小到某个阈值以下,则可以将其与相邻分区合并。此过程与B树顶层发生的过程类似(请参阅“[B树](ch3.md#B树)”)。
|
||||
出于这个原因,按键的范围进行分区的数据库(如 HBase 和 RethinkDB)会动态创建分区。当分区增长到超过配置的大小时(在 HBase 上,默认值是 10GB),会被分成两个分区,每个分区约占一半的数据【26】。与之相反,如果大量数据被删除并且分区缩小到某个阈值以下,则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似(请参阅 “[B 树](ch3.md#B树)”)。
|
||||
|
||||
每个分区分配给一个节点,每个节点可以处理多个分区,就像固定数量的分区一样。大型分区拆分后,可以将其中的一半转移到另一个节点,以平衡负载。在HBase中,分区文件的传输通过HDFS(底层使用的分布式文件系统)来实现【3】。
|
||||
每个分区分配给一个节点,每个节点可以处理多个分区,就像固定数量的分区一样。大型分区拆分后,可以将其中的一半转移到另一个节点,以平衡负载。在 HBase 中,分区文件的传输通过 HDFS(底层使用的分布式文件系统)来实现【3】。
|
||||
|
||||
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据,少量的分区就足够了,所以开销很小;如果有大量的数据,每个分区的大小被限制在一个可配置的最大值【23】。
|
||||
|
||||
需要注意的是,一个空的数据库从一个分区开始,因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小,直到达到第一个分区的分割点,所有写入操作都必须由单个节点处理,而其他节点则处于空闲状态。为了解决这个问题,HBase和MongoDB允许在一个空的数据库上配置一组初始分区(这被称为**预分割**,即pre-splitting)。在键范围分区的情况中,预分割需要提前知道键是如何进行分配的【4,26】。
|
||||
需要注意的是,一个空的数据库从一个分区开始,因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小,直到达到第一个分区的分割点,所有写入操作都必须由单个节点处理,而其他节点则处于空闲状态。为了解决这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为 **预分割**,即 pre-splitting)。在键范围分区的情况中,预分割需要提前知道键是如何进行分配的【4,26】。
|
||||
|
||||
动态分区不仅适用于数据的范围分区,而且也适用于散列分区。从版本2.4开始,MongoDB同时支持范围和散列分区,并且都支持动态分割分区。
|
||||
动态分区不仅适用于数据的范围分区,而且也适用于散列分区。从版本 2.4 开始,MongoDB 同时支持范围和散列分区,并且都支持动态分割分区。
|
||||
|
||||
#### 按节点比例分区
|
||||
|
||||
通过动态分区,分区的数量与数据集的大小成正比,因为拆分和合并过程将每个分区的大小保持在固定的最小值和最大值之间。另一方面,对于固定数量的分区,每个分区的大小与数据集的大小成正比。在这两种情况下,分区的数量都与节点的数量无关。
|
||||
|
||||
Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比——换句话说,每个节点具有固定数量的分区【23,27,28】。在这种情况下,每个分区的大小与数据集大小成比例地增长,而节点数量保持不变,但是当增加节点数时,分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储,因此这种方法也使每个分区的大小较为稳定。
|
||||
Cassandra 和 Ketama 使用的第三种方法是使分区数与节点数成正比 —— 换句话说,每个节点具有固定数量的分区【23,27,28】。在这种情况下,每个分区的大小与数据集大小成比例地增长,而节点数量保持不变,但是当增加节点数时,分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储,因此这种方法也使每个分区的大小较为稳定。
|
||||
|
||||
当一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,然后占有这些拆分分区中每个分区的一半,同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割,但是平均在更大数量的分区上时(在Cassandra中,默认情况下,每个节点有256个分区),新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再平衡的算法来避免不公平的分割【29】。
|
||||
当一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,然后占有这些拆分分区中每个分区的一半,同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割,但是平均在更大数量的分区上时(在 Cassandra 中,默认情况下,每个节点有 256 个分区),新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0 引入了另一种再平衡的算法来避免不公平的分割【29】。
|
||||
|
||||
随机选择分区边界要求使用基于散列的分区(可以从散列函数产生的数字范围中挑选边界)。实际上,这种方法最符合一致性哈希的原始定义【7】(请参阅“[一致性哈希](#一致性哈希)”)。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
|
||||
随机选择分区边界要求使用基于散列的分区(可以从散列函数产生的数字范围中挑选边界)。实际上,这种方法最符合一致性哈希的原始定义【7】(请参阅 “[一致性哈希](#一致性哈希)”)。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
|
||||
|
||||
### 运维:手动还是自动再平衡
|
||||
|
||||
关于再平衡有一个重要问题:自动还是手动进行?
|
||||
|
||||
在全自动重新平衡(系统自动决定何时将分区从一个节点移动到另一个节点,无须人工干预)和完全手动(分区指派给节点由管理员明确配置,仅在管理员明确重新配置时才会更改)之间有一个权衡。例如,Couchbase,Riak和Voldemort会自动生成建议的分区分配,但需要管理员提交才能生效。
|
||||
在全自动重新平衡(系统自动决定何时将分区从一个节点移动到另一个节点,无须人工干预)和完全手动(分区指派给节点由管理员明确配置,仅在管理员明确重新配置时才会更改)之间有一个权衡。例如,Couchbase、Riak 和 Voldemort 会自动生成建议的分区分配,但需要管理员提交才能生效。
|
||||
|
||||
全自动重新平衡可以很方便,因为正常维护的操作工作较少。但是,这可能是不可预测的。再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
|
||||
|
||||
@ -252,45 +253,45 @@ Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比
|
||||
|
||||
## 请求路由
|
||||
|
||||
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题:当客户想要发出请求时,如何知道要连接哪个节点?随着分区重新平衡,分区对节点的分配也发生变化。为了回答这个问题,需要有人知晓这些变化:如果我想读或写键“foo”,需要连接哪个IP地址和端口号?
|
||||
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题:当客户想要发出请求时,如何知道要连接哪个节点?随着分区重新平衡,分区对节点的分配也发生变化。为了回答这个问题,需要有人知晓这些变化:如果我想读或写键 “foo”,需要连接哪个 IP 地址和端口号?
|
||||
|
||||
这个问题可以概括为 **服务发现(service discovery)** ,它不仅限于数据库。任何可通过网络访问的软件都有这个问题,特别是如果它的目标是高可用性(在多台机器上运行冗余配置)。许多公司已经编写了自己的内部服务发现工具,其中许多已经作为开源发布【30】。
|
||||
这个问题可以概括为 **服务发现(service discovery)** ,它不仅限于数据库。任何可通过网络访问的软件都有这个问题,特别是如果它的目标是高可用性(在多台机器上运行冗余配置)。许多公司已经编写了自己的内部服务发现工具,其中许多已经作为开源发布【30】。
|
||||
|
||||
概括来说,这个问题有几种不同的方案(如图6-7所示):
|
||||
概括来说,这个问题有几种不同的方案(如图 6-7 所示):
|
||||
|
||||
1. 允许客户联系任何节点(例如,通过**循环策略的负载均衡**,即Round-Robin Load Balancer)。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将请求转发到适当的节点,接收回复并传递给客户端。
|
||||
1. 允许客户联系任何节点(例如,通过 **循环策略的负载均衡**,即 Round-Robin Load Balancer)。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将请求转发到适当的节点,接收回复并传递给客户端。
|
||||
2. 首先将所有来自客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地转发。此路由层本身不处理任何请求;它仅负责分区的负载均衡。
|
||||
3. 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,而不需要任何中介。
|
||||
|
||||
以上所有情况中的关键问题是:作出路由决策的组件(可能是节点之一,还是路由层或客户端)如何了解分区-节点之间的分配关系变化?
|
||||
以上所有情况中的关键问题是:作出路由决策的组件(可能是节点之一,还是路由层或客户端)如何了解分区 - 节点之间的分配关系变化?
|
||||
|
||||

|
||||
|
||||
**图6-7 将请求路由到正确节点的三种不同方式。**
|
||||
**图 6-7 将请求路由到正确节点的三种不同方式。**
|
||||
|
||||
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见[第九章](ch9.md))。
|
||||
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见 [第九章](ch9.md))。
|
||||
|
||||
许多分布式数据系统都依赖于一个独立的协调服务,比如ZooKeeper来跟踪集群元数据,如[图6-8](img/fig6-8.png)所示。 每个节点在ZooKeeper中注册自己,ZooKeeper维护分区到节点的可靠映射。 其他参与者(如路由层或分区感知客户端)可以在ZooKeeper中订阅此信息。 只要分区分配发生了改变,或者集群中添加或删除了一个节点,ZooKeeper就会通知路由层使路由信息保持最新状态。
|
||||
许多分布式数据系统都依赖于一个独立的协调服务,比如 ZooKeeper 来跟踪集群元数据,如 [图 6-8](img/fig6-8.png) 所示。 每个节点在 ZooKeeper 中注册自己,ZooKeeper 维护分区到节点的可靠映射。 其他参与者(如路由层或分区感知客户端)可以在 ZooKeeper 中订阅此信息。 只要分区分配发生了改变,或者集群中添加或删除了一个节点,ZooKeeper 就会通知路由层使路由信息保持最新状态。
|
||||
|
||||

|
||||
|
||||
**图6-8 使用ZooKeeper跟踪分区分配给节点。**
|
||||
**图 6-8 使用 ZooKeeper 跟踪分区分配给节点。**
|
||||
|
||||
例如,LinkedIn的Espresso使用Helix 【31】进行集群管理(依靠ZooKeeper),实现了如[图6-8](img/fig6-8.png)所示的路由层。 HBase,SolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。 MongoDB具有类似的体系结构,但它依赖于自己的**配置服务器(config server)** 实现和mongos守护进程作为路由层。
|
||||
例如,LinkedIn的Espresso使用Helix 【31】进行集群管理(依靠ZooKeeper),实现了如[图6-8](img/fig6-8.png)所示的路由层。 HBase、SolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。MongoDB具有类似的体系结构,但它依赖于自己的**配置服务器(config server)** 实现和mongos守护进程作为路由层。
|
||||
|
||||
Cassandra和Riak采取不同的方法:他们在节点之间使用**流言协议(gossip protocol)** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图6-7](img/fig6-7.png)中的方法1)。这个模型在数据库节点中增加了更多的复杂性,但是避免了对像ZooKeeper这样的外部协调服务的依赖。
|
||||
Cassandra 和 Riak 采取不同的方法:他们在节点之间使用 **流言协议(gossip protocol)** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图 6-7](img/fig6-7.png) 中的方法 1)。这个模型在数据库节点中增加了更多的复杂性,但是避免了对像 ZooKeeper 这样的外部协调服务的依赖。
|
||||
|
||||
Couchbase不会自动重新平衡,这简化了设计。通常情况下,它配置了一个名为moxi的路由层,它会从集群节点了解路由变化【32】。
|
||||
Couchbase 不会自动重新平衡,这简化了设计。通常情况下,它配置了一个名为 moxi 的路由层,它会从集群节点了解路由变化【32】。
|
||||
|
||||
当使用路由层或向随机节点发送请求时,客户端仍然需要找到要连接的IP地址。这些地址并不像分区的节点分布变化的那么快,所以使用DNS通常就足够了。
|
||||
当使用路由层或向随机节点发送请求时,客户端仍然需要找到要连接的 IP 地址。这些地址并不像分区的节点分布变化的那么快,所以使用 DNS 通常就足够了。
|
||||
|
||||
### 执行并行查询
|
||||
|
||||
到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的次级索引场景下的分散/聚集查询)。这也是大多数NoSQL分布式数据存储所支持的访问层级。
|
||||
到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的次级索引场景下的分散 / 聚集查询)。这也是大多数 NoSQL 分布式数据存储所支持的访问层级。
|
||||
|
||||
然而,通常用于分析的**大规模并行处理(MPP, Massively parallel processing)** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
|
||||
然而,通常用于分析的 **大规模并行处理(MPP, Massively parallel processing)** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP 查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
|
||||
|
||||
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在[第十章](ch10.md)讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述,请参阅参考文献【1,33】。
|
||||
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在 [第十章](ch10.md) 讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述,请参阅参考文献【1,33】。
|
||||
|
||||
## 本章小结
|
||||
|
||||
@ -314,7 +315,7 @@ Couchbase不会自动重新平衡,这简化了设计。通常情况下,它
|
||||
|
||||
我们还讨论了分区和次级索引之间的相互作用。次级索引也需要分区,有两种方法:
|
||||
|
||||
* 基于文档分区(本地索引),其中次级索引存储在与主键和值相同的分区中。这意味着只有一个分区需要在写入时更新,但是读取次级索引需要在所有分区之间进行分散/收集。
|
||||
* 基于文档分区(本地索引),其中次级索引存储在与主键和值相同的分区中。这意味着只有一个分区需要在写入时更新,但是读取次级索引需要在所有分区之间进行分散 / 收集。
|
||||
* 基于关键词分区(全局索引),其中次级索引存在不同的分区中。次级索引中的条目可以包括来自主键的所有分区的记录。当文档写入时,需要更新多个分区中的次级索引;但是可以从单个分区中进行读取。
|
||||
|
||||
最后,我们讨论了将查询路由到适当的分区的技术,从简单的分区负载平衡到复杂的并行查询执行引擎。
|
||||
|
314
zh-tw/ch4.md
314
zh-tw/ch4.md
@ -4,25 +4,25 @@
|
||||
|
||||
> 唯變所適
|
||||
>
|
||||
> —— 以弗所的赫拉克利特,為柏拉圖所引(公元前360年)
|
||||
> —— 以弗所的赫拉克利特,為柏拉圖所引(公元前 360 年)
|
||||
>
|
||||
|
||||
-------------------
|
||||
|
||||
[TOC]
|
||||
|
||||
應用程式不可避免地隨時間而變化。新產品的推出,對需求的深入理解,或者商業環境的變化,總會伴隨著**功能(feature)** 的增增改改。[第一章](ch1.md)介紹了**可演化性(evolvability)** 的概念:應該盡力構建能靈活適應變化的系統(請參閱“[可演化性:擁抱變化](ch1.md#可演化性:擁抱變化)”)。
|
||||
應用程式不可避免地隨時間而變化。新產品的推出,對需求的深入理解,或者商業環境的變化,總會伴隨著 **功能(feature)** 的增增改改。[第一章](ch1.md) 介紹了 **可演化性(evolvability)** 的概念:應該盡力構建能靈活適應變化的系統(請參閱 “[可演化性:擁抱變化](ch1.md#可演化性:擁抱變化)”)。
|
||||
|
||||
在大多數情況下,修改應用程式的功能也意味著需要更改其儲存的資料:可能需要使用新的欄位或記錄型別,或者以新方式展示現有資料。
|
||||
|
||||
我們在[第二章](ch2.md)討論的資料模型有不同的方法來應對這種變化。關係資料庫通常假定資料庫中的所有資料都遵循一個模式:儘管可以更改該模式(透過模式遷移,即`ALTER`語句),但是在任何時間點都有且僅有一個正確的模式。相比之下,**讀時模式**(schema-on-read,或**無模式**,即schemaless)資料庫不會強制一個模式,因此資料庫可以包含在不同時間寫入的新老資料格式的混合(請參閱 “[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)” )。
|
||||
我們在 [第二章](ch2.md) 討論的資料模型有不同的方法來應對這種變化。關係資料庫通常假定資料庫中的所有資料都遵循一個模式:儘管可以更改該模式(透過模式遷移,即 `ALTER` 語句),但是在任何時間點都有且僅有一個正確的模式。相比之下,**讀時模式**(schema-on-read,或 **無模式**,即 schemaless)資料庫不會強制一個模式,因此資料庫可以包含在不同時間寫入的新老資料格式的混合(請參閱 “[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)” )。
|
||||
|
||||
當資料**格式(format)** 或**模式(schema)** 發生變化時,通常需要對應用程式程式碼進行相應的更改(例如,為記錄新增新欄位,然後修改程式開始讀寫該欄位)。但在大型應用程式中,程式碼變更通常不會立即完成:
|
||||
當資料 **格式(format)** 或 **模式(schema)** 發生變化時,通常需要對應用程式程式碼進行相應的更改(例如,為記錄新增新欄位,然後修改程式開始讀寫該欄位)。但在大型應用程式中,程式碼變更通常不會立即完成:
|
||||
|
||||
* 對於 **服務端(server-side)** 應用程式,可能需要執行 **滾動升級 (rolling upgrade)** (也稱為 **階段釋出(staged rollout)** ),一次將新版本部署到少數幾個節點,檢查新版本是否執行正常,然後逐漸部完所有的節點。這樣無需中斷服務即可部署新版本,為頻繁釋出提供了可行性,從而帶來更好的可演化性。
|
||||
* 對於 **客戶端(client-side)** 應用程式,升不升級就要看使用者的心情了。使用者可能相當長一段時間裡都不會去升級軟體。
|
||||
|
||||
這意味著,新舊版本的程式碼,以及新舊資料格式可能會在系統中同時共處。系統想要繼續順利執行,就需要保持**雙向相容性**:
|
||||
這意味著,新舊版本的程式碼,以及新舊資料格式可能會在系統中同時共處。系統想要繼續順利執行,就需要保持 **雙向相容性**:
|
||||
|
||||
* 向後相容 (backward compatibility)
|
||||
|
||||
@ -36,63 +36,63 @@
|
||||
|
||||
向前相容性可能會更棘手,因為舊版的程式需要忽略新版資料格式中新增的部分。
|
||||
|
||||
本章中將介紹幾種編碼資料的格式,包括 JSON,XML,Protocol Buffers,Thrift和Avro。尤其將關注這些格式如何應對模式變化,以及它們如何對新舊程式碼資料需要共存的系統提供支援。然後將討論如何使用這些格式進行資料儲存和通訊:在Web服務中,**表述性狀態傳遞(REST)** 和**遠端過程呼叫(RPC)**,以及**訊息傳遞系統**(如Actor和訊息佇列)。
|
||||
本章中將介紹幾種編碼資料的格式,包括 JSON、XML、Protocol Buffers、Thrift 和 Avro。尤其將關注這些格式如何應對模式變化,以及它們如何對新舊程式碼資料需要共存的系統提供支援。然後將討論如何使用這些格式進行資料儲存和通訊:在 Web 服務中,**表述性狀態傳遞(REST)** 和 **遠端過程呼叫(RPC)**,以及 **訊息傳遞系統**(如 Actor 和訊息佇列)。
|
||||
|
||||
## 編碼資料的格式
|
||||
|
||||
程式通常(至少)使用兩種形式的資料:
|
||||
|
||||
1. 在記憶體中,資料儲存在物件,結構體,列表,陣列,散列表,樹等中。 這些資料結構針對CPU的高效訪問和操作進行了最佳化(通常使用指標)。
|
||||
2. 如果要將資料寫入檔案,或透過網路傳送,則必須將其 **編碼(encode)** 為某種自包含的位元組序列(例如,JSON文件)。 由於每個程序都有自己獨立的地址空間,一個程序中的指標對任何其他程序都沒有意義,所以這個位元組序列表示會與通常在記憶體中使用的資料結構完全不同[^i]。
|
||||
1. 在記憶體中,資料儲存在物件、結構體、列表、陣列、散列表、樹等中。 這些資料結構針對 CPU 的高效訪問和操作進行了最佳化(通常使用指標)。
|
||||
2. 如果要將資料寫入檔案,或透過網路傳送,則必須將其 **編碼(encode)** 為某種自包含的位元組序列(例如,JSON 文件)。 由於每個程序都有自己獨立的地址空間,一個程序中的指標對任何其他程序都沒有意義,所以這個位元組序列表示會與通常在記憶體中使用的資料結構完全不同 [^i]。
|
||||
|
||||
[^i]: 除一些特殊情況外,例如某些記憶體對映檔案或直接在壓縮資料上操作(如“[列壓縮](ch4.md#列壓縮)”中所述)。
|
||||
[^i]: 除一些特殊情況外,例如某些記憶體對映檔案或直接在壓縮資料上操作(如 “[列壓縮](ch4.md#列壓縮)” 中所述)。
|
||||
|
||||
所以,需要在兩種表示之間進行某種型別的翻譯。 從記憶體中表示到位元組序列的轉換稱為 **編碼(Encoding)** (也稱為**序列化(serialization)** 或**編組(marshalling)**),反過來稱為**解碼(Decoding)**[^ii](**解析(Parsing)**,**反序列化(deserialization)**,**反編組( unmarshalling)**)[^譯i]。
|
||||
所以,需要在兩種表示之間進行某種型別的翻譯。 從記憶體中表示到位元組序列的轉換稱為 **編碼(Encoding)** (也稱為 **序列化(serialization)** 或 **編組(marshalling)**),反過來稱為 **解碼(Decoding)**[^ii](**解析(Parsing)**,**反序列化(deserialization)**,**反編組 (unmarshalling)**)[^譯i]。
|
||||
|
||||
[^ii]: 請注意,**編碼(encode)** 與 **加密(encryption)** 無關。 本書不討論加密。
|
||||
[^譯i]: Marshal與Serialization的區別:Marshal不僅傳輸物件的狀態,而且會一起傳輸物件的方法(相關程式碼)。
|
||||
[^譯i]: Marshal 與 Serialization 的區別:Marshal 不僅傳輸物件的狀態,而且會一起傳輸物件的方法(相關程式碼)。
|
||||
|
||||
> #### 術語衝突
|
||||
> 不幸的是,在[第七章](ch7.md): **事務(Transaction)** 的上下文裡,**序列化(Serialization)** 這個術語也出現了,而且具有完全不同的含義。儘管序列化可能是更常見的術語,為了避免術語過載,本書中堅持使用 **編碼(Encoding)** 表達此含義。
|
||||
> 不幸的是,在 [第七章](ch7.md): **事務(Transaction)** 的上下文裡,**序列化(Serialization)** 這個術語也出現了,而且具有完全不同的含義。儘管序列化可能是更常見的術語,為了避免術語過載,本書中堅持使用 **編碼(Encoding)** 表達此含義。
|
||||
|
||||
這是一個常見的問題,因而有許多庫和編碼格式可供選擇。 首先讓我們概覽一下。
|
||||
|
||||
### 語言特定的格式
|
||||
|
||||
許多程式語言都內建了將記憶體物件編碼為位元組序列的支援。例如,Java有`java.io.Serializable` 【1】,Ruby有`Marshal`【2】,Python有`pickle`【3】等等。許多第三方庫也存在,例如`Kryo for Java` 【4】。
|
||||
許多程式語言都內建了將記憶體物件編碼為位元組序列的支援。例如,Java 有 `java.io.Serializable` 【1】,Ruby 有 `Marshal`【2】,Python 有 `pickle`【3】等等。許多第三方庫也存在,例如 `Kryo for Java` 【4】。
|
||||
|
||||
這些編碼庫非常方便,可以用很少的額外程式碼實現記憶體物件的儲存與恢復。但是它們也有一些深層次的問題:
|
||||
|
||||
* 這類編碼通常與特定的程式語言深度繫結,其他語言很難讀取這種資料。如果以這類編碼儲存或傳輸資料,那你就和這門語言綁死在一起了。並且很難將系統與其他組織的系統(可能用的是不同的語言)進行整合。
|
||||
* 為了恢復相同物件型別的資料,解碼過程需要**例項化任意類**的能力,這通常是安全問題的一個來源【5】:如果攻擊者可以讓應用程式解碼任意的位元組序列,他們就能例項化任意的類,這會允許他們做可怕的事情,如遠端執行任意程式碼【6,7】。
|
||||
* 為了恢復相同物件型別的資料,解碼過程需要 **例項化任意類** 的能力,這通常是安全問題的一個來源【5】:如果攻擊者可以讓應用程式解碼任意的位元組序列,他們就能例項化任意的類,這會允許他們做可怕的事情,如遠端執行任意程式碼【6,7】。
|
||||
* 在這些庫中,資料版本控制通常是事後才考慮的。因為它們旨在快速簡便地對資料進行編碼,所以往往忽略了前向後向相容性帶來的麻煩問題。
|
||||
* 效率(編碼或解碼所花費的CPU時間,以及編碼結構的大小)往往也是事後才考慮的。 例如,Java的內建序列化由於其糟糕的效能和臃腫的編碼而臭名昭著【8】。
|
||||
* 效率(編碼或解碼所花費的 CPU 時間,以及編碼結構的大小)往往也是事後才考慮的。 例如,Java 的內建序列化由於其糟糕的效能和臃腫的編碼而臭名昭著【8】。
|
||||
|
||||
因此,除非臨時使用,採用語言內建編碼通常是一個壞主意。
|
||||
|
||||
### JSON、XML和二進位制變體
|
||||
|
||||
當我們談到可以被多種程式語言讀寫的標準編碼時,JSON和XML是最顯眼的角逐者。它們廣為人知,廣受支援,也“廣受憎惡”。 XML經常收到批評:過於冗長與且過份複雜【9】。 JSON的流行則主要源於(透過成為JavaScript的一個子集)Web瀏覽器的內建支援,以及相對於XML的簡單性。 CSV是另一種流行的與語言無關的格式,儘管其功能相對較弱。
|
||||
當我們談到可以被多種程式語言讀寫的標準編碼時,JSON 和 XML 是最顯眼的角逐者。它們廣為人知,廣受支援,也 “廣受憎惡”。 XML 經常收到批評:過於冗長與且過份複雜【9】。 JSON 的流行則主要源於(透過成為 JavaScript 的一個子集)Web 瀏覽器的內建支援,以及相對於 XML 的簡單性。 CSV 是另一種流行的與語言無關的格式,儘管其功能相對較弱。
|
||||
|
||||
JSON,XML和CSV屬於文字格式,因此具有人類可讀性(儘管它們的語法是一個熱門爭議話題)。除了表面的語法問題之外,它們也存在一些微妙的問題:
|
||||
JSON,XML 和 CSV 屬於文字格式,因此具有人類可讀性(儘管它們的語法是一個熱門爭議話題)。除了表面的語法問題之外,它們也存在一些微妙的問題:
|
||||
|
||||
* **數值(numbers)** 的編碼多有歧義之處。XML和CSV不能區分數字和字串(除非引用一個外部模式)。 JSON雖然區分字串與數值,但不區分整數和浮點數,而且不能指定精度。
|
||||
* 當處理更大的數值時,這個問題顯得尤為嚴重。例如大於$2^{53}$的整數無法使用IEEE 754雙精度浮點數精確表示,因此在使用浮點數(例如JavaScript)的語言進行分析時,這些數字會變得不準確。 Twitter有一個關於大於$2^{53}$的數字的例子,它使用64位整數來標識每條推文。 Twitter API返回的JSON包含了兩種推特ID,一種是JSON數值,另一種是十進位制字串,以避免JavaScript程式無法正確解析數字的問題【10】。
|
||||
* JSON和XML對Unicode字串(即人類可讀的文字)有很好的支援,但是它們不支援二進位制資料(即不帶 **字元編碼(character encoding)** 的位元組序列)。二進位制串是很有用的功能,人們透過使用Base64將二進位制資料編碼為文字來繞過此限制。其特有的模式標識著這個值應當被解釋為Base64編碼的二進位制資料。這種方案雖然管用,但比較Hacky,並且會增加三分之一的資料大小。
|
||||
* XML 【11】和 JSON 【12】都有可選的模式支援。這些模式語言相當強大,所以學習和實現起來都相當複雜。 XML模式的使用相當普遍,但許多基於JSON的工具才不會去折騰模式。對資料的正確解讀(例如區分數值與二進位制串)取決於模式中的資訊,因此不使用XML/JSON模式的應用程式可能需要對相應的編碼/解碼邏輯進行硬編碼。
|
||||
* CSV沒有任何模式,因此每行和每列的含義完全由應用程式自行定義。如果應用程式變更添加了新的行或列,那麼這種變更必須透過手工處理。 CSV也是一個相當模糊的格式(如果一個值包含逗號或換行符,會發生什麼?)。儘管其轉義規則已經被正式指定【13】,但並不是所有的解析器都正確的實現了標準。
|
||||
* **數值(numbers)** 的編碼多有歧義之處。XML 和 CSV 不能區分數字和字串(除非引用一個外部模式)。 JSON 雖然區分字串與數值,但不區分整數和浮點數,而且不能指定精度。
|
||||
* 當處理更大的數值時,這個問題顯得尤為嚴重。例如大於 $2^{53}$ 的整數無法使用 IEEE 754 雙精度浮點數精確表示,因此在使用浮點數(例如 JavaScript)的語言進行分析時,這些數字會變得不準確。 Twitter 有一個關於大於 $2^{53}$ 的數字的例子,它使用 64 位整數來標識每條推文。 Twitter API 返回的 JSON 包含了兩種推特 ID,一種是 JSON 數值,另一種是十進位制字串,以避免 JavaScript 程式無法正確解析數字的問題【10】。
|
||||
* JSON 和 XML 對 Unicode 字串(即人類可讀的文字)有很好的支援,但是它們不支援二進位制資料(即不帶 **字元編碼 (character encoding)** 的位元組序列)。二進位制串是很有用的功能,人們透過使用 Base64 將二進位制資料編碼為文字來繞過此限制。其特有的模式標識著這個值應當被解釋為 Base64 編碼的二進位制資料。這種方案雖然管用,但比較 Hacky,並且會增加三分之一的資料大小。
|
||||
* XML 【11】和 JSON 【12】都有可選的模式支援。這些模式語言相當強大,所以學習和實現起來都相當複雜。 XML 模式的使用相當普遍,但許多基於 JSON 的工具才不會去折騰模式。對資料的正確解讀(例如區分數值與二進位制串)取決於模式中的資訊,因此不使用 XML/JSON 模式的應用程式可能需要對相應的編碼 / 解碼邏輯進行硬編碼。
|
||||
* CSV 沒有任何模式,因此每行和每列的含義完全由應用程式自行定義。如果應用程式變更添加了新的行或列,那麼這種變更必須透過手工處理。 CSV 也是一個相當模糊的格式(如果一個值包含逗號或換行符,會發生什麼?)。儘管其轉義規則已經被正式指定【13】,但並不是所有的解析器都正確的實現了標準。
|
||||
|
||||
儘管存在這些缺陷,但JSON,XML和CSV對很多需求來說已經足夠好了。它們很可能會繼續流行下去,特別是作為資料交換格式來說(即將資料從一個組織傳送到另一個組織)。在這種情況下,只要人們對格式是什麼意見一致,格式有多美觀或者效率有多高效就無所謂了。讓不同的組織就這些東西達成一致的難度超過了絕大多數問題。
|
||||
儘管存在這些缺陷,但 JSON、XML 和 CSV 對很多需求來說已經足夠好了。它們很可能會繼續流行下去,特別是作為資料交換格式來說(即將資料從一個組織傳送到另一個組織)。在這種情況下,只要人們對格式是什麼意見一致,格式有多美觀或者效率有多高效就無所謂了。讓不同的組織就這些東西達成一致的難度超過了絕大多數問題。
|
||||
|
||||
#### 二進位制編碼
|
||||
|
||||
對於僅在組織內部使用的資料,使用最小公約數式的編碼格式壓力較小。例如,可以選擇更緊湊或更快的解析格式。雖然對小資料集來說,收益可以忽略不計;但一旦達到TB級別,資料格式的選型就會產生巨大的影響。
|
||||
對於僅在組織內部使用的資料,使用最小公約數式的編碼格式壓力較小。例如,可以選擇更緊湊或更快的解析格式。雖然對小資料集來說,收益可以忽略不計;但一旦達到 TB 級別,資料格式的選型就會產生巨大的影響。
|
||||
|
||||
JSON比XML簡潔,但與二進位制格式相比還是太佔空間。這一事實導致大量二進位制編碼版本JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等) 和 XML(例如WBXML和Fast Infoset)的出現。這些格式已經在各種各樣的領域中採用,但是沒有一個能像文字版JSON和XML那樣被廣泛採用。
|
||||
JSON 比 XML 簡潔,但與二進位制格式相比還是太佔空間。這一事實導致大量二進位制編碼版本 JSON(MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML(例如 WBXML 和 Fast Infoset)的出現。這些格式已經在各種各樣的領域中採用,但是沒有一個能像文字版 JSON 和 XML 那樣被廣泛採用。
|
||||
|
||||
這些格式中的一些擴充套件了一組資料型別(例如,區分整數和浮點數,或者增加對二進位制字串的支援),另一方面,它們沒有改變JSON / XML的資料模型。特別是由於它們沒有規定模式,所以它們需要在編碼資料中包含所有的物件欄位名稱。也就是說,在[例4-1]()中的JSON文件的二進位制編碼中,需要在某處包含字串`userName`,`favoriteNumber`和`interests`。
|
||||
這些格式中的一些擴充套件了一組資料型別(例如,區分整數和浮點數,或者增加對二進位制字串的支援),另一方面,它們沒有改變 JSON / XML 的資料模型。特別是由於它們沒有規定模式,所以它們需要在編碼資料中包含所有的物件欄位名稱。也就是說,在 [例 4-1]() 中的 JSON 文件的二進位制編碼中,需要在某處包含字串 `userName`,`favoriteNumber` 和 `interests`。
|
||||
|
||||
**例4-1 本章中用於展示二進位制編碼的示例記錄**
|
||||
**例 4-1 本章中用於展示二進位制編碼的示例記錄**
|
||||
|
||||
```json
|
||||
{
|
||||
@ -102,26 +102,26 @@ JSON比XML簡潔,但與二進位制格式相比還是太佔空間。這一事
|
||||
}
|
||||
```
|
||||
|
||||
我們來看一個MessagePack的例子,它是一個JSON的二進位制編碼。圖4-1顯示瞭如果使用MessagePack 【14】對[例4-1]()中的JSON文件進行編碼,則得到的位元組序列。前幾個位元組如下:
|
||||
我們來看一個 MessagePack 的例子,它是一個 JSON 的二進位制編碼。圖 4-1 顯示瞭如果使用 MessagePack 【14】對 [例 4-1]() 中的 JSON 文件進行編碼,則得到的位元組序列。前幾個位元組如下:
|
||||
|
||||
1. 第一個位元組`0x83`表示接下來是**3**個欄位(低四位= `0x03`)的**物件 object**(高四位= `0x80`)。 (如果想知道如果一個物件有15個以上的欄位會發生什麼情況,欄位的數量塞不進4個bit裡,那麼它會用另一個不同的型別識別符號,欄位的數量被編碼兩個或四個位元組)。
|
||||
2. 第二個位元組`0xa8`表示接下來是**8**位元組長(低四位=0x08)的字串(高四位= 0x0a)。
|
||||
3. 接下來八個位元組是ASCII字串形式的欄位名稱`userName`。由於之前已經指明長度,不需要任何標記來標識字串的結束位置(或者任何轉義)。
|
||||
4. 接下來的七個位元組對字首為`0xa6`的六個字母的字串值`Martin`進行編碼,依此類推。
|
||||
1. 第一個位元組 `0x83` 表示接下來是 **3** 個欄位(低四位 = `0x03`)的 **物件 object**(高四位 = `0x80`)。 (如果想知道如果一個物件有 15 個以上的欄位會發生什麼情況,欄位的數量塞不進 4 個 bit 裡,那麼它會用另一個不同的型別識別符號,欄位的數量被編碼兩個或四個位元組)。
|
||||
2. 第二個位元組 `0xa8` 表示接下來是 **8** 位元組長(低四位 = `0x08`)的字串(高四位 = `0x0a`)。
|
||||
3. 接下來八個位元組是 ASCII 字串形式的欄位名稱 `userName`。由於之前已經指明長度,不需要任何標記來標識字串的結束位置(或者任何轉義)。
|
||||
4. 接下來的七個位元組對字首為 `0xa6` 的六個字母的字串值 `Martin` 進行編碼,依此類推。
|
||||
|
||||
二進位制編碼長度為66個位元組,僅略小於文字JSON編碼所取的81個位元組(刪除了空白)。所有的JSON的二進位制編碼在這方面是相似的。空間節省了一丁點(以及解析加速)是否能彌補可讀性的損失,誰也說不準。
|
||||
二進位制編碼長度為 66 個位元組,僅略小於文字 JSON 編碼所取的 81 個位元組(刪除了空白)。所有的 JSON 的二進位制編碼在這方面是相似的。空間節省了一丁點(以及解析加速)是否能彌補可讀性的損失,誰也說不準。
|
||||
|
||||
在下面的章節中,能達到比這好得多的結果,只用32個位元組對相同的記錄進行編碼。
|
||||
在下面的章節中,能達到比這好得多的結果,只用 32 個位元組對相同的記錄進行編碼。
|
||||
|
||||
|
||||

|
||||
|
||||
**圖4-1 使用MessagePack編碼的記錄(例4-1)**
|
||||
**圖 4-1 使用 MessagePack 編碼的記錄(例 4-1)**
|
||||
|
||||
### Thrift與Protocol Buffers
|
||||
|
||||
Apache Thrift 【15】和Protocol Buffers(protobuf)【16】是基於相同原理的二進位制編碼庫。 Protocol Buffers最初是在Google開發的,Thrift最初是在Facebook開發的,並且在2007~2008年都是開源的【17】。
|
||||
Thrift和Protocol Buffers都需要一個模式來編碼任何資料。要在Thrift的[例4-1]()中對資料進行編碼,可以使用Thrift **介面定義語言(IDL)** 來描述模式,如下所示:
|
||||
Apache Thrift 【15】和 Protocol Buffers(protobuf)【16】是基於相同原理的二進位制編碼庫。 Protocol Buffers 最初是在 Google 開發的,Thrift 最初是在 Facebook 開發的,並且在 2007~2008 年都是開源的【17】。
|
||||
Thrift 和 Protocol Buffers 都需要一個模式來編碼任何資料。要在 Thrift 的 [例 4-1]() 中對資料進行編碼,可以使用 Thrift **介面定義語言(IDL)** 來描述模式,如下所示:
|
||||
|
||||
```c
|
||||
struct Person {
|
||||
@ -131,7 +131,7 @@ struct Person {
|
||||
}
|
||||
```
|
||||
|
||||
Protocol Buffers的等效模式定義看起來非常相似:
|
||||
Protocol Buffers 的等效模式定義看起來非常相似:
|
||||
|
||||
```protobuf
|
||||
message Person {
|
||||
@ -141,38 +141,38 @@ message Person {
|
||||
}
|
||||
```
|
||||
|
||||
Thrift和Protocol Buffers每一個都帶有一個程式碼生成工具,它採用了類似於這裡所示的模式定義,並且生成了以各種程式語言實現模式的類【18】。你的應用程式程式碼可以呼叫此生成的程式碼來對模式的記錄進行編碼或解碼。
|
||||
用這個模式編碼的資料是什麼樣的?令人困惑的是,Thrift有兩種不同的二進位制編碼格式[^iii],分別稱為BinaryProtocol和CompactProtocol。先來看看BinaryProtocol。使用這種格式的編碼來編碼[例4-1]()中的訊息只需要59個位元組,如[圖4-2](../img/fig4-2.png)所示【19】。
|
||||
Thrift 和 Protocol Buffers 每一個都帶有一個程式碼生成工具,它採用了類似於這裡所示的模式定義,並且生成了以各種程式語言實現模式的類【18】。你的應用程式程式碼可以呼叫此生成的程式碼來對模式的記錄進行編碼或解碼。
|
||||
用這個模式編碼的資料是什麼樣的?令人困惑的是,Thrift 有兩種不同的二進位制編碼格式 [^iii],分別稱為 BinaryProtocol 和 CompactProtocol。先來看看 BinaryProtocol。使用這種格式的編碼來編碼 [例 4-1]() 中的訊息只需要 59 個位元組,如 [圖 4-2](../img/fig4-2.png) 所示【19】。
|
||||
|
||||

|
||||
|
||||
**圖4-2 使用Thrift二進位制協議編碼的記錄**
|
||||
**圖 4-2 使用 Thrift 二進位制協議編碼的記錄**
|
||||
|
||||
[^iii]: 實際上,Thrift有三種二進位制協議:BinaryProtocol、CompactProtocol和DenseProtocol,儘管DenseProtocol只支援C ++實現,所以不算作跨語言【18】。 除此之外,它還有兩種不同的基於JSON的編碼格式【19】。 真逗!
|
||||
[^iii]: 實際上,Thrift 有三種二進位制協議:BinaryProtocol、CompactProtocol 和 DenseProtocol,儘管 DenseProtocol 只支援 C ++ 實現,所以不算作跨語言【18】。 除此之外,它還有兩種不同的基於 JSON 的編碼格式【19】。 真逗!
|
||||
|
||||
與[圖4-1](Img/fig4-1.png)類似,每個欄位都有一個型別註釋(用於指示它是一個字串,整數,列表等),還可以根據需要指定長度(字串的長度,列表中的專案數) 。出現在資料中的字串`(“Martin”, “daydreaming”, “hacking”)`也被編碼為ASCII(或者說,UTF-8),與之前類似。
|
||||
與 [圖 4-1](Img/fig4-1.png) 類似,每個欄位都有一個型別註釋(用於指示它是一個字串,整數,列表等),還可以根據需要指定長度(字串的長度,列表中的專案數) 。出現在資料中的字串 `(“Martin”, “daydreaming”, “hacking”)` 也被編碼為 ASCII(或者說,UTF-8),與之前類似。
|
||||
|
||||
與[圖4-1](../img/fig4-1.png)相比,最大的區別是沒有欄位名`(userName, favoriteNumber, interests)`。相反,編碼資料包含欄位標籤,它們是數字`(1, 2和3)`。這些是模式定義中出現的數字。欄位標記就像欄位的別名 - 它們是說我們正在談論的欄位的一種緊湊的方式,而不必拼出欄位名稱。
|
||||
與 [圖 4-1](../img/fig4-1.png) 相比,最大的區別是沒有欄位名 `(userName, favoriteNumber, interests)`。相反,編碼資料包含欄位標籤,它們是數字 `(1, 2 和 3)`。這些是模式定義中出現的數字。欄位標記就像欄位的別名 - 它們是說我們正在談論的欄位的一種緊湊的方式,而不必拼出欄位名稱。
|
||||
|
||||
Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4-3](../img/fig4-3.png)所示,它只將相同的資訊打包成只有34個位元組。它透過將欄位型別和標籤號打包到單個位元組中,並使用可變長度整數來實現。數字1337不是使用全部八個位元組,而是用兩個位元組編碼,每個位元組的最高位用來指示是否還有更多的位元組。這意味著-64到63之間的數字被編碼為一個位元組,-8192和8191之間的數字以兩個位元組編碼,等等。較大的數字使用更多的位元組。
|
||||
Thrift CompactProtocol 編碼在語義上等同於 BinaryProtocol,但是如 [圖 4-3](../img/fig4-3.png) 所示,它只將相同的資訊打包成只有 34 個位元組。它透過將欄位型別和標籤號打包到單個位元組中,並使用可變長度整數來實現。數字 1337 不是使用全部八個位元組,而是用兩個位元組編碼,每個位元組的最高位用來指示是否還有更多的位元組。這意味著 - 64 到 63 之間的數字被編碼為一個位元組,-8192 和 8191 之間的數字以兩個位元組編碼,等等。較大的數字使用更多的位元組。
|
||||
|
||||

|
||||
|
||||
**圖4-3 使用Thrift壓縮協議編碼的記錄**
|
||||
**圖 4-3 使用 Thrift 壓縮協議編碼的記錄**
|
||||
|
||||
最後,Protocol Buffers(只有一種二進位制編碼格式)對相同的資料進行編碼,如[圖4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但與Thrift的CompactProtocol非常相似。 Protobuf將同樣的記錄塞進了33個位元組中。
|
||||
最後,Protocol Buffers(只有一種二進位制編碼格式)對相同的資料進行編碼,如 [圖 4-4](../img/fig4-4.png) 所示。 它的打包方式稍有不同,但與 Thrift 的 CompactProtocol 非常相似。 Protobuf 將同樣的記錄塞進了 33 個位元組中。
|
||||
|
||||

|
||||
|
||||
**圖4-4 使用Protobuf編碼的記錄**
|
||||
**圖 4-4 使用 Protobuf 編碼的記錄**
|
||||
|
||||
需要注意的一個細節:在前面所示的模式中,每個欄位被標記為必需或可選,但是這對欄位如何編碼沒有任何影響(二進位制資料中沒有任何欄位指示某欄位是否必須)。區別在於,如果欄位設定為 `required`,但未設定該欄位,則所需的執行時檢查將失敗,這對於捕獲錯誤非常有用。
|
||||
|
||||
#### 欄位標籤和模式演變
|
||||
|
||||
我們之前說過,模式不可避免地需要隨著時間而改變。我們稱之為模式演變。 Thrift和Protocol Buffers如何處理模式更改,同時保持向後相容性?
|
||||
我們之前說過,模式不可避免地需要隨著時間而改變。我們稱之為模式演變。 Thrift 和 Protocol Buffers 如何處理模式更改,同時保持向後相容性?
|
||||
|
||||
從示例中可以看出,編碼的記錄就是其編碼欄位的拼接。每個欄位由其標籤號碼(樣本模式中的數字1,2,3)標識,並用資料型別(例如字串或整數)註釋。如果沒有設定欄位值,則簡單地從編碼記錄中省略。從中可以看到,欄位標記對編碼資料的含義至關重要。你可以更改架構中欄位的名稱,因為編碼的資料永遠不會引用欄位名稱,但不能更改欄位的標記,因為這會使所有現有的編碼資料無效。
|
||||
從示例中可以看出,編碼的記錄就是其編碼欄位的拼接。每個欄位由其標籤號碼(樣本模式中的數字 1,2,3)標識,並用資料型別(例如字串或整數)註釋。如果沒有設定欄位值,則簡單地從編碼記錄中省略。從中可以看到,欄位標記對編碼資料的含義至關重要。你可以更改架構中欄位的名稱,因為編碼的資料永遠不會引用欄位名稱,但不能更改欄位的標記,因為這會使所有現有的編碼資料無效。
|
||||
|
||||
你可以新增新的欄位到架構,只要你給每個欄位一個新的標籤號碼。如果舊的程式碼(不知道你新增的新的標籤號碼)試圖讀取新程式碼寫入的資料,包括一個新的欄位,其標籤號碼不能識別,它可以簡單地忽略該欄位。資料型別註釋允許解析器確定需要跳過的位元組數。這保持了向前相容性:舊程式碼可以讀取由新程式碼編寫的記錄。
|
||||
|
||||
@ -182,19 +182,19 @@ Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4
|
||||
|
||||
#### 資料型別和模式演變
|
||||
|
||||
如何改變欄位的資料型別?這也許是可能的——詳細資訊請查閱相關的文件——但是有一個風險,值將失去精度或被截斷。例如,假設你將一個32位的整數變成一個64位的整數。新程式碼可以輕鬆讀取舊程式碼寫入的資料,因為解析器可以用零填充任何缺失的位。但是,如果舊程式碼讀取由新程式碼寫入的資料,則舊程式碼仍使用32位變數來儲存該值。如果解碼的64位值不適合32位,則它將被截斷。
|
||||
如何改變欄位的資料型別?這也許是可能的 —— 詳細資訊請查閱相關的文件 —— 但是有一個風險,值將失去精度或被截斷。例如,假設你將一個 32 位的整數變成一個 64 位的整數。新程式碼可以輕鬆讀取舊程式碼寫入的資料,因為解析器可以用零填充任何缺失的位。但是,如果舊程式碼讀取由新程式碼寫入的資料,則舊程式碼仍使用 32 位變數來儲存該值。如果解碼的 64 位值不適合 32 位,則它將被截斷。
|
||||
|
||||
Protobuf的一個奇怪的細節是,它沒有列表或陣列資料型別,而是有一個欄位的重複標記(`repeated`,這是除必需和可選之外的第三個選項)。如[圖4-4](../img/fig4-4.png)所示,重複欄位的編碼正如它所說的那樣:同一個欄位標記只是簡單地出現在記錄中。這具有很好的效果,可以將可選(單值)欄位更改為重複(多值)欄位。讀取舊資料的新程式碼會看到一個包含零個或一個元素的列表(取決於該欄位是否存在)。讀取新資料的舊程式碼只能看到列表的最後一個元素。
|
||||
Protobuf 的一個奇怪的細節是,它沒有列表或陣列資料型別,而是有一個欄位的重複標記(`repeated`,這是除必需和可選之外的第三個選項)。如 [圖 4-4](../img/fig4-4.png) 所示,重複欄位的編碼正如它所說的那樣:同一個欄位標記只是簡單地出現在記錄中。這具有很好的效果,可以將可選(單值)欄位更改為重複(多值)欄位。讀取舊資料的新程式碼會看到一個包含零個或一個元素的列表(取決於該欄位是否存在)。讀取新資料的舊程式碼只能看到列表的最後一個元素。
|
||||
|
||||
Thrift有一個專用的列表資料型別,它使用列表元素的資料型別進行引數化。這不允許Protocol Buffers所做的從單值到多值的演變,但是它具有支援巢狀列表的優點。
|
||||
Thrift 有一個專用的列表資料型別,它使用列表元素的資料型別進行引數化。這不允許 Protocol Buffers 所做的從單值到多值的演變,但是它具有支援巢狀列表的優點。
|
||||
|
||||
### Avro
|
||||
|
||||
Apache Avro 【20】是另一種二進位制編碼格式,與Protocol Buffers和Thrift有著有趣的不同。 它是作為Hadoop的一個子專案在2009年開始的,因為Thrift不適合Hadoop的用例【21】。
|
||||
Apache Avro 【20】是另一種二進位制編碼格式,與 Protocol Buffers 和 Thrift 有著有趣的不同。 它是作為 Hadoop 的一個子專案在 2009 年開始的,因為 Thrift 不適合 Hadoop 的用例【21】。
|
||||
|
||||
Avro也使用模式來指定正在編碼的資料的結構。 它有兩種模式語言:一種(Avro IDL)用於人工編輯,一種(基於JSON)更易於機器讀取。
|
||||
Avro 也使用模式來指定正在編碼的資料的結構。 它有兩種模式語言:一種(Avro IDL)用於人工編輯,一種(基於 JSON)更易於機器讀取。
|
||||
|
||||
我們用Avro IDL編寫的示例模式可能如下所示:
|
||||
我們用 Avro IDL 編寫的示例模式可能如下所示:
|
||||
|
||||
```c
|
||||
record Person {
|
||||
@ -204,7 +204,7 @@ record Person {
|
||||
}
|
||||
```
|
||||
|
||||
等價的JSON表示:
|
||||
等價的 JSON 表示:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -218,102 +218,102 @@ record Person {
|
||||
}
|
||||
```
|
||||
|
||||
首先,請注意模式中沒有標籤號碼。 如果我們使用這個模式編碼我們的例子記錄([例4-1]()),Avro二進位制編碼只有32個位元組長,這是我們所見過的所有編碼中最緊湊的。 編碼位元組序列的分解如[圖4-5](../img/fig4-5.png)所示。
|
||||
首先,請注意模式中沒有標籤號碼。 如果我們使用這個模式編碼我們的例子記錄([例 4-1]()),Avro 二進位制編碼只有 32 個位元組長,這是我們所見過的所有編碼中最緊湊的。 編碼位元組序列的分解如 [圖 4-5](../img/fig4-5.png) 所示。
|
||||
|
||||
如果你檢查位元組序列,你可以看到沒有什麼可以識別字段或其資料型別。 編碼只是由連在一起的值組成。 一個字串只是一個長度字首,後跟UTF-8位元組,但是在被包含的資料中沒有任何內容告訴你它是一個字串。 它可以是一個整數,也可以是其他的整數。 整數使用可變長度編碼(與Thrift的CompactProtocol相同)進行編碼。
|
||||
如果你檢查位元組序列,你可以看到沒有什麼可以識別字段或其資料型別。 編碼只是由連在一起的值組成。 一個字串只是一個長度字首,後跟 UTF-8 位元組,但是在被包含的資料中沒有任何內容告訴你它是一個字串。 它可以是一個整數,也可以是其他的整數。 整數使用可變長度編碼(與 Thrift 的 CompactProtocol 相同)進行編碼。
|
||||
|
||||

|
||||
|
||||
**圖4-5 使用Avro編碼的記錄**
|
||||
**圖 4-5 使用 Avro 編碼的記錄**
|
||||
|
||||
為了解析二進位制資料,你按照它們出現在模式中的順序遍歷這些欄位,並使用模式來告訴你每個欄位的資料型別。這意味著如果讀取資料的程式碼使用與寫入資料的程式碼完全相同的模式,才能正確解碼二進位制資料。Reader和Writer之間的模式不匹配意味著錯誤地解碼資料。
|
||||
為了解析二進位制資料,你按照它們出現在模式中的順序遍歷這些欄位,並使用模式來告訴你每個欄位的資料型別。這意味著如果讀取資料的程式碼使用與寫入資料的程式碼完全相同的模式,才能正確解碼二進位制資料。Reader 和 Writer 之間的模式不匹配意味著錯誤地解碼資料。
|
||||
|
||||
那麼,Avro如何支援模式演變呢?
|
||||
那麼,Avro 如何支援模式演變呢?
|
||||
|
||||
#### Writer模式與Reader模式
|
||||
|
||||
有了Avro,當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式編碼資料,例如,模式可能被編譯到應用程式中。這被稱為Writer模式。
|
||||
有了 Avro,當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式編碼資料,例如,模式可能被編譯到應用程式中。這被稱為 Writer 模式。
|
||||
|
||||
當一個應用程式想要解碼一些資料(從一個檔案或資料庫讀取資料,從網路接收資料等)時,它希望資料在某個模式中,這就是Reader模式。這是應用程式程式碼所依賴的模式,在應用程式的構建過程中,程式碼可能已經從該模式生成。
|
||||
當一個應用程式想要解碼一些資料(從一個檔案或資料庫讀取資料,從網路接收資料等)時,它希望資料在某個模式中,這就是 Reader 模式。這是應用程式程式碼所依賴的模式,在應用程式的構建過程中,程式碼可能已經從該模式生成。
|
||||
|
||||
Avro的關鍵思想是Writer模式和Reader模式不必是相同的 - 他們只需要相容。當資料解碼(讀取)時,Avro庫透過並排檢視Writer模式和Reader模式並將資料從Writer模式轉換到Reader模式來解決差異。 Avro規範【20】確切地定義了這種解析的工作原理,如[圖4-6](../img/fig4-6.png)所示。
|
||||
Avro 的關鍵思想是 Writer 模式和 Reader 模式不必是相同的 - 他們只需要相容。當資料解碼(讀取)時,Avro 庫透過並排檢視 Writer 模式和 Reader 模式並將資料從 Writer 模式轉換到 Reader 模式來解決差異。 Avro 規範【20】確切地定義了這種解析的工作原理,如 [圖 4-6](../img/fig4-6.png) 所示。
|
||||
|
||||
例如,如果Writer模式和Reader模式的欄位順序不同,這是沒有問題的,因為模式解析透過欄位名匹配欄位。如果讀取資料的程式碼遇到出現在Writer模式中但不在Reader模式中的欄位,則忽略它。如果讀取資料的程式碼需要某個欄位,但是Writer模式不包含該名稱的欄位,則使用在Reader模式中宣告的預設值填充。
|
||||
例如,如果 Writer 模式和 Reader 模式的欄位順序不同,這是沒有問題的,因為模式解析透過欄位名匹配欄位。如果讀取資料的程式碼遇到出現在 Writer 模式中但不在 Reader 模式中的欄位,則忽略它。如果讀取資料的程式碼需要某個欄位,但是 Writer 模式不包含該名稱的欄位,則使用在 Reader 模式中宣告的預設值填充。
|
||||
|
||||

|
||||
|
||||
**圖4-6 一個Avro Reader解決讀寫模式的差異**
|
||||
**圖 4-6 一個 Avro Reader 解決讀寫模式的差異**
|
||||
|
||||
#### 模式演變規則
|
||||
|
||||
使用Avro,向前相容性意味著你可以將新版本的模式作為Writer,並將舊版本的模式作為Reader。相反,向後相容意味著你可以有一個作為Reader的新版本模式和作為Writer的舊版本模式。
|
||||
使用 Avro,向前相容性意味著你可以將新版本的模式作為 Writer,並將舊版本的模式作為 Reader。相反,向後相容意味著你可以有一個作為 Reader 的新版本模式和作為 Writer 的舊版本模式。
|
||||
|
||||
為了保持相容性,你只能新增或刪除具有預設值的欄位(我們的Avro模式中的欄位`favoriteNumber`的預設值為`null`)。例如,假設你添加了一個有預設值的欄位,這個新的欄位將存在於新模式而不是舊模式中。當使用新模式的Reader讀取使用舊模式寫入的記錄時,將為缺少的欄位填充預設值。
|
||||
為了保持相容性,你只能新增或刪除具有預設值的欄位(我們的 Avro 模式中的欄位 `favoriteNumber` 的預設值為 `null`)。例如,假設你添加了一個有預設值的欄位,這個新的欄位將存在於新模式而不是舊模式中。當使用新模式的 Reader 讀取使用舊模式寫入的記錄時,將為缺少的欄位填充預設值。
|
||||
|
||||
如果你要新增一個沒有預設值的欄位,新的Reader將無法讀取舊Writer寫的資料,所以你會破壞向後相容性。如果你要刪除沒有預設值的欄位,舊的Reader將無法讀取新Writer寫入的資料,因此你會打破向前相容性。在一些程式語言中,null是任何變數可以接受的預設值,但在Avro中並不是這樣:如果要允許一個欄位為`null`,則必須使用聯合型別。例如,`union {null, long, string} field;`表示field可以是數字或字串,也可以是`null`。如果要將null作為預設值,則它必須是union的分支之一[^iv]。這樣的寫法比預設情況下就允許任何變數是`null`顯得更加冗長,但是透過明確什麼可以和什麼不可以是`null`,有助於防止出錯【22】。
|
||||
如果你要新增一個沒有預設值的欄位,新的 Reader 將無法讀取舊 Writer 寫的資料,所以你會破壞向後相容性。如果你要刪除沒有預設值的欄位,舊的 Reader 將無法讀取新 Writer 寫入的資料,因此你會打破向前相容性。在一些程式語言中,null 是任何變數可以接受的預設值,但在 Avro 中並不是這樣:如果要允許一個欄位為 `null`,則必須使用聯合型別。例如,`union {null, long, string} field;` 表示 field 可以是數字或字串,也可以是 `null`。如果要將 null 作為預設值,則它必須是 union 的分支之一 [^iv]。這樣的寫法比預設情況下就允許任何變數是 `null` 顯得更加冗長,但是透過明確什麼可以和什麼不可以是 `null`,有助於防止出錯【22】。
|
||||
|
||||
[^iv]: 確切地說,預設值必須是聯合的第一個分支的型別,儘管這是Avro的特定限制,而不是聯合型別的一般特徵。
|
||||
[^iv]: 確切地說,預設值必須是聯合的第一個分支的型別,儘管這是 Avro 的特定限制,而不是聯合型別的一般特徵。
|
||||
|
||||
因此,Avro沒有像Protocol Buffers和Thrift那樣的`optional`和`required`標記(但它有聯合型別和預設值)。
|
||||
因此,Avro 沒有像 Protocol Buffers 和 Thrift 那樣的 `optional` 和 `required` 標記(但它有聯合型別和預設值)。
|
||||
|
||||
只要Avro可以支援相應的型別轉換,就可以改變欄位的資料型別。更改欄位的名稱也是可能的,但有點棘手:Reader模式可以包含欄位名稱的別名,所以它可以匹配舊Writer的模式欄位名稱與別名。這意味著更改欄位名稱是向後相容的,但不能向前相容。同樣,向聯合型別新增分支也是向後相容的,但不能向前相容。
|
||||
只要 Avro 可以支援相應的型別轉換,就可以改變欄位的資料型別。更改欄位的名稱也是可能的,但有點棘手:Reader 模式可以包含欄位名稱的別名,所以它可以匹配舊 Writer 的模式欄位名稱與別名。這意味著更改欄位名稱是向後相容的,但不能向前相容。同樣,向聯合型別新增分支也是向後相容的,但不能向前相容。
|
||||
|
||||
#### 但Writer模式到底是什麼?
|
||||
|
||||
到目前為止,我們一直跳過了一個重要的問題:對於一段特定的編碼資料,Reader如何知道其Writer模式?我們不能只將整個模式包括在每個記錄中,因為模式可能比編碼的資料大得多,從而使二進位制編碼節省的所有空間都是徒勞的。
|
||||
到目前為止,我們一直跳過了一個重要的問題:對於一段特定的編碼資料,Reader 如何知道其 Writer 模式?我們不能只將整個模式包括在每個記錄中,因為模式可能比編碼的資料大得多,從而使二進位制編碼節省的所有空間都是徒勞的。
|
||||
|
||||
答案取決於Avro使用的上下文。舉幾個例子:
|
||||
答案取決於 Avro 使用的上下文。舉幾個例子:
|
||||
|
||||
* 有很多記錄的大檔案
|
||||
|
||||
Avro的一個常見用途 - 尤其是在Hadoop環境中 - 用於儲存包含數百萬條記錄的大檔案,所有記錄都使用相同的模式進行編碼(我們將在[第十章](ch10.md)討論這種情況)。在這種情況下,該檔案的作者可以在檔案的開頭只包含一次Writer模式。 Avro指定了一個檔案格式(物件容器檔案)來做到這一點。
|
||||
Avro 的一個常見用途 - 尤其是在 Hadoop 環境中 - 用於儲存包含數百萬條記錄的大檔案,所有記錄都使用相同的模式進行編碼(我們將在 [第十章](ch10.md) 討論這種情況)。在這種情況下,該檔案的作者可以在檔案的開頭只包含一次 Writer 模式。 Avro 指定了一個檔案格式(物件容器檔案)來做到這一點。
|
||||
|
||||
* 支援獨立寫入的記錄的資料庫
|
||||
|
||||
在一個數據庫中,不同的記錄可能會在不同的時間點使用不同的Writer模式來寫入 - 你不能假定所有的記錄都有相同的模式。最簡單的解決方案是在每個編碼記錄的開始處包含一個版本號,並在資料庫中保留一個模式版本列表。Reader可以獲取記錄,提取版本號,然後從資料庫中獲取該版本號的Writer模式。使用該Writer模式,它可以解碼記錄的其餘部分(例如Espresso 【23】就是這樣工作的)。
|
||||
在一個數據庫中,不同的記錄可能會在不同的時間點使用不同的 Writer 模式來寫入 - 你不能假定所有的記錄都有相同的模式。最簡單的解決方案是在每個編碼記錄的開始處包含一個版本號,並在資料庫中保留一個模式版本列表。Reader 可以獲取記錄,提取版本號,然後從資料庫中獲取該版本號的 Writer 模式。使用該 Writer 模式,它可以解碼記錄的其餘部分(例如 Espresso 【23】就是這樣工作的)。
|
||||
|
||||
* 透過網路連線傳送記錄
|
||||
|
||||
當兩個程序透過雙向網路連線進行通訊時,他們可以在連線設定上協商模式版本,然後在連線的生命週期中使用該模式。 Avro RPC協議(請參閱“[服務中的資料流:REST與RPC](#服務中的資料流:REST與RPC)”)就是這樣工作的。
|
||||
當兩個程序透過雙向網路連線進行通訊時,他們可以在連線設定上協商模式版本,然後在連線的生命週期中使用該模式。 Avro RPC 協議(請參閱 “[服務中的資料流:REST 與 RPC](#服務中的資料流:REST與RPC)”)就是這樣工作的。
|
||||
|
||||
具有模式版本的資料庫在任何情況下都是非常有用的,因為它充當文件併為你提供了檢查模式相容性的機會【24】。作為版本號,你可以使用一個簡單的遞增整數,或者你可以使用模式的雜湊。
|
||||
|
||||
#### 動態生成的模式
|
||||
|
||||
與Protocol Buffers和Thrift相比,Avro方法的一個優點是架構不包含任何標籤號碼。但為什麼這很重要?在模式中保留一些數字有什麼問題?
|
||||
與 Protocol Buffers 和 Thrift 相比,Avro 方法的一個優點是架構不包含任何標籤號碼。但為什麼這很重要?在模式中保留一些數字有什麼問題?
|
||||
|
||||
不同之處在於Avro對動態生成的模式更友善。例如,假如你有一個關係資料庫,你想要把它的內容轉儲到一個檔案中,並且你想使用二進位制格式來避免前面提到的文字格式(JSON,CSV,SQL)的問題。如果你使用Avro,你可以很容易地從關係模式生成一個Avro模式(在我們之前看到的JSON表示中),並使用該模式對資料庫內容進行編碼,並將其全部轉儲到Avro物件容器檔案【25】中。你為每個資料庫表生成一個記錄模式,每個列成為該記錄中的一個欄位。資料庫中的列名稱對映到Avro中的欄位名稱。
|
||||
不同之處在於 Avro 對動態生成的模式更友善。例如,假如你有一個關係資料庫,你想要把它的內容轉儲到一個檔案中,並且你想使用二進位制格式來避免前面提到的文字格式(JSON,CSV,SQL)的問題。如果你使用 Avro,你可以很容易地從關係模式生成一個 Avro 模式(在我們之前看到的 JSON 表示中),並使用該模式對資料庫內容進行編碼,並將其全部轉儲到 Avro 物件容器檔案【25】中。你為每個資料庫表生成一個記錄模式,每個列成為該記錄中的一個欄位。資料庫中的列名稱對映到 Avro 中的欄位名稱。
|
||||
|
||||
現在,如果資料庫模式發生變化(例如,一個表中添加了一列,刪除了一列),則可以從更新的資料庫模式生成新的Avro模式,並在新的Avro模式中匯出資料。資料匯出過程不需要注意模式的改變 - 每次執行時都可以簡單地進行模式轉換。任何讀取新資料檔案的人都會看到記錄的欄位已經改變,但是由於欄位是透過名字來標識的,所以更新的Writer模式仍然可以與舊的Reader模式匹配。
|
||||
現在,如果資料庫模式發生變化(例如,一個表中添加了一列,刪除了一列),則可以從更新的資料庫模式生成新的 Avro 模式,並在新的 Avro 模式中匯出資料。資料匯出過程不需要注意模式的改變 - 每次執行時都可以簡單地進行模式轉換。任何讀取新資料檔案的人都會看到記錄的欄位已經改變,但是由於欄位是透過名字來標識的,所以更新的 Writer 模式仍然可以與舊的 Reader 模式匹配。
|
||||
|
||||
相比之下,如果你為此使用Thrift或Protocol Buffers,則欄位標籤可能必須手動分配:每次資料庫模式更改時,管理員都必須手動更新從資料庫列名到欄位標籤的對映(這可能會自動化,但模式生成器必須非常小心,不要分配以前使用的欄位標籤)。這種動態生成的模式根本不是Thrift或Protocol Buffers的設計目標,而是Avro的。
|
||||
相比之下,如果你為此使用 Thrift 或 Protocol Buffers,則欄位標籤可能必須手動分配:每次資料庫模式更改時,管理員都必須手動更新從資料庫列名到欄位標籤的對映(這可能會自動化,但模式生成器必須非常小心,不要分配以前使用的欄位標籤)。這種動態生成的模式根本不是 Thrift 或 Protocol Buffers 的設計目標,而是 Avro 的。
|
||||
|
||||
#### 程式碼生成和動態型別的語言
|
||||
|
||||
Thrift和Protobuf依賴於程式碼生成:在定義了模式之後,可以使用你選擇的程式語言生成實現此模式的程式碼。這在Java、C++或C#等靜態型別語言中很有用,因為它允許將高效的記憶體中結構用於解碼的資料,並且在編寫訪問資料結構的程式時允許在IDE中進行型別檢查和自動完成。
|
||||
Thrift 和 Protobuf 依賴於程式碼生成:在定義了模式之後,可以使用你選擇的程式語言生成實現此模式的程式碼。這在 Java、C++ 或 C# 等靜態型別語言中很有用,因為它允許將高效的記憶體中結構用於解碼的資料,並且在編寫訪問資料結構的程式時允許在 IDE 中進行型別檢查和自動完成。
|
||||
|
||||
在動態型別程式語言(如JavaScript、Ruby或Python)中,生成程式碼沒有太多意義,因為沒有編譯時型別檢查器來滿足。程式碼生成在這些語言中經常被忽視,因為它們避免了顯式的編譯步驟。而且,對於動態生成的模式(例如從資料庫表生成的Avro模式),程式碼生成對獲取資料是一個不必要的障礙。
|
||||
在動態型別程式語言(如 JavaScript、Ruby 或 Python)中,生成程式碼沒有太多意義,因為沒有編譯時型別檢查器來滿足。程式碼生成在這些語言中經常被忽視,因為它們避免了顯式的編譯步驟。而且,對於動態生成的模式(例如從資料庫表生成的 Avro 模式),程式碼生成對獲取資料是一個不必要的障礙。
|
||||
|
||||
Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是它也可以在不生成任何程式碼的情況下使用。如果你有一個物件容器檔案(它嵌入了Writer模式),你可以簡單地使用Avro庫開啟它,並以與檢視JSON檔案相同的方式檢視資料。該檔案是自描述的,因為它包含所有必要的元資料。
|
||||
Avro 為靜態型別程式語言提供了可選的程式碼生成功能,但是它也可以在不生成任何程式碼的情況下使用。如果你有一個物件容器檔案(它嵌入了 Writer 模式),你可以簡單地使用 Avro 庫開啟它,並以與檢視 JSON 檔案相同的方式檢視資料。該檔案是自描述的,因為它包含所有必要的元資料。
|
||||
|
||||
這個屬性特別適用於動態型別的資料處理語言如Apache Pig 【26】。在Pig中,你可以開啟一些Avro檔案,開始分析它們,並編寫派生資料集以Avro格式輸出檔案,而無需考慮模式。
|
||||
這個屬性特別適用於動態型別的資料處理語言如 Apache Pig 【26】。在 Pig 中,你可以開啟一些 Avro 檔案,開始分析它們,並編寫派生資料集以 Avro 格式輸出檔案,而無需考慮模式。
|
||||
|
||||
### 模式的優點
|
||||
|
||||
正如我們所看到的,Protocol Buffers,Thrift和Avro都使用模式來描述二進位制編碼格式。他們的模式語言比XML模式或者JSON模式簡單得多,而後者支援更詳細的驗證規則(例如,“該欄位的字串值必須與該正則表示式匹配”或“該欄位的整數值必須在0和100之間“)。由於Protocol Buffers,Thrift和Avro實現起來更簡單,使用起來也更簡單,所以它們已經發展到支援相當廣泛的程式語言。
|
||||
正如我們所看到的,Protocol Buffers,Thrift 和 Avro 都使用模式來描述二進位制編碼格式。他們的模式語言比 XML 模式或者 JSON 模式簡單得多,而後者支援更詳細的驗證規則(例如,“該欄位的字串值必須與該正則表示式匹配” 或 “該欄位的整數值必須在 0 和 100 之間 “)。由於 Protocol Buffers,Thrift 和 Avro 實現起來更簡單,使用起來也更簡單,所以它們已經發展到支援相當廣泛的程式語言。
|
||||
|
||||
這些編碼所基於的想法絕不是新的。例如,它們與ASN.1有很多相似之處,它是1984年首次被標準化的模式定義語言【27】。它被用來定義各種網路協議,例如其二進位制編碼(DER)仍然被用於編碼SSL證書(X.509)【28】。 ASN.1支援使用標籤號碼的模式演進,類似於Protocol Buffers和Thrift 【29】。然而,它也非常複雜,而且沒有好的配套文件,所以ASN.1可能不是新應用程式的好選擇。
|
||||
這些編碼所基於的想法絕不是新的。例如,它們與 ASN.1 有很多相似之處,它是 1984 年首次被標準化的模式定義語言【27】。它被用來定義各種網路協議,例如其二進位制編碼(DER)仍然被用於編碼 SSL 證書(X.509)【28】。 ASN.1 支援使用標籤號碼的模式演進,類似於 Protocol Buffers 和 Thrift 【29】。然而,它也非常複雜,而且沒有好的配套文件,所以 ASN.1 可能不是新應用程式的好選擇。
|
||||
|
||||
許多資料系統也為其資料實現了某種專有的二進位制編碼。例如,大多數關係資料庫都有一個網路協議,你可以透過該協議向資料庫傳送查詢並獲取響應。這些協議通常特定於特定的資料庫,並且資料庫供應商提供將來自資料庫的網路協議的響應解碼為記憶體資料結構的驅動程式(例如使用ODBC或JDBC API)。
|
||||
許多資料系統也為其資料實現了某種專有的二進位制編碼。例如,大多數關係資料庫都有一個網路協議,你可以透過該協議向資料庫傳送查詢並獲取響應。這些協議通常特定於特定的資料庫,並且資料庫供應商提供將來自資料庫的網路協議的響應解碼為記憶體資料結構的驅動程式(例如使用 ODBC 或 JDBC API)。
|
||||
|
||||
所以,我們可以看到,儘管JSON,XML和CSV等文字資料格式非常普遍,但基於模式的二進位制編碼也是一個可行的選擇。他們有一些很好的屬性:
|
||||
所以,我們可以看到,儘管 JSON,XML 和 CSV 等文字資料格式非常普遍,但基於模式的二進位制編碼也是一個可行的選擇。他們有一些很好的屬性:
|
||||
|
||||
* 它們可以比各種“二進位制JSON”變體更緊湊,因為它們可以省略編碼資料中的欄位名稱。
|
||||
* 它們可以比各種 “二進位制 JSON” 變體更緊湊,因為它們可以省略編碼資料中的欄位名稱。
|
||||
* 模式是一種有價值的文件形式,因為模式是解碼所必需的,所以可以確定它是最新的(而手動維護的文件可能很容易偏離現實)。
|
||||
* 維護一個模式的資料庫允許你在部署任何內容之前檢查模式更改的向前和向後相容性。
|
||||
* 對於靜態型別程式語言的使用者來說,從模式生成程式碼的能力是有用的,因為它可以在編譯時進行型別檢查。
|
||||
|
||||
總而言之,模式進化允許與JSON資料庫提供的無模式/讀時模式相同的靈活性(請參閱“[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)”),同時還可以更好地保證資料和更好的工具。
|
||||
總而言之,模式進化允許與 JSON 資料庫提供的無模式 / 讀時模式相同的靈活性(請參閱 “[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)”),同時還可以更好地保證資料和更好的工具。
|
||||
|
||||
|
||||
## 資料流的型別
|
||||
@ -324,9 +324,9 @@ Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是
|
||||
|
||||
這是一個相當抽象的概念 - 資料可以透過多種方式從一個流程流向另一個流程。誰編碼資料,誰解碼?在本章的其餘部分中,我們將探討資料如何在流程之間流動的一些最常見的方式:
|
||||
|
||||
* 透過資料庫(請參閱“[資料庫中的資料流](#資料庫中的資料流)”)
|
||||
* 透過服務呼叫(請參閱“[服務中的資料流:REST與RPC](#服務中的資料流:REST與RPC)”)
|
||||
* 透過非同步訊息傳遞(請參閱“[訊息傳遞中的資料流](#訊息傳遞中的資料流)”)
|
||||
* 透過資料庫(請參閱 “[資料庫中的資料流](#資料庫中的資料流)”)
|
||||
* 透過服務呼叫(請參閱 “[服務中的資料流:REST 與 RPC](#服務中的資料流:REST與RPC)”)
|
||||
* 透過非同步訊息傳遞(請參閱 “[訊息傳遞中的資料流](#訊息傳遞中的資料流)”)
|
||||
|
||||
|
||||
### 資料庫中的資料流
|
||||
@ -341,11 +341,11 @@ Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是
|
||||
|
||||
但是,還有一個額外的障礙。假設你將一個欄位新增到記錄模式,並且較新的程式碼將該新欄位的值寫入資料庫。隨後,舊版本的程式碼(尚不知道新欄位)將讀取記錄,更新記錄並將其寫回。在這種情況下,理想的行為通常是舊程式碼保持新的欄位不變,即使它不能被解釋。
|
||||
|
||||
前面討論的編碼格式支援未知欄位的儲存,但是有時候需要在應用程式層面保持謹慎,如圖4-7所示。例如,如果將資料庫值解碼為應用程式中的模型物件,稍後重新編碼這些模型物件,那麼未知欄位可能會在該翻譯過程中丟失。解決這個問題不是一個難題,你只需要意識到它。
|
||||
前面討論的編碼格式支援未知欄位的儲存,但是有時候需要在應用程式層面保持謹慎,如圖 4-7 所示。例如,如果將資料庫值解碼為應用程式中的模型物件,稍後重新編碼這些模型物件,那麼未知欄位可能會在該翻譯過程中丟失。解決這個問題不是一個難題,你只需要意識到它。
|
||||
|
||||

|
||||
|
||||
**圖4-7 當較舊版本的應用程式更新以前由較新版本的應用程式編寫的資料時,如果不小心,資料可能會丟失。**
|
||||
**圖 4-7 當較舊版本的應用程式更新以前由較新版本的應用程式編寫的資料時,如果不小心,資料可能會丟失。**
|
||||
|
||||
#### 在不同的時間寫入不同的值
|
||||
|
||||
@ -353,147 +353,147 @@ Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是
|
||||
|
||||
在部署應用程式的新版本時,也許用不了幾分鐘就可以將所有的舊版本替換為新版本(至少伺服器端應用程式是這樣的)。但資料庫內容並非如此:對於五年前的資料來說,除非對其進行顯式重寫,否則它仍然會以原始編碼形式存在。這種現象有時被概括為:資料的生命週期超出程式碼的生命週期。
|
||||
|
||||
將資料重寫(遷移)到一個新的模式當然是可能的,但是在一個大資料集上執行是一個昂貴的事情,所以大多數資料庫如果可能的話就避免它。大多數關係資料庫都允許簡單的模式更改,例如新增一個預設值為空的新列,而不重寫現有資料[^v]。讀取舊行時,對於磁碟上的編碼資料缺少的任何列,資料庫將填充空值。 LinkedIn的文件資料庫Espresso使用Avro儲存,允許它使用Avro的模式演變規則【23】。
|
||||
將資料重寫(遷移)到一個新的模式當然是可能的,但是在一個大資料集上執行是一個昂貴的事情,所以大多數資料庫如果可能的話就避免它。大多數關係資料庫都允許簡單的模式更改,例如新增一個預設值為空的新列,而不重寫現有資料 [^v]。讀取舊行時,對於磁碟上的編碼資料缺少的任何列,資料庫將填充空值。 LinkedIn 的文件資料庫 Espresso 使用 Avro 儲存,允許它使用 Avro 的模式演變規則【23】。
|
||||
|
||||
因此,模式演變允許整個資料庫看起來好像是用單個模式編碼的,即使底層儲存可能包含用各種歷史版本的模式編碼的記錄。
|
||||
|
||||
[^v]: 除了MySQL,即使並非真的必要,它也經常會重寫整個表,正如“[文件模型中的模式靈活性](ch3.md#文件模型中的模式靈活性)”中所提到的。
|
||||
[^v]: 除了 MySQL,即使並非真的必要,它也經常會重寫整個表,正如 “[文件模型中的模式靈活性](ch3.md#文件模型中的模式靈活性)” 中所提到的。
|
||||
|
||||
|
||||
#### 歸檔儲存
|
||||
|
||||
也許你不時為資料庫建立一個快照,例如備份或載入到資料倉庫(請參閱“[資料倉庫](ch3.md#資料倉庫)”)。在這種情況下,即使源資料庫中的原始編碼包含來自不同時代的模式版本的混合,資料轉儲通常也將使用最新模式進行編碼。既然你不管怎樣都要複製資料,那麼你可以對這個資料複製進行一致的編碼。
|
||||
也許你不時為資料庫建立一個快照,例如備份或載入到資料倉庫(請參閱 “[資料倉庫](ch3.md#資料倉庫)”)。在這種情況下,即使源資料庫中的原始編碼包含來自不同時代的模式版本的混合,資料轉儲通常也將使用最新模式進行編碼。既然你不管怎樣都要複製資料,那麼你可以對這個資料複製進行一致的編碼。
|
||||
|
||||
由於資料轉儲是一次寫入的,而且以後是不可變的,所以Avro物件容器檔案等格式非常適合。這也是一個很好的機會,可以將資料編碼為面向分析的列式格式,例如Parquet(請參閱“[列壓縮](ch3.md#列壓縮)”)。
|
||||
由於資料轉儲是一次寫入的,而且以後是不可變的,所以 Avro 物件容器檔案等格式非常適合。這也是一個很好的機會,可以將資料編碼為面向分析的列式格式,例如 Parquet(請參閱 “[列壓縮](ch3.md#列壓縮)”)。
|
||||
|
||||
在[第十章](ch10.md)中,我們將詳細討論使用檔案儲存中的資料。
|
||||
在 [第十章](ch10.md) 中,我們將詳細討論使用檔案儲存中的資料。
|
||||
|
||||
|
||||
### 服務中的資料流:REST與RPC
|
||||
|
||||
當你需要透過網路進行通訊的程序時,安排該通訊的方式有幾種。最常見的安排是有兩個角色:客戶端和伺服器。伺服器透過網路公開API,並且客戶端可以連線到伺服器以向該API發出請求。伺服器公開的API被稱為服務。
|
||||
當你需要透過網路進行通訊的程序時,安排該通訊的方式有幾種。最常見的安排是有兩個角色:客戶端和伺服器。伺服器透過網路公開 API,並且客戶端可以連線到伺服器以向該 API 發出請求。伺服器公開的 API 被稱為服務。
|
||||
|
||||
Web以這種方式工作:客戶(Web瀏覽器)向Web伺服器發出請求,透過GET請求下載HTML,CSS,JavaScript,影象等,並透過POST請求提交資料到伺服器。 API包含一組標準的協議和資料格式(HTTP,URL,SSL/TLS,HTML等)。由於網路瀏覽器,網路伺服器和網站作者大多同意這些標準,你可以使用任何網路瀏覽器訪問任何網站(至少在理論上!)。
|
||||
Web 以這種方式工作:客戶(Web 瀏覽器)向 Web 伺服器發出請求,透過 GET 請求下載 HTML、CSS、JavaScript、影象等,並透過 POST 請求提交資料到伺服器。 API 包含一組標準的協議和資料格式(HTTP、URL、SSL/TLS、HTML 等)。由於網路瀏覽器、網路伺服器和網站作者大多同意這些標準,你可以使用任何網路瀏覽器訪問任何網站(至少在理論上!)。
|
||||
|
||||
Web瀏覽器不是唯一的客戶端型別。例如,在移動裝置或桌面計算機上執行的本地應用程式也可以向伺服器發出網路請求,並且在Web瀏覽器內執行的客戶端JavaScript應用程式可以使用XMLHttpRequest成為HTTP客戶端(該技術被稱為Ajax 【30】)。在這種情況下,伺服器的響應通常不是用於顯示給人的HTML,而是便於客戶端應用程式程式碼進一步處理的編碼資料(如JSON)。儘管HTTP可能被用作傳輸協議,但頂層實現的API是特定於應用程式的,客戶端和伺服器需要就該API的細節達成一致。
|
||||
Web 瀏覽器不是唯一的客戶端型別。例如,在移動裝置或桌面計算機上執行的本地應用程式也可以向伺服器發出網路請求,並且在 Web 瀏覽器內執行的客戶端 JavaScript 應用程式可以使用 XMLHttpRequest 成為 HTTP 客戶端(該技術被稱為 Ajax 【30】)。在這種情況下,伺服器的響應通常不是用於顯示給人的 HTML,而是便於客戶端應用程式程式碼進一步處理的編碼資料(如 JSON)。儘管 HTTP 可能被用作傳輸協議,但頂層實現的 API 是特定於應用程式的,客戶端和伺服器需要就該 API 的細節達成一致。
|
||||
|
||||
此外,伺服器本身可以是另一個服務的客戶端(例如,典型的Web應用伺服器充當資料庫的客戶端)。這種方法通常用於將大型應用程式按照功能區域分解為較小的服務,這樣當一個服務需要來自另一個服務的某些功能或資料時,就會向另一個服務發出請求。這種構建應用程式的方式傳統上被稱為**面向服務的體系結構(service-oriented architecture,SOA)**,最近被改進和更名為**微服務架構**【31,32】。
|
||||
此外,伺服器本身可以是另一個服務的客戶端(例如,典型的 Web 應用伺服器充當資料庫的客戶端)。這種方法通常用於將大型應用程式按照功能區域分解為較小的服務,這樣當一個服務需要來自另一個服務的某些功能或資料時,就會向另一個服務發出請求。這種構建應用程式的方式傳統上被稱為 **面向服務的體系結構(service-oriented architecture,SOA)**,最近被改進和更名為 **微服務架構**【31,32】。
|
||||
|
||||
在某些方面,服務類似於資料庫:它們通常允許客戶端提交和查詢資料。但是,雖然資料庫允許使用我們在[第二章](ch2.md)中討論的查詢語言進行任意查詢,但是服務公開了一個特定於應用程式的API,它只允許由服務的業務邏輯(應用程式程式碼)預定的輸入和輸出【33】。這種限制提供了一定程度的封裝:服務能夠對客戶可以做什麼和不可以做什麼施加細粒度的限制。
|
||||
在某些方面,服務類似於資料庫:它們通常允許客戶端提交和查詢資料。但是,雖然資料庫允許使用我們在 [第二章](ch2.md) 中討論的查詢語言進行任意查詢,但是服務公開了一個特定於應用程式的 API,它只允許由服務的業務邏輯(應用程式程式碼)預定的輸入和輸出【33】。這種限制提供了一定程度的封裝:服務能夠對客戶可以做什麼和不可以做什麼施加細粒度的限制。
|
||||
|
||||
面向服務/微服務架構的一個關鍵設計目標是透過使服務獨立部署和演化來使應用程式更易於更改和維護。例如,每個服務應該由一個團隊擁有,並且該團隊應該能夠經常釋出新版本的服務,而不必與其他團隊協調。換句話說,我們應該期望伺服器和客戶端的舊版本和新版本同時執行,因此伺服器和客戶端使用的資料編碼必須在不同版本的服務API之間相容——這正是我們在本章所一直在談論的。
|
||||
面向服務 / 微服務架構的一個關鍵設計目標是透過使服務獨立部署和演化來使應用程式更易於更改和維護。例如,每個服務應該由一個團隊擁有,並且該團隊應該能夠經常釋出新版本的服務,而不必與其他團隊協調。換句話說,我們應該期望伺服器和客戶端的舊版本和新版本同時執行,因此伺服器和客戶端使用的資料編碼必須在不同版本的服務 API 之間相容 —— 這正是我們在本章所一直在談論的。
|
||||
|
||||
#### Web服務
|
||||
|
||||
**當服務使用HTTP作為底層通訊協議時,可稱之為Web服務**。這可能是一個小錯誤,因為Web服務不僅在Web上使用,而且在幾個不同的環境中使用。例如:
|
||||
**當服務使用 HTTP 作為底層通訊協議時,可稱之為 Web 服務**。這可能是一個小錯誤,因為 Web 服務不僅在 Web 上使用,而且在幾個不同的環境中使用。例如:
|
||||
|
||||
1. 執行在使用者裝置上的客戶端應用程式(例如,移動裝置上的本地應用程式,或使用Ajax的JavaScript web應用程式)透過HTTP向服務發出請求。這些請求通常透過公共網際網路進行。
|
||||
2. 一種服務向同一組織擁有的另一項服務提出請求,這些服務通常位於同一資料中心內,作為面向服務/微型架構的一部分。 (支援這種用例的軟體有時被稱為 **中介軟體(middleware)** )
|
||||
3. 一種服務透過網際網路向不同組織所擁有的服務提出請求。這用於不同組織後端系統之間的資料交換。此類別包括由線上服務(如信用卡處理系統)提供的公共API,或用於共享訪問使用者資料的OAuth。
|
||||
1. 執行在使用者裝置上的客戶端應用程式(例如,移動裝置上的本地應用程式,或使用 Ajax 的 JavaScript web 應用程式)透過 HTTP 向服務發出請求。這些請求通常透過公共網際網路進行。
|
||||
2. 一種服務向同一組織擁有的另一項服務提出請求,這些服務通常位於同一資料中心內,作為面向服務 / 微型架構的一部分。 (支援這種用例的軟體有時被稱為 **中介軟體(middleware)** )
|
||||
3. 一種服務透過網際網路向不同組織所擁有的服務提出請求。這用於不同組織後端系統之間的資料交換。此類別包括由線上服務(如信用卡處理系統)提供的公共 API,或用於共享訪問使用者資料的 OAuth。
|
||||
|
||||
有兩種流行的Web服務方法:REST和SOAP。他們在哲學方面幾乎是截然相反的,往往也是各自支持者之間的激烈辯論的主題[^vi]。
|
||||
有兩種流行的 Web 服務方法:REST 和 SOAP。他們在哲學方面幾乎是截然相反的,往往也是各自支持者之間的激烈辯論的主題 [^vi]。
|
||||
|
||||
[^vi]: 即使在每個陣營內也有很多爭論。 例如,**HATEOAS(超媒體作為應用程式狀態的引擎)** 就經常引發討論【35】。
|
||||
|
||||
REST不是一個協議,而是一個基於HTTP原則的設計哲學【34,35】。它強調簡單的資料格式,使用URL來標識資源,並使用HTTP功能進行快取控制,身份驗證和內容型別協商。與SOAP相比,REST已經越來越受歡迎,至少在跨組織服務整合的背景下【36】,並經常與微服務相關【31】。根據REST原則設計的API稱為RESTful。
|
||||
REST 不是一個協議,而是一個基於 HTTP 原則的設計哲學【34,35】。它強調簡單的資料格式,使用 URL 來標識資源,並使用 HTTP 功能進行快取控制,身份驗證和內容型別協商。與 SOAP 相比,REST 已經越來越受歡迎,至少在跨組織服務整合的背景下【36】,並經常與微服務相關【31】。根據 REST 原則設計的 API 稱為 RESTful。
|
||||
|
||||
相比之下,SOAP是用於製作網路API請求的基於XML的協議[^vii]。雖然它最常用於HTTP,但其目的是獨立於HTTP,並避免使用大多數HTTP功能。相反,它帶有龐大而複雜的多種相關標準(Web服務框架,稱為`WS-*`),它們增加了各種功能【37】。
|
||||
相比之下,SOAP 是用於製作網路 API 請求的基於 XML 的協議 [^vii]。雖然它最常用於 HTTP,但其目的是獨立於 HTTP,並避免使用大多數 HTTP 功能。相反,它帶有龐大而複雜的多種相關標準(Web 服務框架,稱為 `WS-*`),它們增加了各種功能【37】。
|
||||
|
||||
[^vii]: 儘管首字母縮寫詞相似,SOAP並不是SOA的要求。 SOAP是一種特殊的技術,而SOA是構建系統的一般方法。
|
||||
[^vii]: 儘管首字母縮寫詞相似,SOAP 並不是 SOA 的要求。 SOAP 是一種特殊的技術,而 SOA 是構建系統的一般方法。
|
||||
|
||||
SOAP Web服務的API使用稱為Web服務描述語言(WSDL)的基於XML的語言來描述。 WSDL支援程式碼生成,客戶端可以使用本地類和方法呼叫(編碼為XML訊息並由框架再次解碼)訪問遠端服務。這在靜態型別程式語言中非常有用,但在動態型別程式語言中很少(請參閱“[程式碼生成和動態型別的語言](#程式碼生成和動態型別的語言)”)。
|
||||
SOAP Web 服務的 API 使用稱為 Web 服務描述語言(WSDL)的基於 XML 的語言來描述。 WSDL 支援程式碼生成,客戶端可以使用本地類和方法呼叫(編碼為 XML 訊息並由框架再次解碼)訪問遠端服務。這在靜態型別程式語言中非常有用,但在動態型別程式語言中很少(請參閱 “[程式碼生成和動態型別的語言](#程式碼生成和動態型別的語言)”)。
|
||||
|
||||
由於WSDL的設計不是人類可讀的,而且由於SOAP訊息通常因為過於複雜而無法手動構建,所以SOAP的使用者在很大程度上依賴於工具支援,程式碼生成和IDE【38】。對於SOAP供應商不支援的程式語言的使用者來說,與SOAP服務的整合是困難的。
|
||||
由於 WSDL 的設計不是人類可讀的,而且由於 SOAP 訊息通常因為過於複雜而無法手動構建,所以 SOAP 的使用者在很大程度上依賴於工具支援,程式碼生成和 IDE【38】。對於 SOAP 供應商不支援的程式語言的使用者來說,與 SOAP 服務的整合是困難的。
|
||||
|
||||
儘管SOAP及其各種擴充套件表面上是標準化的,但是不同廠商的實現之間的互操作性往往會造成問題【39】。由於所有這些原因,儘管許多大型企業仍然使用SOAP,但在大多數小公司中已經不再受到青睞。
|
||||
儘管 SOAP 及其各種擴充套件表面上是標準化的,但是不同廠商的實現之間的互操作性往往會造成問題【39】。由於所有這些原因,儘管許多大型企業仍然使用 SOAP,但在大多數小公司中已經不再受到青睞。
|
||||
|
||||
REST風格的API傾向於更簡單的方法,通常涉及較少的程式碼生成和自動化工具。定義格式(如OpenAPI,也稱為Swagger 【40】)可用於描述RESTful API並生成文件。
|
||||
REST 風格的 API 傾向於更簡單的方法,通常涉及較少的程式碼生成和自動化工具。定義格式(如 OpenAPI,也稱為 Swagger 【40】)可用於描述 RESTful API 並生成文件。
|
||||
|
||||
#### 遠端過程呼叫(RPC)的問題
|
||||
|
||||
Web服務僅僅是透過網路進行API請求的一系列技術的最新版本,其中許多技術受到了大量的炒作,但是存在嚴重的問題。 Enterprise JavaBeans(EJB)和Java的**遠端方法呼叫(RMI)** 僅限於Java。**分散式元件物件模型(DCOM)** 僅限於Microsoft平臺。**公共物件請求代理體系結構(CORBA)** 過於複雜,不提供前向或後向相容性【41】。
|
||||
Web 服務僅僅是透過網路進行 API 請求的一系列技術的最新版本,其中許多技術受到了大量的炒作,但是存在嚴重的問題。 Enterprise JavaBeans(EJB)和 Java 的 **遠端方法呼叫(RMI)** 僅限於 Java。**分散式元件物件模型(DCOM)** 僅限於 Microsoft 平臺。**公共物件請求代理體系結構(CORBA)** 過於複雜,不提供前向或後向相容性【41】。
|
||||
|
||||
所有這些都是基於 **遠端過程呼叫(RPC)** 的思想,該過程呼叫自20世紀70年代以來一直存在【42】。 RPC模型試圖向遠端網路服務發出請求,看起來與在同一程序中呼叫程式語言中的函式或方法相同(這種抽象稱為位置透明)。儘管RPC起初看起來很方便,但這種方法根本上是有缺陷的【43,44】。網路請求與本地函式呼叫非常不同:
|
||||
所有這些都是基於 **遠端過程呼叫(RPC)** 的思想,該過程呼叫自 20 世紀 70 年代以來一直存在【42】。 RPC 模型試圖向遠端網路服務發出請求,看起來與在同一程序中呼叫程式語言中的函式或方法相同(這種抽象稱為位置透明)。儘管 RPC 起初看起來很方便,但這種方法根本上是有缺陷的【43,44】。網路請求與本地函式呼叫非常不同:
|
||||
|
||||
* 本地函式呼叫是可預測的,並且成功或失敗僅取決於受你控制的引數。網路請求是不可預知的:由於網路問題,請求或響應可能會丟失,或者遠端計算機可能很慢或不可用,這些問題完全不在你的控制範圍之內。網路問題是常見的,所以你必須預測他們,例如透過重試失敗的請求。
|
||||
* 本地函式呼叫要麼返回結果,要麼丟擲異常,或者永遠不返回(因為進入無限迴圈或程序崩潰)。網路請求有另一個可能的結果:由於超時,它可能會返回沒有結果。在這種情況下,你根本不知道發生了什麼:如果你沒有得到來自遠端服務的響應,你無法知道請求是否透過(我們將在[第八章](ch8.md)更詳細地討論這個問題)。
|
||||
* 如果你重試失敗的網路請求,可能會發生請求實際上正在透過,只有響應丟失。在這種情況下,重試將導致該操作被執行多次,除非你在協議中引入去重機制(**冪等**,即idempotence)。本地函式呼叫沒有這個問題。 (在[第十一章](ch11.md)更詳細地討論冪等性)
|
||||
* 本地函式呼叫要麼返回結果,要麼丟擲異常,或者永遠不返回(因為進入無限迴圈或程序崩潰)。網路請求有另一個可能的結果:由於超時,它可能會返回沒有結果。在這種情況下,你根本不知道發生了什麼:如果你沒有得到來自遠端服務的響應,你無法知道請求是否透過(我們將在 [第八章](ch8.md) 更詳細地討論這個問題)。
|
||||
* 如果你重試失敗的網路請求,可能會發生請求實際上正在透過,只有響應丟失。在這種情況下,重試將導致該操作被執行多次,除非你在協議中引入去重機制(**冪等**,即 idempotence)。本地函式呼叫沒有這個問題。 (在 [第十一章](ch11.md) 更詳細地討論冪等性)
|
||||
* 每次呼叫本地功能時,通常需要大致相同的時間來執行。網路請求比函式呼叫要慢得多,而且其延遲也是非常可變的:好的時候它可能會在不到一毫秒的時間內完成,但是當網路擁塞或者遠端服務超載時,可能需要幾秒鐘的時間完成一樣的東西。
|
||||
* 呼叫本地函式時,可以高效地將引用(指標)傳遞給本地記憶體中的物件。當你發出一個網路請求時,所有這些引數都需要被編碼成可以透過網路傳送的一系列位元組。如果引數是像數字或字串這樣的基本型別倒是沒關係,但是對於較大的物件很快就會變成問題。
|
||||
|
||||
- 客戶端和服務可以用不同的程式語言實現,所以RPC框架必須將資料型別從一種語言翻譯成另一種語言。這可能會捅出大簍子,因為不是所有的語言都具有相同的型別 —— 例如回想一下JavaScript的數字大於$2^{53}$的問題(請參閱“[JSON、XML和二進位制變體](#JSON、XML和二進位制變體)”)。用單一語言編寫的單個程序中不存在此問題。
|
||||
- 客戶端和服務可以用不同的程式語言實現,所以 RPC 框架必須將資料型別從一種語言翻譯成另一種語言。這可能會捅出大簍子,因為不是所有的語言都具有相同的型別 —— 例如回想一下 JavaScript 的數字大於 $2^{53}$ 的問題(請參閱 “[JSON、XML 和二進位制變體](#JSON、XML和二進位制變體)”)。用單一語言編寫的單個程序中不存在此問題。
|
||||
|
||||
所有這些因素意味著嘗試使遠端服務看起來像程式語言中的本地物件一樣毫無意義,因為這是一個根本不同的事情。 REST的部分吸引力在於,它並不試圖隱藏它是一個網路協議的事實(儘管這似乎並沒有阻止人們在REST之上構建RPC庫)。
|
||||
所有這些因素意味著嘗試使遠端服務看起來像程式語言中的本地物件一樣毫無意義,因為這是一個根本不同的事情。 REST 的部分吸引力在於,它並不試圖隱藏它是一個網路協議的事實(儘管這似乎並沒有阻止人們在 REST 之上構建 RPC 庫)。
|
||||
|
||||
#### RPC的當前方向
|
||||
|
||||
儘管有這樣那樣的問題,RPC不會消失。在本章提到的所有編碼的基礎上構建了各種RPC框架:例如,Thrift和Avro帶有RPC支援,gRPC是使用Protocol Buffers的RPC實現,Finagle也使用Thrift,Rest.li使用JSON over HTTP。
|
||||
儘管有這樣那樣的問題,RPC 不會消失。在本章提到的所有編碼的基礎上構建了各種 RPC 框架:例如,Thrift 和 Avro 帶有 RPC 支援,gRPC 是使用 Protocol Buffers 的 RPC 實現,Finagle 也使用 Thrift,Rest.li 使用 JSON over HTTP。
|
||||
|
||||
這種新一代的RPC框架更加明確的是,遠端請求與本地函式呼叫不同。例如,Finagle和Rest.li 使用futures(promises)來封裝可能失敗的非同步操作。`Futures`還可以簡化需要並行發出多項服務並將其結果合併的情況【45】。 gRPC支援流,其中一個呼叫不僅包括一個請求和一個響應,還可以是隨時間的一系列請求和響應【46】。
|
||||
這種新一代的 RPC 框架更加明確的是,遠端請求與本地函式呼叫不同。例如,Finagle 和 Rest.li 使用 futures(promises)來封裝可能失敗的非同步操作。`Futures` 還可以簡化需要並行發出多項服務並將其結果合併的情況【45】。 gRPC 支援流,其中一個呼叫不僅包括一個請求和一個響應,還可以是隨時間的一系列請求和響應【46】。
|
||||
|
||||
其中一些框架還提供服務發現,即允許客戶端找出在哪個IP地址和埠號上可以找到特定的服務。我們將在“[請求路由](ch6.md#請求路由)”中回到這個主題。
|
||||
其中一些框架還提供服務發現,即允許客戶端找出在哪個 IP 地址和埠號上可以找到特定的服務。我們將在 “[請求路由](ch6.md#請求路由)” 中回到這個主題。
|
||||
|
||||
使用二進位制編碼格式的自定義RPC協議可以實現比通用的JSON over REST更好的效能。但是,RESTful API還有其他一些顯著的優點:方便實驗和除錯(只需使用Web瀏覽器或命令列工具curl,無需任何程式碼生成或軟體安裝即可向其請求),能被所有主流的程式語言和平臺所支援,還有大量可用的工具(伺服器,快取,負載平衡器,代理,防火牆,監控,除錯工具,測試工具等)的生態系統。
|
||||
使用二進位制編碼格式的自定義 RPC 協議可以實現比通用的 JSON over REST 更好的效能。但是,RESTful API 還有其他一些顯著的優點:方便實驗和除錯(只需使用 Web 瀏覽器或命令列工具 curl,無需任何程式碼生成或軟體安裝即可向其請求),能被所有主流的程式語言和平臺所支援,還有大量可用的工具(伺服器,快取,負載平衡器,代理,防火牆,監控,除錯工具,測試工具等)的生態系統。
|
||||
|
||||
由於這些原因,REST似乎是公共API的主要風格。 RPC框架的主要重點在於同一組織擁有的服務之間的請求,通常在同一資料中心內。
|
||||
由於這些原因,REST 似乎是公共 API 的主要風格。 RPC 框架的主要重點在於同一組織擁有的服務之間的請求,通常在同一資料中心內。
|
||||
|
||||
#### 資料編碼與RPC的演化
|
||||
|
||||
對於可演化性,重要的是可以獨立更改和部署RPC客戶端和伺服器。與透過資料庫流動的資料相比(如上一節所述),我們可以在透過服務進行資料流的情況下做一個簡化的假設:假定所有的伺服器都會先更新,其次是所有的客戶端。因此,你只需要在請求上具有向後相容性,並且對響應具有前向相容性。
|
||||
對於可演化性,重要的是可以獨立更改和部署 RPC 客戶端和伺服器。與透過資料庫流動的資料相比(如上一節所述),我們可以在透過服務進行資料流的情況下做一個簡化的假設:假定所有的伺服器都會先更新,其次是所有的客戶端。因此,你只需要在請求上具有向後相容性,並且對響應具有前向相容性。
|
||||
|
||||
RPC方案的前後向相容性屬性從它使用的編碼方式中繼承:
|
||||
RPC 方案的前後向相容性屬性從它使用的編碼方式中繼承:
|
||||
|
||||
* Thrift,gRPC(Protobuf)和Avro RPC可以根據相應編碼格式的相容性規則進行演變。
|
||||
* 在SOAP中,請求和響應是使用XML模式指定的。這些可以演變,但有一些微妙的陷阱【47】。
|
||||
* RESTful API通常使用JSON(沒有正式指定的模式)用於響應,以及用於請求的JSON或URI編碼/表單編碼的請求引數。新增可選的請求引數並向響應物件新增新的欄位通常被認為是保持相容性的改變。
|
||||
* Thrift、gRPC(Protobuf)和 Avro RPC 可以根據相應編碼格式的相容性規則進行演變。
|
||||
* 在 SOAP 中,請求和響應是使用 XML 模式指定的。這些可以演變,但有一些微妙的陷阱【47】。
|
||||
* RESTful API 通常使用 JSON(沒有正式指定的模式)用於響應,以及用於請求的 JSON 或 URI 編碼 / 表單編碼的請求引數。新增可選的請求引數並向響應物件新增新的欄位通常被認為是保持相容性的改變。
|
||||
|
||||
由於RPC經常被用於跨越組織邊界的通訊,所以服務的相容性變得更加困難,因此服務的提供者經常無法控制其客戶,也不能強迫他們升級。因此,需要長期保持相容性,也許是無限期的。如果需要進行相容性更改,則服務提供商通常會並排維護多個版本的服務API。
|
||||
由於 RPC 經常被用於跨越組織邊界的通訊,所以服務的相容性變得更加困難,因此服務的提供者經常無法控制其客戶,也不能強迫他們升級。因此,需要長期保持相容性,也許是無限期的。如果需要進行相容性更改,則服務提供商通常會並排維護多個版本的服務 API。
|
||||
|
||||
關於API版本化應該如何工作(即,客戶端如何指示它想要使用哪個版本的API)沒有一致意見【48】)。對於RESTful API,常用的方法是在URL或HTTP Accept頭中使用版本號。對於使用API金鑰來標識特定客戶端的服務,另一種選擇是將客戶端請求的API版本儲存在伺服器上,並允許透過單獨的管理介面更新該版本選項【49】。
|
||||
關於 API 版本化應該如何工作(即,客戶端如何指示它想要使用哪個版本的 API)沒有一致意見【48】)。對於 RESTful API,常用的方法是在 URL 或 HTTP Accept 頭中使用版本號。對於使用 API 金鑰來標識特定客戶端的服務,另一種選擇是將客戶端請求的 API 版本儲存在伺服器上,並允許透過單獨的管理介面更新該版本選項【49】。
|
||||
|
||||
### 訊息傳遞中的資料流
|
||||
|
||||
我們一直在研究從一個過程到另一個過程的編碼資料流的不同方式。到目前為止,我們已經討論了REST和RPC(其中一個程序透過網路向另一個程序傳送請求並期望儘可能快的響應)以及資料庫(一個程序寫入編碼資料,另一個程序在將來再次讀取)。
|
||||
我們一直在研究從一個過程到另一個過程的編碼資料流的不同方式。到目前為止,我們已經討論了 REST 和 RPC(其中一個程序透過網路向另一個程序傳送請求並期望儘可能快的響應)以及資料庫(一個程序寫入編碼資料,另一個程序在將來再次讀取)。
|
||||
|
||||
在最後一節中,我們將簡要介紹一下RPC和資料庫之間的非同步訊息傳遞系統。它們與RPC類似,因為客戶端的請求(通常稱為訊息)以低延遲傳送到另一個程序。它們與資料庫類似,不是透過直接的網路連線傳送訊息,而是透過稱為訊息代理(也稱為訊息佇列或面向訊息的中介軟體)的中介來臨時儲存訊息。
|
||||
在最後一節中,我們將簡要介紹一下 RPC 和資料庫之間的非同步訊息傳遞系統。它們與 RPC 類似,因為客戶端的請求(通常稱為訊息)以低延遲傳送到另一個程序。它們與資料庫類似,不是透過直接的網路連線傳送訊息,而是透過稱為訊息代理(也稱為訊息佇列或面向訊息的中介軟體)的中介來臨時儲存訊息。
|
||||
|
||||
與直接RPC相比,使用訊息代理有幾個優點:
|
||||
與直接 RPC 相比,使用訊息代理有幾個優點:
|
||||
|
||||
* 如果收件人不可用或過載,可以充當緩衝區,從而提高系統的可靠性。
|
||||
* 它可以自動將訊息重新發送到已經崩潰的程序,從而防止訊息丟失。
|
||||
* 避免發件人需要知道收件人的IP地址和埠號(這在虛擬機器經常出入的雲部署中特別有用)。
|
||||
* 避免發件人需要知道收件人的 IP 地址和埠號(這在虛擬機器經常出入的雲部署中特別有用)。
|
||||
* 它允許將一條訊息傳送給多個收件人。
|
||||
* 將發件人與收件人邏輯分離(發件人只是釋出郵件,不關心使用者)。
|
||||
|
||||
然而,與RPC相比,差異在於訊息傳遞通訊通常是單向的:傳送者通常不期望收到其訊息的回覆。一個程序可能傳送一個響應,但這通常是在一個單獨的通道上完成的。這種通訊模式是非同步的:傳送者不會等待訊息被傳遞,而只是傳送它,然後忘記它。
|
||||
然而,與 RPC 相比,差異在於訊息傳遞通訊通常是單向的:傳送者通常不期望收到其訊息的回覆。一個程序可能傳送一個響應,但這通常是在一個單獨的通道上完成的。這種通訊模式是非同步的:傳送者不會等待訊息被傳遞,而只是傳送它,然後忘記它。
|
||||
|
||||
#### 訊息代理
|
||||
|
||||
過去,**訊息代理(Message Broker)** 主要是TIBCO、IBM WebSphere和webMethods等公司的商業軟體的秀場。最近像RabbitMQ、ActiveMQ、HornetQ、NATS和Apache Kafka這樣的開源實現已經流行起來。我們將在[第十一章](ch11.md)中對它們進行更詳細的比較。
|
||||
過去,**訊息代理(Message Broker)** 主要是 TIBCO、IBM WebSphere 和 webMethods 等公司的商業軟體的秀場。最近像 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 這樣的開源實現已經流行起來。我們將在 [第十一章](ch11.md) 中對它們進行更詳細的比較。
|
||||
|
||||
詳細的交付語義因實現和配置而異,但通常情況下,訊息代理的使用方式如下:一個程序將訊息傳送到指定的佇列或主題,代理確保將訊息傳遞給那個佇列或主題的一個或多個消費者或訂閱者。在同一主題上可以有許多生產者和許多消費者。
|
||||
|
||||
一個主題只提供單向資料流。但是,消費者本身可能會將訊息釋出到另一個主題上(因此,可以將它們連結在一起,就像我們將在[第十一章](ch11.md)中看到的那樣),或者傳送給原始訊息的傳送者使用的回覆佇列(允許請求/響應資料流,類似於RPC)。
|
||||
一個主題只提供單向資料流。但是,消費者本身可能會將訊息釋出到另一個主題上(因此,可以將它們連結在一起,就像我們將在 [第十一章](ch11.md) 中看到的那樣),或者傳送給原始訊息的傳送者使用的回覆佇列(允許請求 / 響應資料流,類似於 RPC)。
|
||||
|
||||
訊息代理通常不會執行任何特定的資料模型 —— 訊息只是包含一些元資料的位元組序列,因此你可以使用任何編碼格式。如果編碼是向後和向前相容的,你可以靈活地對釋出者和消費者的編碼進行獨立的修改,並以任意順序進行部署。
|
||||
|
||||
如果消費者重新發布訊息到另一個主題,則可能需要小心保留未知欄位,以防止前面在資料庫環境中描述的問題([圖4-7](../img/fig4-7.png))。
|
||||
如果消費者重新發布訊息到另一個主題,則可能需要小心保留未知欄位,以防止前面在資料庫環境中描述的問題([圖 4-7](../img/fig4-7.png))。
|
||||
|
||||
#### 分散式的Actor框架
|
||||
|
||||
Actor模型是單個程序中併發的程式設計模型。邏輯被封裝在actor中,而不是直接處理執行緒(以及競爭條件,鎖定和死鎖的相關問題)。每個actor通常代表一個客戶或實體,它可能有一些本地狀態(不與其他任何角色共享),它透過傳送和接收非同步訊息與其他角色通訊。不保證訊息傳送:在某些錯誤情況下,訊息將丟失。由於每個角色一次只能處理一條訊息,因此不需要擔心執行緒,每個角色可以由框架獨立排程。
|
||||
Actor 模型是單個程序中併發的程式設計模型。邏輯被封裝在 actor 中,而不是直接處理執行緒(以及競爭條件、鎖定和死鎖的相關問題)。每個 actor 通常代表一個客戶或實體,它可能有一些本地狀態(不與其他任何角色共享),它透過傳送和接收非同步訊息與其他角色通訊。不保證訊息傳送:在某些錯誤情況下,訊息將丟失。由於每個角色一次只能處理一條訊息,因此不需要擔心執行緒,每個角色可以由框架獨立排程。
|
||||
|
||||
在分散式Actor框架中,此程式設計模型用於跨多個節點伸縮應用程式。不管傳送方和接收方是在同一個節點上還是在不同的節點上,都使用相同的訊息傳遞機制。如果它們在不同的節點上,則該訊息被透明地編碼成位元組序列,透過網路傳送,並在另一側解碼。
|
||||
在分散式 Actor 框架中,此程式設計模型用於跨多個節點伸縮應用程式。不管傳送方和接收方是在同一個節點上還是在不同的節點上,都使用相同的訊息傳遞機制。如果它們在不同的節點上,則該訊息被透明地編碼成位元組序列,透過網路傳送,並在另一側解碼。
|
||||
|
||||
位置透明在actor模型中比在RPC中效果更好,因為actor模型已經假定訊息可能會丟失,即使在單個程序中也是如此。儘管網路上的延遲可能比同一個程序中的延遲更高,但是在使用actor模型時,本地和遠端通訊之間的基本不匹配是較少的。
|
||||
位置透明在 actor 模型中比在 RPC 中效果更好,因為 actor 模型已經假定訊息可能會丟失,即使在單個程序中也是如此。儘管網路上的延遲可能比同一個程序中的延遲更高,但是在使用 actor 模型時,本地和遠端通訊之間的基本不匹配是較少的。
|
||||
|
||||
分散式的Actor框架實質上是將訊息代理和actor程式設計模型整合到一個框架中。但是,如果要執行基於actor的應用程式的滾動升級,則仍然需要擔心向前和向後相容性問題,因為訊息可能會從執行新版本的節點發送到執行舊版本的節點,反之亦然。
|
||||
分散式的 Actor 框架實質上是將訊息代理和 actor 程式設計模型整合到一個框架中。但是,如果要執行基於 actor 的應用程式的滾動升級,則仍然需要擔心向前和向後相容性問題,因為訊息可能會從執行新版本的節點發送到執行舊版本的節點,反之亦然。
|
||||
|
||||
三個流行的分散式actor框架處理訊息編碼如下:
|
||||
三個流行的分散式 actor 框架處理訊息編碼如下:
|
||||
|
||||
* 預設情況下,Akka使用Java的內建序列化,不提供前向或後向相容性。 但是,你可以用類似Prototol Buffers的東西替代它,從而獲得滾動升級的能力【50】。
|
||||
* Orleans 預設使用不支援滾動升級部署的自定義資料編碼格式; 要部署新版本的應用程式,你需要設定一個新的叢集,將流量從舊叢集遷移到新叢集,然後關閉舊叢集【51,52】。 像Akka一樣,可以使用自定義序列化外掛。
|
||||
* 在Erlang OTP中,對記錄模式進行更改是非常困難的(儘管系統具有許多為高可用性設計的功能)。 滾動升級是可能的,但需要仔細計劃【53】。 一個新的實驗性的`maps`資料型別(2014年在Erlang R17中引入的類似於JSON的結構)可能使得這個資料型別在未來更容易【54】。
|
||||
* 預設情況下,Akka 使用 Java 的內建序列化,不提供前向或後向相容性。 但是,你可以用類似 Prototol Buffers 的東西替代它,從而獲得滾動升級的能力【50】。
|
||||
* Orleans 預設使用不支援滾動升級部署的自定義資料編碼格式;要部署新版本的應用程式,你需要設定一個新的叢集,將流量從舊叢集遷移到新叢集,然後關閉舊叢集【51,52】。 像 Akka 一樣,可以使用自定義序列化外掛。
|
||||
* 在 Erlang OTP 中,對記錄模式進行更改是非常困難的(儘管系統具有許多為高可用性設計的功能)。 滾動升級是可能的,但需要仔細計劃【53】。 一個新的實驗性的 `maps` 資料型別(2014 年在 Erlang R17 中引入的類似於 JSON 的結構)可能使得這個資料型別在未來更容易【54】。
|
||||
|
||||
|
||||
## 本章小結
|
||||
@ -507,13 +507,13 @@ Actor模型是單個程序中併發的程式設計模型。邏輯被封裝在act
|
||||
我們討論了幾種資料編碼格式及其相容性屬性:
|
||||
|
||||
* 程式語言特定的編碼僅限於單一程式語言,並且往往無法提供前向和後向相容性。
|
||||
* JSON、XML和CSV等文字格式非常普遍,其相容性取決於你如何使用它們。他們有可選的模式語言,這有時是有用的,有時是一個障礙。這些格式對於資料型別有些模糊,所以你必須小心數字和二進位制字串。
|
||||
* 像Thrift、Protocol Buffers和Avro這樣的二進位制模式驅動格式允許使用清晰定義的前向和後向相容性語義進行緊湊,高效的編碼。這些模式可以用於靜態型別語言的文件和程式碼生成。但是,他們有一個缺點,就是在資料可讀之前需要對資料進行解碼。
|
||||
* JSON、XML 和 CSV 等文字格式非常普遍,其相容性取決於你如何使用它們。他們有可選的模式語言,這有時是有用的,有時是一個障礙。這些格式對於資料型別有些模糊,所以你必須小心數字和二進位制字串。
|
||||
* 像 Thrift、Protocol Buffers 和 Avro 這樣的二進位制模式驅動格式允許使用清晰定義的前向和後向相容性語義進行緊湊,高效的編碼。這些模式可以用於靜態型別語言的文件和程式碼生成。但是,他們有一個缺點,就是在資料可讀之前需要對資料進行解碼。
|
||||
|
||||
我們還討論了資料流的幾種模式,說明了資料編碼重要性的不同場景:
|
||||
|
||||
* 資料庫,寫入資料庫的程序對資料進行編碼,並從資料庫讀取程序對其進行解碼
|
||||
* RPC和REST API,客戶端對請求進行編碼,伺服器對請求進行解碼並對響應進行編碼,客戶端最終對響應進行解碼
|
||||
* RPC 和 REST API,客戶端對請求進行編碼,伺服器對請求進行解碼並對響應進行編碼,客戶端最終對響應進行解碼
|
||||
* 非同步訊息傳遞(使用訊息代理或參與者),其中節點之間透過傳送訊息進行通訊,訊息由傳送者編碼並由接收者解碼
|
||||
|
||||
我們可以小心地得出這樣的結論:前向相容性和滾動升級在某種程度上是可以實現的。願你的應用程式的演變迅速、敏捷部署。
|
||||
|
382
zh-tw/ch5.md
382
zh-tw/ch5.md
@ -2,73 +2,73 @@
|
||||
|
||||

|
||||
|
||||
> 與可能出錯的東西比,'不可能'出錯的東西最顯著的特點就是:一旦真的出錯,通常就徹底玩完了。
|
||||
> 與可能出錯的東西比,“不可能”出錯的東西最顯著的特點就是:一旦真的出錯,通常就徹底玩完了。
|
||||
>
|
||||
> —— 道格拉斯·亞當斯(1992)
|
||||
> —— 道格拉斯・亞當斯(1992)
|
||||
|
||||
------
|
||||
|
||||
[TOC]
|
||||
|
||||
複製意味著在透過網路連線的多臺機器上保留相同資料的副本。正如在[第二部分](part-ii.md)的介紹中所討論的那樣,我們希望能複製資料,可能出於各種各樣的原因:
|
||||
複製意味著在透過網路連線的多臺機器上保留相同資料的副本。正如在 [第二部分](part-ii.md) 的介紹中所討論的那樣,我們希望能複製資料,可能出於各種各樣的原因:
|
||||
|
||||
* 使得資料與使用者在地理上接近(從而減少延遲)
|
||||
* 即使系統的一部分出現故障,系統也能繼續工作(從而提高可用性)
|
||||
* 伸縮可以接受讀請求的機器數量(從而提高讀取吞吐量)
|
||||
|
||||
本章將假設你的資料集非常小,每臺機器都可以儲存整個資料集的副本。在[第六章](ch6.md)中將放寬這個假設,討論對單個機器來說太大的資料集的分割(分片)。在後面的章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理這些故障。
|
||||
本章將假設你的資料集非常小,每臺機器都可以儲存整個資料集的副本。在 [第六章](ch6.md) 中將放寬這個假設,討論對單個機器來說太大的資料集的分割(分片)。在後面的章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理這些故障。
|
||||
|
||||
如果複製中的資料不會隨時間而改變,那複製就很簡單:將資料複製到每個節點一次就萬事大吉。複製的困難之處在於處理複製資料的**變更(change)**,這就是本章所要講的。我們將討論三種流行的變更復制演算法:**單領導者(single leader)**,**多領導者(multi leader)** 和**無領導者(leaderless)**。幾乎所有分散式資料庫都使用這三種方法之一。
|
||||
如果複製中的資料不會隨時間而改變,那複製就很簡單:將資料複製到每個節點一次就萬事大吉。複製的困難之處在於處理複製資料的 **變更(change)**,這就是本章所要講的。我們將討論三種流行的變更復制演算法:**單領導者(single leader)**,**多領導者(multi leader)** 和 **無領導者(leaderless)**。幾乎所有分散式資料庫都使用這三種方法之一。
|
||||
|
||||
在複製時需要進行許多權衡:例如,使用同步複製還是非同步複製?如何處理失敗的副本?這些通常是資料庫中的配置選項,細節因資料庫而異,但原理在許多不同的實現中都類似。本章會討論這些決策的後果。
|
||||
|
||||
資料庫的複製算得上是老生常談了 ——70年代研究得出的基本原則至今沒有太大變化【1】,因為網路的基本約束仍保持不變。然而在研究之外,許多開發人員仍然假設一個數據庫只有一個節點。分散式資料庫變為主流只是最近發生的事。許多程式設計師都是這一領域的新手,因此對於諸如 **最終一致性(eventual consistency)** 等問題存在許多誤解。在“[複製延遲問題](#複製延遲問題)”一節,我們將更加精確地瞭解最終的一致性,並討論諸如 **讀己之寫(read-your-writes)** 和 **單調讀(monotonic read)** 保證等內容。
|
||||
資料庫的複製算得上是老生常談了 ——70 年代研究得出的基本原則至今沒有太大變化【1】,因為網路的基本約束仍保持不變。然而在研究之外,許多開發人員仍然假設一個數據庫只有一個節點。分散式資料庫變為主流只是最近發生的事。許多程式設計師都是這一領域的新手,因此對於諸如 **最終一致性(eventual consistency)** 等問題存在許多誤解。在 “[複製延遲問題](#複製延遲問題)” 一節,我們將更加精確地瞭解最終的一致性,並討論諸如 **讀己之寫(read-your-writes)** 和 **單調讀(monotonic read)** 保證等內容。
|
||||
|
||||
## 領導者與追隨者
|
||||
|
||||
儲存資料庫副本的每個節點稱為 **副本(replica)** 。當存在多個副本時,會不可避免的出現一個問題:如何確保所有資料都落在了所有的副本上?
|
||||
|
||||
每一次向資料庫的寫入操作都需要傳播到所有副本上,否則副本就會包含不一樣的資料。最常見的解決方案被稱為 **基於領導者的複製(leader-based replication)** (也稱 **主動/被動(active/passive)** 或 **主/從(master/slave)** 複製),如[圖5-1](#fig5-1.png)所示。它的工作原理如下:
|
||||
每一次向資料庫的寫入操作都需要傳播到所有副本上,否則副本就會包含不一樣的資料。最常見的解決方案被稱為 **基於領導者的複製(leader-based replication)** (也稱 **主動 / 被動(active/passive)** 或 **主 / 從(master/slave)** 複製),如 [圖 5-1](#fig5-1.png) 所示。它的工作原理如下:
|
||||
|
||||
1. 副本之一被指定為 **領導者(leader)**,也稱為 **主庫(master|primary)** 。當客戶端要向資料庫寫入時,它必須將請求傳送給**領導者**,領導者會將新資料寫入其本地儲存。
|
||||
2. 其他副本被稱為**追隨者(followers)**,亦稱為**只讀副本(read replicas)**,**從庫(slaves)**,**備庫( secondaries)**,**熱備(hot-standby)**[^i]。每當領導者將新資料寫入本地儲存時,它也會將資料變更傳送給所有的追隨者,稱之為**複製日誌(replication log)** 記錄或**變更流(change stream)**。每個跟隨者從領導者拉取日誌,並相應更新其本地資料庫副本,方法是按照領導者處理的相同順序應用所有寫入。
|
||||
1. 副本之一被指定為 **領導者(leader)**,也稱為 **主庫(master|primary)** 。當客戶端要向資料庫寫入時,它必須將請求傳送給 **領導者**,領導者會將新資料寫入其本地儲存。
|
||||
2. 其他副本被稱為 **追隨者(followers)**,亦稱為 **只讀副本(read replicas)**,**從庫(slaves)**,**備庫( secondaries)**,**熱備(hot-standby)**[^i]。每當領導者將新資料寫入本地儲存時,它也會將資料變更傳送給所有的追隨者,稱之為 **複製日誌(replication log)** 記錄或 **變更流(change stream)**。每個跟隨者從領導者拉取日誌,並相應更新其本地資料庫副本,方法是按照領導者處理的相同順序應用所有寫入。
|
||||
3. 當客戶想要從資料庫中讀取資料時,它可以向領導者或追隨者查詢。 但只有領導者才能接受寫操作(從客戶端的角度來看從庫都是隻讀的)。
|
||||
|
||||
[^i]: 不同的人對 **熱(hot)**,**溫(warm)**,**冷(cold)** 備份伺服器有不同的定義。 例如在PostgreSQL中,**熱備(hot standby)** 指的是能接受客戶端讀請求的副本。而 **溫備(warm standby)** 只是追隨領導者,但不處理客戶端的任何查詢。 就本書而言,這些差異並不重要。
|
||||
[^i]: 不同的人對 **熱(hot)**,**溫(warm)**,**冷(cold)** 備份伺服器有不同的定義。 例如在 PostgreSQL 中,**熱備(hot standby)** 指的是能接受客戶端讀請求的副本。而 **溫備(warm standby)** 只是追隨領導者,但不處理客戶端的任何查詢。 就本書而言,這些差異並不重要。
|
||||
|
||||

|
||||
**圖5-1 基於領導者(主-從)的複製**
|
||||
**圖 5-1 基於領導者 (主 - 從) 的複製**
|
||||
|
||||
這種複製模式是許多關係資料庫的內建功能,如PostgreSQL(從9.0版本開始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性組【3】。 它也被用於一些非關係資料庫,包括MongoDB,RethinkDB和Espresso 【4】。 最後,基於領導者的複製並不僅限於資料庫:像Kafka 【5】和RabbitMQ高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統,例如DRBD這樣的塊複製裝置也與之類似。
|
||||
這種複製模式是許多關係資料庫的內建功能,如 PostgreSQL(從 9.0 版本開始),MySQL,Oracle Data Guard 【2】和 SQL Server 的 AlwaysOn 可用性組【3】。 它也被用於一些非關係資料庫,包括 MongoDB,RethinkDB 和 Espresso 【4】。 最後,基於領導者的複製並不僅限於資料庫:像 Kafka 【5】和 RabbitMQ 高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統,例如 DRBD 這樣的塊複製裝置也與之類似。
|
||||
|
||||
### 同步複製與非同步複製
|
||||
|
||||
複製系統的一個重要細節是:複製是 **同步(synchronously)** 發生還是 **非同步(asynchronously)** 發生。 (在關係型資料庫中這通常是一個配置項,其他系統通常硬編碼為其中一個)。
|
||||
|
||||
想象[圖5-1](fig5-1.png)中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。
|
||||
想象 [圖 5-1](fig5-1.png) 中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。
|
||||
|
||||
[圖5-2](../img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。
|
||||
[圖 5-2](../img/fig5-2.png) 顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。
|
||||
|
||||

|
||||
**圖5-2 基於領導者的複製:一個同步從庫和一個非同步從庫**
|
||||
**圖 5-2 基於領導者的複製:一個同步從庫和一個非同步從庫**
|
||||
|
||||
在[圖5-2](../img/fig5-2.png)的示例中,從庫1的複製是同步的:在向用戶報告寫入成功,並使結果對其他使用者可見之前,主庫需要等待從庫1的確認,確保從庫1已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者2的複製是非同步的:主庫傳送訊息,但不等待從庫的響應。
|
||||
在 [圖 5-2](../img/fig5-2.png) 的示例中,從庫 1 的複製是同步的:在向用戶報告寫入成功,並使結果對其他使用者可見之前,主庫需要等待從庫 1 的確認,確保從庫 1 已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者 2 的複製是非同步的:主庫傳送訊息,但不等待從庫的響應。
|
||||
|
||||
在這幅圖中,從庫2處理訊息前存在一個顯著的延遲。通常情況下,複製的速度相當快:大多數資料庫系統能在一秒向從庫應用變更,但它們不能提供複製用時的保證。有些情況下,從庫可能落後主庫幾分鐘或更久;例如:從庫正在從故障中恢復,系統在最大容量附近執行,或者如果節點間存在網路問題。
|
||||
在這幅圖中,從庫 2 處理訊息前存在一個顯著的延遲。通常情況下,複製的速度相當快:大多數資料庫系統能在一秒向從庫應用變更,但它們不能提供複製用時的保證。有些情況下,從庫可能落後主庫幾分鐘或更久;例如:從庫正在從故障中恢復,系統在最大容量附近執行,或者如果節點間存在網路問題。
|
||||
|
||||
同步複製的優點是,從庫保證有與主庫一致的最新資料副本。如果主庫突然失效,我們可以確信這些資料仍然能在從庫上上找到。缺點是,如果同步從庫沒有響應(比如它已經崩潰,或者出現網路故障,或其它任何原因),主庫就無法處理寫入操作。主庫必須阻止所有寫入,並等待同步副本再次可用。
|
||||
|
||||
因此,將所有從庫都設定為同步的是不切實際的:任何一個節點的中斷都會導致整個系統停滯不前。實際上,如果在資料庫上啟用同步複製,通常意味著其中**一個**跟隨者是同步的,而其他的則是非同步的。如果同步從庫變得不可用或緩慢,則使一個非同步從庫同步。這保證你至少在兩個節點上擁有最新的資料副本:主庫和同步從庫。 這種配置有時也被稱為 **半同步(semi-synchronous)**【7】。
|
||||
因此,將所有從庫都設定為同步的是不切實際的:任何一個節點的中斷都會導致整個系統停滯不前。實際上,如果在資料庫上啟用同步複製,通常意味著其中 **一個** 跟隨者是同步的,而其他的則是非同步的。如果同步從庫變得不可用或緩慢,則使一個非同步從庫同步。這保證你至少在兩個節點上擁有最新的資料副本:主庫和同步從庫。 這種配置有時也被稱為 **半同步(semi-synchronous)**【7】。
|
||||
|
||||
通常情況下,基於領導者的複製都配置為完全非同步。 在這種情況下,如果主庫失效且不可恢復,則任何尚未複製給從庫的寫入都會丟失。 這意味著即使已經向客戶端確認成功,寫入也不能保證 **持久(Durable)** 。 然而,一個完全非同步的配置也有優點:即使所有的從庫都落後了,主庫也可以繼續處理寫入。
|
||||
|
||||
弱化的永續性可能聽起來像是一個壞的折衷,然而非同步複製已經被廣泛使用了,特別當有很多追隨者,或追隨者異地分佈時。 稍後將在“[複製延遲問題](#複製延遲問題)”中回到這個問題。
|
||||
弱化的永續性可能聽起來像是一個壞的折衷,然而非同步複製已經被廣泛使用了,特別當有很多追隨者,或追隨者異地分佈時。 稍後將在 “[複製延遲問題](#複製延遲問題)” 中回到這個問題。
|
||||
|
||||
> ### 關於複製的研究
|
||||
>
|
||||
> 對於非同步複製系統而言,主庫故障時有可能丟失資料。這可能是一個嚴重的問題,因此研究人員仍在研究不丟資料但仍能提供良好效能和可用性的複製方法。 例如,**鏈式複製**【8,9】]是同步複製的一種變體,已經在一些系統(如Microsoft Azure儲存【10,11】)中成功實現。
|
||||
> 對於非同步複製系統而言,主庫故障時有可能丟失資料。這可能是一個嚴重的問題,因此研究人員仍在研究不丟資料但仍能提供良好效能和可用性的複製方法。 例如,**鏈式複製**【8,9】] 是同步複製的一種變體,已經在一些系統(如 Microsoft Azure 儲存【10,11】)中成功實現。
|
||||
>
|
||||
> 複製的一致性與**共識**(consensus,使幾個節點就某個值達成一致)之間有著密切的聯絡,[第九章](ch9.md)將詳細地探討這一領域的理論。本章主要討論實踐中資料庫常用的簡單複製形式。
|
||||
> 複製的一致性與 **共識**(consensus,使幾個節點就某個值達成一致)之間有著密切的聯絡,[第九章](ch9.md) 將詳細地探討這一領域的理論。本章主要討論實踐中資料庫常用的簡單複製形式。
|
||||
>
|
||||
|
||||
### 設定新從庫
|
||||
@ -79,9 +79,9 @@
|
||||
|
||||
可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但是這會違背高可用的目標。幸運的是,拉起新的從庫通常並不需要停機。從概念上講,過程如下所示:
|
||||
|
||||
1. 在某個時刻獲取主庫的一致性快照(如果可能),而不必鎖定整個資料庫。大多數資料庫都具有這個功能,因為它是備份必需的。對於某些場景,可能需要第三方工具,例如MySQL的innobackupex 【12】。
|
||||
1. 在某個時刻獲取主庫的一致性快照(如果可能),而不必鎖定整個資料庫。大多數資料庫都具有這個功能,因為它是備份必需的。對於某些場景,可能需要第三方工具,例如 MySQL 的 innobackupex 【12】。
|
||||
2. 將快照複製到新的從庫節點。
|
||||
3. 從庫連線到主庫,並拉取快照之後發生的所有資料變更。這要求快照與主庫複製日誌中的位置精確關聯。該位置有不同的名稱:例如,PostgreSQL將其稱為 **日誌序列號(log sequence number, LSN)**,MySQL將其稱為 **二進位制日誌座標(binlog coordinates)**。
|
||||
3. 從庫連線到主庫,並拉取快照之後發生的所有資料變更。這要求快照與主庫複製日誌中的位置精確關聯。該位置有不同的名稱:例如,PostgreSQL 將其稱為 **日誌序列號(log sequence number, LSN)**,MySQL 將其稱為 **二進位制日誌座標(binlog coordinates)**。
|
||||
4. 當從庫處理完快照之後積壓的資料變更,我們說它 **趕上(caught up)** 了主庫。現在它可以繼續處理主庫產生的資料變化了。
|
||||
|
||||
建立從庫的實際步驟因資料庫而異。在某些系統中,這個過程是完全自動化的,而在另外一些系統中,它可能是一個需要由管理員手動執行的,有點神祕的多步驟工作流。
|
||||
@ -98,21 +98,21 @@
|
||||
|
||||
#### 主庫失效:故障切換
|
||||
|
||||
主庫失效處理起來相當棘手:其中一個從庫需要被提升為新的主庫,需要重新配置客戶端,以將它們的寫操作傳送給新的主庫,其他從庫需要開始拉取來自新主庫的資料變更。這個過程被稱為**故障切換(failover)**。
|
||||
主庫失效處理起來相當棘手:其中一個從庫需要被提升為新的主庫,需要重新配置客戶端,以將它們的寫操作傳送給新的主庫,其他從庫需要開始拉取來自新主庫的資料變更。這個過程被稱為 **故障切換(failover)**。
|
||||
|
||||
故障切換可以手動進行(通知管理員主庫掛了,並採取必要的步驟來建立新的主庫)或自動進行。自動故障切換過程通常由以下步驟組成:
|
||||
|
||||
1. 確認主庫失效。有很多事情可能會出錯:崩潰,停電,網路問題等等。沒有萬無一失的方法來檢測出現了什麼問題,所以大多數系統只是簡單使用 **超時(Timeout)** :節點頻繁地相互來回傳遞訊息,並且如果一個節點在一段時間內(例如30秒)沒有響應,就認為它掛了(因為計劃內維護而故意關閉主庫不算)。
|
||||
2. 選擇一個新的主庫。這可以透過選舉過程(主庫由剩餘副本以多數選舉產生)來完成,或者可以由之前選定的**控制器節點(controller node)** 來指定新的主庫。主庫的最佳人選通常是擁有舊主庫最新資料副本的從庫(最小化資料損失)。讓所有的節點同意一個新的領導者,是一個**共識**問題,將在[第九章](ch9.md)詳細討論。
|
||||
3. 重新配置系統以啟用新的主庫。客戶端現在需要將它們的寫請求傳送給新主庫(將在“[請求路由](ch6.md#請求路由)”中討論這個問題)。如果舊主庫恢復,可能仍然認為自己是主庫,而沒有意識到其他副本已經讓它失去領導權了。系統需要確保舊主庫意識到新主庫的存在,併成為一個從庫。
|
||||
1. 確認主庫失效。有很多事情可能會出錯:崩潰,停電,網路問題等等。沒有萬無一失的方法來檢測出現了什麼問題,所以大多數系統只是簡單使用 **超時(Timeout)** :節點頻繁地相互來回傳遞訊息,並且如果一個節點在一段時間內(例如 30 秒)沒有響應,就認為它掛了(因為計劃內維護而故意關閉主庫不算)。
|
||||
2. 選擇一個新的主庫。這可以透過選舉過程(主庫由剩餘副本以多數選舉產生)來完成,或者可以由之前選定的 **控制器節點(controller node)** 來指定新的主庫。主庫的最佳人選通常是擁有舊主庫最新資料副本的從庫(最小化資料損失)。讓所有的節點同意一個新的領導者,是一個 **共識** 問題,將在 [第九章](ch9.md) 詳細討論。
|
||||
3. 重新配置系統以啟用新的主庫。客戶端現在需要將它們的寫請求傳送給新主庫(將在 “[請求路由](ch6.md#請求路由)” 中討論這個問題)。如果舊主庫恢復,可能仍然認為自己是主庫,而沒有意識到其他副本已經讓它失去領導權了。系統需要確保舊主庫意識到新主庫的存在,併成為一個從庫。
|
||||
|
||||
故障切換會出現很多大麻煩:
|
||||
|
||||
* 如果使用非同步複製,則新主庫可能沒有收到老主庫宕機前最後的寫入操作。在選出新主庫後,如果老主庫重新加入叢集,新主庫在此期間可能會收到衝突的寫入,那這些寫入該如何處理?最常見的解決方案是簡單丟棄老主庫未複製的寫入,這很可能打破客戶對於資料永續性的期望。
|
||||
|
||||
* 如果資料庫需要和其他外部儲存相協調,那麼丟棄寫入內容是極其危險的操作。例如在GitHub 【13】的一場事故中,一個過時的MySQL從庫被提升為主庫。資料庫使用自增ID作為主鍵,因為新主庫的計數器落後於老主庫的計數器,所以新主庫重新分配了一些已經被老主庫分配掉的ID作為主鍵。這些主鍵也在Redis中使用,主鍵重用使得MySQL和Redis中資料產生不一致,最後導致一些私有資料洩漏到錯誤的使用者手中。
|
||||
* 如果資料庫需要和其他外部儲存相協調,那麼丟棄寫入內容是極其危險的操作。例如在 GitHub 【13】的一場事故中,一個過時的 MySQL 從庫被提升為主庫。資料庫使用自增 ID 作為主鍵,因為新主庫的計數器落後於老主庫的計數器,所以新主庫重新分配了一些已經被老主庫分配掉的 ID 作為主鍵。這些主鍵也在 Redis 中使用,主鍵重用使得 MySQL 和 Redis 中資料產生不一致,最後導致一些私有資料洩漏到錯誤的使用者手中。
|
||||
|
||||
* 發生某些故障時(見[第八章](ch8.md))可能會出現兩個節點都以為自己是主庫的情況。這種情況稱為 **腦裂(split brain)**,非常危險:如果兩個主庫都可以接受寫操作,卻沒有衝突解決機制(請參閱“[多主複製](#多主複製)”),那麼資料就可能丟失或損壞。一些系統採取了安全防範措施:當檢測到兩個主庫節點同時存在時會關閉其中一個節點[^ii],但設計粗糙的機制可能最後會導致兩個節點都被關閉【14】。
|
||||
* 發生某些故障時(見 [第八章](ch8.md))可能會出現兩個節點都以為自己是主庫的情況。這種情況稱為 **腦裂 (split brain)**,非常危險:如果兩個主庫都可以接受寫操作,卻沒有衝突解決機制(請參閱 “[多主複製](#多主複製)”),那麼資料就可能丟失或損壞。一些系統採取了安全防範措施:當檢測到兩個主庫節點同時存在時會關閉其中一個節點 [^ii],但設計粗糙的機制可能最後會導致兩個節點都被關閉【14】。
|
||||
|
||||
[^ii]: 這種機制稱為 **遮蔽(fencing)**,充滿感情的術語是:**爆彼之頭(Shoot The Other Node In The Head, STONITH)**。
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
|
||||
這些問題沒有簡單的解決方案。因此,即使軟體支援自動故障切換,不少運維團隊還是更願意手動執行故障切換。
|
||||
|
||||
節點故障、不可靠的網路、對副本一致性,永續性,可用性和延遲的權衡 ,這些問題實際上是分散式系統中的基本問題。[第八章](ch8.md)和[第九章](ch9.md)將更深入地討論它們。
|
||||
節點故障、不可靠的網路、對副本一致性,永續性,可用性和延遲的權衡 ,這些問題實際上是分散式系統中的基本問題。[第八章](ch8.md) 和 [第九章](ch9.md) 將更深入地討論它們。
|
||||
|
||||
### 複製日誌的實現
|
||||
|
||||
@ -128,32 +128,32 @@
|
||||
|
||||
#### 基於語句的複製
|
||||
|
||||
在最簡單的情況下,主庫記錄下它執行的每個寫入請求(**語句**,即statement)並將該語句日誌傳送給其從庫。對於關係資料庫來說,這意味著每個`INSERT`、`UPDATE`或`DELETE`語句都被轉發給每個從庫,每個從庫解析並執行該SQL語句,就像從客戶端收到一樣。
|
||||
在最簡單的情況下,主庫記錄下它執行的每個寫入請求(**語句**,即 statement)並將該語句日誌傳送給其從庫。對於關係資料庫來說,這意味著每個 `INSERT`、`UPDATE` 或 `DELETE` 語句都被轉發給每個從庫,每個從庫解析並執行該 SQL 語句,就像從客戶端收到一樣。
|
||||
|
||||
雖然聽上去很合理,但有很多問題會搞砸這種複製方式:
|
||||
|
||||
* 任何呼叫 **非確定性函式(nondeterministic)** 的語句,可能會在每個副本上生成不同的值。例如,使用`NOW()`獲取當前日期時間,或使用`RAND()`獲取一個隨機數。
|
||||
* 如果語句使用了**自增列(auto increment)**,或者依賴於資料庫中的現有資料(例如,`UPDATE ... WHERE <某些條件>`),則必須在每個副本上按照完全相同的順序執行它們,否則可能會產生不同的效果。當有多個併發執行的事務時,這可能成為一個限制。
|
||||
* 有副作用的語句(例如,觸發器,儲存過程,使用者定義的函式)可能會在每個副本上產生不同的副作用,除非副作用是絕對確定的。
|
||||
* 任何呼叫 **非確定性函式(nondeterministic)** 的語句,可能會在每個副本上生成不同的值。例如,使用 `NOW()` 獲取當前日期時間,或使用 `RAND()` 獲取一個隨機數。
|
||||
* 如果語句使用了 **自增列(auto increment)**,或者依賴於資料庫中的現有資料(例如,`UPDATE ... WHERE <某些條件>`),則必須在每個副本上按照完全相同的順序執行它們,否則可能會產生不同的效果。當有多個併發執行的事務時,這可能成為一個限制。
|
||||
* 有副作用的語句(例如:觸發器、儲存過程、使用者定義的函式)可能會在每個副本上產生不同的副作用,除非副作用是絕對確定的。
|
||||
|
||||
的確有辦法繞開這些問題 ——例如,當語句被記錄時,主庫可以用固定的返回值替換任何不確定的函式呼叫,以便從庫獲得相同的值。但是由於邊緣情況實在太多了,現在通常會選擇其他的複製方法。
|
||||
的確有辦法繞開這些問題 —— 例如,當語句被記錄時,主庫可以用固定的返回值替換任何不確定的函式呼叫,以便從庫獲得相同的值。但是由於邊緣情況實在太多了,現在通常會選擇其他的複製方法。
|
||||
|
||||
基於語句的複製在5.1版本前的MySQL中使用。因為它相當緊湊,現在有時候也還在用。但現在在預設情況下,如果語句中存在任何不確定性,MySQL會切換到基於行的複製(稍後討論)。 VoltDB使用了基於語句的複製,但要求事務必須是確定性的,以此來保證安全【15】。
|
||||
基於語句的複製在 5.1 版本前的 MySQL 中使用。因為它相當緊湊,現在有時候也還在用。但現在在預設情況下,如果語句中存在任何不確定性,MySQL 會切換到基於行的複製(稍後討論)。 VoltDB 使用了基於語句的複製,但要求事務必須是確定性的,以此來保證安全【15】。
|
||||
|
||||
#### 傳輸預寫式日誌(WAL)
|
||||
|
||||
在[第三章](ch3.md)中,我們討論了儲存引擎如何在磁碟上表示資料,並且我們發現,通常寫操作都是追加到日誌中:
|
||||
在 [第三章](ch3.md) 中,我們討論了儲存引擎如何在磁碟上表示資料,並且我們發現,通常寫操作都是追加到日誌中:
|
||||
|
||||
* 對於日誌結構儲存引擎(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”),日誌是主要的儲存位置。日誌段在後臺壓縮,並進行垃圾回收。
|
||||
* 對於覆寫單個磁碟塊的[B樹](ch3.md#B樹),每次修改都會先寫入 **預寫式日誌(Write Ahead Log, WAL)**,以便崩潰後索引可以恢復到一個一致的狀態。
|
||||
* 對於日誌結構儲存引擎(請參閱 “[SSTables 和 LSM 樹](ch3.md#SSTables和LSM樹)”),日誌是主要的儲存位置。日誌段在後臺壓縮,並進行垃圾回收。
|
||||
* 對於覆寫單個磁碟塊的 [B 樹](ch3.md#B樹),每次修改都會先寫入 **預寫式日誌(Write Ahead Log, WAL)**,以便崩潰後索引可以恢復到一個一致的狀態。
|
||||
|
||||
在任何一種情況下,日誌都是包含所有資料庫寫入的僅追加位元組序列。可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟之外,主庫還可以透過網路將其傳送給其從庫。
|
||||
|
||||
當從庫應用這個日誌時,它會建立和主庫一模一樣資料結構的副本。
|
||||
|
||||
PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌記錄的資料非常底層:WAL包含哪些磁碟塊中的哪些位元組發生了更改。這使複製與儲存引擎緊密耦合。如果資料庫將其儲存格式從一個版本更改為另一個版本,通常不可能在主庫和從庫上執行不同版本的資料庫軟體。
|
||||
PostgreSQL 和 Oracle 等使用這種複製方法【16】。主要缺點是日誌記錄的資料非常底層:WAL 包含哪些磁碟塊中的哪些位元組發生了更改。這使複製與儲存引擎緊密耦合。如果資料庫將其儲存格式從一個版本更改為另一個版本,通常不可能在主庫和從庫上執行不同版本的資料庫軟體。
|
||||
|
||||
看上去這可能只是一個微小的實現細節,但卻可能對運維產生巨大的影響。如果複製協議允許從庫使用比主庫更新的軟體版本,則可以先升級從庫,然後執行故障切換,使升級後的節點之一成為新的主庫,從而執行資料庫軟體的零停機升級。如果複製協議不允許版本不匹配(傳輸WAL經常出現這種情況),則此類升級需要停機。
|
||||
看上去這可能只是一個微小的實現細節,但卻可能對運維產生巨大的影響。如果複製協議允許從庫使用比主庫更新的軟體版本,則可以先升級從庫,然後執行故障切換,使升級後的節點之一成為新的主庫,從而執行資料庫軟體的零停機升級。如果複製協議不允許版本不匹配(傳輸 WAL 經常出現這種情況),則此類升級需要停機。
|
||||
|
||||
#### 邏輯日誌複製(基於行)
|
||||
|
||||
@ -165,36 +165,36 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
* 對於刪除的行,日誌包含足夠的資訊來唯一標識已刪除的行。通常是主鍵,但是如果表上沒有主鍵,則需要記錄所有列的舊值。
|
||||
* 對於更新的行,日誌包含足夠的資訊來唯一標識更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
|
||||
|
||||
修改多行的事務會生成多個這樣的日誌記錄,後面跟著一條記錄,指出事務已經提交。 MySQL的二進位制日誌(當配置為使用基於行的複製時)使用這種方法【17】。
|
||||
修改多行的事務會生成多個這樣的日誌記錄,後面跟著一條記錄,指出事務已經提交。 MySQL 的二進位制日誌(當配置為使用基於行的複製時)使用這種方法【17】。
|
||||
|
||||
由於邏輯日誌與儲存引擎內部分離,因此可以更容易地保持向後相容,從而使領導者和跟隨者能夠執行不同版本的資料庫軟體甚至不同的儲存引擎。
|
||||
|
||||
對於外部應用程式來說,邏輯日誌格式也更容易解析。如果要將資料庫的內容傳送到外部系統,這一點很有用,例如複製到資料倉庫進行離線分析,或建立自定義索引和快取【18】。 這種技術被稱為 **資料變更捕獲(change data capture)**,[第十一章](ch11.md)將重新講到它。
|
||||
對於外部應用程式來說,邏輯日誌格式也更容易解析。如果要將資料庫的內容傳送到外部系統,這一點很有用,例如複製到資料倉庫進行離線分析,或建立自定義索引和快取【18】。 這種技術被稱為 **資料變更捕獲(change data capture)**,[第十一章](ch11.md) 將重新講到它。
|
||||
|
||||
#### 基於觸發器的複製
|
||||
|
||||
到目前為止描述的複製方法是由資料庫系統實現的,不涉及任何應用程式程式碼。在很多情況下,這就是你想要的。但在某些情況下需要更多的靈活性。例如,如果你只想複製資料的一個子集,或者想從一種資料庫複製到另一種資料庫,或者如果你需要衝突解決邏輯(請參閱“[處理寫入衝突](#處理寫入衝突)”),則可能需要將複製移動到應用程式層。
|
||||
到目前為止描述的複製方法是由資料庫系統實現的,不涉及任何應用程式程式碼。在很多情況下,這就是你想要的。但在某些情況下需要更多的靈活性。例如,如果你只想複製資料的一個子集,或者想從一種資料庫複製到另一種資料庫,或者如果你需要衝突解決邏輯(請參閱 “[處理寫入衝突](#處理寫入衝突)”),則可能需要將複製移動到應用程式層。
|
||||
|
||||
一些工具,如Oracle Golden Gate 【19】,可以透過讀取資料庫日誌,使得其他應用程式可以使用資料。另一種方法是使用許多關係資料庫自帶的功能:觸發器和儲存過程。
|
||||
一些工具,如 Oracle Golden Gate 【19】,可以透過讀取資料庫日誌,使得其他應用程式可以使用資料。另一種方法是使用許多關係資料庫自帶的功能:觸發器和儲存過程。
|
||||
|
||||
觸發器允許你註冊在資料庫系統中發生資料更改(寫入事務)時自動執行的自定義應用程式程式碼。觸發器有機會將更改記錄到一個單獨的表中,使用外部程式讀取這個表,再加上任何業務邏輯處理,會後將資料變更復制到另一個系統去。例如,Databus for Oracle 【20】和Bucardo for Postgres 【21】就是這樣工作的。
|
||||
觸發器允許你註冊在資料庫系統中發生資料更改(寫入事務)時自動執行的自定義應用程式程式碼。觸發器有機會將更改記錄到一個單獨的表中,使用外部程式讀取這個表,再加上任何業務邏輯處理,會後將資料變更復制到另一個系統去。例如,Databus for Oracle 【20】和 Bucardo for Postgres 【21】就是這樣工作的。
|
||||
|
||||
基於觸發器的複製通常比其他複製方法具有更高的開銷,並且比資料庫的內建複製更容易出錯,也有很多限制。然而由於其靈活性,仍然是很有用的。
|
||||
|
||||
|
||||
## 複製延遲問題
|
||||
|
||||
容忍節點故障只是需要複製的一個原因。正如在[第二部分](part-ii.md)的介紹中提到的,另一個原因是可伸縮性(處理比單個機器更多的請求)和延遲(讓副本在地理位置上更接近使用者)。
|
||||
容忍節點故障只是需要複製的一個原因。正如在 [第二部分](part-ii.md) 的介紹中提到的,另一個原因是可伸縮性(處理比單個機器更多的請求)和延遲(讓副本在地理位置上更接近使用者)。
|
||||
|
||||
基於主庫的複製要求所有寫入都由單個節點處理,但只讀查詢可以由任何副本處理。所以對於讀多寫少的場景(Web上的常見模式),一個有吸引力的選擇是建立很多從庫,並將讀請求分散到所有的從庫上去。這樣能減小主庫的負載,並允許向最近的副本傳送讀請求。
|
||||
基於主庫的複製要求所有寫入都由單個節點處理,但只讀查詢可以由任何副本處理。所以對於讀多寫少的場景(Web 上的常見模式),一個有吸引力的選擇是建立很多從庫,並將讀請求分散到所有的從庫上去。這樣能減小主庫的負載,並允許向最近的副本傳送讀請求。
|
||||
|
||||
在這種伸縮體系結構中,只需新增更多的追隨者,就可以提高只讀請求的服務容量。但是,這種方法實際上只適用於非同步複製——如果嘗試同步複製到所有追隨者,則單個節點故障或網路中斷將使整個系統無法寫入。而且越多的節點越有可能會被關閉,所以完全同步的配置是非常不可靠的。
|
||||
在這種伸縮體系結構中,只需新增更多的追隨者,就可以提高只讀請求的服務容量。但是,這種方法實際上只適用於非同步複製 —— 如果嘗試同步複製到所有追隨者,則單個節點故障或網路中斷將使整個系統無法寫入。而且越多的節點越有可能會被關閉,所以完全同步的配置是非常不可靠的。
|
||||
|
||||
不幸的是,當應用程式從非同步從庫讀取時,如果從庫落後,它可能會看到過時的資訊。這會導致資料庫中出現明顯的不一致:同時對主庫和從庫執行相同的查詢,可能得到不同的結果,因為並非所有的寫入都反映在從庫中。這種不一致只是一個暫時的狀態——如果停止寫入資料庫並等待一段時間,從庫最終會趕上並與主庫保持一致。出於這個原因,這種效應被稱為 **最終一致性(eventual consistency)**[^iii]【22,23】
|
||||
不幸的是,當應用程式從非同步從庫讀取時,如果從庫落後,它可能會看到過時的資訊。這會導致資料庫中出現明顯的不一致:同時對主庫和從庫執行相同的查詢,可能得到不同的結果,因為並非所有的寫入都反映在從庫中。這種不一致只是一個暫時的狀態 —— 如果停止寫入資料庫並等待一段時間,從庫最終會趕上並與主庫保持一致。出於這個原因,這種效應被稱為 **最終一致性(eventual consistency)**[^iii]【22,23】
|
||||
|
||||
[^iii]: 道格拉斯·特里(Douglas Terry)等人創造了術語最終一致性。 【24】 並經由Werner Vogels 【22】推廣,成為許多NoSQL專案的口號。 然而,不只有NoSQL資料庫是最終一致的:關係型資料庫中的非同步複製追隨者也有相同的特性。
|
||||
[^iii]: 道格拉斯・特里(Douglas Terry)等人創造了術語最終一致性。 【24】 並經由 Werner Vogels 【22】推廣,成為許多 NoSQL 專案的口號。 然而,不只有 NoSQL 資料庫是最終一致的:關係型資料庫中的非同步複製追隨者也有相同的特性。
|
||||
|
||||
“最終”一詞故意含糊不清:總的來說,副本落後的程度是沒有限制的。在正常的操作中,**複製延遲(replication lag)**,即寫入主庫到反映至從庫之間的延遲,可能僅僅是幾分之一秒,在實踐中並不顯眼。但如果系統在接近極限的情況下執行,或網路中存在問題,延遲可以輕而易舉地超過幾秒,甚至幾分鐘。
|
||||
“最終” 一詞故意含糊不清:總的來說,副本落後的程度是沒有限制的。在正常的操作中,**複製延遲(replication lag)**,即寫入主庫到反映至從庫之間的延遲,可能僅僅是幾分之一秒,在實踐中並不顯眼。但如果系統在接近極限的情況下執行,或網路中存在問題,延遲可以輕而易舉地超過幾秒,甚至幾分鐘。
|
||||
|
||||
因為滯後時間太長引入的不一致性,可不僅是一個理論問題,更是應用設計中會遇到的真實問題。本節將重點介紹三個由複製延遲問題的例子,並簡述解決這些問題的一些方法。
|
||||
|
||||
@ -202,27 +202,27 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
許多應用讓使用者提交一些資料,然後檢視他們提交的內容。可能是使用者資料庫中的記錄,也可能是對討論主題的評論,或其他類似的內容。提交新資料時,必須將其傳送給領導者,但是當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視,但只是偶爾寫入,這是非常合適的。
|
||||
|
||||
但對於非同步複製,問題就來了。如[圖5-3](fig5-3.png)所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。
|
||||
但對於非同步複製,問題就來了。如 [圖 5-3](fig5-3.png) 所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。
|
||||
|
||||

|
||||
|
||||
**圖5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀(read-after-write)的一致性來防止這種異常**
|
||||
**圖 5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀 (read-after-write) 的一致性來防止這種異常**
|
||||
|
||||
在這種情況下,我們需要 **讀寫一致性(read-after-write consistency)**,也稱為 **讀己之寫一致性(read-your-writes consistency)**【24】。這是一個保證,如果使用者重新載入頁面,他們總會看到他們自己提交的任何更新。它不會對其他使用者的寫入做出承諾:其他使用者的更新可能稍等才會看到。它保證使用者自己的輸入已被正確儲存。
|
||||
|
||||
如何在基於領導者的複製系統中實現讀後一致性?有各種可能的技術,這裡說一些:
|
||||
|
||||
* 讀使用者**可能已經修改過**的內容時,都從主庫讀;這就要求有一些方法,不用實際查詢就可以知道使用者是否修改了某些東西。舉個例子,社交網路上的使用者個人資料資訊通常只能由使用者本人編輯,而不能由其他人編輯。因此一個簡單的規則是:從主庫讀取使用者自己的檔案,在從庫讀取其他使用者的檔案。
|
||||
* 讀使用者 **可能已經修改過** 的內容時,都從主庫讀;這就要求有一些方法,不用實際查詢就可以知道使用者是否修改了某些東西。舉個例子,社交網路上的使用者個人資料資訊通常只能由使用者本人編輯,而不能由其他人編輯。因此一個簡單的規則是:從主庫讀取使用者自己的檔案,在從庫讀取其他使用者的檔案。
|
||||
|
||||
* 如果應用中的大部分內容都可能被使用者編輯,那這種方法就沒用了,因為大部分內容都必須從主庫讀取(擴容讀就沒效果了)。在這種情況下可以使用其他標準來決定是否從主庫讀取。例如可以跟蹤上次更新的時間,在上次更新後的一分鐘內,從主庫讀。還可以監控從庫的複製延遲,防止對任意比主庫滯後超過一分鐘的從庫發出查詢。
|
||||
|
||||
* 客戶端可以記住最近一次寫入的時間戳,系統需要確保從庫為該使用者提供任何查詢時,該時間戳前的變更都已經傳播到了本從庫中。如果當前從庫不夠新,則可以從另一個從庫讀,或者等待從庫追趕上來。
|
||||
|
||||
時間戳可以是邏輯時間戳(指示寫入順序的東西,例如日誌序列號)或實際系統時鐘(在這種情況下,時鐘同步變得至關重要;請參閱“[不可靠的時鐘](ch8.md#不可靠的時鐘)”)。
|
||||
時間戳可以是邏輯時間戳(指示寫入順序的東西,例如日誌序列號)或實際系統時鐘(在這種情況下,時鐘同步變得至關重要;請參閱 “[不可靠的時鐘](ch8.md#不可靠的時鐘)”)。
|
||||
|
||||
* 如果你的副本分佈在多個數據中心(出於可用性目的與使用者儘量在地理上接近),則會增加複雜性。任何需要由領導者提供服務的請求都必須路由到包含主庫的資料中心。
|
||||
|
||||
另一種複雜的情況是:如果同一個使用者從多個裝置請求服務,例如桌面瀏覽器和移動APP。這種情況下可能就需要提供跨裝置的寫後讀一致性:如果使用者在某個裝置上輸入了一些資訊,然後在另一個裝置上檢視,則應該看到他們剛輸入的資訊。
|
||||
另一種複雜的情況是:如果同一個使用者從多個裝置請求服務,例如桌面瀏覽器和移動 APP。這種情況下可能就需要提供跨裝置的寫後讀一致性:如果使用者在某個裝置上輸入了一些資訊,然後在另一個裝置上檢視,則應該看到他們剛輸入的資訊。
|
||||
|
||||
在這種情況下,還有一些需要考慮的問題:
|
||||
|
||||
@ -234,20 +234,20 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
從非同步從庫讀取第二個異常例子是,使用者可能會遇到 **時光倒流(moving backward in time)**。
|
||||
|
||||
如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](../img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢,首先查詢了一個延遲很小的從庫,然後是一個延遲較大的從庫(如果使用者重新整理網頁,而每個請求被路由到一個隨機的伺服器,這種情況是很有可能的)。第一個查詢返回最近由使用者1234新增的評論,但是第二個查詢不返回任何東西,因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢,第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容,那問題並不大,因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論,然後又看到它消失,那麼對於使用者2345,就很讓人頭大了。
|
||||
如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖 5-4](../img/fig5-4.png) 顯示了使用者 2345 兩次進行相同的查詢,首先查詢了一個延遲很小的從庫,然後是一個延遲較大的從庫(如果使用者重新整理網頁,而每個請求被路由到一個隨機的伺服器,這種情況是很有可能的)。第一個查詢返回最近由使用者 1234 新增的評論,但是第二個查詢不返回任何東西,因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢,第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容,那問題並不大,因為使用者 2345 可能不知道使用者 1234 最近添加了評論。但如果使用者 2345 先看見使用者 1234 的評論,然後又看到它消失,那麼對於使用者 2345,就很讓人頭大了。
|
||||
|
||||

|
||||
|
||||
**圖5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。**
|
||||
**圖 5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。**
|
||||
|
||||
**單調讀(Monotonic reads)**【23】保證這種異常不會發生。這是一個比 **強一致性(strong consistency)** 更弱,但比 **最終一致性(eventual consistency)** 更強的保證。當讀取資料時,你可能會看到一箇舊值;單調讀取僅意味著如果一個使用者順序地進行多次讀取,則他們不會看到時間後退,即,如果先前讀取到較新的資料,後續讀取不會得到更舊的資料。
|
||||
|
||||
實現單調讀取的一種方式是確保每個使用者總是從同一個副本進行讀取(不同的使用者可以從不同的副本讀取)。例如,可以基於使用者ID的雜湊來選擇副本,而不是隨機選擇副本。但是,如果該副本失敗,使用者的查詢將需要重新路由到另一個副本。
|
||||
實現單調讀取的一種方式是確保每個使用者總是從同一個副本進行讀取(不同的使用者可以從不同的副本讀取)。例如,可以基於使用者 ID 的雜湊來選擇副本,而不是隨機選擇副本。但是,如果該副本失敗,使用者的查詢將需要重新路由到另一個副本。
|
||||
|
||||
|
||||
### 一致字首讀
|
||||
|
||||
第三個複製延遲例子違反了因果律。 想象一下Poons先生和Cake夫人之間的以下簡短對話:
|
||||
第三個複製延遲例子違反了因果律。 想象一下 Poons 先生和 Cake 夫人之間的以下簡短對話:
|
||||
|
||||
*Mr. Poons*
|
||||
> Mrs. Cake,你能看到多遠的未來?
|
||||
@ -255,9 +255,9 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
*Mrs. Cake*
|
||||
> 通常約十秒鐘,Mr. Poons.
|
||||
|
||||
這兩句話之間有因果關係:Cake夫人聽到了Poons先生的問題並回答了這個問題。
|
||||
這兩句話之間有因果關係:Cake 夫人聽到了 Poons 先生的問題並回答了這個問題。
|
||||
|
||||
現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的,但Poons先生所說的內容,從庫的延遲要大的多(見[圖5-5](../img/fig5-5.png))。 於是,這個觀察者會聽到以下內容:
|
||||
現在,想象第三個人正在透過從庫來聽這個對話。 Cake 夫人說的內容是從一個延遲很低的從庫讀取的,但 Poons 先生所說的內容,從庫的延遲要大的多(見 [圖 5-5](../img/fig5-5.png))。 於是,這個觀察者會聽到以下內容:
|
||||
|
||||
*Mrs. Cake*
|
||||
> 通常約十秒鐘,Mr. Poons.
|
||||
@ -265,26 +265,26 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
*Mr. Poons*
|
||||
> Mrs. Cake,你能看到多遠的未來?
|
||||
|
||||
對於觀察者來說,看起來好像Cake夫人在Poons先生提問前就回答了這個問題。
|
||||
對於觀察者來說,看起來好像 Cake 夫人在 Poons 先生提問前就回答了這個問題。
|
||||
這種超能力讓人印象深刻,但也會把人搞糊塗。【25】。
|
||||
|
||||

|
||||
|
||||
**圖5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。**
|
||||
**圖 5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。**
|
||||
|
||||
防止這種異常,需要另一種型別的保證:**一致字首讀(consistent prefix reads)**【23】。 這個保證說:如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看見它們以同樣的順序出現。
|
||||
|
||||
這是**分割槽(partitioned)** 或 **分片(sharded)** 資料庫中的一個特殊問題,將在[第六章](ch6.md)中討論。如果資料庫總是以相同的順序應用寫入,則讀取總是會看到一致的字首,所以這種異常不會發生。但是在許多分散式資料庫中,不同的分割槽獨立執行,因此不存在**全域性寫入順序**:當用戶從資料庫中讀取資料時,可能會看到資料庫的某些部分處於較舊的狀態,而某些處於較新的狀態。
|
||||
這是 **分割槽(partitioned)** 或 **分片(sharded)** 資料庫中的一個特殊問題,將在 [第六章](ch6.md) 中討論。如果資料庫總是以相同的順序應用寫入,則讀取總是會看到一致的字首,所以這種異常不會發生。但是在許多分散式資料庫中,不同的分割槽獨立執行,因此不存在 **全域性寫入順序**:當用戶從資料庫中讀取資料時,可能會看到資料庫的某些部分處於較舊的狀態,而某些處於較新的狀態。
|
||||
|
||||
一種解決方案是,確保任何因果相關的寫入都寫入相同的分割槽。對於某些無法高效完成這種操作的應用,還有一些顯式跟蹤因果依賴關係的演算法,本書將在“[“此前發生”的關係和併發](#“此前發生”的關係和併發)”一節中返回這個主題。
|
||||
一種解決方案是,確保任何因果相關的寫入都寫入相同的分割槽。對於某些無法高效完成這種操作的應用,還有一些顯式跟蹤因果依賴關係的演算法,本書將在 “[“此前發生” 的關係和併發](#“此前發生”的關係和併發)” 一節中返回這個主題。
|
||||
|
||||
### 複製延遲的解決方案
|
||||
|
||||
在使用最終一致的系統時,如果複製延遲增加到幾分鐘甚至幾小時,則應該考慮應用程式的行為。如果答案是“沒問題”,那很好。但如果結果對於使用者來說是不好體驗,那麼設計系統來提供更強的保證是很重要的,例如**寫後讀**。明明是非同步複製卻假設複製是同步的,這是很多麻煩的根源。
|
||||
在使用最終一致的系統時,如果複製延遲增加到幾分鐘甚至幾小時,則應該考慮應用程式的行為。如果答案是 “沒問題”,那很好。但如果結果對於使用者來說是不好體驗,那麼設計系統來提供更強的保證是很重要的,例如 **寫後讀**。明明是非同步複製卻假設複製是同步的,這是很多麻煩的根源。
|
||||
|
||||
如前所述,應用程式可以提供比底層資料庫更強有力的保證,例如透過主庫進行某種讀取。但在應用程式程式碼中處理這些問題是複雜的,容易出錯。
|
||||
|
||||
如果應用程式開發人員不必擔心微妙的複製問題,並可以信賴他們的資料庫“做了正確的事情”,那該多好呀。這就是 **事務(transaction)** 存在的原因:**資料庫透過事務提供強大的保證**,所以應用程式可以更加簡單。
|
||||
如果應用程式開發人員不必擔心微妙的複製問題,並可以信賴他們的資料庫 “做了正確的事情”,那該多好呀。這就是 **事務(transaction)** 存在的原因:**資料庫透過事務提供強大的保證**,所以應用程式可以更加簡單。
|
||||
|
||||
單節點事務已經存在了很長時間。然而在走向分散式(複製和分割槽)資料庫時,許多系統放棄了事務。聲稱事務在效能和可用性上的代價太高,並斷言在可伸縮系統中最終一致性是不可避免的。這個敘述有一些道理,但過於簡單了,本書其餘部分將提出更為細緻的觀點。第七章和第九章將回到事務的話題,並討論一些替代機制。
|
||||
|
||||
@ -293,11 +293,11 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
本章到目前為止,我們只考慮使用單個領導者的複製架構。 雖然這是一種常見的方法,但也有一些有趣的選擇。
|
||||
|
||||
基於領導者的複製有一個主要的缺點:只有一個主庫,而所有的寫入都必須透過它[^iv]。如果出於任何原因(例如和主庫之間的網路連線中斷)無法連線到主庫, 就無法向資料庫寫入。
|
||||
基於領導者的複製有一個主要的缺點:只有一個主庫,而所有的寫入都必須透過它 [^iv]。如果出於任何原因(例如和主庫之間的網路連線中斷)無法連線到主庫, 就無法向資料庫寫入。
|
||||
|
||||
[^iv]: 如果資料庫被分割槽(見[第六章](ch6.md)),每個分割槽都有一個領導。 不同的分割槽可能在不同的節點上有其領導者,但是每個分割槽必須有一個領導者節點。
|
||||
[^iv]: 如果資料庫被分割槽(見 [第六章](ch6.md)),每個分割槽都有一個領導。 不同的分割槽可能在不同的節點上有其領導者,但是每個分割槽必須有一個領導者節點。
|
||||
|
||||
基於領導者的複製模型的自然延伸是允許多個節點接受寫入。 複製仍然以同樣的方式發生:處理寫入的每個節點都必須將該資料更改轉發給所有其他節點。 稱之為**多領導者配置**(也稱多主、多活複製)。 在這種情況下,每個領導者同時扮演其他領導者的追隨者。
|
||||
基於領導者的複製模型的自然延伸是允許多個節點接受寫入。 複製仍然以同樣的方式發生:處理寫入的每個節點都必須將該資料更改轉發給所有其他節點。 稱之為 **多領導者配置**(也稱多主、多活複製)。 在這種情況下,每個領導者同時扮演其他領導者的追隨者。
|
||||
|
||||
### 多主複製的應用場景
|
||||
|
||||
@ -307,11 +307,11 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
假如你有一個數據庫,副本分散在好幾個不同的資料中心(也許這樣可以容忍單個數據中心的故障,或地理上更接近使用者)。 使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。
|
||||
|
||||
多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](../img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。
|
||||
多領導者配置中可以在每個資料中心都有主庫。 [圖 5-6](../img/fig5-6.png) 展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。
|
||||
|
||||

|
||||
|
||||
**圖5-6 跨多個數據中心的多主複製**
|
||||
**圖 5-6 跨多個數據中心的多主複製**
|
||||
|
||||
我們來比較一下在運維多個數據中心時,單主和多主的適應情況。
|
||||
|
||||
@ -327,9 +327,9 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
資料中心之間的通訊通常穿過公共網際網路,這可能不如資料中心內的本地網路可靠。單主配置對這資料中心間的連線問題非常敏感,因為透過這個連線進行的寫操作是同步的。採用非同步複製功能的多主配置通常能更好地承受網路問題:臨時的網路中斷並不會妨礙正在處理的寫入。
|
||||
|
||||
有些資料庫預設情況下支援多主配置,但使用外部工具實現也很常見,例如用於MySQL的Tungsten Replicator 【26】,用於PostgreSQL的BDR【27】以及用於Oracle的GoldenGate 【19】。
|
||||
有些資料庫預設情況下支援多主配置,但使用外部工具實現也很常見,例如用於 MySQL 的 Tungsten Replicator 【26】,用於 PostgreSQL 的 BDR【27】以及用於 Oracle 的 GoldenGate 【19】。
|
||||
|
||||
儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](../img/fig5-6.png)中“衝突解決(conflict resolution)”)。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。
|
||||
儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如 [圖 5-6](../img/fig5-6.png) 中 “衝突解決(conflict resolution)”)。本書將在 “[處理寫入衝突](#處理寫入衝突)” 中詳細討論這個問題。
|
||||
|
||||
由於多主複製在許多資料庫中都屬於改裝的功能,所以常常存在微妙的配置缺陷,且經常與其他資料庫功能之間出現意外的反應。例如自增主鍵、觸發器、完整性約束等,都可能會有麻煩。因此,多主複製往往被認為是危險的領域,應儘可能避免【28】。
|
||||
|
||||
@ -341,13 +341,13 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
在這種情況下,每個裝置都有一個充當領導者的本地資料庫(它接受寫請求),並且在所有裝置上的日曆副本之間同步時,存在非同步的多主複製過程。複製延遲可能是幾小時甚至幾天,具體取決於何時可以訪問網際網路。
|
||||
|
||||
從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個“資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多活配好是多麼困難的一件事。
|
||||
從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個 “資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多活配好是多麼困難的一件事。
|
||||
|
||||
有一些工具旨在使這種多領導者配置更容易。例如,CouchDB就是為這種操作模式而設計的【29】。
|
||||
有一些工具旨在使這種多領導者配置更容易。例如,CouchDB 就是為這種操作模式而設計的【29】。
|
||||
|
||||
#### 協同編輯
|
||||
|
||||
實時協作編輯應用程式允許多個人同時編輯文件。例如,Etherpad 【30】和Google Docs 【31】允許多人同時編輯文字文件或電子表格(該演算法在“[自動衝突解決](#自動衝突解決)”中簡要討論)。我們通常不會將協作式編輯視為資料庫複製問題,但與前面提到的離線編輯用例有許多相似之處。當一個使用者編輯文件時,所做的更改將立即應用到其本地副本(Web瀏覽器或客戶端應用程式中的文件狀態),並非同步複製到伺服器和編輯同一文件的任何其他使用者。
|
||||
實時協作編輯應用程式允許多個人同時編輯文件。例如,Etherpad 【30】和 Google Docs 【31】允許多人同時編輯文字文件或電子表格(該演算法在 “[自動衝突解決](#自動衝突解決)” 中簡要討論)。我們通常不會將協作式編輯視為資料庫複製問題,但與前面提到的離線編輯用例有許多相似之處。當一個使用者編輯文件時,所做的更改將立即應用到其本地副本(Web 瀏覽器或客戶端應用程式中的文件狀態),並非同步複製到伺服器和編輯同一文件的任何其他使用者。
|
||||
|
||||
如果要保證不會發生編輯衝突,則應用程式必須先取得文件的鎖定,然後使用者才能對其進行編輯。如果另一個使用者想要編輯同一個文件,他們首先必須等到第一個使用者提交修改並釋放鎖定。這種協作模式相當於主從複製模型下在主節點上執行事務操作。
|
||||
|
||||
@ -357,11 +357,11 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
多領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。
|
||||
|
||||
例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](../img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B,並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時,會發現衝突【33】。單主資料庫中不會出現此問題。
|
||||
例如,考慮一個由兩個使用者同時編輯的維基頁面,如 [圖 5-7](../img/fig5-7.png) 所示。使用者 1 將頁面的標題從 A 更改為 B,並且使用者 2 同時將標題從 A 更改為 C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時,會發現衝突【33】。單主資料庫中不會出現此問題。
|
||||
|
||||

|
||||
|
||||
**圖5-7 兩個主庫同時更新同一記錄引起的寫入衝突**
|
||||
**圖 5-7 兩個主庫同時更新同一記錄引起的寫入衝突**
|
||||
|
||||
#### 同步與非同步衝突檢測
|
||||
|
||||
@ -373,23 +373,23 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
處理衝突的最簡單的策略就是避免它們:如果應用程式可以確保特定記錄的所有寫入都透過同一個領導者,那麼衝突就不會發生。由於許多的多領導者複製實現在處理衝突時處理得相當不好,避免衝突是一個經常推薦的方法【34】。
|
||||
|
||||
例如,在使用者可以編輯自己的資料的應用程式中,可以確保來自特定使用者的請求始終路由到同一資料中心,並使用該資料中心的領導者進行讀寫。不同的使用者可能有不同的“家庭”資料中心(可能根據使用者的地理位置選擇),但從任何使用者的角度來看,配置基本上都是單一的領導者。
|
||||
例如,在使用者可以編輯自己的資料的應用程式中,可以確保來自特定使用者的請求始終路由到同一資料中心,並使用該資料中心的領導者進行讀寫。不同的使用者可能有不同的 “家庭” 資料中心(可能根據使用者的地理位置選擇),但從任何使用者的角度來看,配置基本上都是單一的領導者。
|
||||
|
||||
但是,有時你可能需要更改指定的記錄的主庫——可能是因為一個數據中心出現故障,你需要將流量重新路由到另一個數據中心,或者可能是因為使用者已經遷移到另一個位置,現在更接近不同的資料中心。在這種情況下,衝突避免會中斷,你必須處理不同主庫同時寫入的可能性。
|
||||
但是,有時你可能需要更改指定的記錄的主庫 —— 可能是因為一個數據中心出現故障,你需要將流量重新路由到另一個數據中心,或者可能是因為使用者已經遷移到另一個位置,現在更接近不同的資料中心。在這種情況下,衝突避免會中斷,你必須處理不同主庫同時寫入的可能性。
|
||||
|
||||
#### 收斂至一致的狀態
|
||||
|
||||
單主資料庫按順序進行寫操作:如果同一個欄位有多個更新,則最後一個寫操作將決定該欄位的最終值。
|
||||
|
||||
在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](../img/fig5-7.png)中,在主庫1中標題首先更新為B而後更新為C;在主庫2中,首先更新為C,然後更新為B。兩個順序都不是“更正確”的。
|
||||
在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在 [圖 5-7](../img/fig5-7.png) 中,在主庫 1 中標題首先更新為 B 而後更新為 C;在主庫 2 中,首先更新為 C,然後更新為 B。兩個順序都不是 “更正確” 的。
|
||||
|
||||
如果每個副本只是按照它看到寫入的順序寫入,那麼資料庫最終將處於不一致的狀態:最終值將是在主庫1的C和主庫2的B。這是不可接受的,每個複製方案都必須確保資料在所有副本中最終都是相同的。因此,資料庫必須以一種 **收斂(convergent)** 的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。
|
||||
如果每個副本只是按照它看到寫入的順序寫入,那麼資料庫最終將處於不一致的狀態:最終值將是在主庫 1 的 C 和主庫 2 的 B。這是不可接受的,每個複製方案都必須確保資料在所有副本中最終都是相同的。因此,資料庫必須以一種 **收斂(convergent)** 的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。
|
||||
|
||||
實現衝突合併解決有多種途徑:
|
||||
|
||||
* 給每個寫入一個唯一的ID(例如,一個時間戳,一個長的隨機數,一個UUID或者一個鍵和值的雜湊),挑選最高ID的寫入作為勝利者,並丟棄其他寫入。如果使用時間戳,這種技術被稱為**最後寫入勝利(LWW, last write wins)**。雖然這種方法很流行,但是很容易造成資料丟失【35】。我們將在本章末尾的[檢測併發寫入](#檢測併發寫入)更詳細地討論LWW。
|
||||
* 為每個副本分配一個唯一的ID,ID編號更高的寫入具有更高的優先順序。這種方法也意味著資料丟失。
|
||||
* 以某種方式將這些值合併在一起 - 例如,按字母順序排序,然後連線它們(在[圖5-7](../img/fig5-7.png)中,合併的標題可能類似於“B/C”)。
|
||||
* 給每個寫入一個唯一的 ID(例如,一個時間戳,一個長的隨機數,一個 UUID 或者一個鍵和值的雜湊),挑選最高 ID 的寫入作為勝利者,並丟棄其他寫入。如果使用時間戳,這種技術被稱為 **最後寫入勝利(LWW, last write wins)**。雖然這種方法很流行,但是很容易造成資料丟失【35】。我們將在本章末尾的 [檢測併發寫入](#檢測併發寫入) 更詳細地討論 LWW。
|
||||
* 為每個副本分配一個唯一的 ID,ID 編號更高的寫入具有更高的優先順序。這種方法也意味著資料丟失。
|
||||
* 以某種方式將這些值合併在一起 - 例如,按字母順序排序,然後連線它們(在 [圖 5-7](../img/fig5-7.png) 中,合併的標題可能類似於 “B/C”)。
|
||||
* 用一種可保留所有資訊的顯式資料結構來記錄衝突,並編寫解決衝突的應用程式程式碼(也許透過提示使用者的方式)。
|
||||
|
||||
|
||||
@ -399,13 +399,13 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
* 寫時執行
|
||||
|
||||
只要資料庫系統檢測到複製更改日誌中存在衝突,就會呼叫衝突處理程式。例如,Bucardo允許你為此編寫一段Perl程式碼。這個處理程式通常不能提示使用者——它在後臺程序中執行,並且必須快速執行。
|
||||
只要資料庫系統檢測到複製更改日誌中存在衝突,就會呼叫衝突處理程式。例如,Bucardo 允許你為此編寫一段 Perl 程式碼。這個處理程式通常不能提示使用者 —— 它在後臺程序中執行,並且必須快速執行。
|
||||
|
||||
* 讀時執行
|
||||
|
||||
當檢測到衝突時,所有衝突寫入被儲存。下一次讀取資料時,會將這些多個版本的資料返回給應用程式。應用程式可能會提示使用者或自動解決衝突,並將結果寫回資料庫。例如,CouchDB以這種方式工作。
|
||||
當檢測到衝突時,所有衝突寫入被儲存。下一次讀取資料時,會將這些多個版本的資料返回給應用程式。應用程式可能會提示使用者或自動解決衝突,並將結果寫回資料庫。例如,CouchDB 以這種方式工作。
|
||||
|
||||
請注意,衝突解決通常適用於單個行或文件層面,而不是整個事務【36】。因此,如果你有一個事務會原子性地進行幾次不同的寫入(請參閱[第七章](ch7.md),對於衝突解決而言,每個寫入仍需分開單獨考慮。
|
||||
請注意,衝突解決通常適用於單個行或文件層面,而不是整個事務【36】。因此,如果你有一個事務會原子性地進行幾次不同的寫入(請參閱 [第七章](ch7.md),對於衝突解決而言,每個寫入仍需分開單獨考慮。
|
||||
|
||||
|
||||
> #### 自動衝突解決
|
||||
@ -414,9 +414,9 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
>
|
||||
> 已經有一些有趣的研究來自動解決由於資料修改引起的衝突。有幾行研究值得一提:
|
||||
>
|
||||
> * **無衝突複製資料型別(Conflict-free replicated datatypes,CRDT)**【32,38】是可以由多個使用者同時編輯的集合,對映,有序列表,計數器等的一系列資料結構,它們以合理的方式自動解決衝突。一些CRDT已經在Riak 2.0中實現【39,40】。
|
||||
> * **可合併的持久資料結構(Mergeable persistent data structures)**【41】顯式跟蹤歷史記錄,類似於Git版本控制系統,並使用三向合併功能(而CRDT使用雙向合併)。
|
||||
> * **可執行的轉換(operational transformation)**[42]是Etherpad 【30】和Google Docs 【31】等合作編輯應用背後的衝突解決演算法。它是專為同時編輯專案的有序列表而設計的,例如構成文字文件的字元列表。
|
||||
> * **無衝突複製資料型別(Conflict-free replicated datatypes,CRDT)**【32,38】是可以由多個使用者同時編輯的集合,對映,有序列表,計數器等的一系列資料結構,它們以合理的方式自動解決衝突。一些 CRDT 已經在 Riak 2.0 中實現【39,40】。
|
||||
> * **可合併的持久資料結構(Mergeable persistent data structures)**【41】顯式跟蹤歷史記錄,類似於 Git 版本控制系統,並使用三向合併功能(而 CRDT 使用雙向合併)。
|
||||
> * **可執行的轉換(operational transformation)**[42] 是 Etherpad 【30】和 Google Docs 【31】等合作編輯應用背後的衝突解決演算法。它是專為同時編輯專案的有序列表而設計的,例如構成文字文件的字元列表。
|
||||
>
|
||||
> 這些演算法在資料庫中的實現還很年輕,但很可能將來它們將被整合到更多的複製資料系統中。自動衝突解決方案可以使應用程式處理多領導者資料同步更為簡單。
|
||||
>
|
||||
@ -424,51 +424,51 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
#### 什麼是衝突?
|
||||
|
||||
有些衝突是顯而易見的。在[圖5-7](../img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。
|
||||
有些衝突是顯而易見的。在 [圖 5-7](../img/fig5-7.png) 的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。
|
||||
|
||||
其他型別的衝突可能更為微妙,難以發現。例如,考慮一個會議室預訂系統:它記錄誰訂了哪個時間段的哪個房間。應用需要確保每個房間只有一組人同時預定(即不得有相同房間的重疊預訂)。在這種情況下,如果同時為同一個房間建立兩個不同的預訂,則可能會發生衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩次預訂是由兩個不同的領導者進行的,則可能會有衝突。
|
||||
|
||||
現在還沒有一個現成的答案,但在接下來的章節中,我們將更好地瞭解這個問題。我們將在[第七章](ch7.md)中看到更多的衝突示例,在[第十二章](ch12.md)中我們將討論用於檢測和解決複製系統中衝突的可伸縮方法。
|
||||
現在還沒有一個現成的答案,但在接下來的章節中,我們將更好地瞭解這個問題。我們將在 [第七章](ch7.md) 中看到更多的衝突示例,在 [第十二章](ch12.md) 中我們將討論用於檢測和解決複製系統中衝突的可伸縮方法。
|
||||
|
||||
|
||||
### 多主複製拓撲
|
||||
|
||||
**複製拓撲**(replication topology)描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如[圖5-7](../img/fig5-7.png)所示,只有一個合理的拓撲結構:領導者1必須把他所有的寫到領導者2,反之亦然。當有兩個以上的領導,各種不同的拓撲是可能的。[圖5-8](../img/fig5-8.png)舉例說明了一些例子。
|
||||
**複製拓撲**(replication topology)描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如 [圖 5-7](../img/fig5-7.png) 所示,只有一個合理的拓撲結構:領導者 1 必須把他所有的寫到領導者 2,反之亦然。當有兩個以上的領導,各種不同的拓撲是可能的。[圖 5-8](../img/fig5-8.png) 舉例說明了一些例子。
|
||||
|
||||

|
||||
|
||||
**圖5-8 三個可以設定多領導者複製的示例拓撲。**
|
||||
**圖 5-8 三個可以設定多領導者複製的示例拓撲。**
|
||||
|
||||
最普遍的拓撲是全部到全部([圖5-8 (c)](../img/fig5-8.png)),其中每個領導者將其寫入每個其他領導。但是,也會使用更多受限制的拓撲:例如,預設情況下,MySQL僅支援**環形拓撲(circular topology)**【34】,其中每個節點接收來自一個節點的寫入,並將這些寫入(加上自己的任何寫入)轉發給另一個節點。另一種流行的拓撲結構具有星形的形狀[^v]。一個指定的根節點將寫入轉發給所有其他節點。星形拓撲可以推廣到樹。
|
||||
最普遍的拓撲是全部到全部([圖 5-8 (c)](../img/fig5-8.png)),其中每個領導者將其寫入每個其他領導。但是,也會使用更多受限制的拓撲:例如,預設情況下,MySQL 僅支援 **環形拓撲(circular topology)**【34】,其中每個節點接收來自一個節點的寫入,並將這些寫入(加上自己的任何寫入)轉發給另一個節點。另一種流行的拓撲結構具有星形的形狀 [^v]。一個指定的根節點將寫入轉發給所有其他節點。星形拓撲可以推廣到樹。
|
||||
|
||||
[^v]: 不要與星型模式混淆(請參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了資料模型的結構,而不是節點之間的通訊拓撲。
|
||||
[^v]: 不要與星型模式混淆(請參閱 “[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”),其中描述了資料模型的結構,而不是節點之間的通訊拓撲。
|
||||
|
||||
在環形和星形拓撲中,寫入可能需要在到達所有副本之前透過多個節點。因此,節點需要轉發從其他節點收到的資料更改。為了防止無限複製迴圈,每個節點被賦予一個唯一的識別符號,並且在複製日誌中,每個寫入都被標記了所有已經過的節點的識別符號【43】。當一個節點收到用自己的識別符號標記的資料更改時,該資料更改將被忽略,因為節點知道它已經被處理過。
|
||||
|
||||
環形和星形拓撲的問題是,如果只有一個節點發生故障,則可能會中斷其他節點之間的複製訊息流,導致它們無法通訊,直到節點修復。拓撲結構可以重新配置為在發生故障的節點上工作,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲結構(例如全部到全部)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。
|
||||
|
||||
另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](../img/fig5-9.png)所示。
|
||||
另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能 “超過” 其他複製訊息,如 [圖 5-9](../img/fig5-9.png) 所示。
|
||||
|
||||

|
||||
|
||||
**圖5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。**
|
||||
**圖 5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。**
|
||||
|
||||
在[圖5-9](../img/fig5-9.png)中,客戶端A向主庫1的表中插入一行,客戶端B在主庫3上更新該行。然而,主庫2可以以不同的順序接收寫入:它可以首先接收更新(從它的角度來看,是對資料庫中不存在的行的更新),並且僅在稍後接收到相應的插入(其應該在更新之前)。
|
||||
在 [圖 5-9](../img/fig5-9.png) 中,客戶端 A 向主庫 1 的表中插入一行,客戶端 B 在主庫 3 上更新該行。然而,主庫 2 可以以不同的順序接收寫入:它可以首先接收更新(從它的角度來看,是對資料庫中不存在的行的更新),並且僅在稍後接收到相應的插入(其應該在更新之前)。
|
||||
|
||||
這是一個因果關係的問題,類似於我們在“[一致字首讀](#一致字首讀)”中看到的:更新取決於先前的插入,所以我們需要確保所有節點先處理插入,然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的,因為時鐘不可能被充分地同步,以便在主庫2處正確地排序這些事件(見[第八章](ch8.md))。
|
||||
這是一個因果關係的問題,類似於我們在 “[一致字首讀](#一致字首讀)” 中看到的:更新取決於先前的插入,所以我們需要確保所有節點先處理插入,然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的,因為時鐘不可能被充分地同步,以便在主庫 2 處正確地排序這些事件(見 [第八章](ch8.md))。
|
||||
|
||||
要正確排序這些事件,可以使用一種稱為 **版本向量(version vectors)** 的技術,本章稍後將討論這種技術(請參閱“[檢測併發寫入](#檢測併發寫入)”)。然而,衝突檢測技術在許多多領導者複製系統中執行得不好。例如,在撰寫本文時,PostgreSQL BDR不提供寫入的因果排序【27】,而Tungsten Replicator for MySQL甚至不嘗試檢測衝突【34】。
|
||||
要正確排序這些事件,可以使用一種稱為 **版本向量(version vectors)** 的技術,本章稍後將討論這種技術(請參閱 “[檢測併發寫入](#檢測併發寫入)”)。然而,衝突檢測技術在許多多領導者複製系統中執行得不好。例如,在撰寫本文時,PostgreSQL BDR 不提供寫入的因果排序【27】,而 Tungsten Replicator for MySQL 甚至不嘗試檢測衝突【34】。
|
||||
|
||||
如果你正在使用具有多領導者複製功能的系統,那麼應該瞭解這些問題,仔細閱讀文件,並徹底測試你的資料庫,以確保它確實提供了你認為具有的保證。
|
||||
|
||||
|
||||
## 無主複製
|
||||
|
||||
我們在本章到目前為止所討論的複製方法 ——單主複製、多主複製——都是這樣的想法:客戶端向一個主庫傳送寫請求,而資料庫系統負責將寫入複製到其他副本。主庫決定寫入的順序,而從庫按相同順序應用主庫的寫入。
|
||||
我們在本章到目前為止所討論的複製方法 —— 單主複製、多主複製 —— 都是這樣的想法:客戶端向一個主庫傳送寫請求,而資料庫系統負責將寫入複製到其他副本。主庫決定寫入的順序,而從庫按相同順序應用主庫的寫入。
|
||||
|
||||
一些資料儲存系統採用不同的方法,放棄主庫的概念,並允許任何副本直接接受來自客戶端的寫入。最早的一些的複製資料系統是**無領導的(leaderless)**【1,44】,但是在關係資料庫主導的時代,這個想法幾乎已被忘卻。在亞馬遜將其用於其內部的Dynamo系統[^vi]之後,它再一次成為資料庫的一種時尚架構【37】。 Riak,Cassandra和Voldemort是由Dynamo啟發的無領導複製模型的開源資料儲存,所以這類資料庫也被稱為*Dynamo風格*。
|
||||
一些資料儲存系統採用不同的方法,放棄主庫的概念,並允許任何副本直接接受來自客戶端的寫入。最早的一些的複製資料系統是 **無領導的(leaderless)**【1,44】,但是在關係資料庫主導的時代,這個想法幾乎已被忘卻。在亞馬遜將其用於其內部的 Dynamo 系統 [^vi] 之後,它再一次成為資料庫的一種時尚架構【37】。 Riak,Cassandra 和 Voldemort 是由 Dynamo 啟發的無領導複製模型的開源資料儲存,所以這類資料庫也被稱為 *Dynamo 風格*。
|
||||
|
||||
[^vi]: Dynamo不適用於Amazon以外的使用者。 令人困惑的是,AWS提供了一個名為DynamoDB的託管資料庫產品,它使用了完全不同的體系結構:它基於單領導者複製。
|
||||
[^vi]: Dynamo 不適用於 Amazon 以外的使用者。 令人困惑的是,AWS 提供了一個名為 DynamoDB 的託管資料庫產品,它使用了完全不同的體系結構:它基於單領導者複製。
|
||||
|
||||
在一些無領導者的實現中,客戶端直接將寫入傳送到幾個副本中,而另一些情況下,一個 **協調者(coordinator)** 節點代表客戶端進行寫入。但與主庫資料庫不同,協調者不執行特定的寫入順序。我們將會看到,這種設計上的差異對資料庫的使用方式有著深遠的影響。
|
||||
|
||||
@ -476,84 +476,84 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
假設你有一個帶有三個副本的資料庫,而其中一個副本目前不可用,或許正在重新啟動以安裝系統更新。在基於主機的配置中,如果要繼續處理寫入,則可能需要執行故障切換(請參閱「[處理節點宕機](#處理節點宕機)」)。
|
||||
|
||||
另一方面,在無領導配置中,故障切換不存在。[圖5-10](../img/fig5-10.png)顯示了發生了什麼事情:客戶端(使用者1234)並行傳送寫入到所有三個副本,並且兩個可用副本接受寫入,但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的:在使用者1234已經收到兩個確定的響應之後,我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。
|
||||
另一方面,在無領導配置中,故障切換不存在。[圖 5-10](../img/fig5-10.png) 顯示了發生了什麼事情:客戶端(使用者 1234)並行傳送寫入到所有三個副本,並且兩個可用副本接受寫入,但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的:在使用者 1234 已經收到兩個確定的響應之後,我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。
|
||||
|
||||

|
||||
|
||||
**圖5-10 法定寫入,法定讀取,並在節點中斷後讀修復。**
|
||||
**圖 5-10 法定寫入,法定讀取,並在節點中斷後讀修復。**
|
||||
|
||||
現在想象一下,不可用的節點重新聯機,客戶端開始讀取它。節點關閉時發生的任何寫入都從該節點丟失。因此,如果你從該節點讀取資料,則可能會將陳舊(過時)值視為響應。
|
||||
|
||||
為了解決這個問題,當一個客戶端從資料庫中讀取資料時,它不僅僅傳送它的請求到一個副本:讀請求也被並行地傳送到多個節點。客戶可能會從不同的節點獲得不同的響應。即來自一個節點的最新值和來自另一個節點的陳舊值。版本號用於確定哪個值更新(請參閱“[檢測併發寫入](#檢測併發寫入)”)。
|
||||
為了解決這個問題,當一個客戶端從資料庫中讀取資料時,它不僅僅傳送它的請求到一個副本:讀請求也被並行地傳送到多個節點。客戶可能會從不同的節點獲得不同的響應。即來自一個節點的最新值和來自另一個節點的陳舊值。版本號用於確定哪個值更新(請參閱 “[檢測併發寫入](#檢測併發寫入)”)。
|
||||
|
||||
#### 讀修復和反熵
|
||||
|
||||
複製方案應確保最終將所有資料複製到每個副本。在一個不可用的節點重新聯機之後,它如何趕上它錯過的寫入?
|
||||
|
||||
在Dynamo風格的資料儲存中經常使用兩種機制:
|
||||
在 Dynamo 風格的資料儲存中經常使用兩種機制:
|
||||
|
||||
* 讀修復(Read repair)
|
||||
|
||||
當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](../img/fig5-10.png)中,使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值,並將新值寫回到該副本。這種方法適用於讀頻繁的值。
|
||||
當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在 [圖 5-10](../img/fig5-10.png) 中,使用者 2345 獲得了來自副本 3 的版本 6 值和來自副本 1 和 2 的版本 7 值。客戶端發現副本 3 具有陳舊值,並將新值寫回到該副本。這種方法適用於讀頻繁的值。
|
||||
|
||||
* 反熵過程(Anti-entropy process)
|
||||
|
||||
此外,一些資料儲存具有後臺程序,該程序不斷查詢副本之間的資料差異,並將任何缺少的資料從一個副本複製到另一個副本。與基於領導者的複製中的複製日誌不同,此反熵過程不會以任何特定的順序複製寫入,並且在複製資料之前可能會有顯著的延遲。
|
||||
|
||||
並不是所有的系統都實現了這兩個,例如,Voldemort目前沒有反熵過程。請注意,如果沒有反熵過程,某些副本中很少讀取的值可能會丟失,從而降低了永續性,因為只有在應用程式讀取值時才執行讀修復。
|
||||
並不是所有的系統都實現了這兩個,例如,Voldemort 目前沒有反熵過程。請注意,如果沒有反熵過程,某些副本中很少讀取的值可能會丟失,從而降低了永續性,因為只有在應用程式讀取值時才執行讀修復。
|
||||
|
||||
#### 讀寫的法定人數
|
||||
|
||||
在[圖5-10](../img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?以此類推,究竟多少個副本完成才可以認為寫成功?
|
||||
在 [圖 5-10](../img/fig5-10.png) 的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?以此類推,究竟多少個副本完成才可以認為寫成功?
|
||||
|
||||
如果我們知道,每個成功的寫操作意味著在三個副本中至少有兩個出現,這意味著至多有一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確定至少有一個是最新的。如果第三個副本停機或響應速度緩慢,則讀取仍可以繼續返回最新值。
|
||||
|
||||
更一般地說,如果有n個副本,每個寫入必須由w節點確認才能被認為是成功的,並且我們必須至少為每個讀取查詢r個節點。 (在我們的例子中,$n = 3,w = 2,r = 2$)。只要$w + r> n$,我們期望在讀取時獲得最新的值,因為r個讀取中至少有一個節點是最新的。遵循這些r值,w值的讀寫稱為**法定人數(quorum)**[^vii]的讀和寫【44】。你可以認為,r和w是有效讀寫所需的最低票數。
|
||||
更一般地說,如果有 n 個副本,每個寫入必須由 w 節點確認才能被認為是成功的,並且我們必須至少為每個讀取查詢 r 個節點。 (在我們的例子中,$n = 3,w = 2,r = 2$)。只要 $w + r> n$,我們期望在讀取時獲得最新的值,因為 r 個讀取中至少有一個節點是最新的。遵循這些 r 值,w 值的讀寫稱為 **法定人數(quorum)**[^vii] 的讀和寫【44】。你可以認為,r 和 w 是有效讀寫所需的最低票數。
|
||||
|
||||
[^vii]: 有時候這種法定人數被稱為嚴格的法定人數,相對“寬鬆的法定人數”而言(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”)
|
||||
[^vii]: 有時候這種法定人數被稱為嚴格的法定人數,相對 “寬鬆的法定人數” 而言(見 “[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”)
|
||||
|
||||
在Dynamo風格的資料庫中,引數n,w和r通常是可配置的。一個常見的選擇是使n為奇數(通常為3或5)並設定 $w = r =(n + 1)/ 2$(向上取整)。但是可以根據需要更改數字。例如,設定$w = n$和$r = 1$的寫入很少且讀取次數較多的工作負載可能會受益。這使得讀取速度更快,但具有隻有一個失敗節點導致所有資料庫寫入失敗的缺點。
|
||||
在 Dynamo 風格的資料庫中,引數 n,w 和 r 通常是可配置的。一個常見的選擇是使 n 為奇數(通常為 3 或 5)並設定 $w = r =(n + 1)/ 2$(向上取整)。但是可以根據需要更改數字。例如,設定 $w = n$ 和 $r = 1$ 的寫入很少且讀取次數較多的工作負載可能會受益。這使得讀取速度更快,但具有隻有一個失敗節點導致所有資料庫寫入失敗的缺點。
|
||||
|
||||
> 叢集中可能有多於n的節點。(叢集的機器數可能多於副本數目),但是任何給定的值只能儲存在n個節點上。這允許對資料集進行分割槽,從而可以支援比單個節點的儲存能力更大的資料集。我們將在[第六章](ch6.md)繼續討論分割槽。
|
||||
> 叢集中可能有多於 n 的節點。(叢集的機器數可能多於副本數目),但是任何給定的值只能儲存在 n 個節點上。這允許對資料集進行分割槽,從而可以支援比單個節點的儲存能力更大的資料集。我們將在 [第六章](ch6.md) 繼續討論分割槽。
|
||||
>
|
||||
|
||||
法定人數條件$w + r> n$允許系統容忍不可用的節點,如下所示:
|
||||
法定人數條件 $w + r> n$ 允許系統容忍不可用的節點,如下所示:
|
||||
|
||||
* 如果$w <n$,如果節點不可用,我們仍然可以處理寫入。
|
||||
* 如果$r <n$,如果節點不可用,我們仍然可以處理讀取。
|
||||
* 對於$n = 3,w = 2,r = 2$,我們可以容忍一個不可用的節點。
|
||||
* 對於$n = 5,w = 3,r = 3$,我們可以容忍兩個不可用的節點。 這個案例如[圖5-11](../img/fig5-11.png)所示。
|
||||
* 通常,讀取和寫入操作始終並行傳送到所有n個副本。 引數w和r決定我們等待多少個節點,即在我們認為讀或寫成功之前,有多少個節點需要報告成功。
|
||||
* 如果 $w <n$,如果節點不可用,我們仍然可以處理寫入。
|
||||
* 如果 $r <n$,如果節點不可用,我們仍然可以處理讀取。
|
||||
* 對於 $n = 3,w = 2,r = 2$,我們可以容忍一個不可用的節點。
|
||||
* 對於 $n = 5,w = 3,r = 3$,我們可以容忍兩個不可用的節點。 這個案例如 [圖 5-11](../img/fig5-11.png) 所示。
|
||||
* 通常,讀取和寫入操作始終並行傳送到所有 n 個副本。 引數 w 和 r 決定我們等待多少個節點,即在我們認為讀或寫成功之前,有多少個節點需要報告成功。
|
||||
|
||||

|
||||
|
||||
**圖5-11 如果$w + r > n$,讀取r個副本,至少有一個r副本必然包含了最近的成功寫入**
|
||||
**圖 5-11 如果 $w + r > n$,讀取 r 個副本,至少有一個 r 副本必然包含了最近的成功寫入**
|
||||
|
||||
如果少於所需的w或r節點可用,則寫入或讀取將返回錯誤。 由於許多原因,節點可能不可用:因為執行操作的錯誤(由於磁碟已滿而無法寫入),因為節點關閉(崩潰,關閉電源),由於客戶端和伺服器節點之間的網路中斷,或任何其他原因。 我們只關心節點是否返回了成功的響應,而不需要區分不同型別的錯誤。
|
||||
如果少於所需的 w 或 r 節點可用,則寫入或讀取將返回錯誤。 由於許多原因,節點可能不可用:因為執行操作的錯誤(由於磁碟已滿而無法寫入),因為節點關閉(崩潰,關閉電源),由於客戶端和伺服器節點之間的網路中斷,或任何其他原因。 我們只關心節點是否返回了成功的響應,而不需要區分不同型別的錯誤。
|
||||
|
||||
|
||||
### 法定人數一致性的侷限性
|
||||
|
||||
如果你有n個副本,並且你選擇w和r,使得$w + r> n$,你通常可以期望每個鍵的讀取都能返回最近寫入的值。情況就是這樣,因為你寫入的節點集合和你讀取的節點集合必須重疊。也就是說,你讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](../img/fig5-11.png)所示)。
|
||||
如果你有 n 個副本,並且你選擇 w 和 r,使得 $w + r> n$,你通常可以期望每個鍵的讀取都能返回最近寫入的值。情況就是這樣,因為你寫入的節點集合和你讀取的節點集合必須重疊。也就是說,你讀取的節點中必須至少有一個具有最新值的節點(如 [圖 5-11](../img/fig5-11.png) 所示)。
|
||||
|
||||
通常,r和w被選為多數(超過 $n/2$ )節點,因為這確保了$w + r> n$,同時仍然容忍多達$n/2$個節點故障。但是,法定人數不一定必須是大多數,只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的,這使得分散式演算法的設計有一定的靈活性【45】。
|
||||
通常,r 和 w 被選為多數(超過 $n/2$ )節點,因為這確保了 $w + r> n$,同時仍然容忍多達 $n/2$ 個節點故障。但是,法定人數不一定必須是大多數,只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的,這使得分散式演算法的設計有一定的靈活性【45】。
|
||||
|
||||
你也可以將w和r設定為較小的數字,以使$w + r≤n$(即法定條件不滿足)。在這種情況下,讀取和寫入操作仍將被傳送到n個節點,但操作成功只需要少量的成功響應。
|
||||
你也可以將 w 和 r 設定為較小的數字,以使 $w + r≤n$(即法定條件不滿足)。在這種情況下,讀取和寫入操作仍將被傳送到 n 個節點,但操作成功只需要少量的成功響應。
|
||||
|
||||
較小的w和r更有可能會讀取過時的資料,因為你的讀取更有可能不包含具有最新值的節點。另一方面,這種配置允許更低的延遲和更高的可用性:如果存在網路中斷,並且許多副本變得無法訪問,則可以繼續處理讀取和寫入的機會更大。只有當可達副本的數量低於w或r時,資料庫才分別變得不可用於寫入或讀取。
|
||||
較小的 w 和 r 更有可能會讀取過時的資料,因為你的讀取更有可能不包含具有最新值的節點。另一方面,這種配置允許更低的延遲和更高的可用性:如果存在網路中斷,並且許多副本變得無法訪問,則可以繼續處理讀取和寫入的機會更大。只有當可達副本的數量低於 w 或 r 時,資料庫才分別變得不可用於寫入或讀取。
|
||||
|
||||
但是,即使在$w + r> n$的情況下,也可能存在返回陳舊值的邊緣情況。這取決於實現,但可能的情況包括:
|
||||
但是,即使在 $w + r> n$ 的情況下,也可能存在返回陳舊值的邊緣情況。這取決於實現,但可能的情況包括:
|
||||
|
||||
* 如果使用寬鬆的法定人數(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”),w個寫入和r個讀取落在完全不同的節點上,因此r節點和w之間不再保證有重疊節點【46】。
|
||||
* 如果兩個寫入同時發生,不清楚哪一個先發生。在這種情況下,唯一安全的解決方案是合併併發寫入(請參閱“[處理寫入衝突](#處理寫入衝突)”)。如果根據時間戳(最後寫入勝利)挑選出一個勝者,則由於時鐘偏差【35】,寫入可能會丟失。我們將在“[檢測併發寫入](#檢測併發寫入)”繼續討論此話題。
|
||||
* 如果使用寬鬆的法定人數(見 “[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”),w 個寫入和 r 個讀取落在完全不同的節點上,因此 r 節點和 w 之間不再保證有重疊節點【46】。
|
||||
* 如果兩個寫入同時發生,不清楚哪一個先發生。在這種情況下,唯一安全的解決方案是合併併發寫入(請參閱 “[處理寫入衝突](#處理寫入衝突)”)。如果根據時間戳(最後寫入勝利)挑選出一個勝者,則由於時鐘偏差【35】,寫入可能會丟失。我們將在 “[檢測併發寫入](#檢測併發寫入)” 繼續討論此話題。
|
||||
* 如果寫操作與讀操作同時發生,寫操作可能僅反映在某些副本上。在這種情況下,不確定讀取是返回舊值還是新值。
|
||||
* 如果寫操作在某些副本上成功,而在其他節點上失敗(例如,因為某些節點上的磁碟已滿),在小於w個副本上寫入成功。所以整體判定寫入失敗,但整體寫入失敗並沒有在寫入成功的副本上回滾。這意味著如果一個寫入雖然報告失敗,後續的讀取仍然可能會讀取這次失敗寫入的值【47】。
|
||||
* 如果攜帶新值的節點失敗,需要讀取其他帶有舊值的副本。並且其資料從帶有舊值的副本中恢復,則儲存新值的副本數可能會低於w,從而打破法定人數條件。
|
||||
* 即使一切工作正常,有時也會不幸地出現關於**時序(timing)** 的邊緣情況,我們將在“[線性一致性和法定人數](ch9.md#線性一致性和法定人數)”中看到這點。
|
||||
* 如果寫操作在某些副本上成功,而在其他節點上失敗(例如,因為某些節點上的磁碟已滿),在小於 w 個副本上寫入成功。所以整體判定寫入失敗,但整體寫入失敗並沒有在寫入成功的副本上回滾。這意味著如果一個寫入雖然報告失敗,後續的讀取仍然可能會讀取這次失敗寫入的值【47】。
|
||||
* 如果攜帶新值的節點失敗,需要讀取其他帶有舊值的副本。並且其資料從帶有舊值的副本中恢復,則儲存新值的副本數可能會低於 w,從而打破法定人數條件。
|
||||
* 即使一切工作正常,有時也會不幸地出現關於 **時序(timing)** 的邊緣情況,我們將在 “[線性一致性和法定人數](ch9.md#線性一致性和法定人數)” 中看到這點。
|
||||
|
||||
因此,儘管法定人數似乎保證讀取返回最新的寫入值,但在實踐中並不那麼簡單。 Dynamo風格的資料庫通常針對可以忍受最終一致性的用例進行最佳化。允許透過引數w和r來調整讀取陳舊值的概率,但把它們當成絕對的保證是不明智的。
|
||||
因此,儘管法定人數似乎保證讀取返回最新的寫入值,但在實踐中並不那麼簡單。 Dynamo 風格的資料庫通常針對可以忍受最終一致性的用例進行最佳化。允許透過引數 w 和 r 來調整讀取陳舊值的概率,但把它們當成絕對的保證是不明智的。
|
||||
|
||||
尤其是,因為通常沒有得到“[複製延遲問題](#複製延遲問題)”中討論的保證(讀己之寫,單調讀,一致字首讀),前面提到的異常可能會發生在應用程式中。更強有力的保證通常需要**事務**或**共識**。我們將在[第七章](ch7.md)和[第九章](ch9.md)回到這些話題。
|
||||
尤其是,因為通常沒有得到 “[複製延遲問題](#複製延遲問題)” 中討論的保證(讀己之寫,單調讀,一致字首讀),前面提到的異常可能會發生在應用程式中。更強有力的保證通常需要 **事務** 或 **共識**。我們將在 [第七章](ch7.md) 和 [第九章](ch9.md) 回到這些話題。
|
||||
|
||||
#### 監控陳舊度
|
||||
|
||||
@ -563,42 +563,42 @@ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌
|
||||
|
||||
然而,在無領導者複製的系統中,沒有固定的寫入順序,這使得監控變得更加困難。而且,如果資料庫只使用讀修復(沒有反熵過程),那麼對於一個值可能會有多大的限制是沒有限制的 - 如果一個值很少被讀取,那麼由一個陳舊副本返回的值可能是古老的。
|
||||
|
||||
已經有一些關於衡量無主複製資料庫中的複製陳舊度的研究,並根據引數n,w和r來預測陳舊讀取的預期百分比【48】。不幸的是,這還不是很常見的做法,但是將陳舊測量值包含在資料庫的度量標準集中是一件好事。雖然最終一致性是一種有意模糊的保證,但是從可操作性角度來說,能夠量化“最終”也是很重要的。
|
||||
已經有一些關於衡量無主複製資料庫中的複製陳舊度的研究,並根據引數 n,w 和 r 來預測陳舊讀取的預期百分比【48】。不幸的是,這還不是很常見的做法,但是將陳舊測量值包含在資料庫的度量標準集中是一件好事。雖然最終一致性是一種有意模糊的保證,但是從可操作性角度來說,能夠量化 “最終” 也是很重要的。
|
||||
|
||||
### 寬鬆的法定人數與提示移交
|
||||
|
||||
合理配置的法定人數可以使資料庫無需故障切換即可容忍個別節點的故障。也可以容忍個別節點變慢,因為請求不必等待所有n個節點響應——當w或r節點響應時它們可以返回。對於需要高可用、低延時、且能夠容忍偶爾讀到陳舊值的應用場景來說,這些特性使無主複製的資料庫很有吸引力。
|
||||
合理配置的法定人數可以使資料庫無需故障切換即可容忍個別節點的故障。也可以容忍個別節點變慢,因為請求不必等待所有 n 個節點響應 —— 當 w 或 r 節點響應時它們可以返回。對於需要高可用、低延時、且能夠容忍偶爾讀到陳舊值的應用場景來說,這些特性使無主複製的資料庫很有吸引力。
|
||||
|
||||
然而,法定人數(如迄今為止所描述的)並不像它們可能的那樣具有容錯性。網路中斷可以很容易地將客戶端從大量的資料庫節點上切斷。雖然這些節點是活著的,而其他客戶端可能能夠連線到它們,但是從資料庫節點切斷的客戶端來看,它們也可能已經死亡。在這種情況下,剩餘的可用節點可能會少於w或r,因此客戶端不再能達到法定人數。
|
||||
然而,法定人數(如迄今為止所描述的)並不像它們可能的那樣具有容錯性。網路中斷可以很容易地將客戶端從大量的資料庫節點上切斷。雖然這些節點是活著的,而其他客戶端可能能夠連線到它們,但是從資料庫節點切斷的客戶端來看,它們也可能已經死亡。在這種情況下,剩餘的可用節點可能會少於 w 或 r,因此客戶端不再能達到法定人數。
|
||||
|
||||
在一個大型的叢集中(節點數量明顯多於n個),網路中斷期間客戶端可能仍能連線到一些資料庫節點,但又不足以組成一個特定值的法定人數。在這種情況下,資料庫設計人員需要權衡一下:
|
||||
在一個大型的叢集中(節點數量明顯多於 n 個),網路中斷期間客戶端可能仍能連線到一些資料庫節點,但又不足以組成一個特定值的法定人數。在這種情況下,資料庫設計人員需要權衡一下:
|
||||
|
||||
* 對於所有無法達到w或r節點法定人數的請求,是否返回錯誤是更好的?
|
||||
* 或者我們是否應該接受寫入,然後將它們寫入一些可達的節點,但不在這些值通常所存在的n個節點上?
|
||||
* 對於所有無法達到 w 或 r 節點法定人數的請求,是否返回錯誤是更好的?
|
||||
* 或者我們是否應該接受寫入,然後將它們寫入一些可達的節點,但不在這些值通常所存在的 n 個節點上?
|
||||
|
||||
後者被認為是一個**寬鬆的法定人數(sloppy quorum)**【37】:寫和讀仍然需要w和r成功的響應,但這些響應可能來自不在指定的n個“主”節點中的其它節點。比方說,如果你把自己鎖在房子外面,你可能會敲開鄰居的門,問你是否可以暫時呆在沙發上。
|
||||
後者被認為是一個 **寬鬆的法定人數(sloppy quorum)**【37】:寫和讀仍然需要 w 和 r 成功的響應,但這些響應可能來自不在指定的 n 個 “主” 節點中的其它節點。比方說,如果你把自己鎖在房子外面,你可能會敲開鄰居的門,問你是否可以暫時呆在沙發上。
|
||||
|
||||
一旦網路中斷得到解決,代表另一個節點臨時接受的一個節點的任何寫入都被傳送到適當的“主”節點。這就是所謂的**提示移交(hinted handoff)**(一旦你再次找到你的房子的鑰匙,你的鄰居禮貌地要求你離開沙發回家)。
|
||||
一旦網路中斷得到解決,代表另一個節點臨時接受的一個節點的任何寫入都被傳送到適當的 “主” 節點。這就是所謂的 **提示移交(hinted handoff)**(一旦你再次找到你的房子的鑰匙,你的鄰居禮貌地要求你離開沙發回家)。
|
||||
|
||||
寬鬆的法定人數對寫入可用性的提高特別有用:只要有任何w節點可用,資料庫就可以接受寫入。然而,這意味著即使當$w + r> n$時,也不能確定讀取某個鍵的最新值,因為最新的值可能已經臨時寫入了n之外的某些節點【47】。
|
||||
寬鬆的法定人數對寫入可用性的提高特別有用:只要有任何 w 節點可用,資料庫就可以接受寫入。然而,這意味著即使當 $w + r> n$ 時,也不能確定讀取某個鍵的最新值,因為最新的值可能已經臨時寫入了 n 之外的某些節點【47】。
|
||||
|
||||
因此,在傳統意義上,一個寬鬆的法定人數實際上不是一個法定人數。這只是一個保證,即資料儲存在w節點的地方。但不能保證r節點的讀取,直到提示移交已經完成。
|
||||
因此,在傳統意義上,一個寬鬆的法定人數實際上不是一個法定人數。這只是一個保證,即資料儲存在 w 節點的地方。但不能保證 r 節點的讀取,直到提示移交已經完成。
|
||||
|
||||
在所有常見的Dynamo實現中,寬鬆的法定人數是可選的。在Riak中,它們預設是啟用的,而在Cassandra和Voldemort中它們預設是禁用的【46,49,50】。
|
||||
在所有常見的 Dynamo 實現中,寬鬆的法定人數是可選的。在 Riak 中,它們預設是啟用的,而在 Cassandra 和 Voldemort 中它們預設是禁用的【46,49,50】。
|
||||
|
||||
#### 運維多個數據中心
|
||||
|
||||
我們先前討論了跨資料中心複製作為多主複製的用例(請參閱“[多主複製](#多主複製)”)。無主複製也適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。
|
||||
我們先前討論了跨資料中心複製作為多主複製的用例(請參閱 “[多主複製](#多主複製)”)。無主複製也適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。
|
||||
|
||||
Cassandra和Voldemort在正常的無主模型中實現了他們的多資料中心支援:副本的數量n包括所有資料中心的節點,在配置中,你可以指定每個資料中心中你想擁有的副本的數量。無論資料中心如何,每個來自客戶端的寫入都會發送到所有副本,但客戶端通常只等待來自其本地資料中心內的法定節點的確認,從而不會受到跨資料中心鏈路延遲和中斷的影響。對其他資料中心的高延遲寫入通常被配置為非同步發生,儘管配置有一定的靈活性【50,51】。
|
||||
Cassandra 和 Voldemort 在正常的無主模型中實現了他們的多資料中心支援:副本的數量 n 包括所有資料中心的節點,在配置中,你可以指定每個資料中心中你想擁有的副本的數量。無論資料中心如何,每個來自客戶端的寫入都會發送到所有副本,但客戶端通常只等待來自其本地資料中心內的法定節點的確認,從而不會受到跨資料中心鏈路延遲和中斷的影響。對其他資料中心的高延遲寫入通常被配置為非同步發生,儘管配置有一定的靈活性【50,51】。
|
||||
|
||||
Riak將客戶端和資料庫節點之間的所有通訊保持在一個數據中心本地,因此n描述了一個數據中心內的副本數量。資料庫叢集之間的跨資料中心複製在後臺非同步發生,其風格類似於多領導者複製【52】。
|
||||
Riak 將客戶端和資料庫節點之間的所有通訊保持在一個數據中心本地,因此 n 描述了一個數據中心內的副本數量。資料庫叢集之間的跨資料中心複製在後臺非同步發生,其風格類似於多領導者複製【52】。
|
||||
|
||||
### 檢測併發寫入
|
||||
|
||||
Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key,這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似(請參閱“[處理寫入衝突](#處理寫入衝突)”),但在Dynamo樣式的資料庫中,在**讀修復**或**提示移交**期間也可能會產生衝突。
|
||||
Dynamo 風格的資料庫允許多個客戶端同時寫入相同的 Key,這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似(請參閱 “[處理寫入衝突](#處理寫入衝突)”),但在 Dynamo 樣式的資料庫中,在 **讀修復** 或 **提示移交** 期間也可能會產生衝突。
|
||||
|
||||
問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](../img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X:
|
||||
問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖 5-12](../img/fig5-12.png) 顯示了兩個客戶機 A 和 B 同時寫入三節點資料儲存區中的鍵 X:
|
||||
|
||||
* 節點 1 接收來自 A 的寫入,但由於暫時中斷,未接收到來自 B 的寫入。
|
||||
* 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。
|
||||
@ -606,72 +606,72 @@ Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key,這意
|
||||
|
||||

|
||||
|
||||
**圖5-12 併發寫入Dynamo風格的資料儲存:沒有明確定義的順序。**
|
||||
**圖 5-12 併發寫入 Dynamo 風格的資料儲存:沒有明確定義的順序。**
|
||||
|
||||
如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](../img/fig5-12.png)中的最終獲取請求所示:節點2認為 X 的最終值是 B,而其他節點認為值是 A 。
|
||||
如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如 [圖 5-12](../img/fig5-12.png) 中的最終獲取請求所示:節點 2 認為 X 的最終值是 B,而其他節點認為值是 A 。
|
||||
|
||||
為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。
|
||||
|
||||
在“[處理寫入衝突](#處理寫入衝突)”一節中已經簡要介紹了一些解決衝突的技術。在總結本章之前,讓我們來更詳細地探討這個問題。
|
||||
在 “[處理寫入衝突](#處理寫入衝突)” 一節中已經簡要介紹了一些解決衝突的技術。在總結本章之前,讓我們來更詳細地探討這個問題。
|
||||
|
||||
#### 最後寫入勝利(丟棄併發寫入)
|
||||
|
||||
實現最終融合的一種方法是宣告每個副本只需要儲存最 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是“最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。
|
||||
實現最終融合的一種方法是宣告每個副本只需要儲存最 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是 “最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。
|
||||
|
||||
正如 **“最近”** 的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](../img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是 **併發(concurrent)** 的,所以它們的順序是不確定的。
|
||||
正如 **“最近”** 的引號所表明的,這個想法其實頗具誤導性。在 [圖 5-12](../img/fig5-12.png) 的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說 “發生” 是沒有意義的:我們說寫入是 **併發(concurrent)** 的,所以它們的順序是不確定的。
|
||||
|
||||
即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最 **“最近”** 的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為 **最後寫入勝利(LWW, last write wins)**,是Cassandra 【53】唯一支援的衝突解決方法,也是Riak 【35】中的一個可選特徵。
|
||||
即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最 **“最近”** 的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為 **最後寫入勝利(LWW, last write wins)**,是 Cassandra 【53】唯一支援的衝突解決方法,也是 Riak 【35】中的一個可選特徵。
|
||||
|
||||
LWW實現了最終收斂的目標,但以**永續性**為代價:如果同一個Key有多個併發寫入,即使它們報告給客戶端的都是成功(因為它們被寫入 w 個副本),也只有一個寫入將存活,而其他寫入將被靜默丟棄。此外,LWW甚至可能會刪除不是併發的寫入,我們將在的“[有序事件的時間戳](ch8.md#有序事件的時間戳)”中討論。
|
||||
LWW 實現了最終收斂的目標,但以 **永續性** 為代價:如果同一個 Key 有多個併發寫入,即使它們報告給客戶端的都是成功(因為它們被寫入 w 個副本),也只有一個寫入將存活,而其他寫入將被靜默丟棄。此外,LWW 甚至可能會刪除不是併發的寫入,我們將在的 “[有序事件的時間戳](ch8.md# 有序事件的時間戳)” 中討論。
|
||||
|
||||
有一些情況,如快取,其中丟失的寫入可能是可以接受的。如果丟失資料不可接受,LWW是解決衝突的一個很爛的選擇。
|
||||
有一些情況,如快取,其中丟失的寫入可能是可以接受的。如果丟失資料不可接受,LWW 是解決衝突的一個很爛的選擇。
|
||||
|
||||
與LWW一起使用資料庫的唯一安全方法是確保一個鍵只寫入一次,然後視為不可變,從而避免對同一個鍵進行併發更新。例如,Cassandra推薦使用的方法是使用UUID作為鍵,從而為每個寫操作提供一個唯一的鍵【53】。
|
||||
與 LWW 一起使用資料庫的唯一安全方法是確保一個鍵只寫入一次,然後視為不可變,從而避免對同一個鍵進行併發更新。例如,Cassandra 推薦使用的方法是使用 UUID 作為鍵,從而為每個寫操作提供一個唯一的鍵【53】。
|
||||
|
||||
#### “此前發生”的關係和併發
|
||||
|
||||
我們如何判斷兩個操作是否是併發的?為了建立一個直覺,讓我們看看一些例子:
|
||||
|
||||
* 在[圖5-9](fig5-9.png)中,兩個寫入不是併發的:A的插入發生在B的遞增之前,因為B遞增的值是A插入的值。換句話說,B的操作建立在A的操作上,所以B的操作必須有後來發生。我們也可以說B是 **因果依賴(causally dependent)** 於A。
|
||||
* 另一方面,[圖5-12](fig5-12.png)中的兩個寫入是併發的:當每個客戶端啟動操作時,它不知道另一個客戶端也正在執行操作同樣的鍵。因此,操作之間不存在因果關係。
|
||||
* 在 [圖 5-9](fig5-9.png) 中,兩個寫入不是併發的:A 的插入發生在 B 的遞增之前,因為 B 遞增的值是 A 插入的值。換句話說,B 的操作建立在 A 的操作上,所以 B 的操作必須有後來發生。我們也可以說 B 是 **因果依賴(causally dependent)** 於 A。
|
||||
* 另一方面,[圖 5-12](fig5-12.png) 中的兩個寫入是併發的:當每個客戶端啟動操作時,它不知道另一個客戶端也正在執行操作同樣的鍵。因此,操作之間不存在因果關係。
|
||||
|
||||
如果操作B瞭解操作A,或者依賴於A,或者以某種方式構建於操作A之上,則操作A在另一個操作B之前發生。在另一個操作之前是否發生一個操作是定義什麼併發的關鍵。事實上,我們可以簡單地說,如果兩個操作都不在另一個之前發生,那麼兩個操作是併發的(即,兩個操作都不知道另一個)【54】。
|
||||
如果操作 B 瞭解操作 A,或者依賴於 A,或者以某種方式構建於操作 A 之上,則操作 A 在另一個操作 B 之前發生。在另一個操作之前是否發生一個操作是定義什麼併發的關鍵。事實上,我們可以簡單地說,如果兩個操作都不在另一個之前發生,那麼兩個操作是併發的(即,兩個操作都不知道另一個)【54】。
|
||||
|
||||
因此,只要有兩個操作A和B,就有三種可能性:A在B之前發生,或者B在A之前發生,或者A和B併發。我們需要的是一個演算法來告訴我們兩個操作是否是併發的。如果一個操作發生在另一個操作之前,則後面的操作應該覆蓋較早的操作,但是如果這些操作是併發的,則存在需要解決的衝突。
|
||||
因此,只要有兩個操作 A 和 B,就有三種可能性:A 在 B 之前發生,或者 B 在 A 之前發生,或者 A 和 B 併發。我們需要的是一個演算法來告訴我們兩個操作是否是併發的。如果一個操作發生在另一個操作之前,則後面的操作應該覆蓋較早的操作,但是如果這些操作是併發的,則存在需要解決的衝突。
|
||||
|
||||
|
||||
> #### 併發性,時間和相對性
|
||||
>
|
||||
> 如果兩個操作 **“同時”** 發生,似乎應該稱為併發——但事實上,它們在字面時間上重疊與否並不重要。由於分散式系統中的時鐘問題,現實中是很難判斷兩個事件是否**同時**發生的,這個問題我們將在[第八章](ch8.md)中詳細討論。
|
||||
> 如果兩個操作 **“同時”** 發生,似乎應該稱為併發 —— 但事實上,它們在字面時間上重疊與否並不重要。由於分散式系統中的時鐘問題,現實中是很難判斷兩個事件是否 **同時** 發生的,這個問題我們將在 [第八章](ch8.md) 中詳細討論。
|
||||
>
|
||||
> 為了定義併發性,確切的時間並不重要:如果兩個操作都意識不到對方的存在,就稱這兩個操作**併發**,而不管它們發生的物理時間。人們有時把這個原理和狹義相對論的物理學聯絡起來【54】,它引入了資訊不能比光速更快的思想。因此,如果兩個事件發生的時間差小於光透過它們之間的距離所需要的時間,那麼這兩個事件不可能相互影響。
|
||||
> 為了定義併發性,確切的時間並不重要:如果兩個操作都意識不到對方的存在,就稱這兩個操作 **併發**,而不管它們發生的物理時間。人們有時把這個原理和狹義相對論的物理學聯絡起來【54】,它引入了資訊不能比光速更快的思想。因此,如果兩個事件發生的時間差小於光透過它們之間的距離所需要的時間,那麼這兩個事件不可能相互影響。
|
||||
>
|
||||
> 在計算機系統中,即使光速原則上允許一個操作影響另一個操作,但兩個操作也可能是**並行的**。例如,如果網路緩慢或中斷,兩個操作間可能會出現一段時間間隔,但仍然是併發的,因為網路問題阻止一個操作意識到另一個操作的存在。
|
||||
> 在計算機系統中,即使光速原則上允許一個操作影響另一個操作,但兩個操作也可能是 **並行的**。例如,如果網路緩慢或中斷,兩個操作間可能會出現一段時間間隔,但仍然是併發的,因為網路問題阻止一個操作意識到另一個操作的存在。
|
||||
|
||||
|
||||
#### 捕獲"此前發生"關係
|
||||
|
||||
來看一個演算法,它確定兩個操作是否為併發的,還是一個在另一個之前。為了簡單起見,我們從一個只有一個副本的資料庫開始。一旦我們已經制定了如何在單個副本上完成這項工作,我們可以將該方法推廣到具有多個副本的無領導者資料庫。
|
||||
|
||||
[圖5-13](../img/fig5-13.png)顯示了兩個客戶端同時向同一購物車新增專案。 (如果這樣的例子讓你覺得太麻煩了,那麼可以想象,兩個空中交通管制員同時把飛機新增到他們正在跟蹤的區域)最初,購物車是空的。在它們之間,客戶端向資料庫發出五次寫入:
|
||||
[圖 5-13](../img/fig5-13.png) 顯示了兩個客戶端同時向同一購物車新增專案。 (如果這樣的例子讓你覺得太麻煩了,那麼可以想象,兩個空中交通管制員同時把飛機新增到他們正在跟蹤的區域)最初,購物車是空的。在它們之間,客戶端向資料庫發出五次寫入:
|
||||
|
||||
1. 客戶端 1 將牛奶加入購物車。這是該鍵的第一次寫入,伺服器成功儲存了它併為其分配版本號1,最後將值與版本號一起回送給客戶端。
|
||||
2. 客戶端 2 將雞蛋加入購物車,不知道客戶端 1 同時添加了牛奶(客戶端 2 認為它的雞蛋是購物車中的唯一物品)。伺服器為此寫入分配版本號 2,並將雞蛋和牛奶儲存為兩個單獨的值。然後它將這兩個值**都**返回給客戶端 2 ,並附上版本號 2 。
|
||||
3. 客戶端 1 不知道客戶端 2 的寫入,想要將麵粉加入購物車,因此認為當前的購物車內容應該是 [牛奶,麵粉]。它將此值與伺服器先前向客戶端 1 提供的版本號 1 一起傳送到伺服器。伺服器可以從版本號中知道[牛奶,麵粉]的寫入取代了[牛奶]的先前值,但與[雞蛋]的值是**併發**的。因此,伺服器將版本 3 分配給[牛奶,麵粉],覆蓋版本1值[牛奶],但保留版本 2 的值[蛋],並將所有的值返回給客戶端 1 。
|
||||
4. 同時,客戶端 2 想要加入火腿,不知道客端戶 1 剛剛加了麵粉。客戶端 2 在最後一個響應中從伺服器收到了兩個值[牛奶]和[蛋],所以客戶端 2 現在合併這些值,並新增火腿形成一個新的值,[雞蛋,牛奶,火腿]。它將這個值傳送到伺服器,帶著之前的版本號 2 。伺服器檢測到新值會覆蓋版本 2 [雞蛋],但新值也會與版本 3 [牛奶,麵粉]**併發**,所以剩下的兩個是v3 [牛奶,麵粉],和v4:[雞蛋,牛奶,火腿]
|
||||
5. 最後,客戶端 1 想要加培根。它以前在v3中從伺服器接收[牛奶,麵粉]和[雞蛋],所以它合併這些,新增培根,並將最終值[牛奶,麵粉,雞蛋,培根]連同版本號v3發往伺服器。這會覆蓋v3[牛奶,麵粉](請注意[雞蛋]已經在最後一步被覆蓋),但與v4[雞蛋,牛奶,火腿]併發,所以伺服器保留這兩個併發值。
|
||||
1. 客戶端 1 將牛奶加入購物車。這是該鍵的第一次寫入,伺服器成功儲存了它併為其分配版本號 1,最後將值與版本號一起回送給客戶端。
|
||||
2. 客戶端 2 將雞蛋加入購物車,不知道客戶端 1 同時添加了牛奶(客戶端 2 認為它的雞蛋是購物車中的唯一物品)。伺服器為此寫入分配版本號 2,並將雞蛋和牛奶儲存為兩個單獨的值。然後它將這兩個值 **都** 返回給客戶端 2 ,並附上版本號 2 。
|
||||
3. 客戶端 1 不知道客戶端 2 的寫入,想要將麵粉加入購物車,因此認為當前的購物車內容應該是 [牛奶,麵粉]。它將此值與伺服器先前向客戶端 1 提供的版本號 1 一起傳送到伺服器。伺服器可以從版本號中知道 [牛奶,麵粉] 的寫入取代了 [牛奶] 的先前值,但與 [雞蛋] 的值是 **併發** 的。因此,伺服器將版本 3 分配給 [牛奶,麵粉],覆蓋版本 1 值 [牛奶],但保留版本 2 的值 [蛋],並將所有的值返回給客戶端 1 。
|
||||
4. 同時,客戶端 2 想要加入火腿,不知道客端戶 1 剛剛加了麵粉。客戶端 2 在最後一個響應中從伺服器收到了兩個值 [牛奶] 和 [蛋],所以客戶端 2 現在合併這些值,並新增火腿形成一個新的值,[雞蛋,牛奶,火腿]。它將這個值傳送到伺服器,帶著之前的版本號 2 。伺服器檢測到新值會覆蓋版本 2 [雞蛋],但新值也會與版本 3 [牛奶,麵粉] **併發**,所以剩下的兩個是 v3 [牛奶,麵粉],和 v4:[雞蛋,牛奶,火腿]
|
||||
5. 最後,客戶端 1 想要加培根。它以前在 v3 中從伺服器接收 [牛奶,麵粉] 和 [雞蛋],所以它合併這些,新增培根,並將最終值 [牛奶,麵粉,雞蛋,培根] 連同版本號 v3 發往伺服器。這會覆蓋 v3 [牛奶,麵粉](請注意 [雞蛋] 已經在最後一步被覆蓋),但與 v4 [雞蛋,牛奶,火腿] 併發,所以伺服器保留這兩個併發值。
|
||||
|
||||

|
||||
|
||||
**圖5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。**
|
||||
**圖 5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。**
|
||||
|
||||
[圖5-13](../img/fig5-13.png)中的操作之間的資料流如[圖5-14](../img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。
|
||||
[圖 5-13](../img/fig5-13.png) 中的操作之間的資料流如 [圖 5-14](../img/fig5-14.png) 所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。
|
||||
|
||||

|
||||
|
||||
**圖5-14 圖5-13中的因果依賴關係圖。**
|
||||
**圖 5-14 圖 5-13 中的因果依賴關係圖。**
|
||||
|
||||
請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的——它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下:
|
||||
請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的 —— 它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下:
|
||||
|
||||
* 伺服器為每個鍵保留一個版本號,每次寫入鍵時都增加版本號,並將新版本號與寫入的值一起儲存。
|
||||
* 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。
|
||||
@ -682,31 +682,31 @@ LWW實現了最終收斂的目標,但以**永續性**為代價:如果同一
|
||||
|
||||
#### 合併同時寫入的值
|
||||
|
||||
這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:客戶端隨後必須透過合併併發寫入的值來進行清理。 Riak稱這些併發值為**兄弟(siblings)**。
|
||||
這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:客戶端隨後必須透過合併併發寫入的值來進行清理。 Riak 稱這些併發值為 **兄弟(siblings)**。
|
||||
|
||||
合併併發值,本質上是與多領導者複製中的衝突解決問題相同,我們先前討論過(請參閱“[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)來選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中額外做些更聰明的事情。
|
||||
合併併發值,本質上是與多領導者複製中的衝突解決問題相同,我們先前討論過(請參閱 “[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)來選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中額外做些更聰明的事情。
|
||||
|
||||
以購物車為例,一種合理的合併值的方法就是做並集。在[圖5-14](../img/fig5-14.png)中,最後的合併結果是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋同時出現在兩個併發值裡,即使他們每個只被寫過一次。合併的值可以是[牛奶,麵粉,雞蛋,培根,火腿],他們沒有重複。
|
||||
以購物車為例,一種合理的合併值的方法就是做並集。在 [圖 5-14](../img/fig5-14.png) 中,最後的合併結果是 [牛奶,麵粉,雞蛋,燻肉] 和 [雞蛋,牛奶,火腿]。注意牛奶和雞蛋同時出現在兩個併發值裡,即使他們每個只被寫過一次。合併的值可以是 [牛奶,麵粉,雞蛋,培根,火腿],他們沒有重複。
|
||||
|
||||
然而,如果你想讓人們也可以從他們的購物車中**刪除**東西,而不是僅僅新增東西,那麼把併發值做並集可能不會產生正確的結果:如果你合併了兩個客戶端的購物車,並且只在其中一個客戶端裡面刪掉了它,那麼被刪除的專案會重新出現在這兩個客戶端的交集結果中【37】。為了防止這個問題,一個專案在刪除時不能簡單地從資料庫中刪除;相反,系統必須留下一個具有適當版本號的標記,在合併兄弟時表明該專案已被刪除。這種刪除標記被稱為**墓碑(tombstone)**(我們之前在“[雜湊索引”](ch3.md#雜湊索引)中的日誌壓縮的上下文中看到了墓碑)。
|
||||
然而,如果你想讓人們也可以從他們的購物車中 **刪除** 東西,而不是僅僅新增東西,那麼把併發值做並集可能不會產生正確的結果:如果你合併了兩個客戶端的購物車,並且只在其中一個客戶端裡面刪掉了它,那麼被刪除的專案會重新出現在這兩個客戶端的交集結果中【37】。為了防止這個問題,一個專案在刪除時不能簡單地從資料庫中刪除;相反,系統必須留下一個具有適當版本號的標記,在合併兄弟時表明該專案已被刪除。這種刪除標記被稱為 **墓碑(tombstone)**(我們之前在 “[雜湊索引”](ch3.md#雜湊索引) 中的日誌壓縮的上下文中看到了墓碑)。
|
||||
|
||||
因為在應用程式程式碼中做合併是複雜且易出錯,所以有一些資料結構被設計出來用於自動執行這種合併,如“[自動衝突解決](#自動衝突解決)”中討論的。例如,Riak的資料型別支援使用稱為CRDT的資料結構家族【38,39,55】可以以合理的方式自動合併,包括保留刪除。
|
||||
因為在應用程式程式碼中做合併是複雜且易出錯,所以有一些資料結構被設計出來用於自動執行這種合併,如 “[自動衝突解決](#自動衝突解決)” 中討論的。例如,Riak 的資料型別支援使用稱為 CRDT 的資料結構家族【38,39,55】可以以合理的方式自動合併,包括保留刪除。
|
||||
|
||||
#### 版本向量
|
||||
|
||||
[圖5-13](../img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改?
|
||||
[圖 5-13](../img/fig5-13.png) 中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改?
|
||||
|
||||
[圖5-13](../img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,還需要在**每個副本**以及**每個鍵**使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些併發值,以及保留哪些併發值。
|
||||
[圖 5-13](../img/fig5-13.png) 使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,還需要在 **每個副本** 以及 **每個鍵** 使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些併發值,以及保留哪些併發值。
|
||||
|
||||
所有副本的版本號集合稱為**版本向量(version vector)**【56】。這個想法的一些變體正在被使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**虛線版本向量(dotted version vector)**【57】。我們不會深入細節,但是它的工作方式與我們在購物車示例中看到的非常相似。
|
||||
所有副本的版本號集合稱為 **版本向量(version vector)**【56】。這個想法的一些變體正在被使用,但最有趣的可能是在 Riak 2.0 【58,59】中使用的 **虛線版本向量(dotted version vector)**【57】。我們不會深入細節,但是它的工作方式與我們在購物車示例中看到的非常相似。
|
||||
|
||||
與[圖5-13](../img/fig5-13.png)中的版本號一樣,當讀取值時,版本向量會從資料庫副本傳送到客戶端,並且隨後寫入值時需要將其傳送回資料庫。(Riak將版本向量編碼為一個字串,並稱其為**因果上下文**,即causal context)。版本向量允許資料庫區分覆蓋寫入和併發寫入。
|
||||
與 [圖 5-13](../img/fig5-13.png) 中的版本號一樣,當讀取值時,版本向量會從資料庫副本傳送到客戶端,並且隨後寫入值時需要將其傳送回資料庫。(Riak 將版本向量編碼為一個字串,並稱其為 **因果上下文**,即 causal context)。版本向量允許資料庫區分覆蓋寫入和併發寫入。
|
||||
|
||||
另外,就像在單個副本中的情況一樣,應用程式可能需要合併併發值。版本向量結構能夠確保從一個副本讀取並隨後寫回到另一個副本是安全的。這樣做雖然可能會在其他副本上面建立資料,但只要能正確合併就不會丟失資料。
|
||||
|
||||
> #### 版本向量和向量時鐘
|
||||
>
|
||||
> 版本向量有時也被稱為向量時鐘,即使它們不完全相同。 差別很微妙——細節請參閱參考資料【57,60,61】。 簡而言之,在比較副本的狀態時,版本向量是正確的資料結構。
|
||||
> 版本向量有時也被稱為向量時鐘,即使它們不完全相同。 差別很微妙 —— 細節請參閱參考資料【57,60,61】。 簡而言之,在比較副本的狀態時,版本向量是正確的資料結構。
|
||||
>
|
||||
|
||||
## 本章小結
|
||||
|
179
zh-tw/ch6.md
179
zh-tw/ch6.md
@ -11,43 +11,44 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
在[第五章](ch5.md)中,我們討論了複製——即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行**分割槽(partitions)**,也稱為**分片(sharding)**[^i]。
|
||||
在 [第五章](ch5.md) 中,我們討論了複製 —— 即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行 **分割槽(partitions)**,也稱為 **分片(sharding)**[^i]。
|
||||
|
||||
[^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與 **網路分割槽(network partitions, netsplits)** 無關,這是節點之間網路故障的一種。我們將在[第八章](ch8.md)討論這些錯誤。
|
||||
[^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與 **網路分割槽(network partitions, netsplits)** 無關,這是節點之間網路故障的一種。我們將在 [第八章](ch8.md) 討論這些錯誤。
|
||||
|
||||
> #### 術語澄清
|
||||
>
|
||||
> 上文中的**分割槽(partition)**,在MongoDB,Elasticsearch和Solr Cloud中被稱為**分片(shard)**,在HBase中稱之為**區域(Region)**,Bigtable中則是 **表塊(tablet)**,Cassandra和Riak中是**虛節點(vnode)**,Couchbase中叫做**虛桶(vBucket)**。但是**分割槽(partitioning)** 是最約定俗成的叫法。
|
||||
> 上文中的 **分割槽 (partition)**,在 MongoDB,Elasticsearch 和 Solr Cloud 中被稱為 **分片 (shard)**,在 HBase 中稱之為 **區域 (Region)**,Bigtable 中則是 **表塊(tablet)**,Cassandra 和 Riak 中是 **虛節點(vnode)**,Couchbase 中叫做 **虛桶 (vBucket)**。但是 **分割槽 (partitioning)** 是最約定俗成的叫法。
|
||||
>
|
||||
|
||||
通常情況下,每條資料(每條記錄,每行或每個文件)屬於且僅屬於一個分割槽。有很多方法可以實現這一點,本章將進行深入討論。實際上,每個分割槽都是自己的小型資料庫,儘管資料庫可能支援同時進行多個分割槽的操作。
|
||||
|
||||
分割槽主要是為了**可伸縮性**。不同的分割槽可以放在不共享叢集中的不同節點上(請參閱[第二部分](part-ii.md)關於[無共享架構](part-ii.md#無共享架構)的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。
|
||||
分割槽主要是為了 **可伸縮性**。不同的分割槽可以放在不共享叢集中的不同節點上(請參閱 [第二部分](part-ii.md) 關於 [無共享架構](part-ii.md#無共享架構) 的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。
|
||||
|
||||
對於在單個分割槽上執行的查詢,每個節點可以獨立執行對自己的查詢,因此可以透過新增更多的節點來擴大查詢吞吐量。大型,複雜的查詢可能會跨越多個節點並行處理,儘管這也帶來了新的困難。
|
||||
|
||||
分割槽資料庫在20世紀80年代由Teradata和NonStop SQL【1】等產品率先推出,最近因為NoSQL資料庫和基於Hadoop的資料倉庫重新被關注。有些系統是為事務性工作設計的,有些系統則用於分析(請參閱“[事務處理還是分析](ch3.md#事務處理還是分析)”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。
|
||||
分割槽資料庫在 20 世紀 80 年代由 Teradata 和 NonStop SQL【1】等產品率先推出,最近因為 NoSQL 資料庫和基於 Hadoop 的資料倉庫重新被關注。有些系統是為事務性工作設計的,有些系統則用於分析(請參閱 “[事務處理還是分析](ch3.md#事務處理還是分析)”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。
|
||||
|
||||
在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論[分割槽再平衡(rebalancing)](#分割槽再平衡),如果想要新增或刪除叢集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。
|
||||
在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論 [分割槽再平衡(rebalancing)](#分割槽再平衡),如果想要新增或刪除叢集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。
|
||||
|
||||
## 分割槽與複製
|
||||
|
||||
分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。 這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。
|
||||
分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。
|
||||
|
||||
一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1](../img/fig6-1.png)所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。
|
||||
我們在[第五章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。
|
||||
一個節點可能儲存多個分割槽。如果使用主從複製模型,則分割槽和複製的組合如 [圖 6-1](../img/fig6-1.png) 所示。每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。
|
||||
|
||||
我們在 [第五章](ch5.md) 討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。
|
||||
|
||||

|
||||
|
||||
**圖6-1 組合使用複製和分割槽:每個節點充當某些分割槽的領導者,其他分割槽充當追隨者。**
|
||||
**圖 6-1 組合使用複製和分割槽:每個節點充當某些分割槽的領導者,其他分割槽充當追隨者。**
|
||||
|
||||
## 鍵值資料的分割槽
|
||||
|
||||
假設你有大量資料並且想要分割槽,如何決定在哪些節點上儲存哪些記錄呢?
|
||||
|
||||
分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載,那麼理論上10個節點應該能夠處理10倍的資料量和10倍的單個節點的讀寫吞吐量(暫時忽略複製)。
|
||||
分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載,那麼理論上 10 個節點應該能夠處理 10 倍的資料量和 10 倍的單個節點的讀寫吞吐量(暫時忽略複製)。
|
||||
|
||||
如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為**偏斜(skew)**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下,所有的負載可能壓在一個分割槽上,其餘9個節點空閒的,瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為**熱點(hot spot)**。
|
||||
如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為 **偏斜(skew)**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下,所有的負載可能壓在一個分割槽上,其餘 9 個節點空閒的,瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為 **熱點(hot spot)**。
|
||||
|
||||
避免熱點最簡單的方法是將記錄隨機分配給節點。這將在所有節點上平均分配資料,但是它有一個很大的缺點:當你試圖讀取一個特定的值時,你無法知道它在哪個節點上,所以你必須並行地查詢所有的節點。
|
||||
|
||||
@ -55,19 +56,19 @@
|
||||
|
||||
### 根據鍵的範圍分割槽
|
||||
|
||||
一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙質百科全書的卷([圖6-2](../img/fig6-2.png))。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果你還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。
|
||||
一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙質百科全書的卷([圖 6-2](../img/fig6-2.png))。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果你還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。
|
||||
|
||||

|
||||
|
||||
**圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽**
|
||||
**圖 6-2 印刷版百科全書按照關鍵字範圍進行分割槽**
|
||||
|
||||
鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在[圖6-2](../img/fig6-2.png)中,第1捲包含以A和B開頭的單詞,但第12卷則包含以T,U,V,X,Y和Z開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料,分割槽邊界需要依據資料調整。
|
||||
鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在 [圖 6-2](../img/fig6-2.png) 中,第 1 捲包含以 A 和 B 開頭的單詞,但第 12 卷則包含以 T、U、V、X、Y 和 Z 開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料,分割槽邊界需要依據資料調整。
|
||||
|
||||
分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在“[分割槽再平衡](#分割槽再平衡)”中更詳細地討論分割槽邊界的選擇)。 Bigtable使用了這種分割槽策略,以及其開源等價物HBase 【2, 3】,RethinkDB和2.4版本之前的MongoDB 【4】。
|
||||
分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在 “[分割槽再平衡](#分割槽再平衡)” 中更詳細地討論分割槽邊界的選擇)。 Bigtable 使用了這種分割槽策略,以及其開源等價物 HBase 【2, 3】,RethinkDB 和 2.4 版本之前的 MongoDB 【4】。
|
||||
|
||||
在每個分割槽中,我們可以按照一定的順序儲存鍵(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)。好處是進行範圍掃描非常簡單,你可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(請參閱“[多列索引](ch3.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。
|
||||
在每個分割槽中,我們可以按照一定的順序儲存鍵(請參閱 “[SSTables 和 LSM 樹](ch3.md#SSTables和LSM樹)”)。好處是進行範圍掃描非常簡單,你可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(請參閱 “[多列索引](ch3.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。
|
||||
|
||||
然而,Key Range分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是,由於我們在測量發生時將資料從感測器寫入資料庫,因此所有寫入操作都會轉到同一個分割槽(即今天的分割槽),這樣分割槽可能會因寫入而過載,而其他分割槽則處於空閒狀態【5】。
|
||||
然而,Key Range 分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是,由於我們在測量發生時將資料從感測器寫入資料庫,因此所有寫入操作都會轉到同一個分割槽(即今天的分割槽),這樣分割槽可能會因寫入而過載,而其他分割槽則處於空閒狀態【5】。
|
||||
|
||||
為了避免感測器資料庫中的這個問題,需要使用除了時間戳以外的其他東西作為主鍵的第一個部分。 例如,可以在每個時間戳前新增感測器名稱,這樣會首先按感測器名稱,然後按時間進行分割槽。 假設有多個感測器同時執行,寫入負載將最終均勻分佈在不同分割槽上。 現在,當想要在一個時間範圍內獲取多個感測器的值時,你需要為每個感測器名稱執行一個單獨的範圍查詢。
|
||||
|
||||
@ -75,39 +76,39 @@
|
||||
|
||||
由於偏斜和熱點的風險,許多分散式資料儲存使用雜湊函式來確定給定鍵的分割槽。
|
||||
|
||||
一個好的雜湊函式可以將偏斜的資料均勻分佈。假設你有一個32位雜湊函式,無論何時給定一個新的字串輸入,它將返回一個0到$2^{32}$ -1之間的“隨機”數。即使輸入的字串非常相似,它們的雜湊也會均勻分佈在這個數字範圍內。
|
||||
一個好的雜湊函式可以將偏斜的資料均勻分佈。假設你有一個 32 位雜湊函式,無論何時給定一個新的字串輸入,它將返回一個 0 到 $2^{32}$ -1 之間的 “隨機” 數。即使輸入的字串非常相似,它們的雜湊也會均勻分佈在這個數字範圍內。
|
||||
|
||||
出於分割槽的目的,雜湊函式不需要多麼強壯的加密演算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式(它們用於散列表),但是它們可能不適合分割槽:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一個鍵可能在不同的程序中有不同的雜湊值【6】。
|
||||
出於分割槽的目的,雜湊函式不需要多麼強壯的加密演算法:例如,Cassandra 和 MongoDB 使用 MD5,Voldemort 使用 Fowler-Noll-Vo 函式。許多程式語言都有內建的簡單雜湊函式(它們用於散列表),但是它們可能不適合分割槽:例如,在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash`,同一個鍵可能在不同的程序中有不同的雜湊值【6】。
|
||||
|
||||
一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](../img/fig6-3.png)所示。
|
||||
一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如 [圖 6-3](../img/fig6-3.png) 所示。
|
||||
|
||||

|
||||
|
||||
**圖6-3 按雜湊鍵分割槽**
|
||||
**圖 6-3 按雜湊鍵分割槽**
|
||||
|
||||
這種技術擅長在分割槽之間公平地分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為**一致性雜湊**,即consistent hashing)。
|
||||
這種技術擅長在分割槽之間公平地分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為 **一致性雜湊**,即 consistent hashing)。
|
||||
|
||||
> #### 一致性雜湊
|
||||
>
|
||||
> 一致性雜湊由Karger等人定義。【7】 用於跨網際網路級別的快取系統,例如CDN中,是一種能均勻分配負載的方法。它使用隨機選擇的 **分割槽邊界(partition boundaries)** 來避免中央控制或分散式共識的需要。 請注意,這裡的一致性與複製一致性(請參閱[第五章](ch5.md))或ACID一致性(請參閱[第七章](ch7.md))無關,而只是描述了一種重新平衡(reblancing)的特定方法。
|
||||
> 一致性雜湊由 Karger 等人定義。【7】 用於跨網際網路級別的快取系統,例如 CDN 中,是一種能均勻分配負載的方法。它使用隨機選擇的 **分割槽邊界(partition boundaries)** 來避免中央控制或分散式共識的需要。 請注意,這裡的一致性與複製一致性(請參閱 [第五章](ch5.md))或 ACID 一致性(請參閱 [第七章](ch7.md))無關,而只是描述了一種重新平衡(reblancing)的特定方法。
|
||||
>
|
||||
> 正如我們將在“[分割槽再平衡](#分割槽再平衡)”中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然會使用一致性雜湊的說法,但是它往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為**雜湊分割槽(hash partitioning)**。
|
||||
> 正如我們將在 “[分割槽再平衡](#分割槽再平衡)” 中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然會使用一致性雜湊的說法,但是它往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為 **雜湊分割槽(hash partitioning)**。
|
||||
|
||||
不幸的是,透過使用鍵雜湊進行分割槽,我們失去了鍵範圍分割槽的一個很好的屬性:高效執行範圍查詢的能力。曾經相鄰的鍵現在分散在所有分割槽中,所以它們之間的順序就丟失了。在MongoDB中,如果你使用了基於雜湊的分割槽模式,則任何範圍查詢都必須傳送到所有分割槽【4】。Riak【9】、Couchbase 【10】或Voldemort不支援主鍵上的範圍查詢。
|
||||
不幸的是,透過使用鍵雜湊進行分割槽,我們失去了鍵範圍分割槽的一個很好的屬性:高效執行範圍查詢的能力。曾經相鄰的鍵現在分散在所有分割槽中,所以它們之間的順序就丟失了。在 MongoDB 中,如果你使用了基於雜湊的分割槽模式,則任何範圍查詢都必須傳送到所有分割槽【4】。Riak【9】、Couchbase 【10】或 Voldemort 不支援主鍵上的範圍查詢。
|
||||
|
||||
Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據,而其他列則被用作Casssandra的SSTables中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表,但如果第一列已經指定了固定值,則可以對該鍵的其他列執行有效的範圍掃描。
|
||||
Cassandra 採取了折衷的策略【11, 12, 13】。 Cassandra 中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據,而其他列則被用作 Casssandra 的 SSTables 中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表,但如果第一列已經指定了固定值,則可以對該鍵的其他列執行有效的範圍掃描。
|
||||
|
||||
組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為`(user_id, update_timestamp)`,那麼你可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。
|
||||
組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為 `(user_id, update_timestamp)`,那麼你可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。
|
||||
|
||||
### 負載偏斜與熱點消除
|
||||
|
||||
如前所述,雜湊分割槽可以幫助減少熱點。但是,它不能完全避免它們:在極端情況下,所有的讀寫操作都是針對同一個鍵的,所有的請求都會被路由到同一個分割槽。
|
||||
|
||||
這種場景也許並不常見,但並非聞所未聞:例如,在社交媒體網站上,一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致同一個鍵的大量寫入(鍵可能是名人的使用者ID,或者人們正在評論的動作的ID)。雜湊策略不起作用,因為兩個相同ID的雜湊值仍然是相同的。
|
||||
這種場景也許並不常見,但並非聞所未聞:例如,在社交媒體網站上,一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致同一個鍵的大量寫入(鍵可能是名人的使用者 ID,或者人們正在評論的動作的 ID)。雜湊策略不起作用,因為兩個相同 ID 的雜湊值仍然是相同的。
|
||||
|
||||
如今,大多數資料系統無法自動補償這種高度偏斜的負載,因此應用程式有責任減少偏斜。例如,如果一個主鍵被認為是非常火爆的,一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為100種不同的主鍵,從而儲存在不同的分割槽中。
|
||||
如今,大多數資料系統無法自動補償這種高度偏斜的負載,因此應用程式有責任減少偏斜。例如,如果一個主鍵被認為是非常火爆的,一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為 100 種不同的主鍵,從而儲存在不同的分割槽中。
|
||||
|
||||
然而,將主鍵進行分割之後,任何讀取都必須要做額外的工作,因為他們必須從所有100個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄:只需要對少量熱點附加隨機數;對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此,你還需要一些方法來跟蹤哪些鍵需要被分割。
|
||||
然而,將主鍵進行分割之後,任何讀取都必須要做額外的工作,因為他們必須從所有 100 個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄:只需要對少量熱點附加隨機數;對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此,你還需要一些方法來跟蹤哪些鍵需要被分割。
|
||||
|
||||
也許在將來,資料系統將能夠自動檢測和補償偏斜的工作負載;但現在,你需要自己來權衡。
|
||||
|
||||
@ -117,132 +118,132 @@ Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使
|
||||
|
||||
到目前為止,我們討論的分割槽方案依賴於鍵值資料模型。如果只通過主鍵訪問記錄,我們可以從該鍵確定分割槽,並使用它來將讀寫請求路由到負責該鍵的分割槽。
|
||||
|
||||
如果涉及次級索引,情況會變得更加複雜(參考“[其他索引結構](ch3.md#其他索引結構)”)。次級索引通常並不能唯一地標識記錄,而是一種搜尋記錄中出現特定值的方式:查詢使用者123的所有操作,查詢包含詞語`hogwash`的所有文章,查詢所有顏色為紅色的車輛等等。
|
||||
如果涉及次級索引,情況會變得更加複雜(參考 “[其他索引結構](ch3.md#其他索引結構)”)。次級索引通常並不能唯一地標識記錄,而是一種搜尋記錄中出現特定值的方式:查詢使用者 123 的所有操作,查詢包含詞語 `hogwash` 的所有文章,查詢所有顏色為紅色的車輛等等。
|
||||
|
||||
次級索引是關係型資料庫的基礎,並且在文件資料庫中也很普遍。許多鍵值儲存(如HBase和Volde-mort)為了減少實現的複雜度而放棄了次級索引,但是一些(如Riak)已經開始新增它們,因為它們對於資料模型實在是太有用了。並且次級索引也是Solr和Elasticsearch等搜尋伺服器的基石。
|
||||
次級索引是關係型資料庫的基礎,並且在文件資料庫中也很普遍。許多鍵值儲存(如 HBase 和 Volde-mort)為了減少實現的複雜度而放棄了次級索引,但是一些(如 Riak)已經開始新增它們,因為它們對於資料模型實在是太有用了。並且次級索引也是 Solr 和 Elasticsearch 等搜尋伺服器的基石。
|
||||
|
||||
次級索引的問題是它們不能整齊地對映到分割槽。有兩種用次級索引對資料庫進行分割槽的方法:**基於文件的分割槽(document-based)** 和**基於關鍵詞(term-based)的分割槽**。
|
||||
次級索引的問題是它們不能整齊地對映到分割槽。有兩種用次級索引對資料庫進行分割槽的方法:**基於文件的分割槽(document-based)** 和 **基於關鍵詞(term-based)的分割槽**。
|
||||
|
||||
### 基於文件的次級索引進行分割槽
|
||||
|
||||
假設你正在經營一個銷售二手車的網站(如[圖6-4](../img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽(例如,分割槽0中的ID 0到499,分割槽1中的ID 500到999等)。
|
||||
假設你正在經營一個銷售二手車的網站(如 [圖 6-4](../img/fig6-4.png) 所示)。 每個列表都有一個唯一的 ID—— 稱之為文件 ID—— 並且用文件 ID 對資料庫進行分割槽(例如,分割槽 0 中的 ID 0 到 499,分割槽 1 中的 ID 500 到 999 等)。
|
||||
|
||||
你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位(field)**,關係資料庫中這些是**列(column)** )。 如果你聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。
|
||||
你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是 **欄位(field)**,關係資料庫中這些是 **列(column)** )。 如果你聲明瞭索引,則資料庫可以自動執行索引 [^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目 `color:red` 的文件 ID 列表中。
|
||||
|
||||
[^ii]: 如果資料庫僅支援鍵值模型,則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現次級索引。 如果沿著這條路線走下去,請萬分小心,確保你的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 請參閱“[多物件事務的需求](ch7.md#多物件事務的需求)”。
|
||||
[^ii]: 如果資料庫僅支援鍵值模型,則你可能會嘗試在應用程式程式碼中建立從值到文件 ID 的對映來實現次級索引。 如果沿著這條路線走下去,請萬分小心,確保你的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 請參閱 “[多物件事務的需求](ch7.md#多物件事務的需求)”。
|
||||
|
||||

|
||||
|
||||
**圖6-4 基於文件的次級索引進行分割槽**
|
||||
**圖 6-4 基於文件的次級索引進行分割槽**
|
||||
|
||||
在這種索引方法中,每個分割槽是完全獨立的:每個分割槽維護自己的次級索引,僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時你需要寫入資料庫(新增,刪除或更新文件),只需處理包含你正在編寫的文件ID的分割槽即可。出於這個原因,**文件分割槽索引**也被稱為**本地索引**(而不是將在下一節中描述的**全域性索引**)。
|
||||
在這種索引方法中,每個分割槽是完全獨立的:每個分割槽維護自己的次級索引,僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時你需要寫入資料庫(新增,刪除或更新文件),只需處理包含你正在編寫的文件 ID 的分割槽即可。出於這個原因,**文件分割槽索引** 也被稱為 **本地索引**(而不是將在下一節中描述的 **全域性索引**)。
|
||||
|
||||
但是,從文件分割槽索引中讀取需要注意:除非你對文件ID做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](../img/fig6-4.png)中,紅色汽車出現在分割槽0和分割槽1中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。
|
||||
但是,從文件分割槽索引中讀取需要注意:除非你對文件 ID 做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在 [圖 6-4](../img/fig6-4.png) 中,紅色汽車出現在分割槽 0 和分割槽 1 中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。
|
||||
|
||||
|
||||
這種查詢分割槽資料庫的方法有時被稱為**分散/聚集(scatter/gather)**,並且可能會使次級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(請參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”)。然而,它被廣泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文件分割槽次級索引。大多數資料庫供應商建議你構建一個能從單個分割槽提供次級索引查詢的分割槽方案,但這並不總是可行,尤其是當在單個查詢中使用多個次級索引時(例如同時需要按顏色和製造商查詢)。
|
||||
這種查詢分割槽資料庫的方法有時被稱為 **分散 / 聚集(scatter/gather)**,並且可能會使次級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散 / 聚集也容易導致尾部延遲放大(請參閱 “[實踐中的百分位點](ch1.md#實踐中的百分位點)”)。然而,它被廣泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和 VoltDB 【19】都使用文件分割槽次級索引。大多數資料庫供應商建議你構建一個能從單個分割槽提供次級索引查詢的分割槽方案,但這並不總是可行,尤其是當在單個查詢中使用多個次級索引時(例如同時需要按顏色和製造商查詢)。
|
||||
|
||||
|
||||
### 基於關鍵詞(Term)的次級索引進行分割槽
|
||||
|
||||
我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。
|
||||
我們可以構建一個覆蓋所有分割槽資料的 **全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。
|
||||
|
||||
[圖6-5](../img/fig6-5.png)描述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中,`s`到`z`的在分割槽1。汽車製造商的索引也與之類似(分割槽邊界在`f`和`h`之間)。
|
||||
[圖 6-5](../img/fig6-5.png) 描述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從 `a` 到 `r` 的顏色在分割槽 0 中,`s` 到 `z` 的在分割槽 1。汽車製造商的索引也與之類似(分割槽邊界在 `f` 和 `h` 之間)。
|
||||
|
||||

|
||||
|
||||
**圖6-5 基於關鍵詞對次級索引進行分割槽**
|
||||
**圖 6-5 基於關鍵詞對次級索引進行分割槽**
|
||||
|
||||
我們將這種索引稱為**關鍵詞分割槽(term-partitioned)**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`color:red`。**關鍵詞(Term)** 這個名稱來源於全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。
|
||||
我們將這種索引稱為 **關鍵詞分割槽(term-partitioned)**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`color:red`。**關鍵詞(Term)** 這個名稱來源於全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。
|
||||
|
||||
和之前一樣,我們可以透過**關鍵詞**本身或者它的雜湊進行索引分割槽。根據關鍵詞本身來分割槽對於範圍掃描非常有用(例如對於數值類的屬性,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。
|
||||
和之前一樣,我們可以透過 **關鍵詞** 本身或者它的雜湊進行索引分割槽。根據關鍵詞本身來分割槽對於範圍掃描非常有用(例如對於數值類的屬性,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。
|
||||
|
||||
關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要**分散/收集**所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。
|
||||
關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要 **分散 / 收集** 所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。
|
||||
|
||||
理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱[第七章](ch7.md)和[第九章](ch9.md))。
|
||||
理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱 [第七章](ch7.md) 和 [第九章](ch9.md))。
|
||||
|
||||
在實踐中,對全域性次級索引的更新通常是**非同步**的(也就是說,如果在寫入之後不久讀取索引,剛才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB聲稱在正常情況下,其全域性次級索引會在不到一秒的時間內更新,但在基礎架構出現故障的情況下可能會有延遲【20】。
|
||||
在實踐中,對全域性次級索引的更新通常是 **非同步** 的(也就是說,如果在寫入之後不久讀取索引,剛才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB 聲稱在正常情況下,其全域性次級索引會在不到一秒的時間內更新,但在基礎架構出現故障的情況下可能會有延遲【20】。
|
||||
|
||||
全域性關鍵詞分割槽索引的其他用途包括Riak的搜尋功能【21】和Oracle資料倉庫,它允許你在本地和全域性索引之間進行選擇【22】。我們將在[第十二章](ch12.md)中繼續關鍵詞分割槽次級索引實現的話題。
|
||||
全域性關鍵詞分割槽索引的其他用途包括 Riak 的搜尋功能【21】和 Oracle 資料倉庫,它允許你在本地和全域性索引之間進行選擇【22】。我們將在 [第十二章](ch12.md) 中繼續關鍵詞分割槽次級索引實現的話題。
|
||||
|
||||
## 分割槽再平衡
|
||||
|
||||
隨著時間的推移,資料庫會有各種變化:
|
||||
|
||||
* 查詢吞吐量增加,所以你想要新增更多的CPU來處理負載。
|
||||
* 資料集大小增加,所以你想新增更多的磁碟和RAM來儲存它。
|
||||
* 查詢吞吐量增加,所以你想要新增更多的 CPU 來處理負載。
|
||||
* 資料集大小增加,所以你想新增更多的磁碟和 RAM 來儲存它。
|
||||
* 機器出現故障,其他機器需要接管故障機器的責任。
|
||||
|
||||
所有這些更改都需要資料和請求從一個節點移動到另一個節點。 將負載從叢集中的一個節點向另一個節點移動的過程稱為**再平衡(rebalancing)**。
|
||||
所有這些更改都需要資料和請求從一個節點移動到另一個節點。 將負載從叢集中的一個節點向另一個節點移動的過程稱為 **再平衡(rebalancing)**。
|
||||
|
||||
無論使用哪種分割槽方案,再平衡通常都要滿足一些最低要求:
|
||||
|
||||
* 再平衡之後,負載(資料儲存,讀取和寫入請求)應該在叢集中的節點之間公平地共享。
|
||||
* 再平衡發生時,資料庫應該繼續接受讀取和寫入。
|
||||
* 節點之間只移動必須的資料,以便快速再平衡,並減少網路和磁碟I/O負載。
|
||||
* 節點之間只移動必須的資料,以便快速再平衡,並減少網路和磁碟 I/O 負載。
|
||||
|
||||
|
||||
### 再平衡策略
|
||||
|
||||
有幾種不同的分割槽分配方法【23】,讓我們依次簡要討論一下。
|
||||
有幾種不同的分割槽分配方法【23】,讓我們依次簡要討論一下。
|
||||
|
||||
#### 反面教材:hash mod N
|
||||
|
||||
我們在前面說過([圖6-3](../img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key)<b_0$,則將鍵分配給分割槽0,如果$b_0 ≤ hash(key) <b_1$,則分配給分割槽1)
|
||||
我們在前面說過([圖 6-3](../img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果 $0 ≤ hash(key)< b_0$,則將鍵分配給分割槽 0,如果 $b_0 ≤ hash(key) < b_1$,則分配給分割槽 1)
|
||||
|
||||
也許你想知道為什麼我們不使用 ***取模(mod)***(許多程式語言中的%運算子)。例如,`hash(key) mod 10`會返回一個介於0和9之間的數字(如果我們將雜湊寫為十進位制數,雜湊模10將是最後一個數字)。如果我們有10個節點,編號為0到9,這似乎是將每個鍵分配給一個節點的簡單方法。
|
||||
也許你想知道為什麼我們不使用 ***取模(mod)***(許多程式語言中的 % 運算子)。例如,`hash(key) mod 10` 會返回一個介於 0 和 9 之間的數字(如果我們將雜湊寫為十進位制數,雜湊模 10 將是最後一個數字)。如果我們有 10 個節點,編號為 0 到 9,這似乎是將每個鍵分配給一個節點的簡單方法。
|
||||
|
||||
模N($mod N$)方法的問題是,如果節點數量N發生變化,大多數鍵將需要從一個節點移動到另一個節點。例如,假設$hash(key)=123456$。如果最初有10個節點,那麼這個鍵一開始放在節點6上(因為$123456\ mod\ 10 = 6$)。當你增長到11個節點時,鍵需要移動到節點3($123456\ mod\ 11 = 3$),當你增長到12個節點時,需要移動到節點0($123456\ mod\ 12 = 0$)。這種頻繁的舉動使得重新平衡過於昂貴。
|
||||
模 N($mod N$)方法的問題是,如果節點數量 N 發生變化,大多數鍵將需要從一個節點移動到另一個節點。例如,假設 $hash(key)=123456$。如果最初有 10 個節點,那麼這個鍵一開始放在節點 6 上(因為 $123456\ mod\ 10 = 6$)。當你增長到 11 個節點時,鍵需要移動到節點 3($123456\ mod\ 11 = 3$),當你增長到 12 個節點時,需要移動到節點 0($123456\ mod\ 12 = 0$)。這種頻繁的舉動使得重新平衡過於昂貴。
|
||||
|
||||
我們需要一種只移動必需資料的方法。
|
||||
|
||||
#### 固定數量的分割槽
|
||||
|
||||
幸運的是,有一個相當簡單的解決方案:建立比節點更多的分割槽,併為每個節點分配多個分割槽。例如,執行在10個節點的叢集上的資料庫可能會從一開始就被拆分為1,000個分割槽,因此大約有100個分割槽被分配給每個節點。
|
||||
幸運的是,有一個相當簡單的解決方案:建立比節點更多的分割槽,併為每個節點分配多個分割槽。例如,執行在 10 個節點的叢集上的資料庫可能會從一開始就被拆分為 1,000 個分割槽,因此大約有 100 個分割槽被分配給每個節點。
|
||||
|
||||
現在,如果一個節點被新增到叢集中,新節點可以從當前每個節點中**竊取**一些分割槽,直到分割槽再次公平分配。這個過程如[圖6-6](../img/fig6-6.png)所示。如果從叢集中刪除一個節點,則會發生相反的情況。
|
||||
現在,如果一個節點被新增到叢集中,新節點可以從當前每個節點中 **竊取** 一些分割槽,直到分割槽再次公平分配。這個過程如 [圖 6-6](../img/fig6-6.png) 所示。如果從叢集中刪除一個節點,則會發生相反的情況。
|
||||
|
||||
只有分割槽在節點之間的移動。分割槽的數量不會改變,鍵所指定的分割槽也不會改變。唯一改變的是分割槽所在的節點。這種變更並不是即時的 — 在網路上傳輸大量的資料需要一些時間 — 所以在傳輸過程中,原有分割槽仍然會接受讀寫操作。
|
||||
|
||||

|
||||
|
||||
**圖6-6 將新節點新增到每個節點具有多個分割槽的資料庫叢集。**
|
||||
**圖 6-6 將新節點新增到每個節點具有多個分割槽的資料庫叢集。**
|
||||
|
||||
原則上,你甚至可以解決叢集中的硬體不匹配問題:透過為更強大的節點分配更多的分割槽,可以強制這些節點承載更多的負載。在Riak 【15】,Elasticsearch 【24】,Couchbase 【10】和Voldemort 【25】中使用了這種再平衡的方法。
|
||||
原則上,你甚至可以解決叢集中的硬體不匹配問題:透過為更強大的節點分配更多的分割槽,可以強制這些節點承載更多的負載。在 Riak 【15】、Elasticsearch 【24】、Couchbase 【10】和 Voldemort 【25】中使用了這種再平衡的方法。
|
||||
|
||||
在這種配置中,分割槽的數量通常在資料庫第一次建立時確定,之後不會改變。雖然原則上可以分割和合並分割槽(請參閱下一節),但固定數量的分割槽在操作上更簡單,因此許多固定分割槽資料庫選擇不實施分割槽分割。因此,一開始配置的分割槽數就是你可以擁有的最大節點數量,所以你需要選擇足夠多的分割槽以適應未來的增長。但是,每個分割槽也有管理開銷,所以選擇太大的數字會適得其反。
|
||||
|
||||
如果資料集的總大小難以預估(例如,可能它開始很小,但隨著時間的推移會變得更大),選擇正確的分割槽數是困難的。由於每個分割槽包含了總資料量固定比率的資料,因此每個分割槽的大小與叢集中的資料總量成比例增長。如果分割槽非常大,再平衡和從節點故障恢復變得昂貴。但是,如果分割槽太小,則會產生太多的開銷。當分割槽大小“恰到好處”的時候才能獲得很好的效能,如果分割槽數量固定,但資料量變動很大,則難以達到最佳效能。
|
||||
如果資料集的總大小難以預估(例如,可能它開始很小,但隨著時間的推移會變得更大),選擇正確的分割槽數是困難的。由於每個分割槽包含了總資料量固定比率的資料,因此每個分割槽的大小與叢集中的資料總量成比例增長。如果分割槽非常大,再平衡和從節點故障恢復變得昂貴。但是,如果分割槽太小,則會產生太多的開銷。當分割槽大小 “恰到好處” 的時候才能獲得很好的效能,如果分割槽數量固定,但資料量變動很大,則難以達到最佳效能。
|
||||
|
||||
#### 動態分割槽
|
||||
|
||||
對於使用鍵範圍分割槽的資料庫(請參閱“[根據鍵的範圍分割槽](#根據鍵的範圍分割槽)”),具有固定邊界的固定數量的分割槽將非常不便:如果出現邊界錯誤,則可能會導致一個分割槽中的所有資料或者其他分割槽中的所有資料為空。手動重新配置分割槽邊界將非常繁瑣。
|
||||
對於使用鍵範圍分割槽的資料庫(請參閱 “[根據鍵的範圍分割槽](#根據鍵的範圍分割槽)”),具有固定邊界的固定數量的分割槽將非常不便:如果出現邊界錯誤,則可能會導致一個分割槽中的所有資料或者其他分割槽中的所有資料為空。手動重新配置分割槽邊界將非常繁瑣。
|
||||
|
||||
出於這個原因,按鍵的範圍進行分割槽的資料庫(如HBase和RethinkDB)會動態建立分割槽。當分割槽增長到超過配置的大小時(在HBase上,預設值是10GB),會被分成兩個分割槽,每個分割槽約佔一半的資料【26】。與之相反,如果大量資料被刪除並且分割槽縮小到某個閾值以下,則可以將其與相鄰分割槽合併。此過程與B樹頂層發生的過程類似(請參閱“[B樹](ch3.md#B樹)”)。
|
||||
出於這個原因,按鍵的範圍進行分割槽的資料庫(如 HBase 和 RethinkDB)會動態建立分割槽。當分割槽增長到超過配置的大小時(在 HBase 上,預設值是 10GB),會被分成兩個分割槽,每個分割槽約佔一半的資料【26】。與之相反,如果大量資料被刪除並且分割槽縮小到某個閾值以下,則可以將其與相鄰分割槽合併。此過程與 B 樹頂層發生的過程類似(請參閱 “[B 樹](ch3.md#B樹)”)。
|
||||
|
||||
每個分割槽分配給一個節點,每個節點可以處理多個分割槽,就像固定數量的分割槽一樣。大型分割槽拆分後,可以將其中的一半轉移到另一個節點,以平衡負載。在HBase中,分割槽檔案的傳輸透過HDFS(底層使用的分散式檔案系統)來實現【3】。
|
||||
每個分割槽分配給一個節點,每個節點可以處理多個分割槽,就像固定數量的分割槽一樣。大型分割槽拆分後,可以將其中的一半轉移到另一個節點,以平衡負載。在 HBase 中,分割槽檔案的傳輸透過 HDFS(底層使用的分散式檔案系統)來實現【3】。
|
||||
|
||||
動態分割槽的一個優點是分割槽數量適應總資料量。如果只有少量的資料,少量的分割槽就足夠了,所以開銷很小;如果有大量的資料,每個分割槽的大小被限制在一個可配置的最大值【23】。
|
||||
|
||||
需要注意的是,一個空的資料庫從一個分割槽開始,因為沒有關於在哪裡繪製分割槽邊界的先驗資訊。資料集開始時很小,直到達到第一個分割槽的分割點,所有寫入操作都必須由單個節點處理,而其他節點則處於空閒狀態。為了解決這個問題,HBase和MongoDB允許在一個空的資料庫上配置一組初始分割槽(這被稱為**預分割**,即pre-splitting)。在鍵範圍分割槽的情況中,預分割需要提前知道鍵是如何進行分配的【4,26】。
|
||||
需要注意的是,一個空的資料庫從一個分割槽開始,因為沒有關於在哪裡繪製分割槽邊界的先驗資訊。資料集開始時很小,直到達到第一個分割槽的分割點,所有寫入操作都必須由單個節點處理,而其他節點則處於空閒狀態。為了解決這個問題,HBase 和 MongoDB 允許在一個空的資料庫上配置一組初始分割槽(這被稱為 **預分割**,即 pre-splitting)。在鍵範圍分割槽的情況中,預分割需要提前知道鍵是如何進行分配的【4,26】。
|
||||
|
||||
動態分割槽不僅適用於資料的範圍分割槽,而且也適用於雜湊分割槽。從版本2.4開始,MongoDB同時支援範圍和雜湊分割槽,並且都支援動態分割分割槽。
|
||||
動態分割槽不僅適用於資料的範圍分割槽,而且也適用於雜湊分割槽。從版本 2.4 開始,MongoDB 同時支援範圍和雜湊分割槽,並且都支援動態分割分割槽。
|
||||
|
||||
#### 按節點比例分割槽
|
||||
|
||||
透過動態分割槽,分割槽的數量與資料集的大小成正比,因為拆分和合並過程將每個分割槽的大小保持在固定的最小值和最大值之間。另一方面,對於固定數量的分割槽,每個分割槽的大小與資料集的大小成正比。在這兩種情況下,分割槽的數量都與節點的數量無關。
|
||||
|
||||
Cassandra和Ketama使用的第三種方法是使分割槽數與節點數成正比——換句話說,每個節點具有固定數量的分割槽【23,27,28】。在這種情況下,每個分割槽的大小與資料集大小成比例地增長,而節點數量保持不變,但是當增加節點數時,分割槽將再次變小。由於較大的資料量通常需要較大數量的節點進行儲存,因此這種方法也使每個分割槽的大小較為穩定。
|
||||
Cassandra 和 Ketama 使用的第三種方法是使分割槽數與節點數成正比 —— 換句話說,每個節點具有固定數量的分割槽【23,27,28】。在這種情況下,每個分割槽的大小與資料集大小成比例地增長,而節點數量保持不變,但是當增加節點數時,分割槽將再次變小。由於較大的資料量通常需要較大數量的節點進行儲存,因此這種方法也使每個分割槽的大小較為穩定。
|
||||
|
||||
當一個新節點加入叢集時,它隨機選擇固定數量的現有分割槽進行拆分,然後佔有這些拆分分割槽中每個分割槽的一半,同時將每個分割槽的另一半留在原地。隨機化可能會產生不公平的分割,但是平均在更大數量的分割槽上時(在Cassandra中,預設情況下,每個節點有256個分割槽),新節點最終從現有節點獲得公平的負載份額。 Cassandra 3.0引入了另一種再平衡的演算法來避免不公平的分割【29】。
|
||||
當一個新節點加入叢集時,它隨機選擇固定數量的現有分割槽進行拆分,然後佔有這些拆分分割槽中每個分割槽的一半,同時將每個分割槽的另一半留在原地。隨機化可能會產生不公平的分割,但是平均在更大數量的分割槽上時(在 Cassandra 中,預設情況下,每個節點有 256 個分割槽),新節點最終從現有節點獲得公平的負載份額。 Cassandra 3.0 引入了另一種再平衡的演算法來避免不公平的分割【29】。
|
||||
|
||||
隨機選擇分割槽邊界要求使用基於雜湊的分割槽(可以從雜湊函式產生的數字範圍中挑選邊界)。實際上,這種方法最符合一致性雜湊的原始定義【7】(請參閱“[一致性雜湊](#一致性雜湊)”)。最新的雜湊函式可以在較低元資料開銷的情況下達到類似的效果【8】。
|
||||
隨機選擇分割槽邊界要求使用基於雜湊的分割槽(可以從雜湊函式產生的數字範圍中挑選邊界)。實際上,這種方法最符合一致性雜湊的原始定義【7】(請參閱 “[一致性雜湊](#一致性雜湊)”)。最新的雜湊函式可以在較低元資料開銷的情況下達到類似的效果【8】。
|
||||
|
||||
### 運維:手動還是自動再平衡
|
||||
|
||||
關於再平衡有一個重要問題:自動還是手動進行?
|
||||
|
||||
在全自動重新平衡(系統自動決定何時將分割槽從一個節點移動到另一個節點,無須人工干預)和完全手動(分割槽指派給節點由管理員明確配置,僅在管理員明確重新配置時才會更改)之間有一個權衡。例如,Couchbase,Riak和Voldemort會自動生成建議的分割槽分配,但需要管理員提交才能生效。
|
||||
在全自動重新平衡(系統自動決定何時將分割槽從一個節點移動到另一個節點,無須人工干預)和完全手動(分割槽指派給節點由管理員明確配置,僅在管理員明確重新配置時才會更改)之間有一個權衡。例如,Couchbase、Riak 和 Voldemort 會自動生成建議的分割槽分配,但需要管理員提交才能生效。
|
||||
|
||||
全自動重新平衡可以很方便,因為正常維護的操作工作較少。但是,這可能是不可預測的。再平衡是一個昂貴的操作,因為它需要重新路由請求並將大量資料從一個節點移動到另一個節點。如果沒有做好,這個過程可能會使網路或節點負載過重,降低其他請求的效能。
|
||||
|
||||
@ -252,45 +253,45 @@ Cassandra和Ketama使用的第三種方法是使分割槽數與節點數成正
|
||||
|
||||
## 請求路由
|
||||
|
||||
現在我們已經將資料集分割到多個機器上執行的多個節點上。但是仍然存在一個懸而未決的問題:當客戶想要發出請求時,如何知道要連線哪個節點?隨著分割槽重新平衡,分割槽對節點的分配也發生變化。為了回答這個問題,需要有人知曉這些變化:如果我想讀或寫鍵“foo”,需要連線哪個IP地址和埠號?
|
||||
現在我們已經將資料集分割到多個機器上執行的多個節點上。但是仍然存在一個懸而未決的問題:當客戶想要發出請求時,如何知道要連線哪個節點?隨著分割槽重新平衡,分割槽對節點的分配也發生變化。為了回答這個問題,需要有人知曉這些變化:如果我想讀或寫鍵 “foo”,需要連線哪個 IP 地址和埠號?
|
||||
|
||||
這個問題可以概括為 **服務發現(service discovery)** ,它不僅限於資料庫。任何可透過網路訪問的軟體都有這個問題,特別是如果它的目標是高可用性(在多臺機器上執行冗餘配置)。許多公司已經編寫了自己的內部服務發現工具,其中許多已經作為開源釋出【30】。
|
||||
這個問題可以概括為 **服務發現(service discovery)** ,它不僅限於資料庫。任何可透過網路訪問的軟體都有這個問題,特別是如果它的目標是高可用性(在多臺機器上執行冗餘配置)。許多公司已經編寫了自己的內部服務發現工具,其中許多已經作為開源釋出【30】。
|
||||
|
||||
概括來說,這個問題有幾種不同的方案(如圖6-7所示):
|
||||
概括來說,這個問題有幾種不同的方案(如圖 6-7 所示):
|
||||
|
||||
1. 允許客戶聯絡任何節點(例如,透過**迴圈策略的負載均衡**,即Round-Robin Load Balancer)。如果該節點恰巧擁有請求的分割槽,則它可以直接處理該請求;否則,它將請求轉發到適當的節點,接收回復並傳遞給客戶端。
|
||||
1. 允許客戶聯絡任何節點(例如,透過 **迴圈策略的負載均衡**,即 Round-Robin Load Balancer)。如果該節點恰巧擁有請求的分割槽,則它可以直接處理該請求;否則,它將請求轉發到適當的節點,接收回復並傳遞給客戶端。
|
||||
2. 首先將所有來自客戶端的請求傳送到路由層,它決定了應該處理請求的節點,並相應地轉發。此路由層本身不處理任何請求;它僅負責分割槽的負載均衡。
|
||||
3. 要求客戶端知道分割槽和節點的分配。在這種情況下,客戶端可以直接連線到適當的節點,而不需要任何中介。
|
||||
|
||||
以上所有情況中的關鍵問題是:作出路由決策的元件(可能是節點之一,還是路由層或客戶端)如何瞭解分割槽-節點之間的分配關係變化?
|
||||
以上所有情況中的關鍵問題是:作出路由決策的元件(可能是節點之一,還是路由層或客戶端)如何瞭解分割槽 - 節點之間的分配關係變化?
|
||||
|
||||

|
||||
|
||||
**圖6-7 將請求路由到正確節點的三種不同方式。**
|
||||
**圖 6-7 將請求路由到正確節點的三種不同方式。**
|
||||
|
||||
這是一個具有挑戰性的問題,因為重要的是所有參與者都達成共識 - 否則請求將被傳送到錯誤的節點,得不到正確的處理。 在分散式系統中有達成共識的協議,但很難正確地實現(見[第九章](ch9.md))。
|
||||
這是一個具有挑戰性的問題,因為重要的是所有參與者都達成共識 - 否則請求將被傳送到錯誤的節點,得不到正確的處理。 在分散式系統中有達成共識的協議,但很難正確地實現(見 [第九章](ch9.md))。
|
||||
|
||||
許多分散式資料系統都依賴於一個獨立的協調服務,比如ZooKeeper來跟蹤叢集元資料,如[圖6-8](../img/fig6-8.png)所示。 每個節點在ZooKeeper中註冊自己,ZooKeeper維護分割槽到節點的可靠對映。 其他參與者(如路由層或分割槽感知客戶端)可以在ZooKeeper中訂閱此資訊。 只要分割槽分配發生了改變,或者叢集中新增或刪除了一個節點,ZooKeeper就會通知路由層使路由資訊保持最新狀態。
|
||||
許多分散式資料系統都依賴於一個獨立的協調服務,比如 ZooKeeper 來跟蹤叢集元資料,如 [圖 6-8](../img/fig6-8.png) 所示。 每個節點在 ZooKeeper 中註冊自己,ZooKeeper 維護分割槽到節點的可靠對映。 其他參與者(如路由層或分割槽感知客戶端)可以在 ZooKeeper 中訂閱此資訊。 只要分割槽分配發生了改變,或者叢集中新增或刪除了一個節點,ZooKeeper 就會通知路由層使路由資訊保持最新狀態。
|
||||
|
||||

|
||||
|
||||
**圖6-8 使用ZooKeeper跟蹤分割槽分配給節點。**
|
||||
**圖 6-8 使用 ZooKeeper 跟蹤分割槽分配給節點。**
|
||||
|
||||
例如,LinkedIn的Espresso使用Helix 【31】進行叢集管理(依靠ZooKeeper),實現瞭如[圖6-8](../img/fig6-8.png)所示的路由層。 HBase,SolrCloud和Kafka也使用ZooKeeper來跟蹤分割槽分配。 MongoDB具有類似的體系結構,但它依賴於自己的**配置伺服器(config server)** 實現和mongos守護程序作為路由層。
|
||||
例如,LinkedIn的Espresso使用Helix 【31】進行叢集管理(依靠ZooKeeper),實現瞭如[圖6-8](../img/fig6-8.png)所示的路由層。 HBase、SolrCloud和Kafka也使用ZooKeeper來跟蹤分割槽分配。MongoDB具有類似的體系結構,但它依賴於自己的**配置伺服器(config server)** 實現和mongos守護程序作為路由層。
|
||||
|
||||
Cassandra和Riak採取不同的方法:他們在節點之間使用**流言協議(gossip protocol)** 來傳播叢集狀態的變化。請求可以傳送到任意節點,該節點會轉發到包含所請求的分割槽的適當節點([圖6-7](../img/fig6-7.png)中的方法1)。這個模型在資料庫節點中增加了更多的複雜性,但是避免了對像ZooKeeper這樣的外部協調服務的依賴。
|
||||
Cassandra 和 Riak 採取不同的方法:他們在節點之間使用 **流言協議(gossip protocol)** 來傳播叢集狀態的變化。請求可以傳送到任意節點,該節點會轉發到包含所請求的分割槽的適當節點([圖 6-7](../img/fig6-7.png) 中的方法 1)。這個模型在資料庫節點中增加了更多的複雜性,但是避免了對像 ZooKeeper 這樣的外部協調服務的依賴。
|
||||
|
||||
Couchbase不會自動重新平衡,這簡化了設計。通常情況下,它配置了一個名為moxi的路由層,它會從叢集節點了解路由變化【32】。
|
||||
Couchbase 不會自動重新平衡,這簡化了設計。通常情況下,它配置了一個名為 moxi 的路由層,它會從叢集節點了解路由變化【32】。
|
||||
|
||||
當使用路由層或向隨機節點發送請求時,客戶端仍然需要找到要連線的IP地址。這些地址並不像分割槽的節點分佈變化的那麼快,所以使用DNS通常就足夠了。
|
||||
當使用路由層或向隨機節點發送請求時,客戶端仍然需要找到要連線的 IP 地址。這些地址並不像分割槽的節點分佈變化的那麼快,所以使用 DNS 通常就足夠了。
|
||||
|
||||
### 執行並行查詢
|
||||
|
||||
到目前為止,我們只關注讀取或寫入單個鍵的非常簡單的查詢(加上基於文件分割槽的次級索引場景下的分散/聚集查詢)。這也是大多數NoSQL分散式資料儲存所支援的訪問層級。
|
||||
到目前為止,我們只關注讀取或寫入單個鍵的非常簡單的查詢(加上基於文件分割槽的次級索引場景下的分散 / 聚集查詢)。這也是大多數 NoSQL 分散式資料儲存所支援的訪問層級。
|
||||
|
||||
然而,通常用於分析的**大規模並行處理(MPP, Massively parallel processing)** 關係型資料庫產品在其支援的查詢型別方面要複雜得多。一個典型的資料倉庫查詢包含多個連線,過濾,分組和聚合操作。 MPP查詢最佳化器將這個複雜的查詢分解成許多執行階段和分割槽,其中許多可以在資料庫叢集的不同節點上並行執行。涉及掃描大規模資料集的查詢特別受益於這種並行執行。
|
||||
然而,通常用於分析的 **大規模並行處理(MPP, Massively parallel processing)** 關係型資料庫產品在其支援的查詢型別方面要複雜得多。一個典型的資料倉庫查詢包含多個連線,過濾,分組和聚合操作。 MPP 查詢最佳化器將這個複雜的查詢分解成許多執行階段和分割槽,其中許多可以在資料庫叢集的不同節點上並行執行。涉及掃描大規模資料集的查詢特別受益於這種並行執行。
|
||||
|
||||
資料倉庫查詢的快速並行執行是一個專門的話題,由於分析有很重要的商業意義,可以帶來很多利益。我們將在[第十章](ch10.md)討論並行查詢執行的一些技巧。有關並行資料庫中使用的技術的更詳細的概述,請參閱參考文獻【1,33】。
|
||||
資料倉庫查詢的快速並行執行是一個專門的話題,由於分析有很重要的商業意義,可以帶來很多利益。我們將在 [第十章](ch10.md) 討論並行查詢執行的一些技巧。有關並行資料庫中使用的技術的更詳細的概述,請參閱參考文獻【1,33】。
|
||||
|
||||
## 本章小結
|
||||
|
||||
@ -314,7 +315,7 @@ Couchbase不會自動重新平衡,這簡化了設計。通常情況下,它
|
||||
|
||||
我們還討論了分割槽和次級索引之間的相互作用。次級索引也需要分割槽,有兩種方法:
|
||||
|
||||
* 基於文件分割槽(本地索引),其中次級索引儲存在與主鍵和值相同的分割槽中。這意味著只有一個分割槽需要在寫入時更新,但是讀取次級索引需要在所有分割槽之間進行分散/收集。
|
||||
* 基於文件分割槽(本地索引),其中次級索引儲存在與主鍵和值相同的分割槽中。這意味著只有一個分割槽需要在寫入時更新,但是讀取次級索引需要在所有分割槽之間進行分散 / 收集。
|
||||
* 基於關鍵詞分割槽(全域性索引),其中次級索引存在不同的分割槽中。次級索引中的條目可以包括來自主鍵的所有分割槽的記錄。當文件寫入時,需要更新多個分割槽中的次級索引;但是可以從單個分割槽中進行讀取。
|
||||
|
||||
最後,我們討論了將查詢路由到適當的分割槽的技術,從簡單的分割槽負載平衡到複雜的並行查詢執行引擎。
|
||||
|
484
zh-tw/ch7.md
484
zh-tw/ch7.md
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user