update ch5.md for leaderless replication

This commit is contained in:
Gang Yin 2022-04-26 15:15:47 +08:00
parent 05c425f386
commit 7475983d54
2 changed files with 114 additions and 114 deletions

114
ch5.md
View File

@ -304,7 +304,7 @@
假如你有一个数据库,副本分散在好几个不同的数据中心(可能会用来容忍单个数据中心的故障,或者为了在地理上更接近用户)。如果使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。
领导者配置中可以在每个数据中心都有主库。[图 5-6](img/fig5-6.png) 展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
配置中可以在每个数据中心都有主库。[图 5-6](img/fig5-6.png) 展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
![](img/fig5-6.png)
@ -338,9 +338,9 @@
在这种情况下,每个设备都有一个充当主库的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。
从架构的角度来看,这种设置实际上与数据中心之间的多领导者复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多主复制用好是多么困难的一件事。
从架构的角度来看,这种设置实际上与数据中心之间的多复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多主复制用好是多么困难的一件事。
有一些工具旨在使这种多领导者配置更容易。例如CouchDB 就是为这种操作模式而设计的【29】。
有一些工具旨在使这种多配置更容易。例如CouchDB 就是为这种操作模式而设计的【29】。
#### 协同编辑
@ -352,7 +352,7 @@
### 处理写入冲突
领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
例如,考虑一个由两个用户同时编辑的维基页面,如 [图 5-7](img/fig5-7.png) 所示。用户 1 将页面的标题从 A 更改为 B并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。
@ -583,17 +583,17 @@
#### 运维多个数据中心
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅 “[多主复制](#多主复制)”)。无主复制也适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
我们先前讨论了跨数据中心复制作为多主复制的用例(请参阅 “[多主复制](#多主复制)”)。其实无主复制也适用于多数据中心操作,既然它旨在容忍冲突的并发写入、网络中断和延迟尖峰。
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 的写入。
@ -603,7 +603,7 @@ Dynamo 风格的数据库允许多个客户端同时写入相同的 Key这意
**图 5-12 并发写入 Dynamo 风格的数据存储:没有明确定义的顺序。**
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如 [图 5-12](img/fig5-12.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B而其他节点认为值是 A 。
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致,如 [图 5-12](img/fig5-12.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B而其他节点认为值是 A 。
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
@ -611,87 +611,87 @@ Dynamo 风格的数据库允许多个客户端同时写入相同的 Key这意
#### 最后写入胜利(丢弃并发写入)
实现最终融合的一种方法是声明每个副本只需要存储 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是 “最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
实现最终收敛的一种方法是声明每个副本只需要存储 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是 “最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在 [图 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 实现了最终收敛的目标,但以 **持久性** 为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 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 之前发生happens before。一个操作是否在另一个操作之前发生是定义并发含义的关键。事实上,我们可以简单地说,如果两个操作中的任何一个都不在另一个之前发生(即,两个操作都不知道对方),那么这两个操作是并发的【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 [鸡蛋,牛奶,火腿] 并发,所以服务器保留这两个并发值。
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值 **都** 返回给客户端 2 ,并附上版本号 2。
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道 [牛奶,面粉] 的写入取代了 [牛奶] 的先前值,但与 [鸡蛋] 的值是 **并发** 的。因此,服务器将版本 3 分配给 [牛奶,面粉],覆盖版本 1 值 [牛奶],但保留版本 2 的值 [蛋],并将所有的值返回给客户端 1。
4. 同时,客户端 2 想要加入火腿,不知道客户端 1 刚刚加了面粉。客户端 2 在最近一次响应中从服务器收到了两个值 [牛奶] 和 [蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值 [鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 的值 [鸡蛋],但新值也会与版本 3 的值 [牛奶,面粉] **并发**,所以剩下的两个值是版本 3 的 [牛奶,面粉],和版本 4 的 [鸡蛋,牛奶,火腿]。
5. 最后,客户端 1 想要加培根。它之前从服务器接收到了版本 3 的 [牛奶,面粉] 和 [鸡蛋],所以它合并这些,添加培根,并将最终值 [牛奶,面粉,鸡蛋,培根] 连同版本号 3 发往服务器。这会覆盖版本 3 的值 [牛奶,面粉](请注意 [鸡蛋] 已经在上一步被覆盖),但与版本 4 的值 [鸡蛋,牛奶,火腿] 并发,所以服务器保留这两个并发值。
![](img/fig5-13.png)
**图 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) 所示。箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。在这个例子中,客户端永远不会完全拿到服务器上的最新数据,因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖,并且不会丢失任何写入。
![](img/fig5-14.png)
**图 5-14 图 5-13 中的因果依赖关系图。**
请注意,服务器可以通过查看版本号来确定两个操作是否是并发的 —— 它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下:
请注意,服务器可以通过查看版本号来确定两个操作是否是并发的 —— 它不需要对值本身进行解释(因此该值可以是任何数据结构)。该算法的工作原理如下:
* 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起(针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来)。
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与随后的写入是并发的)。
* 服务器为每个键维护一个版本号,每次写入该键时都递增版本号,并将新版本号与写入的值一起存储。
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起(针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来)。
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与正在进行的其它写入是并发的)。
当一个写入包含前一次读取的版本号时,它会告诉我们的写入是基于之前的哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
#### 合并同时写入的值
#### 合并并发写入的值
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:客户端随后必须通过合并并发写入的值来进行清理。 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】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
@ -701,8 +701,8 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
> #### 版本向量和向量时钟
>
> 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙 —— 细节请参阅参考资料【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。
>
> 版本向量有时也被称为向量时钟,即使它们不完全相同。其中的差别很微妙 —— 细节请参阅参考资料【57,60,61】。简而言之在比较副本的状态时版本向量是正确的数据结构。
## 本章小结
@ -718,7 +718,7 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
* 延迟
将数据放置在距离用户较近的地方,以便用户能够更快地与其交互
将数据放置在地理上距离用户较近的地方,以便用户能够更快地与其交互
* 可伸缩性
@ -731,35 +731,35 @@ LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同
* 单主复制
客户端将所有写入操作发送到单个节点(领导者),该节点将数据更改事件流发送到其他副本(追随者)。读取可以在任何副本上执行,但从追随者读取可能是陈旧的。
客户端将所有写入操作发送到单个节点(主库),该节点将数据更改事件流发送到其他副本(从库)。读取可以在任何副本上执行,但从库的读取结果可能是陈旧的。
* 多主复制
客户端发送每个写入到几个领导节点之一,其中任何一个都可以接受写入。领导者将数据更改事件流发送给彼此以及任何跟随者节点。
客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。
* 无主复制
客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点、网络中断和延迟峰值的情况下,多主复制和无主复制可以更加健壮,其代价是难以推理并且仅提供非常弱的一致性保证
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制滞后增加和服务器故障时会发生什么,这一点很重要。如果一个领导者失败了,并且你提升了一个异步更新的追随者成为新的领导者,那么最近提交的数据可能会丢失。
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制延迟增加和服务器故障时会发生什么,这一点很重要。如果主库失败后你将一个异步更新的从库提升为新的主库,那么最近提交的数据可能会丢失。
我们研究了一些可能由复制滞后引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型:
我们研究了一些可能由复制延迟引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制延迟时的行为的一致性模型:
* 写后读
* 写后读一致性
用户应该总是看到自己提交的数据。
用户应该总是看到自己提交的数据。
* 单调读
用户在看到某个时间点的数据后,他们不应该再看到某个更早时间点的数据
用户在看到某个时间点的数据后,他们不应该再看到该数据在更早时间点的情况
* 一致前缀读
用户应该看到数据处于一种具有因果意义的状态:例如,按正确的顺序看到一个问题和对应的回答。
最后,我们讨论了多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。我们还谈到了通过合并并发更新来解决冲突的方法。
最后,我们讨论了多主复制和无主复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可以使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否并发发生。我们还谈到了通过合并并发更新来解决冲突的方法。
在下一章中,我们将继续研究分布在多个机器上的数据,通过复制的同僚:将大数据集分割成分区。

View File

@ -304,7 +304,7 @@
假如你有一個數據庫,副本分散在好幾個不同的資料中心(可能會用來容忍單個數據中心的故障,或者為了在地理上更接近使用者)。如果使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。
領導者配置中可以在每個資料中心都有主庫。[圖 5-6](../img/fig5-6.png) 展示了這個架構。在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。
配置中可以在每個資料中心都有主庫。[圖 5-6](../img/fig5-6.png) 展示了這個架構。在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。
![](../img/fig5-6.png)
@ -338,9 +338,9 @@
在這種情況下,每個裝置都有一個充當主庫的本地資料庫(它接受寫請求),並且在所有裝置上的日曆副本之間同步時,存在非同步的多主複製過程。複製延遲可能是幾小時甚至幾天,具體取決於何時可以訪問網際網路。
從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個 “資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多主複製用好是多麼困難的一件事。
從架構的角度來看,這種設定實際上與資料中心之間的多複製類似,每個裝置都是一個 “資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多主複製用好是多麼困難的一件事。
有一些工具旨在使這種多領導者配置更容易。例如CouchDB 就是為這種操作模式而設計的【29】。
有一些工具旨在使這種多配置更容易。例如CouchDB 就是為這種操作模式而設計的【29】。
#### 協同編輯
@ -352,7 +352,7 @@
### 處理寫入衝突
領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。
複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。
例如,考慮一個由兩個使用者同時編輯的維基頁面,如 [圖 5-7](../img/fig5-7.png) 所示。使用者 1 將頁面的標題從 A 更改為 B並且使用者 2 同時將標題從 A 更改為 C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時會發現衝突【33】。單主資料庫中不會出現此問題。
@ -583,17 +583,17 @@
#### 運維多個數據中心
我們先前討論了跨資料中心複製作為多主複製的用例(請參閱 “[多主複製](#多主複製)”)。無主複製也適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。
我們先前討論了跨資料中心複製作為多主複製的用例(請參閱 “[多主複製](#多主複製)”)。其實無主複製也適用於多資料中心操作,既然它旨在容忍衝突的併發寫入、網路中斷和延遲尖峰。
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 的寫入。
@ -603,7 +603,7 @@ Dynamo 風格的資料庫允許多個客戶端同時寫入相同的 Key這意
**圖 5-12 併發寫入 Dynamo 風格的資料儲存:沒有明確定義的順序。**
如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如 [圖 5-12](../img/fig5-12.png) 中的最終獲取請求所示:節點 2 認為 X 的最終值是 B而其他節點認為值是 A 。
如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆寫某個鍵值,那麼節點就會永久地不一致,如 [圖 5-12](../img/fig5-12.png) 中的最終獲取請求所示:節點 2 認為 X 的最終值是 B而其他節點認為值是 A 。
為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。
@ -611,87 +611,87 @@ Dynamo 風格的資料庫允許多個客戶端同時寫入相同的 Key這意
#### 最後寫入勝利(丟棄併發寫入)
實現最終融合的一種方法是宣告每個副本只需要儲存 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是 “最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。
實現最終收斂的一種方法是宣告每個副本只需要儲存 **“最近”** 的值,並允許 **“更舊”** 的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是 “最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。
正如 **“最近”** 的引號所表明的,這個想法其實頗具誤導性。在 [圖 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 實現了最終收斂的目標,但以 **永續性** 為代價:如果同一個鍵有多個併發寫入,即使它們反饋給客戶端的結果都是成功的(因為它們被寫入 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 之前發生happens before。一個操作是否在另一個操作之前發生是定義併發含義的關鍵。事實上,我們可以簡單地說,如果兩個操作中的任何一個都不在另一個之前發生(即,兩個操作都不知道對方),那麼這兩個操作是併發的【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 [雞蛋,牛奶,火腿] 併發,所以伺服器保留這兩個併發值。
2. 客戶端 2 將雞蛋加入購物車,不知道客戶端 1 同時添加了牛奶(客戶端 2 認為它的雞蛋是購物車中的唯一物品)。伺服器為此寫入分配版本號 2並將雞蛋和牛奶儲存為兩個單獨的值。然後它將這兩個值 **都** 返回給客戶端 2 ,並附上版本號 2。
3. 客戶端 1 不知道客戶端 2 的寫入,想要將麵粉加入購物車,因此認為當前的購物車內容應該是 [牛奶,麵粉]。它將此值與伺服器先前向客戶端 1 提供的版本號 1 一起傳送到伺服器。伺服器可以從版本號中知道 [牛奶,麵粉] 的寫入取代了 [牛奶] 的先前值,但與 [雞蛋] 的值是 **併發** 的。因此,伺服器將版本 3 分配給 [牛奶,麵粉],覆蓋版本 1 值 [牛奶],但保留版本 2 的值 [蛋],並將所有的值返回給客戶端 1。
4. 同時,客戶端 2 想要加入火腿,不知道客戶端 1 剛剛加了麵粉。客戶端 2 在最近一次響應中從伺服器收到了兩個值 [牛奶] 和 [蛋],所以客戶端 2 現在合併這些值,並新增火腿形成一個新的值 [雞蛋,牛奶,火腿]。它將這個值傳送到伺服器,帶著之前的版本號 2 。伺服器檢測到新值會覆蓋版本 2 的值 [雞蛋],但新值也會與版本 3 的值 [牛奶,麵粉] **併發**,所以剩下的兩個值是版本 3 的 [牛奶,麵粉],和版本 4 的 [雞蛋,牛奶,火腿]。
5. 最後,客戶端 1 想要加培根。它之前從伺服器接收到了版本 3 的 [牛奶,麵粉] 和 [雞蛋],所以它合併這些,新增培根,並將最終值 [牛奶,麵粉,雞蛋,培根] 連同版本號 3 發往伺服器。這會覆蓋版本 3 的值 [牛奶,麵粉](請注意 [雞蛋] 已經在上一步被覆蓋),但與版本 4 的值 [雞蛋,牛奶,火腿] 併發,所以伺服器保留這兩個併發值。
![](../img/fig5-13.png)
**圖 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) 所示。箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。在這個例子中,客戶端永遠不會完全拿到伺服器上的最新資料,因為總是有另一個操作同時進行。但是舊版本的值最終會被覆蓋,並且不會丟失任何寫入。
![](../img/fig5-14.png)
**圖 5-14 圖 5-13 中的因果依賴關係圖。**
請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的 —— 它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下:
請注意,伺服器可以只通過檢視版本號來確定兩個操作是否是併發的 —— 它不需要對值本身進行解釋(因此該值可以是任何資料結構)。該演算法的工作原理如下:
* 伺服器為每個鍵保留一個版本號,每次寫入鍵時都增加版本號,並將新版本號與寫入的值一起儲存。
* 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。
* 客戶端寫入鍵時,必須包含之前讀取的版本號,並且必須將之前讀取的所有值合併在一起(針對寫入請求的響應可以像讀取請求一樣,返回所有當前值,這使得我們可以像購物車示例那樣將多個寫入串聯起來)。
* 當伺服器接收到具有特定版本號的寫入時,它可以覆蓋該版本號或更低版本的所有值(因為它知道它們已經被合併到新的值中),但是它必須用更高的版本號來儲存所有值(因為這些值與隨後的寫入是併發的)。
* 伺服器為每個鍵維護一個版本號,每次寫入該鍵時都遞增版本號,並將新版本號與寫入的值一起儲存。
* 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。
* 客戶端寫入鍵時,必須包含之前讀取的版本號,並且必須將之前讀取的所有值合併在一起(針對寫入請求的響應可以像讀取請求一樣,返回所有當前值,這使得我們可以像購物車示例那樣將多個寫入串聯起來)。
* 當伺服器接收到具有特定版本號的寫入時,它可以覆蓋該版本號或更低版本的所有值(因為它知道它們已經被合併到新的值中),但是它必須用更高的版本號來儲存所有值(因為這些值與正在進行的其它寫入是併發的)。
當一個寫入包含前一次讀取的版本號時,它會告訴我們的寫入是基於之前的哪一種狀態。如果在不包含版本號的情況下進行寫操作,則與所有其他寫操作併發,因此它不會覆蓋任何內容 —— 只會在隨後的讀取中作為其中一個值返回。
#### 合併同時寫入的值
#### 合併併發寫入的值
這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:客戶端隨後必須透過合併併發寫入的值來進行清理。 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】。我們不會深入細節但是它的工作方式與我們在購物車示例中看到的非常相似。
@ -701,8 +701,8 @@ LWW 實現了最終收斂的目標,但以 **永續性** 為代價:如果同
> #### 版本向量和向量時鐘
>
> 版本向量有時也被稱為向量時鐘,即使它們不完全相同。 差別很微妙 —— 細節請參閱參考資料【57,60,61】。 簡而言之,在比較副本的狀態時,版本向量是正確的資料結構。
>
> 版本向量有時也被稱為向量時鐘,即使它們不完全相同。其中的差別很微妙 —— 細節請參閱參考資料【57,60,61】。簡而言之在比較副本的狀態時版本向量是正確的資料結構。
## 本章小結
@ -718,7 +718,7 @@ LWW 實現了最終收斂的目標,但以 **永續性** 為代價:如果同
* 延遲
將資料放置在距離使用者較近的地方,以便使用者能夠更快地與其互動
將資料放置在地理上距離使用者較近的地方,以便使用者能夠更快地與其互動
* 可伸縮性
@ -731,35 +731,35 @@ LWW 實現了最終收斂的目標,但以 **永續性** 為代價:如果同
* 單主複製
客戶端將所有寫入操作傳送到單個節點(領導者),該節點將資料更改事件流傳送到其他副本(追隨者)。讀取可以在任何副本上執行,但從追隨者讀取可能是陳舊的。
客戶端將所有寫入操作傳送到單個節點(主庫),該節點將資料更改事件流傳送到其他副本(從庫)。讀取可以在任何副本上執行,但從庫的讀取結果可能是陳舊的。
* 多主複製
客戶端傳送每個寫入到幾個領導節點之一,其中任何一個都可以接受寫入。領導者將資料更改事件流傳送給彼此以及任何跟隨者節點。
客戶端將每個寫入傳送到幾個主庫節點之一,其中任何一個主庫都可以接受寫入。主庫將資料更改事件流傳送給彼此以及任何從庫節點。
* 無主複製
客戶端傳送每個寫入到幾個節點,並從多個節點並行讀取,以檢測和糾正具有陳舊資料的節點。
客戶端將每個寫入傳送到幾個節點,並從多個節點並行讀取,以檢測和糾正具有陳舊資料的節點。
每種方法都有優點和缺點。單主複製是非常流行的,因為它很容易理解,不需要擔心衝突解決。在出現故障節點,網路中斷和延遲峰值的情況下,多領導者和無領導者複製可以更加穩健,但以更難以推理並僅提供非常弱的一致性保證為代價
每種方法都有優點和缺點。單主複製是非常流行的,因為它很容易理解,不需要擔心衝突解決。在出現故障節點、網路中斷和延遲峰值的情況下,多主複製和無主複製可以更加健壯,其代價是難以推理並且僅提供非常弱的一致性保證
複製可以是同步的,也可以是非同步的,這在發生故障時對系統行為有深遠的影響。儘管在系統執行平穩時非同步複製速度很快,但是要弄清楚在複製滯後增加和伺服器故障時會發生什麼,這一點很重要。如果一個領導者失敗了,並且你提升了一個非同步更新的追隨者成為新的領導者,那麼最近提交的資料可能會丟失。
複製可以是同步的,也可以是非同步的,這在發生故障時對系統行為有深遠的影響。儘管在系統執行平穩時非同步複製速度很快,但是要弄清楚在複製延遲增加和伺服器故障時會發生什麼,這一點很重要。如果主庫失敗後你將一個非同步更新的從庫提升為新的主庫,那麼最近提交的資料可能會丟失。
我們研究了一些可能由複製滯後引起的奇怪效應,我們也討論了一些有助於決定應用程式在複製滯後時的行為的一致性模型:
我們研究了一些可能由複製延遲引起的奇怪效應,我們也討論了一些有助於決定應用程式在複製延遲時的行為的一致性模型:
* 寫後讀
* 寫後讀一致性
使用者應該總是看到自己提交的資料。
使用者應該總是看到自己提交的資料。
* 單調讀
使用者在看到某個時間點的資料後,他們不應該再看到某個更早時間點的資料
使用者在看到某個時間點的資料後,他們不應該再看到該資料在更早時間點的情況
* 一致字首讀
使用者應該看到資料處於一種具有因果意義的狀態:例如,按正確的順序看到一個問題和對應的回答。
最後,我們討論了多領導者和無領導者複製方法所固有的併發問題:因為他們允許多個寫入併發發生,這可能會導致衝突。我們研究了一個數據庫可能使用的演算法來確定一個操作是否發生在另一個操作之前,或者它們是否同時發生。我們還談到了透過合併併發更新來解決衝突的方法。
最後,我們討論了多主複製和無主複製方法所固有的併發問題:因為他們允許多個寫入併發發生,這可能會導致衝突。我們研究了一個數據庫可以使用的演算法來確定一個操作是否發生在另一個操作之前,或者它們是否併發發生。我們還談到了透過合併併發更新來解決衝突的方法。
在下一章中,我們將繼續研究分佈在多個機器上的資料,透過複製的同僚:將大資料集分割成分割槽。