mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
2962a0b5a8
@ -106,7 +106,7 @@
|
||||
| 第六章:分区 | 初翻 | |
|
||||
| 第七章:事务 | 精翻 60% | Vonng |
|
||||
| 第八章:分布式系统中的问题 | 初翻 | |
|
||||
| 第九章:一致性与共识 | 初翻 65% | Vonng |
|
||||
| 第九章:一致性与共识 | 初翻 80% | Vonng |
|
||||
| 第三部分:前言 | 精翻 | |
|
||||
| 第十章:批处理 | 草翻 | |
|
||||
| 第十一章:流处理 | 草翻 | |
|
||||
|
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)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
|
||||
|
||||
|
48
ch7.md
48
ch7.md
@ -82,7 +82,7 @@ ACID一致性的概念是,**对数据的一组特定陈述必须始终成立**
|
||||
|
||||
但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
|
||||
|
||||
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用程序可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。
|
||||
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。
|
||||
|
||||
[^i]: 乔·海勒斯坦(Joe Hellerstein)指出,在论Härder与Reuter的论文中,“ACID中的C”是被“扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。
|
||||
|
||||
@ -90,7 +90,7 @@ ACID一致性的概念是,**对数据的一组特定陈述必须始终成立**
|
||||
|
||||
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。
|
||||
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加1,再回写新值。[图7-1](img/fig7-1.png)中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至43。
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。
|
||||
|
||||
ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。
|
||||
|
||||
@ -131,7 +131,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**
|
||||
|
||||
***原子性***
|
||||
|
||||
如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“**宁为玉碎,不为瓦全**(all-or-nothing)”的保证。
|
||||
如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“**宁为玉碎,不为瓦全(all-or-nothing)**”的保证。
|
||||
|
||||
***隔离性***
|
||||
|
||||
@ -145,7 +145,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。
|
||||
|
||||
在[图7-2](img/fig7-2.png)中,用户2遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
|
||||
在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。
|
||||
|
||||
[^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。
|
||||
|
||||
@ -159,7 +159,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
**图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致**
|
||||
|
||||
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,`BEGIN TRANSACTION`和`COMMIT`语句之间的所有内容,被认为是同一事务的一部分.[^iii]
|
||||
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,`BEGIN TRANSACTION` 和 `COMMIT` 语句之间的所有内容,被认为是同一事务的一部分.[^iii]
|
||||
|
||||
[^iii]: 这并不完美。如果TCP连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定TCP连接。后续再“[数据库端到端的争论](ch12.md#数据库端到端的争论)”一节将回到这个主题。
|
||||
|
||||
@ -167,30 +167,30 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
#### 单对象写入
|
||||
|
||||
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个20 KB的JSON文档:
|
||||
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
|
||||
|
||||
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10 KB JSON片段?
|
||||
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10KB JSON片段?
|
||||
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
|
||||
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
|
||||
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(请参阅第82页的“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
|
||||
|
||||
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像[图7-1](img/fig7-1.png)那样的读取-修改-写入序列了。同样流行的是**[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))**操作,当值没有并发被其他人修改过时,才允许执行写操作。
|
||||
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是**[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))**操作,当值没有并发被其他人修改过时,才允许执行写操作。
|
||||
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但是,它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
|
||||
|
||||
[^iv]: 严格地说,**原子自增(atomic increment)**这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下,它实际上应该被称为**孤立(isolated)**的或**可序列化(serializable)**的增量。 但这就太吹毛求疵了。
|
||||
|
||||
#### 多对象事务的需求
|
||||
|
||||
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第9章](ch9.md)将讨论分布式事务的实现。
|
||||
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第9章](ch9.md) 将讨论分布式事务的实现。
|
||||
|
||||
但是我们是否需要多对象事务?**是否有可能只用键值数据模型和单对象操作来实现任何应用程序?**
|
||||
|
||||
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
|
||||
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。 (类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如[图7-2](img/fig7-2.png)所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
|
||||
|
||||
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。
|
||||
@ -248,7 +248,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做**脏读(dirty reads)**【2】。
|
||||
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1设置了`x = 3`,但用户2的`get x`仍旧返回旧值2,而用户1尚未提交。
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。
|
||||
|
||||
![](img/fig7-4.png)
|
||||
|
||||
@ -267,7 +267,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
通过防止脏写,这个隔离级别避免了一些并发问题:
|
||||
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑[图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png)以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
|
||||
|
||||
![](img/fig7-5.png)
|
||||
@ -338,7 +338,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
表中的每一行都有一个`created_by`字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个`deleted_by`字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将`deleted_by`字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。[^译注ii]
|
||||
|
||||
[^译注ii]: 在PostgreSQL中,`created_by`实际名称为`xmin`,`deleted_by`实际名称为`xmax`
|
||||
[^译注ii]: 在PostgreSQL中,`created_by` 实际名称为`xmin`,`deleted_by` 实际名称为`xmax`
|
||||
|
||||
`UPDATE`操作在内部翻译为`DELETE`和`INSERT`。例如,在[图7-7]()中,事务13从账户2中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2的记录:余额为\$500的行被标记为**被事务13删除**,余额为\$400的行**由事务13创建**。
|
||||
|
||||
@ -626,7 +626,7 @@ COMMIT;
|
||||
|
||||
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
|
||||
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png)所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
|
||||
![](img/fig7-9.png)
|
||||
|
||||
@ -673,7 +673,7 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
大约30年来,在数据库中只有一种广泛使用的序列化算法:**两阶段锁定(2PL,two-phase locking)**[^xi]
|
||||
|
||||
[^xi]: 有时也称为严格两阶段锁定(SS2PL, strict two-phas locking),以便和其他2PL变体区分。
|
||||
[^xi]: 有时也称为**严格两阶段锁定(SS2PL, strict two-phas locking)**,以便和其他2PL变体区分。
|
||||
|
||||
> #### 2PL不是2PC
|
||||
>
|
||||
@ -692,8 +692,6 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别【23,36】。
|
||||
|
||||
[^xi]: 有时被称为强有力的严格的两阶段锁定(SS2PL),以区别于2PL的其他变种。
|
||||
|
||||
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于**共享模式(shared mode)**或**独占模式(exclusive mode)**。锁使用如下:
|
||||
|
||||
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
|
||||
@ -719,7 +717,7 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读(phantoms)**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止**幻读**。
|
||||
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见[例7-2]()),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
|
||||
如何实现这一点?从概念上讲,我们需要一个**谓词锁(predicate lock)**【3】。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
|
||||
|
||||
@ -732,7 +730,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
谓词锁限制访问,如下所示:
|
||||
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在这个`SELECT`查询中那样,它必须获取查询条件上的**共享谓词锁(shared-mode predicate lock)**。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在这个 `SELECT` 查询中那样,它必须获取查询条件上的**共享谓词锁(shared-mode predicate lock)**。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
|
||||
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
|
||||
|
||||
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
|
||||
@ -743,7 +741,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间(不只是123号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
|
||||
|
||||
在房间预订数据库中,您可能会在`room_id`列上有一个索引,并且/或者在`start_time`和`end_time`上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
在房间预订数据库中,您可能会在`room_id`列上有一个索引,并且/或者在`start_time` 和 `end_time`上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
|
||||
- 假设您的索引位于`room_id`上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间用于预订。
|
||||
- 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
|
||||
@ -817,7 +815,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务到其他事务完成,而是像一个引线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
|
||||
|
||||
在[图7-11]()中,事务43通知事务42其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43的写影响了42,但因为事务43尚未提交,所以写入尚未生效。然而当事务43想要提交时,来自事务42的冲突写入已经被提交,所以43必须中止。
|
||||
在[图7-11]()中,事务43 通知事务42 其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43 的写影响了42 ,但因为事务43 尚未提交,所以写入尚未生效。然而当事务43 想要提交时,来自事务42 的冲突写入已经被提交,所以事务43 必须中止。
|
||||
|
||||
#### 可序列化的快照隔离的性能
|
||||
|
||||
@ -829,7 +827,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
与串行执行相比,可序列化快照隔离并不局限于单个CPU核的吞吐量:FoundationDB将检测到的序列化冲突分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可序列化隔离等级的同时读写多个分区中的数据【54】。
|
||||
|
||||
中止率显着影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(长时间运行的只读事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
中止率显着影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读长事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
|
||||
|
||||
|
||||
@ -871,7 +869,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
***字面意义上的串行执行***
|
||||
|
||||
如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个CPU内核上处理,这是一个简单而有效的选择。
|
||||
如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个CPU核上处理,这是一个简单而有效的选择。
|
||||
|
||||
***两阶段锁定***
|
||||
|
||||
|
4
ch8.md
4
ch8.md
@ -464,7 +464,7 @@ while(true){
|
||||
|
||||
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数——不能同时存在两个相互冲突的多数决定。当我们在[第9章](ch9.md)中讨论**共识算法(consensus algorithms)**时,我们将更详细地讨论法定人数的应用。
|
||||
|
||||
#### 领导和锁
|
||||
#### 领导者与锁定
|
||||
|
||||
通常情况下,一些东西在一个系统中只能有一个。例如:
|
||||
|
||||
@ -588,7 +588,7 @@ Web应用程序确实需要预期受终端用户控制的客户端(如Web浏
|
||||
|
||||
***单调序列***
|
||||
|
||||
如果请求$x$返回了令牌$t_x$,并且请求$y$返回了令牌$t_y$,并且$x$在$y$开始之前已经完成,那么$t_x <t_y$。
|
||||
如果请求 $x$ 返回了令牌 $t_x$,并且请求$y$返回了令牌$t_y$,并且 $x$ 在 $y$ 开始之前已经完成,那么$t_x <t_y$。
|
||||
|
||||
***可用性***
|
||||
|
||||
|
440
ch9.md
440
ch9.md
@ -11,7 +11,7 @@
|
||||
|
||||
[TOC]
|
||||
|
||||
正如[第8章](ch8.md)所讨论的,分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法——即使某些内部组件出现故障,服务也能正常运行。
|
||||
正如[第8章](ch8.md)所讨论的,分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法—— 即使某些内部组件出现故障,服务也能正常运行。
|
||||
|
||||
在本章中,我们将讨论构建容错分布式系统的算法和协议的一些例子。我们将假设[第8章](ch8.md)的所有问题都可能发生:网络中的数据包可能会丢失,重新排序,重复递送或任意延迟;时钟只是尽其所能地近似;且节点可以暂停(例如,由于垃圾收集)或随时崩溃。
|
||||
|
||||
@ -19,13 +19,13 @@
|
||||
|
||||
现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概念。例如,分布式系统最重要的抽象之一就是**共识(consensus)**:**就是让所有的节点对某件事达成一致**。正如我们在本章中将会看到的那样,尽管存在网络故障和流程故障,可靠地达成共识是一个令人惊讶的棘手问题。
|
||||
|
||||
一旦达成共识,应用程序可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂点,并且需要故障转移到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在“[处理节点宕机](ch5.md#处理节点宕机)”中所讨论的那样,重要的是只有一个领导者,并且所有的节点都认同其为领导。如果两个节点都认为自己是领导者,这种情况被称为**脑裂(split brain)**,且经常导致数据丢失。正确实现共识有助于避免这种问题。
|
||||
一旦达成共识,应用程序可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂点,并且需要故障转移到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在“[处理节点宕机](ch5.md#处理节点宕机)”中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为**脑裂(split brain)**,且经常导致数据丢失。正确实现共识有助于避免这种问题。
|
||||
|
||||
在本章后面的“[分布式事务和共识](#分布式事务和共识)”中,我们将研究解决共识和相关问题的算法。但首先,我们首先需要探索可以在分布式系统中提供的保证和抽象的范围。
|
||||
|
||||
我们需要了解可以做什么和不可以做什么的范围:在某些情况下,系统可以容忍故障并继续工作;在其他情况下,这是不可能的。我们将深入研究什么可能而什么不可能的限制,既通过理论证明,也通过实际实现。我们将在本章中概述这些基本限制。
|
||||
|
||||
分布式系统领域的研究人员几十年来一直在研究这些主题,所以有很多资料——我们只能介绍一些皮毛。在本书中,我们没有空间去详细介绍形式模型和证明的细节,所以我们将坚持非正式的直觉。如果你有兴趣,参考文献可以提供更多的深度。
|
||||
分布式系统领域的研究人员几十年来一直在研究这些主题,所以有很多资料—— 我们只能介绍一些皮毛。在本书中,我们没有空间去详细介绍形式模型和证明的细节,所以我们将坚持非正式的直觉。如果你有兴趣,参考文献可以提供更多的深度。
|
||||
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
|
||||
* 首先看一下常用的**最强一致性模型**之一,**线性一致性(linearizability)**,并考察其优缺点。
|
||||
* 然后我们将检查分布式系统中[**事件顺序**](#顺序保证)的问题,特别是因果关系和全局顺序的问题。
|
||||
* 在第三部分(“[分布式事务和共识](#分布式事务和共识)”)中将探讨如何自动提交分布式事务,这将最终引导我们解决共识问题。
|
||||
* 在第三部分(“[分布式事务和共识](#分布式事务和共识)”)中将探讨如何原子地提交分布式事务,这将最终引领我们走向共识问题的解决方案。
|
||||
|
||||
|
||||
|
||||
@ -58,15 +58,15 @@
|
||||
|
||||
在**最终一致**的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。这很让人困惑。如果数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么事情就简单太多了。那么每个客户端都会有相同的数据视图,且不必担心复制滞后了。
|
||||
|
||||
这就是**线性一致性(linearizability)**背后的想法【6】(也称为**原子一致性(atomic consistency)**【7】,**强一致性(strong consistency)**,**立即一致性(immediate consistency)**或**外部一致性(external consistency )**【8】)。线性一致性的精确定义相当微妙,我们将在本节的剩余部分探讨它。但是基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。
|
||||
这就是**线性一致性(linearizability)**背后的想法【6】(也称为**原子一致性(atomic consistency)**【7】,**强一致性(strong consistency)**,**立即一致性(immediate consistency)**或**外部一致性(external consistency )**【8】)。线性一致性的精确定义相当微妙,我们将在本节的剩余部分探讨它。但是基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。
|
||||
|
||||
在一个线性一致性系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,不是来自一个陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。
|
||||
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。
|
||||
|
||||
![](img/fig9-1.png)
|
||||
|
||||
**图9-1 这个系统是非线性一致的,导致了球迷的困惑**
|
||||
|
||||
[图9-1](img/fig9-1.png)展示了一个非线性一致性的,关于体育网站的例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
|
||||
[图9-1 ](img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
|
||||
|
||||
如果Alice和Bob在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而Bob是在听到Alice惊呼最后得分**之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。
|
||||
|
||||
[图9-2](img/fig9-2.png)显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如它可以是键值存储中的一个**键**,关系数据库中的一**行**或文档数据库中的一个**文档**。
|
||||
[图9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。
|
||||
|
||||
![](img/fig9-2.png)
|
||||
|
||||
@ -86,16 +86,16 @@
|
||||
|
||||
在这个例子中,寄存器有两种类型的操作:
|
||||
|
||||
* $ read(x)⇒v$表示客户端请求读取寄存器`x`的值,数据库返回值`v`。
|
||||
* $ read(x)⇒v$表示客户端请求读取寄存器 `x` 的值,数据库返回值 `v`。
|
||||
|
||||
|
||||
* $write(x,v)⇒r$表示客户端请求将寄存器`x`设置为值`v`,数据库返回响应`r`(可能正确,可能错误)。
|
||||
* $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。
|
||||
|
||||
在[图9-2](img/fig9-2.png)中,x的值最初为`0`,客户端C执行写请求将其设置为`1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应?
|
||||
在[图9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应?
|
||||
|
||||
* 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值`0`。
|
||||
* 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值`1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。
|
||||
* 与写操作在时间上重叠的任何读操作,可能会返回0或1,因为我们不知道读取时,写操作是否已经生效。这些操作是**并发(concurrent)**的。
|
||||
* 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`。
|
||||
* 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。
|
||||
* 与写操作在时间上重叠的任何读操作,可能会返回 `0` 或 `1` ,因为我们不知道读取时,写操作是否已经生效。这些操作是**并发(concurrent)**的。
|
||||
|
||||
但是,这还不足以完全描述线性一致性:如果与写入同时发生的读取可以返回旧值或新值,那么读者可能会在写入期间看到数值在旧值和新值之间来回翻转。这不是我们所期望的仿真“单一数据副本”的系统。[^ii]
|
||||
|
||||
@ -106,15 +106,15 @@
|
||||
![](img/fig9-3.png)
|
||||
**图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。**
|
||||
|
||||
在一个线性一致的系统中,我们可以想象,在`x`的值自动翻转从`0`到`1`的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值`1`,即使写操作尚未完成,所有后续读取也必须返回新值。
|
||||
在一个线性一致的系统中,我们可以想象,在 `x` 的值从`0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。
|
||||
|
||||
[图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A是第一个读取新的值`1`的位置。在A的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回`1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。)
|
||||
[图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。)
|
||||
|
||||
我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](img/fig9-4.png)显示了一个更复杂的例子【10】。
|
||||
|
||||
在[图9-4]()中,除了读写之外,还增加了第三种类型的操作:
|
||||
|
||||
* $cas(x, v_{old}, v_{new})$⇒r表示客户端请求进行原子性的[**比较与设置**](ch7.md#比较并设置(CAS))操作。如果寄存器$x$的当前值等于$v_{old}$,则应该原子地设置为$v_{new}$。如果$x≠vold$,则操作应该保持寄存器不变并返回一个错误。 $r$是数据库的响应(正确或错误)。
|
||||
* $cas(x, v_{old}, v_{new})⇒r$ 表示客户端请求进行原子性的[**比较与设置**](ch7.md#比较并设置(CAS))操作。如果寄存器 $x$ 的当前值等于 $v_{old}$ ,则应该原子地设置为 $v_{new}$ 。如果 $x≠vold$ ,则操作应该保持寄存器不变并返回一个错误。 $r$ 是数据库的响应(正确或错误)。
|
||||
|
||||
[图9-4]()中的每个操作都在我们认为执行操作的时候用竖线标出(在每个操作的条柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(**每次读取都必须返回最近一次写入设置的值**)。
|
||||
|
||||
@ -126,13 +126,13 @@
|
||||
|
||||
[图9-4]()中有一些有趣的细节需要指出:
|
||||
|
||||
* 第一个客户端B发送一个读取`x`的请求,然后客户端D发送一个请求将`x`设置为`0`,然后客户端A发送请求将`x`设置为`1`。尽管如此,返回到B的读取值为`1`(由A写入的值)。这是可以的:这意味着数据库首先处理D的写入,然后是A的写入,最后是B的读取。虽然这不是请求发送的顺序,但这是一个可以接受的顺序,因为这三个请求是并发的。也许B的读请求在网络上略有延迟,所以它在两次写入之后才到达数据库。
|
||||
* 第一个客户端B发送一个读取 `x` 的请求,然后客户端D发送一个请求将 `x` 设置为 `0`,然后客户端A发送请求将 `x` 设置为 `1`。尽管如此,返回到B的读取值为 `1`(由A写入的值)。这是可以的:这意味着数据库首先处理D的写入,然后是A的写入,最后是B的读取。虽然这不是请求发送的顺序,但这是一个可以接受的顺序,因为这三个请求是并发的。也许B的读请求在网络上略有延迟,所以它在两次写入之后才到达数据库。
|
||||
|
||||
* 在客户端A从数据库收到响应之前,客户端B的读取返回`1`,表示写入值`1`已成功。这也是可以的:这并不意味着在写之前读到了值,这只是意味着从数据库到客户端A的正确响应在网络中略有延迟。
|
||||
* 在客户端A从数据库收到响应之前,客户端B的读取返回 `1` ,表示写入值 `1` 已成功。这也是可以的:这并不意味着在写之前读到了值,这只是意味着从数据库到客户端A的正确响应在网络中略有延迟。
|
||||
|
||||
* 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,C首先读取`1`,然后读取`2`,因为两次读取之间的值由B更改。可以使用原子**比较并设置(cas)**操作来检查该值是否未被另一客户端同时更改:B和C的**cas**请求成功,但是D的**cas**请求失败(在数据库处理它时,`x`的值不再是`0`)。
|
||||
* 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,C首先读取 `1` ,然后读取 `2` ,因为两次读取之间的值由B更改。可以使用原子**比较并设置(cas)**操作来检查该值是否未被另一客户端同时更改:B和C的**cas**请求成功,但是D的**cas**请求失败(在数据库处理它时,`x` 的值不再是 `0` )。
|
||||
|
||||
* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将`x`从`2`更新为`4`)。在没有其他请求的情况下,B的读取返回`2`是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值4 ,因此不允许B读取比A更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。
|
||||
* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。
|
||||
|
||||
这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。
|
||||
|
||||
@ -178,13 +178,13 @@
|
||||
|
||||
如果想要确保银行账户余额永远不会为负数,或者不会出售比仓库里的库存更多的物品,或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值(账户余额,库存水平,座位占用率)。
|
||||
|
||||
在实际应用中,处理这些限制有时是可以接受的(例如,如果航班超额预订,你可以将客户转移到不同的航班并为其提供补偿)。在这种情况下,可能不需要线性一致性,我们将在第524页的“[及时性和完整性]()”中讨论这种松散解释的约束。
|
||||
在实际应用中,处理这些限制有时是可以接受的(例如,如果航班超额预订,你可以将客户转移到不同的航班并为其提供补偿)。在这种情况下,可能不需要线性一致性,我们将在“[及时性与完整性](ch12.md#及时性与完整性)”中讨论这种松散解释的约束。
|
||||
|
||||
然而,一个硬性的唯一性约束(关系型数据库中常见的那种)需要线性一致性。其他类型的约束,如外键或属性约束,可以在不需要线性一致性的情况下实现【19】。
|
||||
|
||||
#### 跨信道的时序依赖
|
||||
|
||||
注意[图9-1](img/fig9-1.png)中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。
|
||||
注意[图9-1](img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。
|
||||
|
||||
计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](img/fig9-5.png)所示。
|
||||
|
||||
@ -224,9 +224,9 @@
|
||||
|
||||
***无主复制(也许不是线性一致的)***
|
||||
|
||||
对于无领导者复制的系统(Dynamo风格;参阅“[无主复制](ch5.md#无主复制)”),有时候人们会声称通过要求法定人数读写($w + r> n$)可以获得“强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。
|
||||
对于无领导者复制的系统(Dynamo风格;参阅“[无主复制](ch5.md#无主复制)”),有时候人们会声称通过要求法定人数读写( $w + r> n$ )可以获得“强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。
|
||||
|
||||
基于时钟(例如,在Cassandra中;参见“[依赖同步时钟](ch8.md#依赖同步时钟)”)的“最后写入胜利”冲突解决方法几乎可以确定是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。松散的法定人数(第183页的“[松散法定人数与暗示接力](#马虎法定人数和暗示交接)”)也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的,如下一节所示。
|
||||
基于时钟(例如,在Cassandra中;参见“[依赖同步时钟](ch8.md#依赖同步时钟)”)的“最后写入胜利”冲突解决方法几乎可以确定是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。[松散的法定人数](ch5.md#马虎法定人数和暗示交接)也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的,如下节所示。
|
||||
|
||||
#### 线性一致性和法定人数
|
||||
|
||||
@ -236,9 +236,9 @@
|
||||
|
||||
**图9-6 非线性一致的执行,尽管使用了严格的法定人数**
|
||||
|
||||
在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本($n = 3, w = 3$)发送写入将 $x$ 更新为1。客户端A并发地从两个节点组成的法定人群($r = 2$)中读取数据,并在其中一个节点上看到新值1。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值0。
|
||||
在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。
|
||||
|
||||
仲裁条件满足($w + r> n$),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]())
|
||||
仲裁条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]())
|
||||
|
||||
有趣的是,通过牺牲性能,可以使Dynamo风格的法定人数线性化:读取者必须在将结果返回给应用程序之前,同步执行读取修复(参阅“[读时修复与反熵过程](ch5.md#读时修复与反熵过程)”) ,并且写入者必须在发送写入之前,读取法定数量节点的最新状态【24,25】。然而,由于性能损失,Riak不执行同步读取修复【26】。 Cassandra在进行法定人数读取时,**确实**在等待读取修复完成【27】;但是由于使用了最后写入为准的冲突解决方案,当同一个键有多个并发写入时,将不能保证线性一致性。
|
||||
|
||||
@ -279,7 +279,7 @@
|
||||
|
||||
因此不需要线性一致性的应用对网络问题有更强的容错能力。这种见解通常被称为CAP定理【29,30,31,32】,由Eric Brewer于2000年命名,尽管70年代的分布式数据库设计者早就知道了这种权衡【33,34,35,36】。
|
||||
|
||||
CAP最初是作为一个经验法则提出的,没有准确的定义,目的是开始讨论数据库的权衡。那时候许多分布式数据库侧重于在共享存储的集群上提供线性一致性的语义【18】,CAP定理鼓励数据库工程师向分布式无共享系统的设计领域深入探索,这类架构更适合实现大规模的网络服务【37】。 对于这种文化上的转变,CAP值得赞扬——它见证了自00年代中期以来新数据库的技术爆炸(即NoSQL)。
|
||||
CAP最初是作为一个经验法则提出的,没有准确的定义,目的是开始讨论数据库的权衡。那时候许多分布式数据库侧重于在共享存储的集群上提供线性一致性的语义【18】,CAP定理鼓励数据库工程师向分布式无共享系统的设计领域深入探索,这类架构更适合实现大规模的网络服务【37】。 对于这种文化上的转变,CAP值得赞扬 —— 它见证了自00年代中期以来新数据库的技术爆炸(即NoSQL)。
|
||||
|
||||
> ### CAP定理没有帮助
|
||||
>
|
||||
@ -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)**的。例如,快照隔离提供了因果一致性:当你从数据库中读取到一些数据时,你一定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。
|
||||
|
||||
@ -344,7 +344,7 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
然而数学集合并不完全是全序的:`{a, b}` 比 `{b, c}` 更大吗?好吧,你没法真正比较它们,因为二者都不是对方的子集。我们说它们是**无法比较(incomparable)**的,因此数学集合是**偏序(partially order)**的:在某些情况下,可以说一个集合大于另一个(如果一个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的[^译注i]。
|
||||
|
||||
[^译注i]: 设R为非空集合A上的关系,如果R是自反的、反对称的和可传递的,则称R为A上的偏序关系。简称偏序,通常记作≦。一个集合A与A上的偏序关系R一起叫作偏序集,记作$<A,R>$或$<A, ≦>$。全序、偏序、关系、集合,这些概念的精确定义可以参考任意一本离散数学教材。
|
||||
[^译注i]: 设R为非空集合A上的关系,如果R是自反的、反对称的和可传递的,则称R为A上的偏序关系。简称偏序,通常记作≦。一个集合A与A上的偏序关系R一起叫作偏序集,记作$(A,R)$或$(A, ≦)$。全序、偏序、关系、集合,这些概念的精确定义可以参考任意一本离散数学教材。
|
||||
|
||||
全序和偏序之间的差异反映在不同的数据库一致性模型中:
|
||||
|
||||
@ -358,191 +358,191 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。
|
||||
|
||||
并发意味着时间线会分岔然后合并——在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md)并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖——操作的偏序。
|
||||
并发意味着时间线会分岔然后合并——在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖——操作的偏序。
|
||||
|
||||
如果你熟悉像Git这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个**提交(Commit)**发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并(Merge)**会在这些并发创建的提交相融合时创建。
|
||||
|
||||
#### 线性一致性强于因果一致性
|
||||
|
||||
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png)中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
|
||||
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(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操作也可以完成这项工作。
|
||||
|
||||
该算法很简单:对于每个要通过全部顺序广播发送的消息,你将递增并获取线性一致性的整数,然后将从寄存器获得的值作为序号附加到消息中。然后,你可以将消息发送到所有节点(重新发送任何丢失的消息),并且收件人将按序号连续发送消息。
|
||||
该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行**自增并返回**操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号连续发送消息。
|
||||
|
||||
请注意,与兰伯特时间戳不同,你通过递增线性一致性寄存器获得的数字形成一个没有间隙的序列。因此,如果一个节点已经发送了消息4并且接收到序列号为6的传入消息,则它知道它在传递消息6之前必须等待消息5.同样的情况并非如此
|
||||
请注意,与兰伯特时间戳不同,通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 ——事实上,这是全序广播和时间戳排序间的关键区别。
|
||||
|
||||
与兰伯特时间戳——事实上,这是全序广播和时间戳顺序之间的关键区别。
|
||||
实现一个带有原子性**自增并返回**操作的线性一致寄存器有多困难?像往常一样,如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的网络连接中断时的情况,并在该节点失效时能恢复这个值【59】。一般来说,如果你对线性一致性的序列号生成器进行深入过足够深入的思考,你不可避免地会得出一个共识算法。
|
||||
|
||||
使用原子增量和获取操作来创建线性一致性整数有多困难?像往常一样,如果事情从来没有失败过,那很容易:你可以把它保存在一个节点的变量中。问题在于处理当该节点的网络连接中断时的情况,并在该节点失败时恢复该值【59】。一般来说,如果你对线性一致性序列号的产生者认真思考,你不可避免地会得出一个一致的算法。
|
||||
|
||||
这并非巧合:可以证明,线性一致性的比较和设置(或增量和取得)寄存器和全序广播都相当于【28,67】。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。这是相当深刻和令人惊讶的洞察力!
|
||||
这并非巧合:可以证明,线性一致的CAS(或自增并返回)寄存器与全序广播都都等价于**共识**问题【28,67】。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。这是相当深刻和令人惊讶的洞察!
|
||||
|
||||
现在是时候正面处理共识问题了,我们将在本章的其余部分进行讨论。
|
||||
|
||||
@ -550,251 +550,251 @@ CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一
|
||||
|
||||
## 分布式事务与共识
|
||||
|
||||
共识是分布式计算中最重要也是最基本的问题之一。从表面上看,似乎很简单:非正式地说,目标只是让几个节点达成一致。你可能会认为这不应该太难。不幸的是,许多破损的系统已经被误认为这个问题很容易解决。
|
||||
**共识**是分布式计算中最重要也是最基本的问题之一。从表面上看似乎很简单:非正式地讲,目标只是**让几个节点达成一致(get serveral nodes to agree on something)**。你也许会认为这不会太难。不幸的是,许多出故障的系统都是因为错误地轻信这个问题很容易解决。
|
||||
|
||||
虽然共识是非常重要的,但关于它的部分在本书的后半部分已经出现了,因为这个主题非常微妙,欣赏细微之处需要一些必要的知识。即使在学术研究界,对共识的理解也只是在几十年的时间内逐渐显现出来,一路上有许多误解。现在我们已经讨论了复制([第5章](ch5.md)),事务([第7章](ch7.md)),系统模型([第8章](ch8.md)),线性一致性以及全序([本章](ch9.md)),我们终于准备好解决共识问题了。
|
||||
尽管共识非常重要,但关于它的内容出现在本书的后半部分,因为这个主题非常微妙,欣赏细微之处需要一些必要的知识。即使在学术界,对共识的理解也是在几十年的过程中逐渐沉淀而来,一路上也有着许多误解。现在我们已经讨论了复制([第5章](ch5.md)),事务([第7章](ch7.md)),系统模型([第8章](ch8.md)),线性一致以及全序([本章](ch9.md)),我们终于准备好解决共识问题了。
|
||||
|
||||
在节点达成一致的情况下,有许多情况是很重要的。例如:
|
||||
节点能达成一致,在很多场景下都非常重要,例如:
|
||||
|
||||
***领导选举***
|
||||
|
||||
在具有单引导程序复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网络故障而无法与其他节点通信,则可能会引起争议。在这种情况下,一致性对于避免错误的故障切换非常重要,从而导致两个节点都认为自己是领导者的脑裂情况(参阅“[处理节点宕机](ch5.md#处理节点宕机)”)。如果有两个领导者,它们都会接受写入,他们的数据会发生分歧,导致不一致和数据丢失。
|
||||
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网络故障而无法与其他节点通信,则可能会对领导权的归属引起争议。在这种情况下,共识对于避免错误的故障切换非常重要。错误的故障切换会导致两个节点都认为自己是领导者(**脑裂**,参阅“[处理节点宕机](ch5.md#处理节点宕机)”)。如果有两个领导者,它们都会接受写入,它们的数据会发生分歧,从而导致不一致和数据丢失。
|
||||
|
||||
***原子提交***
|
||||
|
||||
在支持跨越多个节点或分区的事务的数据库中,有一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性(就ACID而言,请参“[原子性](ch7.md#原子性)”),我们必须让所有节点对事务的结果达成一致:要么全部中止/回滚(如果出现任何错误)或者他们都承诺(如果没有出错)。这个共识的例子被称为原子提交问题[^xii]。
|
||||
在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性(就ACID而言,请参“[原子性](ch7.md#原子性)”),我们必须让所有节点对事务的结果达成一致:要么全部中止/回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。这个共识的例子被称为**原子提交(atomic commit)**问题[^xii]。
|
||||
|
||||
|
||||
[^xii]: 原子提交的形式化与共识稍有不同:原子事务只有在所有参与者投票提交的情况下才能提交,如果有任何参与者需要中止,则必须中止。 允许共识决定其中一位参与者提出的任何值。 然而,原子的承诺和共识是可以相互压缩的【70,71】。 非阻塞原子提交比共识更难——参阅“[三阶段提交](#三阶段提交)”。
|
||||
[^xii]: 原子提交的形式化与共识稍有不同:原子事务只有在**所有**参与者投票提交的情况下才能提交,如果有任何参与者需要中止,则必须中止。 共识则允许就**任意一个**被参与者提出的候选值达成一致。 然而,原子提交和共识可以相互简化为对方【70,71】。 **非阻塞**原子提交则要比共识更为困难 —— 参阅“[三阶段提交](#三阶段提交)”。
|
||||
|
||||
> ### 共识的不可能性
|
||||
>
|
||||
> 你可能已经听说过作者Fischer,Lynch和Paterson之后的FLP结果【68】,这证明如果存在节点可能崩溃的风险,则不存在总是能够达成一致的算法。在分布式系统中,我们必须假设节点可能会崩溃,所以可靠的共识是不可能的。然而,在这里,我们正在讨论达成共识的算法。这里发生了什么?
|
||||
> 你可能已经听说过作者Fischer,Lynch和Paterson之后的FLP结果【68】,它证明,如果存在节点可能崩溃的风险,则不存在**总是**能够达成共识的算法。在分布式系统中,我们必须假设节点可能会崩溃,所以可靠的共识是不可能的。然而这里我们正在讨论达成共识的算法,到底是怎么回事?
|
||||
>
|
||||
> 答案是FLP结果在异步系统模型中得到了证明(参阅“[系统模型与现实](#系统模型与现实)”),这是一个非常有限的模型,它假定确定性算法不能使用任何时钟或超时。如果算法被允许使用超时或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),那么共识就变得可以解决了【67】。即使只允许算法使用随机数也足以绕过不可能的结果【69】。
|
||||
> 答案是FLP结果在**异步系统模型**中得到了证明(参阅“[系统模型与现实](ch8.md#系统模型与现实)”),这是一种限制性很强的模型,它假定确定性算法不能使用任何时钟或超时。如果允许算法使用**超时**或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),则共识变为一个可解的问题【67】。即使仅仅允许算法使用随机数,也足以绕过这个不可能的结果【69】。
|
||||
>
|
||||
> 因此,FLP虽然不可能达成共识,但理论上具有重要意义,但实际上分布式系统通常可以达成共识。
|
||||
> 因此,FLP是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的。
|
||||
|
||||
在本节中,我们将首先更详细地检查原子提交问题。具体来说,我们将讨论两阶段提交(2PC)算法,这是解决原子提交最常见的方法,并在各种数据库、消息传递系统和应用服务器中实现。事实证明,2PC是一种一致的算法,但并不是一个很好的算法【70,71】。
|
||||
在本节中,我们将首先更详细地研究**原子提交**问题。具体来说,我们将讨论**两阶段提交(2PC, two-phase commit)**算法,这是解决原子提交问题最常见的办法,并在各种数据库、消息队列和应用服务器中实现。事实证明2PC是一种共识算法,但不是一个非常好的算法【70,71】。
|
||||
|
||||
通过对2PC学习,我们将继续努力实现更好的一致性算法,比如ZooKeeper(Zab)和etcd(Raft)中使用的算法。
|
||||
通过对2PC的学习,我们将继续努力实现更好的一致性算法,比如ZooKeeper(Zab)和etcd(Raft)中使用的算法。
|
||||
|
||||
|
||||
|
||||
### 原子提交与二阶段提交(2PC)
|
||||
|
||||
在[第7章](ch7.md)中,我们了解到事务原子性的目的是在出现几次写错的情况下提供简单的语义。事务的结果要么是成功的提交,在这种情况下,所有事务的写入都是持久的,或者中止,在这种情况下,所有事务的写入都被回滚(即撤消或丢弃)。
|
||||
在[第7章](ch7.md)中我们了解到,事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃)。
|
||||
|
||||
原子性可以防止失败的事务搅乱数据库,其结果是半成品和半更新状态。这对于多对象事务(参阅“[单对象和多对象操作](ch7.md#单对象和多对象操作)”)和维护二级索引的数据库尤其重要。每个辅助索引都是与主数据分离的数据结构——因此,如果你修改了一些数据,则还需要在辅助索引中进行相应的更改。原子性确保二级索引与主数据保持一致(如果索引与主数据不一致,则不会很有用)。
|
||||
原子性可以防止失败的事务搅乱数据库,避免数据库陷入半成品结果和半更新状态。这对于多对象事务(参阅“[单对象和多对象操作](ch7.md#单对象和多对象操作)”)和维护次级索引的数据库尤其重要。每个辅助索引都是与主数据相分离的数据结构—— 因此,如果你修改了一些数据,则还需要在辅助索引中进行相应的更改。原子性确保二级索引与主数据保持一致(如果索引与主数据不一致,就没什么用了)。
|
||||
|
||||
#### 从单节点到分布式原子提交
|
||||
|
||||
对于在单个数据库节点执行的事务,原子性通常由存储引擎执行。当客户端请求数据库节点提交事务时,数据库使事务的写入持久化(通常在预写式日志中:参阅“[使B树可靠](ch3.md#使B树可靠)”),然后将提交记录追加到日志中磁盘。如果数据库在这个过程中间崩溃,当节点重新启动时,事务从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。
|
||||
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化(通常在预写式日志中:参阅“[使B树可靠](ch3.md#使B树可靠)”),然后将提交记录追加到磁盘中的日志里。如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。
|
||||
|
||||
因此,在单个节点上,事务承诺主要取决于数据持久写入磁盘的顺序:首先是数据,然后是提交记录【72】。事务提交或放弃的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中止(由于崩溃),但在此之后,事务已经提交(即使数据库崩溃)。因此,这是一个单一的设备(一个特定的磁盘驱动器的控制器,连接到一个特定的节点),使得提交具有原子性。
|
||||
因此,在单个节点上,事务的提交主要取决于数据持久化落盘的**顺序**:首先是数据,然后是提交记录【72】。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中止(由于崩溃),但在此之后,事务已经提交(即使数据库崩溃)。因此,是单一的设备(连接到单个磁盘驱动的控制器,且挂载在单台机器上)使得提交具有原子性。
|
||||
|
||||
但是,如果一个事务中涉及多个节点呢?例如,也许在分区数据库中有一个多对象事务,或者是一个由关键词分区的二级索引(其中索引条目可能位于与主数据不同的节点上;参阅“[分区和二级索引](ch6.md#分区和二级索引)”)。大多数“NoSQL”分布式数据存储不支持这种分布式事务,而是各种集群关系系统(请参见“[实践中的分布式事务](#实践中的分布式事务)”)。
|
||||
但是,如果一个事务中涉及多个节点呢?例如,你也许在分区数据库中会有一个多对象事务,或者是一个按关键词分区的二级索引(其中索引条目可能位于与主数据不同的节点上;参阅“[分区和二级索引](ch6.md#分区和二级索引)”)。大多数“NoSQL”分布式数据存储不支持这种分布式事务,但是很多关系型数据库集群支持(参见“[实践中的分布式事务](#实践中的分布式事务)”)。
|
||||
|
||||
在这些情况下,仅向所有节点发送提交请求并且独立提交每个节点的事务是不够的。这样做很容易发生:提交在某些节点上成功,在其他节点上失败,这违反了原子性保证:
|
||||
在这些情况下,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:
|
||||
|
||||
* 某些节点可能会检测到约束冲突或冲突,因此需要中止,而其他节点则可以成功进行提交。
|
||||
* 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
|
||||
* 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。
|
||||
|
||||
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如[图7-3](img/fig7-3.png)所示)。而且一旦在一个节点上提交了一个事务,如果事后证明它在另一个节点上被中止,它将不能被撤回。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。
|
||||
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。
|
||||
|
||||
事务提交必须是不可撤销的——你不能改变主意,并在事务提交后追溯中止事务。这个规则的原因是,一旦数据被提交,其他事务就可以看到,因此其他客户可能会开始依赖这些数据。这个原则构成了读已提交隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有读取提交数据的事务将基于被追溯声明不存在的数据,所以它们也必须恢复。
|
||||
事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了**读已提交**隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了**已提交却又被追溯声明不存在数据**的事务也必须回滚。
|
||||
|
||||
(提交事务的效果有可能后续通过另一个补偿事务取消【73,74】,但从数据库的角度来看,这是一个单独的事务,因此任何关于交叉事务正确性的保证是应用自己的问题。)
|
||||
(提交事务的结果有可能通过事后执行另一个补偿事务来取消【73,74】,但从数据库的角度来看,这是一个单独的事务,因此任何关于跨事务正确性的保证都是应用自己的问题。)
|
||||
|
||||
#### 介绍两阶段提交
|
||||
#### 两阶段提交简介
|
||||
|
||||
两阶段提交是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,并且还以XA事务【76,77】(例如Java Transaction API支持)或通过`WS-AtomicTransaction for SOAP Web`服务的形式提供给应用程序【78,79】。
|
||||
**两阶段提交(two-phase commit)**是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,也以**XA事务**的形式对应用可用【76,77】(例如Java Transaction API支持)或以SOAP Web服务的`WS-AtomicTransaction` 形式提供给应用程序【78,79】。
|
||||
|
||||
[图9-9](img/fig9-9)说明了2PC的基本流程。 与单节点事务一样,2PC中的提交/终止进程分为两个阶段(因此而得名),而不是单个提交请求。
|
||||
[图9-9](img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。
|
||||
|
||||
![](img/fig9-9.png)
|
||||
|
||||
**图9-9 两阶段提交(2PC)的成功执行**
|
||||
|
||||
> #### 不要混淆2PC和2PL
|
||||
> #### 不要把2PC和2PL搞混了
|
||||
>
|
||||
> 两阶段提交(2PC)和两阶段锁定(参阅“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)是两个完全不同的事情。 2PC在分布式数据库中提供原子提交,而2PL提供可序列化的隔离。为了避免混淆,最好把它们看作完全独立的概念,并忽略名称中的不幸的相似性。
|
||||
> 两阶段提交(2PC)和两阶段锁定(参阅“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)是两个完全不同的东西。 2PC在分布式数据库中提供原子提交,而2PL提供可序列化的隔离等级。为了避免混淆,最好把它们看作完全独立的概念,并忽略名称中不幸的相似性。
|
||||
|
||||
2PC使用一个通常不会出现在单节点事务中的新组件:**协调者(coordinator)**(也称为事务管理器)。协调者通常在请求事务的相同应用程序进程(例如,嵌入在Java EE容器中)中实现为库,但也可以是单独的进程或服务。这种协调者的例子包括Narayana,JOTM,BTM或MSDTC。
|
||||
2PC使用一个通常不会出现在单节点事务中的新组件:**协调者(coordinator)**(也称为**事务管理器(transaction manager)**)。协调者通常在请求事务的相同应用程序进程中以库的形式实现(例如,嵌入在Java EE容器中),但也可以是单独的进程或服务。这种协调者的例子包括Narayana,JOTM,BTM或MSDTC。
|
||||
|
||||
正常情况下,2PC事务从应用程序在多个数据库节点上读写数据开始。我们把这些数据库节点称为事务参与者。当应用程序准备提交时,协调者开始阶段1:它发送一个准备请求到每个节点,询问他们是否能够提交。协调者然后跟踪参与者的回应:
|
||||
正常情况下,2PC事务以应用程序在多个数据库节点上读写数据开始。我们称这些数据库节点为**参与者(participants)**。当应用程序准备提交时,协调者开始阶段 1 :它发送一个**准备(prepare)**请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:
|
||||
|
||||
* 如果所有参与者都回答“是”,表示他们已经准备好提交,那么协调者在阶段2发出**提交(commit)**请求,实际发生提交。
|
||||
* 如果任何参与者回复“否”,则协调者在阶段2中向所有节点发送**中止(abort)**请求。
|
||||
* 如果所有参与者都回答“是”,表示它们已经准备好提交,那么协调者在阶段 2 发出**提交(commit)**请求,然后提交真正发生。
|
||||
* 如果任意一个参与者回复了“否”,则协调者在阶段2 中向所有节点发送**中止(abort)**请求。
|
||||
|
||||
这个过程有点像西方传统婚姻仪式:司仪分别询问新娘和新郎是否要结婚,通常是从两方都收到“我愿意”的答复。收到两者的回复后,司仪宣布这对情侣成为夫妻:事务就提交了,这一幸福事实会广播至所有的参与者中。如果新娘与新郎之一没有回复”我愿意“,婚礼就会中止【73】。
|
||||
|
||||
#### 承诺系统
|
||||
#### 系统承诺
|
||||
|
||||
从这个简短的描述可能不清楚为什么两阶段提交确保了原子性,而跨几个节点的一阶段提交没有。准备和提交请求当然可以在两阶段的情况下轻易地丢失。 2PC有什么不同?
|
||||
这个简短的描述可能并没有说清楚为什么两阶段提交保证了原子性,而跨多个节点的一阶段提交却没有。在两阶段提交的情况下,准备请求和提交请求当然也可以轻易丢失。 2PC又有什么不同呢?
|
||||
|
||||
为了理解它的工作原理,我们必须更详细地分解这个过程:
|
||||
|
||||
1. 当应用想要开启一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
|
||||
2. 应用在每个参与者上开始单节点事务,并将全局唯一事务ID附到单节点事务上。所有的读写都是在这些单节点事务之一中完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
|
||||
3. 当应用程序准备提交时,协调者向所有参与者发送一个准备请求,标记为全局事务ID。如果这些请求中的任何一个失败或超时,则协调者向所有参与者发送针对该事务ID的放弃请求。
|
||||
4. 参与者收到准备请求时,确保在任何情况下都可以明确地进行事务。这包括将所有事务数据写入磁盘(出现故障,电源故障或硬盘空间不足以拒绝稍后提交)以及检查是否存在任何冲突或约束违规。通过向协调者回答“是”,节点承诺在没有错误的情况下提交事务。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
|
||||
5. 当协调者收到所有准备请求的答复时,就是否提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才提交)。协调者必须把这个决定写到磁盘上的事务日志中,以便它知道它决定的方式,以防随后发生崩溃。这被称为**提交点(commit point)**。
|
||||
6. 一旦协调者的决定写入磁盘,提交或放弃请求被发送给所有参与者。如果此请求失败或超时,则协调者必须一直重试,直到成功为止。没有更多的事情要做,如果做出决定,那么决定必须执行,不管它需要多少次重试。如果参与者在此期间崩溃,事务将在恢复时进行——由于参与者投票“是”,因此恢复时不能拒绝提交。
|
||||
1. 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
|
||||
2. 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
|
||||
3. 当应用准备提交时,协调者向所有参与者发送一个**准备**请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
|
||||
4. 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是”,节点承诺,只要请求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
|
||||
5. 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为**提交点(commit point)**。
|
||||
6. 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。
|
||||
|
||||
因此,该协议包含两个关键的“不归路”点:当参与者投票“是”时,它承诺它肯定能够稍后提交(尽管协调者可能仍然选择放弃)。一旦协调者决定,这个决定是不可撤销的。这些承诺保证了2PC的原子性。 (单节点原子提交将这两个事件合并为一个:将提交记录写入事务日志。)
|
||||
因此,该协议包含两个关键的“不归路”点:当参与者投票“是”时,它承诺它稍后肯定能够提交(尽管协调者可能仍然选择放弃)。一旦协调者做出决定,这一决定是不可撤销的。这些承诺保证了2PC的原子性。 (单节点原子提交将这两个事件混为一谈:将提交记录写入事务日志。)
|
||||
|
||||
回到婚姻的比喻,在说“我是”之前,你和你的新娘/新郎有“中止”这个事务的自由,通过回复“不行!”(或者差不多效果的话)。然而,在说“我愿意”之后,你就不能收回那个声明了。如果你说“我愿意”后晕倒了,而你没有听到司仪说“你们现在是夫妻了”,那并不会改变事务已经提交的事实。当你稍后恢复意识时,你可以通过查询司仪的全局事务ID状态来查明你是否已婚,或者你可以等待司仪重试下一次提交请求(因为重试将在你无意识期间一直持续)。
|
||||
回到婚姻的比喻,在说“我是”之前,你和你的新娘/新郎有中止这个事务的自由,通过回复 “没门!”(或者有类似效果的话)。然而在说了“我愿意”之后,你就不能撤回那个声明了。如果你说“我愿意”后晕倒了,没有听到司仪说“你们现在是夫妻了”,那也并不会改变事务已经提交的现实。当你稍后恢复意识时,可以通过查询司仪的全局事务ID状态来确定你是否已经成婚,或者你可以等待司仪重试下一次提交请求(因为重试将在你无意识期间一直持续)。
|
||||
|
||||
#### 协调者失效
|
||||
|
||||
我们已经讨论了在2PC期间如果其中一个参与者或网络发生故障会发生什么情况:如果任何一个准备请求失败或者超时,协调者就中止事务。如果任何提交或中止请求失败,协调者将无条件重试。但是,如果协调者崩溃,会发生什么情况并不太清楚。
|
||||
我们已经讨论了在2PC期间,如果参与者之一或网络发生故障时会发生什么情况:如果任何一个**准备**请求失败或者超时,协调者就会中止事务。如果任何提交或中止请求失败,协调者将无条件重试。但是如果协调者崩溃,会发生什么情况就不太清楚了。
|
||||
|
||||
如果协调者在发送准备请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃——必须等待协调者回答事务是否已经发生或中止。如果此时协调者崩溃或网络出现故障,参与者只能等待。参与者在这个状态下的事务是存疑的或不确定的。
|
||||
如果协调者在发送**准备**请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)**的或**不确定(uncertain)**的。
|
||||
|
||||
情况如[图9-10](img/fig9-10)所示。在这个特定的例子中,协调者实际上决定提交,数据库2收到提交请求。但是,协调者在将提交请求发送到数据库1之前发生崩溃,因此数据库1不知道是否提交或中止。即使超时在这里也没有帮助:如果数据库1在超时后单方面中止,它将最终与提交的数据库2不一致。同样,单方面犯也是不安全的,因为另一个参与者可能已经中止了。
|
||||
情况如[图9-10](img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。
|
||||
|
||||
![](img/fig9-10.png)
|
||||
**图9-10 参与者投赞成票后,协调者崩溃。数据库1不知道是否提交或中止**
|
||||
|
||||
没有协调者的消息,参与者无法知道是否承诺或放弃。原则上,参与者可以相互沟通,找出每个参与者如何投票并达成一致,但这不是2PC协议的一部分。
|
||||
没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。
|
||||
|
||||
2PC可以完成的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此,2PC的**提交点**归结为协调者上的常规单节点原子提交。
|
||||
可以完成2PC的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此,2PC的**提交点**归结为协调者上的常规单节点原子提交。
|
||||
|
||||
#### 三阶段提交
|
||||
|
||||
两阶段提交被称为阻塞原子提交协议,因为2PC可能卡住,等待协调者恢复。理论上,可以使一个原子提交协议非阻塞,以便在节点失败时不会卡住。但在实践中这个工作并不那么简单。
|
||||
两阶段提交被称为**阻塞(blocking)**原子提交协议,因为存在2PC可能卡住并等待协调者恢复的情况。理论上,可以使一个原子提交协议变为**非阻塞(nonblocking)**的,以便在节点失败时不会卡住。但是让这个协议能在实践中工作并没有那么简单。
|
||||
|
||||
作为2PC的替代方案,已经提出了一种称为三阶段提交(3PC)的算法【13,80】。然而,3PC假定一个有界延迟的网络和有限响应时间的节点;在大多数具有无限网络延迟和进程暂停的实际系统中(见[第8章](ch8.md)),它并不能保证原子性。
|
||||
作为2PC的替代方案,已经提出了一种称为**三阶段提交(3PC)**的算法【13,80】。然而,3PC假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际系统中(见[第8章](ch8.md)),它并不能保证原子性。
|
||||
|
||||
通常,非阻塞原子提交需要一个完美的故障检测器【67,71】—— 即一个可靠的机制来判断一个节点是否已经崩溃。在无限延迟的网络中,超时不是可靠的故障检测器,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。
|
||||
通常,非阻塞原子提交需要一个**完美的故障检测器(perfect failure detector)**【67,71】—— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。
|
||||
|
||||
|
||||
|
||||
### 实践中的分布式事务
|
||||
|
||||
分布式事务,尤其是那些通过两阶段提交实现的事务,毁誉参半。一方面,它们被看作是提供一个难以实现的重要的安全保证;另一方面,他们被批评为造成运维问题,造成性能下降,承诺超过他们能够实现的目标【81,82,83,84】。许多云服务由于其导致的运维问题而选择不实现分布式事务【85,86】。
|
||||
分布式事务的名声毁誉参半,尤其是那些通过两阶段提交实现的。一方面,它被视作提供了一个难以实现的重要的安全性保证;另一方面,它们因为导致运维问题,造成性能下降,做出超过能力范围的承诺而饱受批评【81,82,83,84】。许多云服务由于其导致的运维问题,而选择不实现分布式事务【85,86】。
|
||||
|
||||
分布式事务的某些实现会带来严重的性能损失——例如,MySQL中的分布式事务被报告比单节点事务慢10倍以上【87】,所以当人们建议不要使用这些事务时就不足为奇了。两阶段提交所固有的大部分性能成本是由于崩溃恢复所需的额外强制刷盘(`fsync`)【88】以及额外的网络往返。
|
||||
分布式事务的某些实现会带来严重的性能损失 —— 例如据报告称,MySQL中的分布式事务比单节点事务慢10倍以上【87】,所以当人们建议不要使用它们时就不足为奇了。两阶段提交所固有的性能成本,大部分是由于崩溃恢复所需的额外强制刷盘(`fsync`)【88】以及额外的网络往返。
|
||||
|
||||
但是,我们不应该直接抛弃分布式事务,而应该更加仔细地审视这些事务,因为从中可以汲取重要的经验教训。首先,我们应该精确地说明“**分布式事务**”的含义。两种截然不同的分布式事务类型经常被混淆:
|
||||
但我们不应该直接忽视分布式事务,而应当更加仔细地审视这些事务,因为从中可以汲取重要的经验教训。首先,我们应该精确地说明“**分布式事务**”的含义。两种截然不同的分布式事务类型经常被混淆:
|
||||
|
||||
***数据库内部的分布式事务***
|
||||
|
||||
一些分布式数据库(即在其标准配置中使用复制和分区的数据库)支持该数据库节点之间的内部事务。例如,VoltDB和MySQL Cluster的NDB存储引擎就有这样的内部事务支持。在这种情况下,所有参与事务的节点都运行相同的数据库软件。
|
||||
一些分布式数据库(即在其标准配置中使用复制和分区的数据库)支持数据库节点之间的内部事务。例如,VoltDB和MySQL Cluster的NDB存储引擎就有这样的内部事务支持。在这种情况下,所有参与事务的节点都运行相同的数据库软件。
|
||||
|
||||
***异构分布式事务***
|
||||
|
||||
在异构事务中,参与者有两种或两种以上不同的技术:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。
|
||||
在**异构(heterogeneous)**事务中,参与者是两种或以上不同技术:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。
|
||||
|
||||
数据库内部事务不必与任何其他系统兼容,因此他们可以使用任何协议并应用特定技术的特定优化。因此,数据库内部的分布式事务通常工作地很好。另一方面,跨越异构技术的事务则更有挑战性。
|
||||
数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议,并能针对特定技术进行特定的优化。因此数据库内部的分布式事务通常工作地很好。另一方面,跨异构技术的事务则更有挑战性。
|
||||
|
||||
#### 恰好一次的消息处理
|
||||
|
||||
异构的分布式事务处理能够以强大的方式集成不同的系统。例如,当且仅当用于处理消息的数据库事务处理时,来自消息队列的消息才能被确认为已处理成功承诺。这是通过自动提交消息确认和数据库写入单个事务来实现的。使用分布式事务支持,即使消息代理和数据库是在不同机器上运行的两个不相关技术,也是可能的。
|
||||
异构的分布式事务处理能够以强大的方式集成不同的系统。例如:消息队列中的一条消息可以被确认为已处理,当且仅当用于处理消息的数据库事务成功提交。这是通过在同一个事务中原子提交**消息确认**和**数据库写入**两个操作来实现的。藉由分布式事务的支持,即使消息代理和数据库是在不同机器上运行的两种不相关的技术,这种操作也是可能的。
|
||||
|
||||
如果消息传递或数据库事务失败,两者都会中止,因此消息代理可能会稍后安全地重新传递消息。因此,通过自动提交消息及其处理的副作用,即使在成功之前需要几次重试,也可以确保消息被有效处理一次。中止放弃部分完成的事务的任何副作用。
|
||||
如果消息传递或数据库事务任意一者失败,两者都会中止,因此消息代理可能会在稍后安全地重传消息。因此,通过原子提交**消息处理及其副作用**,即使在成功之前需要几次重试,也可以确保消息被**有效地(effectively)**恰好处理一次。中止会抛弃部分完成事务所导致的任何副作用。
|
||||
|
||||
这样的分布式事务只有在所有受事务影响的系统都能够使用相同的原子提交协议的情况下才是可能的。例如,处理消息的副作用是发送邮件,而邮件服务器不支持两阶段提交:如果邮件处理失败并重试,可能会发送两次或更多次的邮件。但是,如果处理消息的所有副作用在事务中止时回滚,那么可以安全地重新尝试处理步骤,就好像什么都没发生过一样。
|
||||
然而,只有当所有受事务影响的系统都使用同样的**原子提交协议(atomic commit protocl)**时,这样的分布式事务才是可能的。例如,假设处理消息的副作用是发送一封邮件,而邮件服务器并不支持两阶段提交:如果消息处理失败并重试,则可能会发送两次或更多次的邮件。但如果处理消息的所有副作用都可以在事务中止时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。
|
||||
|
||||
我们将在[第11章](ch11.md)中再次回到”恰好一次“消息处理的主题。让我们首先看看允许这种异构分布式事务的原子提交协议。
|
||||
在[第11章](ch11.md)中将再次回到”恰好一次“消息处理的主题。让我们先来看看允许这种异构分布式事务的原子提交协议。
|
||||
|
||||
#### XA事务
|
||||
|
||||
*X/Open XA*(**扩展架构(eXtended Architecture)**的缩写)是跨异构技术实现两阶段提交的标准【76,77】。它于1991年推出并得到了广泛的实施:许多传统关系数据库(包括PostgreSQL,MySQL,DB2,SQL Server和Oracle)和消息代理(包括ActiveMQ,HornetQ,MSMQ和IBM MQ) 。
|
||||
*X/Open XA*(**扩展架构(eXtended Architecture)**的缩写)是跨异构技术实现两阶段提交的标准【76,77】。它于1991年推出并得到了广泛的实现:许多传统关系数据库(包括PostgreSQL,MySQL,DB2,SQL Server和Oracle)和消息代理(包括ActiveMQ,HornetQ,MSMQ和IBM MQ) 都支持XA。
|
||||
|
||||
XA不是一个网络协议——它只是一个用于与事务协调者连接的C API。此API的绑定以其他语言存在;例如,在Java EE应用程序的世界中,XA事务是使用Java事务API(JTA)实现的,而Java事务API(JTA)则由许多用于使用Java数据库连接(JDBC)的数据库驱动程序以及使用Java消息服务(JMS)API。
|
||||
XA不是一个网络协议——它只是一个用来与事务协调者连接的C API。其他语言也有这种API的绑定;例如在Java EE应用的世界中,XA事务是使用**Java事务API(JTA, Java Transaction API)**实现的,而许多使用**Java数据库连接(JDBC, Java Database Connectivity)**的数据库驱动,以及许多使用**Java消息服务(JMS)**API的消息代理都支持**Java事务API(JTA)**。
|
||||
|
||||
XA假定你的应用程序使用网络驱动程序或客户端库来与参与者数据库或消息传递服务进行通信。如果驱动程序支持XA,则表示它调用XA API以查明操作是否应该是分布式事务的一部分——如果是,则将必要的信息发送到数据库服务器。驱动还会提供回调,协调者可以通过回调来要求参与者准备,提交或中止。
|
||||
XA假定你的应用使用网络驱动或客户端库来与**参与者**进行通信(数据库或消息服务)。如果驱动支持XA,则意味着它会调用XA API 以查明操作是否为分布式事务的一部分 —— 如果是,则将必要的信息发往数据库服务器。驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备,提交或中止。
|
||||
|
||||
事务协调者实现XA API。标准没有指定应该如何实现,但实际上,协调者通常只是一个库,与发出事务的应用程序(不是单独的服务)一起被加载到相同的进程中。它跟踪事务的参与者,在要求他们准备(通过回调驱动程序)之后收集参与者的回答,并使用本地磁盘上的日志记录每次事务的提交/中止决定。
|
||||
事务协调者需要实现XA API。标准没有指明应该如何实现,但实际上协调者通常只是一个库,被加载到发起事务的应用的同一个进程中(而不是单独的服务)。它在事务中个跟踪所有的参与者,并在要求它们**准备**之后收集参与者的响应(通过驱动回调),并使用本地磁盘上的日志记录每次事务的决定(提交/中止)。
|
||||
|
||||
如果应用程序进程崩溃,或者运行应用程序的机器死亡,协调者就会使用它。然后任何有准备但未提交的事务的参与者都被怀疑。由于协调程序的日志位于应用程序服务器的本地磁盘上,因此必须重新启动该服务器,并且协调程序库必须读取日志以恢复每个事务的提交/中止结果。只有这样,协调者才能使用数据库驱动程序的XA回调来要求参与者提交或中止。数据库服务器不能直接联系协调者,因为所有通信都必须通过其客户端库。
|
||||
如果应用进程崩溃,或者运行应用的机器报销了,协调者也随之往生极乐。然后任何带有**准备了**但未提交事务的参与者都会在疑虑中卡死。由于协调程序的日志位于应用服务器的本地磁盘上,因此必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的提交/中止结果。只有这样,协调者才能使用数据库驱动的XA回调来要求参与者提交或中止。数据库服务器不能直接联系协调者,因为所有通信都必须通过客户端库。
|
||||
|
||||
#### 怀疑时持有锁
|
||||
|
||||
为什么我们非常关心存疑事务?系统的其他部分不能继续工作,而忽视最终将被清理的有问题的事务吗?
|
||||
为什么我们这么关心存疑事务?系统的其他部分就不能继续正常工作,无视那些终将被清理的存疑事务吗?
|
||||
|
||||
问题在于**锁**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常对其修改的行进行行级别的排他锁定,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须对事务读取的任何行执行共享锁定(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。
|
||||
问题在于**锁(locking)**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常获取待修改的行上的**行级排他锁**,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。
|
||||
|
||||
在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个时间内保持锁定状态。如果协调者已经崩溃,需要20分钟才能重新启动,这些锁将会持有20分钟。如果协调者的日志由于某种原因完全丢失,这些锁将永久保存,或者至少在管理员手动解决该情况之前。
|
||||
在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。
|
||||
|
||||
当这些锁被保留时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能被阻止读取这些行。因此,其他事务不能简单地继续他们的业务 - 如果他们想访问相同的数据,他们将被阻止。这可能会导致大部分应用程序变得不可用,直到有问题的事务得到解决。
|
||||
当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。
|
||||
|
||||
#### 从协调者故障中恢复
|
||||
|
||||
理论上,如果协调者崩溃并重新启动,它应该干净地从日志中恢复其状态,并解决任何有问题的事务。然而,在实践中,孤立的不确定事务确实发生【89,90】,也就是说,协调者不能以任何理由决定结果的事务(例如,因为事务日志已经由于软件错误)。这些事务不能自动解决,所以他们永远待在数据库中,持有锁并阻止其他事务。
|
||||
理论上,如果协调者崩溃并重新启动,它应该干净地从日志中恢复其状态,并解决任何存疑事务。然而在实践中,**孤立(orphaned)**的存疑事务确实会出现【89,90】,即无论出于何种理由,协调者无法确定事务的结果(例如事务日志已经由于软件错误丢失或损坏)。这些事务无法自动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。
|
||||
|
||||
即使重新启动数据库服务器也不能解决这个问题,因为2PC的正确实现必须在重新启动时保留一个有问题的事务的锁(否则就会冒有违反原子性保证的风险)。这是一个棘手的情况。
|
||||
即使重启数据库服务器也无法解决这个问题,因为在2PC的正确实现中,即使重启也必须保留存疑事务的锁(否则就会冒有违反原子性保证的风险)。这是一种棘手的情况。
|
||||
|
||||
唯一的出路是让管理员手动决定是提交还是回滚事务。管理员必须检查每个有问题的事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。解决这个问题潜在地需要大量的人力,并且可能发生在严重的生产中断期间(否则,为什么协调者处于这样一个糟糕的状态),很可能需要在高压力和时间压力下完成。
|
||||
唯一的出路是让管理员手动决定提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。解决这个问题潜在地需要大量的人力,并且可能发生在严重的生产中断期间(不然为什么协调者处于这种糟糕的状态),并很可能要在巨大精神压力和时间压力下完成。
|
||||
|
||||
许多XA的实现都有一个叫做**启发式决策(heuristic decistions)**的紧急逃生舱口:允许参与者单方面决定放弃或提交一个有疑问的事务,而不需要协调者做出明确的决定【76,77,91】。要清楚的是,这里的启发式是可能破坏原子性的委婉说法,因为它违背了两阶段承诺的承诺体系。因此,启发式决策只是为了逃出灾难性的情况,而不是经常性的使用。
|
||||
许多XA的实现都有一个叫做**启发式决策(heuristic decistions)**的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务,而无需协调者做出最终决定【76,77,91】。要清楚的是,这里**启发式**是**可能破坏原子性(probably breaking atomicity)**的委婉说法,因为它违背了两阶段提交的系统承诺。因此,启发式决策只是为了逃出灾难性的情况而准备的,而不是为了日常使用的。
|
||||
|
||||
#### 分布式事务的限制
|
||||
|
||||
XA事务解决了保持多个参与者数据系统一致的真实而重要的问题,但正如我们所看到的那样,它们也引入了主要的运维问题。特别是,关键的实现是事务协调者本身就是一种数据库(在其中存储事务结果),因此需要像其他重要数据库一样小心:
|
||||
XA事务解决了保持多个参与者(数据系统)相互一致的现实的重要问题,但正如我们所看到的那样,它也引入了严重的运维问题。特别来讲,这里的核心认识是:事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库一样小心地打交道:
|
||||
|
||||
* 如果协调者没有被复制,而是只在一台机器上运行,那么整个系统是一个失效的单点(因为它的失效会导致其他应用程序服务器阻塞在有问题的事务处理的锁上)。令人惊讶的是,许多协调者实现默认情况下不是高度可用,或者只有基本的复制支持。
|
||||
* 许多服务器端应用程序都是在无状态模式下开发的(受到HTTP的青睐),所有持久状态都存储在数据库中,具有应用程序服务器可随意添加和删除的优点。但是,当协调者是应用程序服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分——与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复疑问事务所必需的。这样的应用程序服务器不再是无状态的。
|
||||
* 由于XA需要与各种数据系统兼容,因此它是必须的最小公分母。例如,它不能检测到不同系统间的死锁(因为这将需要一个标准化的协议来让系统交换每个事务正在等待的锁的信息),而且它不适用于[SSI](ch7.md#可串行快照隔离(SSI) ),因为这需要一个协议来识别不同系统之间的冲突。
|
||||
* 对于数据库内部的分布式事务(而不是XA),限制不是很大,例如SSI的分布式版本是可能的。然而,仍然存在2PC成功进行事务的问题,所有参与者都必须作出回应。因此,如果系统的任何部分损坏,事务也会失败。因此,分布式事务有扩大故障的趋势,这与我们构建容错系统的目标背道而驰。
|
||||
* 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上)。令人惊讶的是,许多协调者实现默认情况下并不是高可用的,或者只有基本的复制支持。
|
||||
* 许多服务器端应用都是使用无状态模式开发的(受HTTP的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应用服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分—— 与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了。
|
||||
* 由于XA需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与[SSI ](ch7.md#可串行快照隔离(SSI) )协同工作,因为这需要一个跨系统定位冲突的协议。
|
||||
* 对于数据库内部的分布式事务(不是XA),限制没有这么大,例如,分布式版本的SSI 是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应。因此,如果系统的**任何**部分损坏,事务也会失败。因此,分布式事务又有**扩大失效(amplifying failures)**的趋势,这又与我们构建容错系统的目标背道而驰。
|
||||
|
||||
这些事实是否意味着我们应该放弃保持几个系统一致的所有希望?不完全是——有其他的方法可以让我们在没有异构分布式事务的痛苦的情况下实现同样的事情。我们将在[第十一章](ch11.md)和[第十二章](ch12.md)回到这些章节。但首先,我们应该总结共识的话题。
|
||||
这些事实是否意味着我们应该放弃保持几个系统相互一致的所有希望?不完全是 —— 还有其他的办法,可以让我们在没有异构分布式事务的痛苦的情况下实现同样的事情。我们将在[第11章](ch11.md) 和[第12章](ch12.md) 回到这些章节。但首先,我们应该概括一下关于**共识**的话题。
|
||||
|
||||
|
||||
|
||||
### 容错的共识
|
||||
### 容错共识
|
||||
|
||||
非正式地,共识意味着让几个节点达成一致。例如,如果有几个人同时尝试预订飞机上的最后一个座位或剧院中的同一个座位,或者尝试使用相同的用户名注册一个帐户,则可以使用一个一致的算法来确定哪个其中一个互不相容的行动应该是赢家。
|
||||
非正式地,共识意味着让几个节点就某事达成一致。例如,如果有几个人**同时(concurrently)**尝试预订飞机上的最后一个座位,或剧院中的同一个座位,或者尝试使用相同的用户名注册一个帐户。共识算法可以用来确定这些**互不相容(mutually incompatible)**的操作中,哪一个才是赢家。
|
||||
|
||||
共识问题通常形式化如下:一个或多个节点可以**提出(propose)**某个值,而共识算法**决定(decides)**采用其中的一个值。在座位预订的例子中,当几个顾客同时试图购买最后一个座位时,处理顾客请求的每个节点可以提出正在服务的顾客的ID,且决定指明了哪个顾客获得座位。
|
||||
共识问题通常形式化如下:一个或多个节点可以**提议(propose)**某些值,而共识算法**决定(decides)**采用其中的某个值。在座位预订的例子中,当几个顾客同时试图订购最后一个座位时,处理顾客请求的每个节点可以**提议**正在服务的顾客的ID,而**决定**指明了哪个顾客获得了座位。
|
||||
|
||||
在这种形式中,共识算法必须满足以下性质【25】:[^xiii]
|
||||
在这种形式下,共识算法必须满足以下性质【25】:[^xiii]
|
||||
|
||||
[^xiii]: 这种共识的特殊形式被称为统一共识,相当于在具有不可靠故障检测器的异步系统中的常规共识【71】。学术文献通常指的是过程而不是节点,但我们在这里使用节点来与本书的其余部分保持一致。
|
||||
[^xiii]: 这种共识的特殊形式被称为**统一共识(uniform consensus)**,相当于在具有不可靠故障检测器的异步系统中的**常规共识(regular consensus)**【71】。学术文献通常指的是**进程(process)**而不是节点,但我们在这里使用**节点(node)**来与本书的其余部分保持一致。
|
||||
|
||||
***一致同意***
|
||||
***一致同意(Uniform agreement)***
|
||||
|
||||
没有两个节点的决定不同。
|
||||
|
||||
***完整性***
|
||||
***完整性(Integrity)***
|
||||
|
||||
没有节点决定两次。
|
||||
|
||||
***有效性***
|
||||
***有效性(Validity)***
|
||||
|
||||
如果一个节点决定值 `v`,则`v`由某个节点提出。
|
||||
如果一个节点决定了值 `v` ,则 `v` 由某个节点所提议。
|
||||
|
||||
***终止***
|
||||
***终止(Termination)***
|
||||
由所有未崩溃的节点来最终决定值。
|
||||
|
||||
一致同意和完整性属性定义了共识的核心思想:每个人都决定了相同的结果,一旦决定了,你就不能改变主意。有效性属性主要是为了排除平凡的解决方案:例如,无论提出什么建议,都可以有一个总是决定为空的算法;该算法将满足协议和完整性属性,但不符合有效性属性。
|
||||
**一致同意**和**完整性**属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。**有效性**属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为`null`的算法。;该算法满足**一致同意**和**完整性**属性,但不满足**有效性**属性。
|
||||
|
||||
如果你不关心容错,那么满足前三个属性很容易:你可以将一个节点硬编码为“独裁者”,并让该节点做出所有的决定。但是,如果该节点失效,那么系统就不能再做出任何决定。事实上,这就是我们在两阶段提交的情况下所看到的:如果协调者失败了,那么不确定的参与者就不能决定是否提交或中止。
|
||||
如果你不关心容错,那么满足前三个属性很容易:你可以将一个节点硬编码为“独裁者”,并让该节点做出所有的决定。但如果该节点失效,那么系统就无法再做出任何决定。事实上,这就是我们在两阶段提交的情况中所看到的:如果协调者失效,那么存疑的参与者就无法决定提交还是中止。
|
||||
|
||||
终止属性正式形成了容错的思想。它基本上说的是,一个共识算法不能简单地坐下来,永远不做任何事——换句话说,它必须取得进展。即使有些节点出现故障,其他节点也必须做出决定。 (终止是一种**活性属性**,而另外三种是安全属性——参见“[安全性和活性](ch8.md#安全性和活性)”。)
|
||||
**终止**属性正式形成了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定。 (**终止**是一种**活性属性**,而另外三种是安全属性 —— 参见“[安全性和活性](ch8.md#安全性和活性)”。)
|
||||
|
||||
共识的系统模型假设,当一个节点“崩溃”时,它突然消失且永远不会回来。(而不是软件崩溃,想象一下地震,包含你的节点的数据中心被山体滑坡所摧毁,你必须假设节点被埋在30英尺以下的泥土中,并且永远不会回到在线状态。)这个系统模型,任何等待节点恢复的算法都不能满足终止属性。特别是2PC不符合终止的要求。
|
||||
共识的系统模型假设,当一个节点“崩溃”时,它会突然消失而且永远不会回来。(不像软件崩溃,想象一下地震,包含你的节点的数据中心被山体滑坡所摧毁,你必须假设节点被埋在30英尺以下的泥土中,并且永远不会重新上线)在这个系统模型中,任何需要等待节点恢复的算法都不能满足**终止**属性。特别是,2PC不符合终止属性的要求。
|
||||
|
||||
当然,如果所有的节点都崩溃,而且没有一个正在运行,那么任何算法都不可能决定什么。算法可以容忍的失败次数有一个限制:事实上,可以证明任何一致性算法都需要至少大部分节点正确运行,以确保终止属性【67】。大多数人可以安全地形成法定人数(参阅“[读写的法定人数](ch5.md#读写的法定人数)”)。
|
||||
当然如果**所有**的节点都崩溃了,没有一个在运行,那么所有算法都不可能决定任何事情。算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体**多数(majority)**的节点正确工作,以确保终止属性【67】。多数可以安全地组成法定人数(参阅“[读写的法定人数](ch5.md#读写的法定人数)”)。
|
||||
|
||||
因此,终止属性受到不到一半的节点崩溃或不可达的假设。然而,即使大多数节点出现故障或存在严重的网络问题,大多数共识的实施都能确保始终满足安全属性——同意,完整性和有效性【92】。因此,大规模的中断可能会阻止系统处理请求,但是它不能通过使系统做出无效的决定来破坏共识系统。
|
||||
因此**终止**属性取决于一个假设,**不超过一半的节点崩溃或不可达**。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足—— 一致同意,完整性和有效性【92】。因此,大规模的中断可能会阻止系统处理请求,但是它不能通过使系统做出无效的决定来破坏共识系统。
|
||||
|
||||
大多数一致性算法假定没有**拜占庭式错误**,正如在“[拜占庭式错误](#拜占庭式错误)”一节中所讨论的那样。也就是说,如果一个节点没有正确地遵循协议(例如,如果它发送矛盾的消息到不同的节点),它可能会破坏协议的安全属性。只要少于三分之一的节点是拜占庭故障【25,93】,就可以对拜占庭故障形成共识,但我们没有地方在本书中详细讨论这些算法。
|
||||
大多数共识算法假设不存在**拜占庭式错误**,正如在“[拜占庭式错误](#拜占庭式错误)”一节中所讨论的那样。也就是说,如果一个节点没有正确地遵循协议(例如,如果它向不同节点发送矛盾的消息),它就可能会破坏协议的安全属性。克服拜占庭故障,稳健地达成共识是可能的,只要少于三分之一的节点存在拜占庭故障【25,93】。但我们没有地方在本书中详细讨论这些算法了。
|
||||
|
||||
#### 共识算法和全序广播
|
||||
|
||||
最着名的容错一致性算法是**视图戳复制(viewstamped replication)**(VSR)【94,95】,Paxos 【96,97,98,99】,Raft 【22,100,101】和Zab 【15,21,102】 。这些算法之间有相当多的相似之处,但它们并不相同【103】。在本书中,我们不会详细介绍不同的算法:只要了解一些共同的高级思想就足够了,除非你准备自己实现一个共识系统。(这可能不是一个明智的做法,相当困难【98,104】)
|
||||
最著名的容错共识算法是**视图戳复制(VSR, viewstamped replication)**【94,95】,Paxos 【96,97,98,99】,Raft 【22,100,101】以及 Zab 【15,21,102】 。这些算法之间有不少相似之处,但它们并不相同【103】。在本书中我们不会介绍各种算法的详细细节:了解一些它们共通的高级思想通常已经足够了,除非你准备自己实现一个共识系统。(可能并不明智,相当难【98,104】)
|
||||
|
||||
这些算法中的大多数实际上并不直接使用这里描述的形式化模型(建议和决定单个值,一致同意,完整性,有效性和终止性质)。相反,它们决定了值的顺序,这使得它们成为了全序广播算法,正如本章前面所讨论的那样(参阅“[全序广播](#全序广播)”)。
|
||||
大多数这些算法实际上并不直接使用这里描述的形式化模型(提议与决定单个值,一致同意,完整性,有效性和终止属性)。取而代之的是,它们决定了值的**顺序(sequence)**,这使它们成为全序广播算法,正如本章前面所讨论的那样(参阅“[全序广播](#全序广播)”)。
|
||||
|
||||
请记住,全序广播要求将消息按照相同的顺序准确传送到所有节点。如果仔细思考,这相当于进行了几轮的共识:在每一轮中,节点提出下一个要发送的消息,然后决定下一个要发送的消息总数【67】。
|
||||
请记住,全序广播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。如果仔细思考,这相当于进行了几轮共识:在每一轮中,节点提议下一条要发送的消息,然后决定在全序中下一条要发送的消息【67】。
|
||||
|
||||
所以,全序广播相当于重复的一轮共识(每个共同的决定对应于一个消息传递):
|
||||
所以,全序广播相当于重复进行多轮共识(每个共识决定与一次消息传递相对应):
|
||||
|
||||
* 由于协商一致意见,所有节点决定以相同的顺序传递相同的消息。
|
||||
* 由于一致同意属性,所有节点决定以相同的顺序传递相同的消息。
|
||||
|
||||
|
||||
* 由于完整性属性,消息不重复。
|
||||
@ -807,7 +807,7 @@ XA事务解决了保持多个参与者数据系统一致的真实而重要的问
|
||||
|
||||
在[第5章](ch5.md)中,我们讨论了单领导者复制(参见“[领导者和追随者](ch5.md#领导者和追随者)”),它将所有的写入操作都交给领导者,并以相同的顺序将他们应用到追随者,从而使副本保持最新状态。这不是基本上全序广播?我们怎么不用担心[第五章](ch5.md)的共识?
|
||||
|
||||
答案取决于如何选择领导者。如果领导人是由你的运营团队中的人员手动选择和配置的,那么你基本上拥有独裁种类的“一致性算法”:只允许一个节点接受写入(即,决定写入的顺序复制日志),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为主管。这样的制度在实践中可以很好地发挥作用,但是不能达到共识的终止属性,因为它需要人为干预才能取得进展。
|
||||
答案取决于如何选择领导者。如果领导人是由你的运维团队人员手动选择和配置的,那么你基本上拥有独裁类型的“一致性算法”:只允许一个节点接受写入(即,决定写入的顺序复制日志),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为领导者。这样的制度在实践中效果很好,但是无法达到共识的**终止**属性,因为它需要人为干预才能取得进展。
|
||||
|
||||
一些数据库执行自动领导者选举和故障转移,如果旧领导者失败,则促使追随者成为新的领导者(参见“[处理节点宕机](ch5.md#处理节点宕机)”)。这使我们更接近容错的全面命令播出,从而达成共识。
|
||||
|
||||
@ -837,13 +837,13 @@ XA事务解决了保持多个参与者数据系统一致的真实而重要的问
|
||||
|
||||
节点在决定之前对节点进行投票的过程是一种同步复制。如“[同步与异步复制](ch5.md#同步与异步复制)”中所述,通常将数据库配置为使用异步复制。在这种配置中,一些承诺的数据在故障转移时可能会丢失——但是为了获得更好的性能,许多人选择接受这种风险。
|
||||
|
||||
共识体系总是需要严格的多数来操作。这意味着你至少需要三个节点才能容忍一个故障(其余三个为大多数),或者至少有五个节点容忍两个故障(其余三个为五分之一)。如果网络故障切断了其余节点的某些节点,则只有大部分网络可以继续工作,其余部分将被阻塞(另参阅“[线性一致性的代价](#线性一致性的代价)”(第295页))。
|
||||
共识体系总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障(其余三个为大多数),或者至少有五个节点来容忍两个节点发生故障(其余三个为五分之一)。如果网络故障切断了其余节点的某些节点,则只有大部分网络可以继续工作,其余部分将被阻塞(另参阅“[线性一致性的代价](#线性一致性的代价)”)。
|
||||
|
||||
大多数一致性算法假定一组参与投票的节点,这意味着你不能只添加或删除集群中的节点。对共识算法的动态成员扩展允许集群中的节点集随着时间的推移而变化,但是它们比静态成员算法要好得多。
|
||||
|
||||
共识系统通常依靠超时来检测失败的节点。在网络延迟高度变化的环境中,特别是在地理上分布的系统中,经常发生一个节点错误地认为由于暂时的网络问题,导致失败的原因。虽然这个错误不会损害安全属性,但频繁的领导者选举会导致糟糕的表现,因为系统最终会花费更多的时间来选择领导者而不是做任何有用的工作。
|
||||
|
||||
有时,共识算法对网络问题特别敏感。例如,Raft已被证明有不愉快的边缘情况【106】:如果整个网络工作正常,除了一个特定的网络连接一直不可靠,Raft可以进入领导层不断在两个节点之间弹跳的情况,或者目前的领导者不断被迫辞职,所以这个制度从来没有取得进展。其他一致性算法也存在类似的问题,而对不可靠网络更具鲁棒性的设计算法仍然是一个开放的研究问题。
|
||||
有时共识算法对网络问题特别敏感。例如Raft已被证明存在不愉快的边缘情况【106】:如果整个网络工作正常,除了一个特定的网络连接一直不可靠,Raft可以进入领导层不断在两个节点之间弹跳的情况,或者目前的领导者不断被迫辞职,所以这个制度从来没有取得进展。其他一致性算法也存在类似的问题,而对不可靠网络更具鲁棒性的设计算法仍然是一个开放的研究问题。
|
||||
|
||||
### 成员与协调服务
|
||||
|
||||
@ -857,7 +857,7 @@ ZooKeeper模仿Google的Chubby锁定服务【14,98】,不仅实现了全序广
|
||||
|
||||
***线性一致性的原子操作***
|
||||
|
||||
使用原子比较和设置操作,可以实现锁定:如果多个节点同时尝试执行相同的操作,则只有其中一个节点会成功。共识协议保证了操作将是原子性和线性一致性的,即使节点发生故障或网络在任何时候都被中断。分布式锁通常作为一个租约来实现,这个租约有一个到期时间,以便在客户端失败的情况下最终被释放(参阅“[进程暂停](ch8.md#进程暂停)”)。
|
||||
使用原子CAS操作可以实现锁:如果多个节点同时尝试执行相同的操作,则只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常作为一个租约来实现,这个租约有一个到期时间,以便在客户端失败的情况下最终被释放(参阅“[进程暂停](ch8.md#进程暂停)”)。
|
||||
|
||||
***操作的全序***
|
||||
|
||||
@ -881,11 +881,11 @@ ZooKeeper/Chubby模型运行良好的一个例子是,如果你有几个流程
|
||||
|
||||
另一个例子是,当你有一些分区资源(数据库,消息流,文件存储,分布式参与者系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加入群集时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载(参阅“[重新平衡分区](ch6.md#重新平衡分区)”)。当节点被移除或失败时,其他节点需要接管失败节点的工作。
|
||||
|
||||
这些类型的任务可以通过在ZooKeeper中明智地使用原子操作,各种节点和通知来实现。如果正确完成,这种方法允许应用程序自动从故障中恢复,无需人工干预。尽管Apache Curator 【17】等库已经出现在ZooKeeper客户端API的顶层提供了更高级别的工具,但这样做并不容易,但它仍然比尝试从头开始实现必要的一致性算法要好得多,成绩不佳【107】。
|
||||
这些类型的任务可以通过在ZooKeeper中明智地使用原子操作,各种节点和通知来实现。如果正确完成,这种方法允许应用程序自动从故障中恢复,无需人工干预。尽管Apache Curator 【17】等库已经出现在ZooKeeper客户端API的顶层提供了更高层的工具,但这样做并不容易,但它仍然比尝试从头开始实现必要的一致性算法要好得多,成绩不佳【107】。
|
||||
|
||||
应用最初只能在单个节点上运行,但最终可能会增长到数千个节点。试图在如此之多的节点上进行多数选票将是非常低效的。相反,ZooKeeper在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper提供了一种将协调节点(共识,操作排序和故障检测)的一些工作“外包”到外部服务的方式。
|
||||
应用最初只能在单个节点上运行,但最终可能会增长到数千个节点。试图在如此之多的节点上进行多数投票将是非常低效的。相反,ZooKeeper在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper提供了一种将协调节点(共识,操作排序和故障检测)的一些工作“外包”到外部服务的方式。
|
||||
|
||||
通常,由ZooKeeper管理的数据的类型变化十分缓慢:代表“分区7中的节点运行在`10.1.1.23`上”的信息可能会在几分钟或几小时的时间内发生变化。它不是用来存储应用程序的运行时状态的,每秒可能会改变数千甚至数百万次。如果应用程序状态需要从一个节点复制到另一个节点,则可以使用其他工具(如Apache BookKeeper 【108】)。
|
||||
通常,由ZooKeeper管理的数据的类型变化十分缓慢:代表“分区 7 中的节点运行在 `10.1.1.23` 上”的信息可能会在几分钟或几小时的时间内发生变化。它不是用来存储应用程序的运行时状态的,每秒可能会改变数千甚至数百万次。如果应用程序状态需要从一个节点复制到另一个节点,则可以使用其他工具(如Apache BookKeeper 【108】)。
|
||||
|
||||
#### 服务发现
|
||||
|
||||
@ -901,7 +901,7 @@ ZooKeeper和它的小伙伴们可以看作是成员服务研究的悠久历史
|
||||
|
||||
成员资格服务确定哪些节点当前处于活动状态并且是群集的活动成员。正如我们在[第8章](ch8.md)中看到的那样,由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过一致的方式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
|
||||
|
||||
即使它确实存在,仍然可能发生一个节点被错误地宣布死于共识。但是对于一个系统来说,在哪些节点构成当前的成员关系方面是非常有用的。例如,选择领导者可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对现有成员的成员有不同意见,则这种方法将不起作用。
|
||||
即使它确实存在,仍然可能发生一个节点被共识错误地宣告死亡。但是对于一个系统来说,在哪些节点构成当前的成员关系方面是非常有用的。例如,选择领导者可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对现有成员的成员有不同意见,则这种方法将不起作用。
|
||||
|
||||
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
Loading…
Reference in New Issue
Block a user