mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
ch9 75%
This commit is contained in:
parent
fd3cbf5701
commit
9dc5a3fe8f
30
ch5.md
30
ch5.md
@ -606,19 +606,19 @@ Riak将客户端和数据库节点之间的所有通信保持在一个数据中
|
||||
|
||||
### 检测并发写入
|
||||
|
||||
Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(请参阅第171页的“处理写冲突”),但在Dynamo样式的数据库中,在**读修复**或**带提示的接力**期间也可能会产生冲突。
|
||||
Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**带提示的接力**期间也可能会产生冲突。
|
||||
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,图5-12显示了两个客户机A和B同时写入三节点数据存储区中的键X:
|
||||
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X:
|
||||
|
||||
* 节点1接收来自A的写入,但由于暂时中断,从不接收来自B的写入。
|
||||
* 节点2首先接收来自A的写入,然后接收来自B的写入。
|
||||
* 节点3首先接收来自B的写入,然后从A写入。
|
||||
* 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。
|
||||
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
|
||||
* 节点 3 首先接收来自 B 的写入,然后从 A 写入。
|
||||
|
||||
![](img/fig5-12.png)
|
||||
|
||||
**图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。**
|
||||
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为X的最终值是B,而其他节点认为值是A.
|
||||
如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。
|
||||
|
||||
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
|
||||
|
||||
@ -632,7 +632,7 @@ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意
|
||||
|
||||
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入为准(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。
|
||||
|
||||
LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一个Key有多个并发写入,即使它们都被报告为客户端成功(因为它们被写入w个副本),其中一个写道会生存下来,其他的将被无声丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
|
||||
LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一个Key有多个并发写入,即使它们都被报告为客户端成功(因为它们被写入 w 个副本),其中一个写道会生存下来,其他的将被无声丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
|
||||
|
||||
有一些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW是解决冲突的一个很烂的选择。
|
||||
|
||||
@ -661,17 +661,17 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
|
||||
|
||||
|
||||
#### 捕捉"此前发生"关系
|
||||
#### 捕获"此前发生"关系
|
||||
|
||||
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法概括为具有多个副本的无领导者数据库。
|
||||
|
||||
[图5-13]()显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
|
||||
|
||||
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 [eggs],但新值也会与v3 [牛奶,面粉]**并发**,所以剩下的两个值是v3 [milk,flour],和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 [eggs],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个值是v3 [milk,flour],和v4:[鸡蛋,牛奶,火腿]。
|
||||
5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。
|
||||
|
||||
![](img/fig5-13.png)
|
||||
|
||||
@ -690,13 +690,13 @@ LWW实现了最终收敛的目标,但以**持久性**为代价:如果同一
|
||||
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 (来自写入请求的响应可以像读取一样,返回所有当前值,这使得我们可以像购物车示例那样连接多个写入。)
|
||||
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须保持所有值更高版本号(因为这些值与传入的写入同时发生)。
|
||||
|
||||
当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 - 只会在随后的读取中作为其中一个值返回。
|
||||
当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
|
||||
|
||||
#### 合并同时写入的值
|
||||
|
||||
这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。 Riak称这些并发值**兄弟(siblings)**。
|
||||
|
||||
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(请参阅第171页的“[处理写冲突](#处理写冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
|
||||
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
|
||||
|
||||
以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
|
||||
|
||||
|
2
ch8.md
2
ch8.md
@ -464,7 +464,7 @@ while(true){
|
||||
|
||||
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数——不能同时存在两个相互冲突的多数决定。当我们在[第9章](ch9.md)中讨论**共识算法(consensus algorithms)**时,我们将更详细地讨论法定人数的应用。
|
||||
|
||||
#### 领导和锁
|
||||
#### 领导者与锁定
|
||||
|
||||
通常情况下,一些东西在一个系统中只能有一个。例如:
|
||||
|
||||
|
156
ch9.md
156
ch9.md
@ -311,9 +311,9 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
## 顺序保证
|
||||
|
||||
之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作在某个时间点似乎都是原子性生效的。这个定义意味着,操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。
|
||||
之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。
|
||||
|
||||
**顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其他**顺序**曾经出现过的上下文:
|
||||
**顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它**顺序**曾经出现过的上下文:
|
||||
|
||||
* 在[第5章](ch5.md)中我们看到,领导者在单主复制中的主要目的就是,在复制日志中确定**写入顺序(order of write)**——也就是从库应用这些写入的顺序。如果不存在一个领导者,则并发操作可能导致冲突(参阅“[处理写入冲突](ch5.md#处理写入冲突)”)。
|
||||
* 在[第7章](ch7.md)中讨论的**可序列化**,是关于事务表现的像按**某种序列顺序(some sequential order)**执行的保证。它可以通过字面意义上地**序列顺序(serial order)**执行事务来实现,或者通过允许并行执行,同时防止序列化冲突来实现(通过锁或中止事务)。
|
||||
@ -325,14 +325,14 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
**顺序**反复出现有几个原因,其中一个原因是,它有助于保持**因果关系(causality)**。在本书中我们已经看到了几个例子,其中因果关系是很重要的:
|
||||
|
||||
* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题得到了回答,显然这个问题得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。
|
||||
* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。
|
||||
* [图5-9](img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。
|
||||
* 在“[检测并发写入](ch5.md#检测并发写入)”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B**并发**。这种**此前发生(happened before)**关系是因果关系的另一种表述:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是**并发**的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。
|
||||
* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](img/fig7-6)所示)。
|
||||
* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。
|
||||
* 在爱丽丝和鲍勃看球的例子中([图9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。
|
||||
|
||||
因果关系对事件施加了一种顺序:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。
|
||||
因果关系对事件施加了一种**顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。
|
||||
|
||||
如果一个系统服从因果关系所规定的顺序,我们说它是**因果一致(causally)**的。例如,快照隔离提供了因果一致性:当你从数据库中读取到一些数据时,你一定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。
|
||||
|
||||
@ -366,173 +366,175 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png)中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
|
||||
|
||||
线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来却更困难。
|
||||
线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。
|
||||
|
||||
好消息是,折衷是可能的。线性一致性并不是保持因果性的唯一途径——还有其他方法。一个系统可以是因果一致的,而无需承担线性一致带来的性能折损(尤其是,CAP定理不适用的情况)。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用【2,42】。
|
||||
好消息是存在折衷的可能性。线性一致性并不是保持因果性的唯一途径 —— 还有其他方法。一个系统可以是因果一致的,而无需承担线性一致带来的性能折损(尤其对于CAP定理不适用的情况)。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用【2,42】。
|
||||
|
||||
在许多情况下,似乎需要线性一致性的系统实际上只需要因果一致性,因果一致性可以更高效地实现。基于这种观察,研究人员正在探索新型的数据库,既能保证因果一致性,且性能与可用性与最终一致系统类似【49,50,51】。
|
||||
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。基于这种观察结果,研究人员正在探索新型的数据库,既能保证因果一致性,且性能与可用性与最终一致的系统类似【49,50,51】。
|
||||
|
||||
这方面的研究相当新鲜,其中很多尚未应用到生产系统,仍然有不少挑战需要克服【52,53】。但对于未来的系统而言,这是一个有前景的方向。
|
||||
|
||||
#### 捕获因果关系
|
||||
|
||||
我们不会在这里讨论非线性系统如何保证因果一致性的细节,而只是简要地探讨一些关键的思想。
|
||||
我们不会在这里讨论非线性一致的系统如何保证因果性的细节,而只是简要地探讨一些关键的思想。
|
||||
|
||||
为了保持因果性,你需要知道哪个操作发生在哪个其他操作之前(**happened before**)。这是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。
|
||||
为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(**happened before**)。这是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。
|
||||
|
||||
为了确定因果依赖关系,我们需要一些方法来描述系统中节点的“知识”。如果节点在发出写入Y时已经看到X值,则X和Y可能是因果关系的。这个分析使用了你在欺诈指控的刑事调查中所期望的那些问题:CEO在做出决定时是否知道X?
|
||||
为了确定因果依赖,我们需要一些方法来描述系统中节点的“知识”。如果节点在发出写入Y 的请求时已经看到了 X的值,则 X 和 Y 可能存在因果关系。这个分析使用了那些在欺诈指控刑事调查中常见的问题:CEO在做出决定 Y 时是否**知道** X ?
|
||||
|
||||
在其他操作之前确定哪些操作发生的技术与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。该节讨论无领导者数据存储区中的因果关系,我们需要检测到同一个关键字为了防止丢失更新。因果关系更进一步:它需要跟踪整个数据库的因果关系,而不仅仅是一个关键。版本向量可以被推广到做这个【54】。
|
||||
用于确定*哪些操作发生在其他操作之前* 的技术,与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。
|
||||
|
||||
为了确定因果顺序,数据库需要知道应用程序读取哪个版本的数据。这就是为什么在[图5-13](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它读取的数据版本是否仍然运行至今。为此,数据库跟踪哪个数据已经被哪个事务读取。
|
||||
为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
|
||||
|
||||
|
||||
|
||||
### 序列号顺序
|
||||
|
||||
虽然因果关系是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用程序中,客户端在写入内容之前会先读取大量数据,然后不清楚写入是因果关系依赖于全部还是仅仅一些先前的读取。显式跟踪所有已读取的数据将意味着很大的开销。
|
||||
虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用中,客户端在写入内容之前会先读取大量数据,我们无法弄清写入因果依赖于先前全部的读取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。
|
||||
|
||||
但是,还有一个更好的方法:我们可以使用序列号或时间戳来排序事件。时间戳不一定来自时钟(或物理时钟,有很多问题,如“[不可靠时钟](ch8.md#不可靠的时钟)”)。它可以来自一个逻辑时钟,这是一个算法来产生一个数字序列来识别操作,通常使用计数器,每个操作增加计数器。
|
||||
但还有一个更好的方法:我们可以使用**序列号(sequence nunber)**或**时间戳(timestamp)**来排序事件。时间戳不一定来自时钟(或物理时钟,存在许多问题,如 “[不可靠时钟](ch8.md#不可靠的时钟)” 中所述)。它可以来自一个**逻辑时钟(logical clock)**,这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
|
||||
|
||||
这样的序列号或时间戳是紧凑的(只有几个字节大小),它们提供了一个总的顺序:也就是说,每一个操作都有一个唯一的序列号,你总是可以比较两个序列号来确定哪个更大(即,哪些操作发生在后面)。
|
||||
|
||||
特别是,我们可以按照与因果关系一致的顺序创建序列号[^vii]:我们保证,如果操作A因果关系发生在B之前,那么A在总顺序之前发生在B之前(A具有比B更小的序列号)。并行操作可以任意命令。这样一个总的秩序捕获所有的因果信息,但也强加了比由于因果关系所严格要求的更多的秩序。
|
||||
这样的序列号或时间戳是紧凑的(只有几个字节大小),它提供了一个全序关系:也就是说每操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。
|
||||
|
||||
[^vii]: 与因果关系不一致的整个顺序很容易创建,但不是很有用。例如,你可以为每个操作生成随机UUID,并按照字典顺序比较UUID以定义操作的总顺序。这是一个有效的总顺序,但是随机的UUID并不告诉你哪个操作首先实际发生,或者操作是否是并发的。
|
||||
特别是,我们可以使用**与因果一致(consistent with causality)**的全序来生成序列号[^vii]:我们保证,如果操作 A 因果后继于操作 B,那么在这个全序中 A 在 B 前( A 具有比 B 更小的序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但也施加了一个比因果性要求更为严格的顺序。
|
||||
|
||||
在单主复制的数据库中(请参见“[领导者与追随者](ch5.md#领导者与追随者)”),复制日志定义了与因果关系一致的写操作总顺序。领导者可以简单地为每个操作增加一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个追随者按照他们在复制日志中出现的顺序来应用写入,那么追随者的状态始终是因果一致的(即使它落后于领导者)。
|
||||
[^vii]: 与因果关系不一致的全序很容易创建,但没啥用。例如你可以为每个操作生成随机的UUID,并按照字典序比较UUID,以定义操作的全序。这是一个有效的全序,但是随机的UUID并不能告诉你哪个操作先发生,或者操作是否为并发的。
|
||||
|
||||
#### 非因果序列号发生器
|
||||
在单主复制的数据库中(参见“[领导者与追随者](ch5.md#领导者与追随者)”),复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。
|
||||
|
||||
如果没有一个领导者(可能是因为你使用的是多领导者或无领导者的数据库,或者是因为数据库是分区的),那么如何为操作生成序列号还不太清楚。实践中使用了各种方法:
|
||||
#### 非因果序列号生成器
|
||||
|
||||
* 每个节点都可以生成自己独立的一组序列号。例如,如果有两个节点,一个节点只能生成奇数,而另一个节点只能生成偶数。通常,可以在序列号的二进制表示中保留一些位以包含唯一的节点标识符,这将确保两个不同的节点永远不会生成相同的序列号。
|
||||
* 你可以将时间戳从时钟(物理时钟)附加到每个操作【55】。这样的时间戳是不连续的,但是如果它们具有足够高的分辨率,那么它们可能足以完成命令操作。这个事实用于最后写入为准的冲突解决方法(参阅“[有序事件的时间戳](ch8.md#有序事件的时间戳)”)。
|
||||
* 你可以预先分配序列号的块。例如,节点A可能要求从1到1,000的序列号的块,并且节点B可能要求该区块从1,001到2,000。然后,每个节点可以独立分配其块的序列号,并在序列号的提供开始变低时分配一个新的块。
|
||||
如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:
|
||||
|
||||
这三个选项都比单独的领导者增加一个计数器的表现更好,并且更具可扩展性。它们为每个操作生成一个唯一的,大约增加的序列号。然而,他们都有一个问题:他们产生的序列号与因果关系不一致。
|
||||
* 每个节点都可以生成自己独立的一组序列号。例如有两个节点,一个节点只能生成奇数,而另一个节点只能生成偶数。通常,可以在序列号的二进制表示中预留一些位,用于唯一的节点标识符,这样可以确保两个不同的节点永远不会生成相同的序列号。
|
||||
* 可以将时钟(物理时钟)时间戳附加到每个操作上【55】。这种时间戳并不连续,但是如果它具有足够高的分辨率,那也许足以提供一个操作的全序关系。这一事实应用于 *最后写入为准* 的冲突解决方法中(参阅“[有序事件的时间戳](ch8.md#有序事件的时间戳)”)。
|
||||
* 可以预先分配序列号区块。例如,节点 A 可能要求从序列号1到1,000区块的所有权,而节点 B 可能要求序列号1,001到2,000区块的所有权。然后每个节点可以独立分配所属区块中的序列号,并在序列号告急时请求分配一个新的区块。
|
||||
|
||||
因为这些序列号发生器不能正确地捕获不同节点上操作的顺序,所以会出现因果关系问题:
|
||||
这三个选项都比单一主库的自增计数器表现要好,并且更具可扩展性。它们为每个操作生成一个唯一的,近似自增的序列号。然而它们都有同一个问题:生成的序列号与因果不一致。
|
||||
|
||||
* 每个节点可以每秒处理不同数量的操作。因此,如果一个节点产生偶数而另一个产生奇数,则偶数的计数器可能落后于奇数的计数器,反之亦然。如果你有一个奇数的操作和一个偶数的操作,你不能准确地说出哪一个因果关系发生了。
|
||||
因为这些序列号生成器不能正确地捕获跨节点的操作顺序,所以会出现因果关系的问题:
|
||||
|
||||
* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果性不一致。例如,见[图8-3](img/fig8-3.png),其中显示了一个情况,其中后来发生因果关系的操作实际上被分配了较低的时间戳。[^vii]
|
||||
* 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。
|
||||
|
||||
[^viii]: 可以使物理时钟时间戳与因果关系一致:在第294页的“用于全局快照的同步时钟”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这个方法确保了一个事实上的后续事务得到了更大的时间戳。 但是,大多数时钟不能提供所需的不确定性度量。
|
||||
* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii]
|
||||
|
||||
* 在块分配器的情况下,一个操作可能会被赋予一个范围从1,001到2,000的序列号,而一个因果较晚的操作可能被赋予一个范围从1到1,000的数字。在这里,序列号与因果关系也是不一致的。
|
||||
[^viii]: 可以使物理时钟时间戳与因果关系保持一致:在“[用于全局快照的同步时钟](#用于全局快照的同步时钟)”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这中方法确保了实际上靠后的事务会有更大的时间戳。 但是大多数时钟不能提供这种所需的不确定性度量。
|
||||
|
||||
* 在分配区块的情况下,某个操作可能会被赋予一个范围在1,001到2,000内的序列号,然而一个因果上更晚的操作可能被赋予一个范围在1到1,000之间的数字。这里序列号与因果关系也是不一致的。
|
||||
|
||||
|
||||
|
||||
#### 兰伯特时间戳
|
||||
|
||||
尽管刚才描述的三个序列号发生器与因果关系不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。
|
||||
尽管刚才描述的三个序列号生成器与因果不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。
|
||||
|
||||
[图9-8](img/fig9-8.png)说明了兰伯特时间戳的使用。每个节点都有一个唯一的标识符,每个节点都保存一个处理操作数量的计数器。 兰伯特时间戳然后是一对(计数器,节点ID)。二节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。
|
||||
[图9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。
|
||||
|
||||
![](img/fig9-8.png)
|
||||
|
||||
**图9-8 Lamport时间戳提供了与因果关系一致的总排序。**
|
||||
|
||||
|
||||
兰伯特时间戳与物理时间时钟没有任何关系,但是它提供了总计次数:如果你有两个时间戳,则计数器值较大的时间戳是较大的时间戳。如果计数器值相同,则节点ID越大的时间戳越大。
|
||||
兰伯特时间戳与物理时间时钟没有任何关系,但是它提供了一个全序:如果你有两个时间戳,则**计数器**值大者是更大的时间戳。如果计数器值相同,则节点ID越大的,时间戳越大。
|
||||
|
||||
到目前为止,这个描述与上一节描述的偶数/奇数计数器基本相同。关于兰伯特时间戳的关键思想,使它们与因果关系一致,如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大计数器值,并在每个请求中包含最大计数器值。当一个节点接收到一个最大计数器值大于其自身计数器值的请求或响应时,它立即增加自己的计数器到最大值。
|
||||
迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大**计数器**值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。
|
||||
|
||||
这如[图9-8](img/fig9-8.png)所示,其中客户端A从节点2接收计数器值5,然后将最大值5发送到节点1.此时,节点1的计数器仅为1,但是它立即向前移动到5,所以下一个操作的计数器值增加了6。
|
||||
这如 [图9-8](img/fig9-8.png) 所示,其中客户端 A 从节点 2 接收计数器值 5 ,然后将最大值 5 发送到节点 1 。此时,节点 1 的计数器仅为 1 ,但是它立即前移至 5 ,所以下一个操作的计数器的值为 6 。
|
||||
|
||||
只要最大计数器值与每一个操作一起进行,这个方案确保兰伯特时间戳的排序与因果性一致,因为每个因果关系导致时间戳增加。
|
||||
只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。
|
||||
|
||||
兰伯特时间戳有时会与版本向量混淆,我们在第184页上的“检测并发写入”中看到了这些向量戳。虽然存在一些相似之处,但它们具有不同的目的:版本向量可以区分两个操作是并发还是因果依赖另一个,而兰伯特时间戳总是执行一个总的顺序。从兰伯特的全部顺序时间戳,你不能分辨两个操作是并行还是因果关系。 兰伯特时间戳优于版本向量的优点是它们更紧凑。
|
||||
兰伯特时间戳有时会与我们在 “[检测并发写入](ch5.md#检测并发写入)” 中看到的版本向量相混淆。虽然两者有一些相似之处,但它们有着不同的目的:版本向量可以区分两个操作是并发的,还是一个因果依赖另一个;而兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中,你无法分辨两个操作是并发的还是因果依赖的。 兰伯特时间戳优于版本向量的地方是,它更加紧凑。
|
||||
|
||||
#### 光有时间戳排序还不够
|
||||
|
||||
虽然兰伯特时间戳定义了与因果关系一致的操作总顺序,但它们还不足以解决分布式系统中的许多常见问题。
|
||||
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
|
||||
|
||||
例如,考虑一个需要确保用户名唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败。 (我们之前在“[领导和锁定](#领导和锁定)”中提到过这个问题。)
|
||||
例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败。 (我们之前在“[领导者与锁定]](ch8.md#领导者与锁定)”中提到过这个问题。)
|
||||
|
||||
乍看之下,似乎总的操作顺序(例如,使用兰伯特时间戳)应该足以解决此问题:如果创建了两个具有相同用户名的帐户,请选择时间戳较低的那个作为获胜者(一个谁先抓住用户名),并让更大的时间戳失败。由于时间戳是完全有序的,所以这个比较总是有效的。
|
||||
乍看之下,似乎操作的全序关系足以解决这一问题(例如使用兰伯特时间戳):如果创建了两个具有相同用户名的帐户,选择时间戳较小的那个作为胜者(第一个抓到用户名的人),并让带有更大时间戳者失败。由于时间戳上有全序关系,所以这个比较总是可行的。
|
||||
|
||||
这种方法适用于事后确定胜利者:一旦收集了系统中的所有用户名创建操作,就可以比较他们的时间戳。然而,当一个节点刚刚收到用户的一个请求来创建一个用户名,并且现在需要决定这个请求是成功还是失败,这是不够的。此时,节点不知道另一个节点是否正在同时创建具有相同用户名的帐户,以及其他节点可以分配给该操作的时间戳。
|
||||
这种方法适用于事后确定胜利者:一旦你收集了系统中的所有用户名创建操作,就可以比较它们的时间戳。然而当某个节点需要实时处理用户创建用户名的请求时,这样的方法就无法满足了。节点需要**马上(right now)**决定这个请求是成功还是失败。在那个时刻,节点并不知道是否存其他节点正在并发执行创建同样用户名的操作,罔论其它节点可能分配给那个操作的时间戳。
|
||||
|
||||
为了确保没有其他节点正在使用相同的用户名和较低的时间戳同时创建一个帐户,你必须检查每个节点,看看它在做什么【56】。如果其中一个节点由于网络问题而出现故障或无法到达,则该系统将停止工作。这不是我们需要的那种容错系统。
|
||||
为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,你必须检查其它每个节点,看看它在做什么【56】。如果其中一个节点由于网络问题出现故障或不可达,则整个系统可能被拖至停机。这不是我们需要的那种容错系统。
|
||||
|
||||
这里的问题是,只有在收集了所有的操作之后,操作的总顺序才会出现。如果另一个节点已经产生了一些操作,但是你还不知道它们是什么,那么就不能构造最终的操作顺序:来自另一个节点的未知操作可能需要被插入到总数的不同位置订购。
|
||||
这里的问题是,只有在所有的操作都被收集之后,操作的全序才会出现。如果另一个节点已经产生了一些操作,但你还不知道那些操作是什么,那就无法构造所有操作最终的全序关系:来自另一个节点的未知操作可能需要被插入到全序中的不同位置。
|
||||
|
||||
总之:为了实现像用户名的唯一性约束这样的事情,仅仅对操作进行全面的排序是不够的,你还需要知道该命令何时完成。如果你有创建用户名的操作,并且你确定没有其他节点可以在你的操作之前为全部顺序插入相同用户名的声明,则可以安全地声明操作成功。
|
||||
总之:为了实诸如如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定。如果你有一个创建用户名的操作,并且确定在全序中,没有任何其他节点可以在你的操作之前插入对同一用户名的声称,那么你就可以安全地宣告操作执行成功。
|
||||
|
||||
这个知道什么时候你的总顺序被完成的概念被记录在全序广播的话题中。
|
||||
如何知道你的全序关系已经尘埃落定,这个想法将在[全序广播](#全序广播)一节中详细说明。
|
||||
|
||||
### 全序广播
|
||||
|
||||
如果你的程序只运行在一个CPU内核上,那么定义一个操作总的顺序是很容易的:它只是CPU执行的顺序。但是,在分布式系统中,让所有节点在相同的操作顺序上达成一致是非常棘手的。在最后一节中,我们讨论了按时间戳或序列号进行排序,但发现它不如单主复制(如果使用时间戳排序来实现唯一性约束,则不能容忍任何错误)。
|
||||
如果你的程序只运行在单个CPU核上,那么定义一个操作全序是很容易的:可以简单地就是CPU执行这些操作的顺序。但是在分布式系统中,让所有节点对同一个全局操作顺序达成一致可能相当棘手。在上一节中,我们讨论了按时间戳或序列号进行排序,但发现它还不如单主复制给力(如果你使用时间戳排序来实现唯一性约束,而且不能容忍任何错误)。
|
||||
|
||||
如前所述,单引导程序复制通过选择一个节点作为引导程序来确定操作的总顺序,并对引导程序上的单个CPU核心上的所有操作进行排序。接下来的挑战是如果吞吐量大于单个领导者可以处理的情况下如何扩展系统,以及如果领导者失败(“[处理节点宕机](#处理节点宕机)”),如何处理故障转移。在分布式系统文献中,这个问题被称为全序广播或原子广播[^ix]【25,57,58】。
|
||||
如前所述,单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个CPU核上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效(“[处理节点宕机](#处理节点宕机)”),如何处理故障转移。在分布式系统文献中,这个问题被称为**全序广播(total order broadcast)**或**原子广播(atomic broadcast)**[^ix]【25,57,58】。
|
||||
|
||||
[^ix]: “原子广播”这个术语是传统的,但是它是非常混乱的,因为它与原子的其他用法不一致:它与ACID事务中的原子性没有任何关系,只是与原子操作(在多线程编程的意义上 )或原子寄存器(线性一致性存储)。 总的顺序组播是另一个同义词。
|
||||
[^ix]: “原子广播”是一个传统的术语,非常混乱,而且与“原子”一词的其他用法不一致:它与ACID事务中的原子性没有任何关系,只是与原子操作(在多线程编程的意义上 )或原子寄存器(线性一致存储)有间接的联系。全序广播是另一个同义词。
|
||||
|
||||
> #### 顺序保证的范围
|
||||
>
|
||||
> 每个分区有一个单独的引导程序的分区数据库通常只对每个分区进行排序,这意味着它们不能提供跨分区的一致性保证(例如,一致的快照,外键引用)。 所有分区的总排序是可能的,但需要额外的协调【59】。
|
||||
> 每个分区各有一个主库的分区数据库,通常只在每个分区内维持顺序,这意味着它们不能提供跨分区的一致性保证(例如,一致性快照,外键引用)。 跨所有分区的全序是可能的,但需要额外的协调【59】。
|
||||
|
||||
全序广播通常被描述为在节点之间交换消息的协议。 非正式地,它要求总是满足两个安全属性:
|
||||
全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性:
|
||||
|
||||
***可靠交付(reliable delivery)***
|
||||
|
||||
没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
|
||||
没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
|
||||
|
||||
***完全有序交付(totally ordered delivery)***
|
||||
**全序交付(totally ordered delivery)***
|
||||
|
||||
消息以相同的顺序传递给每个节点。
|
||||
消息以相同的顺序传递给每个节点。
|
||||
|
||||
全序广播的正确算法必须确保始终满足可靠性和订购属性,即使节点或网络出现故障。当然,在网络中断的时候,消息不会被传送,但是一个算法可以继续重试,以便在网络被最终修复的时候消息能够通过(然后它们仍然必须按照正确的顺序传送)。
|
||||
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
|
||||
|
||||
#### 使用全序广播
|
||||
|
||||
像ZooKeeper和etcd这样的共识服务实际上是实现全面的顺序播放。这个事实暗示了整个命令广播和共识之间有着密切的联系,我们将在本章后面进行探讨。
|
||||
像ZooKeeper和etcd这样的共识服务实际上实现了全序广播。这一事实暗示了全序广播与共识之间有着紧密联系,我们将在本章稍后进行探讨。
|
||||
|
||||
全序广播正是你所需的数据库复制:如果每封邮件都表示写入数据库,并且每个副本按相同的顺序处理相同的写入,则副本将保持一致(除了临时复制滞后)。这个原则被称为状态机复制【60】,我们将在[第11章](ch11.md)中回到它。
|
||||
类似地,可以使用全序广播来实现可序列化的事务:如“[真的串行执行](ch7.md#真的串行执行)”中所述,如果每个消息表示一个确定性事务作为存储过程来执行,并且每个节点都处理这些消息相同的顺序,那么数据库的分区和副本保持一致【61】。
|
||||
全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为**状态机复制(state machine replication)**【60】,我们将在[第11章](ch11.md)中重新回到这个概念。
|
||||
|
||||
全序广播的一个重要方面是顺序在交付消息时是固定的:如果后续消息已经交付,节点不允许追溯地将消息插入顺序中的较早位置。这个事实使得全序广播比时间戳命令更强。
|
||||
与之类似,可以使用全序广播来实现可序列化的事务:如“[真的串行执行](ch7.md#真的串行执行)”中所述,如果每个消息都表示一个确定性事务,以存储过程的形式来执行,且每个节点都以相同的顺序处理这些消息,那么数据库的分区和副本就可以相互保持一致【61】。
|
||||
|
||||
查看全序广播的另一种方式是创建日志(如在复制日志,事务日志或预写日志中):传递消息就像附加到日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志并看到相同的消息序列。
|
||||
全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳命令更强。
|
||||
|
||||
全面订购广播对于实施提供防护令牌的锁定服务也很有用(参见“[防护令牌](ch8.md#防护令牌)”)。每个获取锁的请求都作为消息添加到日志中,并且所有消息都按它们在日志中出现的顺序依次编号。序列号可以作为一个击剑标记,因为它是单调递增的。在ZooKeeper中,这个序列号被称为`zxid` 【15】。
|
||||
考量全序广播的另一种方式是,这是一种创建日志的方式(如在复制日志,事务日志或预写式日志中):传递消息就像附加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
|
||||
|
||||
#### 使用全序广播实现线性一致性的存储
|
||||
全序广播对于实现提供防护令牌的锁服务也很有用(参见“[防护令牌](ch8.md#防护令牌)”)。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在ZooKeeper中,这个序列号被称为`zxid` 【15】。
|
||||
|
||||
如[图9-4](img/fig9-4.png)所示,在线性一致性的系统中,有一个操作的全序。这是否意味着线性一致性与全序播放相同?不完全,但两者之间有密切的联系[^x]。
|
||||
#### 使用全序广播实现线性一致的存储
|
||||
|
||||
[^x]: 从形式上讲,线性读写寄存器是一个“更容易”的问题。 全序广播等同于共识【67】,在异步崩溃停止模型【68】中没有确定性的解决方案,而线性一致性的读写寄存器可以在同一系统模型中实现【23,24,25】。 然而,支持原子操作,如比较和设置,或者在寄存器中增加和获取,使得它相当于共识【28】。 因此,共识问题和线性一致性的注册问题密切相关。
|
||||
如 [图9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。
|
||||
|
||||
全部顺序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被传送(所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是最近的保证:读取保证看到写入的最新值。
|
||||
[^x]: 从形式上讲,线性一致读写寄存器是一个“更容易”的问题。 全序广播等价于共识【67】,而共识问题在异步的崩溃-停止模型【68】中没有确定性的解决方案,而线性一致的读写寄存器**可以**在这种模型中实现【23,24,25】。 然而,支持诸如**比较并设置(CAS, compare-and-set)**,或**自增并返回(increment-and-get)**的原子操作使它等价于共识问题【28】。 因此,共识问题与线性一致寄存器问题密切相关。
|
||||
|
||||
但是,如果你有全序广播,则可以在其上构建线性一致性存储。例如,你可以确保用户名唯一标识用户帐户。
|
||||
全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息**何时**被送达(所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是新鲜性的保证:读取一定能看见最新的写入值。
|
||||
|
||||
想象一下,对于每一个可能的用户名,你都可以拥有一个带有原子比较和设置操作的线性一致性寄存器。每个寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名的注册表执行比较设置操作,在前一个注册值为空的情况下,将其设置为用户账号。如果多个用户试图同时获取相同的用户名,则只有一个比较和设置操作会成功,因为其他用户将看到非空值(由于线性一致性)。
|
||||
但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保用户名能唯一标识用户帐户。
|
||||
|
||||
你可以通过使用全序广播作为仅追加日志【62,63】来执行如下线性一致性的比较和设置操作:
|
||||
设想对于每一个可能的用户名,你都可以有一个带有CAS原子操作的线性一致寄存器。每个寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行CAS操作,在先前寄存器值为空的条件,将其值设置为用户的账号ID。如果多个用户试图同时获取相同的用户名,则只有一个CAS操作会成功,因为其他用户会看到非空的值(由于线性一致性)。
|
||||
|
||||
1. 在日志中添加一条消息,暂时指明你要声明的用户名。
|
||||
2. 阅读日志,并等待你附加的信息被传回给你。[^xi]
|
||||
3. 检查是否有任何消息声称你想要的用户名。如果所需用户名的第一条消息是你自己的消息,那么你是成功的:你可以提交用户名声明(也许通过附加另一条消息到日志)并确认给客户端。如果所需用户名的第一条消息来自其他用户,则中止操作。
|
||||
你可以通过将全序广播当成仅追加日志【62,63】的方式来实现这种线性一致的CAS操作:
|
||||
|
||||
[^xi]: 如果你不等待,但是在入队之后立即确认写入,则会得到类似于多核x86处理器的内存一致性模型【43】。 该模型既不是线性的也不是连续的。
|
||||
1. 在日志中追加一条消息,试探性地指明你要声明的用户名。
|
||||
2. 读日志,并等待你所附加的信息被回送。[^xi]
|
||||
3. 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。
|
||||
|
||||
由于日志条目以相同顺序传递到所有节点,因此如果有多个并发写入,则所有节点将首先同意哪个节点。选择第一个冲突的写入作为胜利者,并中止后面的写入,确保所有节点都同意写入是提交还是中止。一个类似的方法可以用来在一个日志之上实现可序列化的多对象事务【62】。
|
||||
[^xi]: 如果你不等待,而是在消息入队之后立即确认写入,则会得到类似于多核x86处理器内存的一致性模型【43】。 该模型既不是线性一致的也不是顺序一致的。
|
||||
|
||||
虽然此过程确保线性写入,但不能保证线性一致性读取 - 如果你从与日志异步更新的存储中读取数据,则可能是陈旧的。 (具体来说,这里描述的过程提供了顺序一致性【47,64】,有时也称为时间线一致性【65,66】,这比线性一致性要弱一些)。为了使读取线性一致,有几个选项:
|
||||
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可序列化的多对象事务【62】。
|
||||
|
||||
* 你可以通过附加消息,读取日志以及在消息被传回给你时执行实际读取来对日志进行排序。消息在日志中的位置因此定义了读取发生的时间点。 (法定读取etcd的工作有点像这样【16】。)
|
||||
* 如果日志允许以线性方式获取最新日志消息的位置,则可以查询该位置,等待直到该位置的所有条目传送给你,然后执行读取。 (这是Zookeeper的`sync()`操作背后的思想【15】)。
|
||||
* 你可以从写入时同步更新的副本进行读取,因此可以确保最新。 (这种技术用于链式复制【63】;参阅“[复制研究](ch5.md#设置新从库)”。)
|
||||
尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的 —— 如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。 (精确地说,这里描述的过程提供了**顺序一致性(sequential consistency)**【47,64】,有时也称为**时间线一致性(timeline consistency)**【65,66】,比线性一致性稍微弱一些的保证)。为了使读取也线性一致,有几个选项:
|
||||
|
||||
* 你可以通过追加一条消息,当消息回送时读取日志,执行实际的读取。消息在日志中的位置因此定义了读取发生的时间点。 (etcd的法定人数读取有些类似这种情况【16】。)
|
||||
* 如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待直到该位置前的所有消息都传达到你,然后执行读取。 (这是Zookeeper `sync()` 操作背后的思想【15】)。
|
||||
* 你可以从同步更新的副本中进行读取,因此可以确保结果是最新的。 (这种技术用于链式复制【63】;参阅“[复制研究](ch5.md#设置新从库)”。)
|
||||
|
||||
#### 使用线性一致性存储实现全序广播
|
||||
|
||||
最后一节介绍了如何从全序广播中构建一个线性一致性的比较和设置操作。我们也可以把它转过来,假设我们有线性一致性的存储,并展示如何从它构建全部命令播放。
|
||||
上一节介绍了如何从全序广播构建一个线性一致的CAS操作。我们也可以把它反过来,假设我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。
|
||||
|
||||
最简单的方法是假设你有一个线性一致性的寄存器来存储一个整数,并且有一个原子增量和获取操作【28】。或者,原子比较和设置操作也可以完成这项工作。
|
||||
最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子**增量和获取**操作【28】。或者CAS操作也可以完成这项工作。
|
||||
|
||||
该算法很简单:对于每个要通过全部顺序广播发送的消息,你将递增并获取线性一致性的整数,然后将从寄存器获得的值作为序号附加到消息中。然后,你可以将消息发送到所有节点(重新发送任何丢失的消息),并且收件人将按序号连续发送消息。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user