Merge pull request #121 from yingang/master

translation updates (chapter 5 to chapter 8)
This commit is contained in:
Feng Ruohang 2021-08-09 21:52:41 +08:00 committed by GitHub
commit 8eb7de1488
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 350 additions and 362 deletions

2
ch1.md
View File

@ -264,7 +264,7 @@
人们经常讨论**纵向伸缩scaling up****垂直伸缩vertical scaling**,转向更强大的机器)和**横向伸缩scaling out** **水平伸缩horizontal scaling**,将负载分布到多台小机器上)之间的对立。跨多台机器分配负载也称为“**无共享shared-nothing**”架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载**极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(参阅“[重新平衡分区](ch6.md#分区再平衡)”)。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载**极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(参阅“[分区再平衡](ch6.md#分区再平衡)”)。
跨多台机器部署 **无状态服务stateless services** 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。

View File

@ -351,11 +351,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5
如果存在与单个键关联的大量数据,则“将具有相同键的所有记录放到相同的位置”这种模式就被破坏了。例如在社交网络中,大多数用户可能会与几百人有连接,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为**关键对象linchpin object**【38】或**热键hot key**。
在单个Reducer中收集与某个名流相关的所有活动例如他们发布内容的回复可能导致严重的倾斜也称为**热点hot spot**)—— 也就是说一个Reducer必须比其他Reducer处理更多的记录参见“[负载倾斜与消除热点](ch6.md#负载倾斜与消除热点)“。由于MapReduce作业只有在所有Mapper和Reducer都完成时才完成所有后续作业必须等待最慢的Reducer才能启动。
在单个Reducer中收集与某个名流相关的所有活动例如他们发布内容的回复可能导致严重的倾斜也称为**热点hot spot**)—— 也就是说一个Reducer必须比其他Reducer处理更多的记录参见“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)“。由于MapReduce作业只有在所有Mapper和Reducer都完成时才完成所有后续作业必须等待最慢的Reducer才能启动。
如果连接的输入存在热点键可以使用一些算法进行补偿。例如Pig中的**倾斜连接skewed join**方法首先运行一个抽样作业来确定哪些键是热键【39】。连接实际执行时Mapper会将热键的关联记录**随机**相对于传统MapReduce基于键散列的确定性方法发送到几个Reducer之一。对于另外一侧的连接输入与热键相关的记录需要被复制到所有处理该键的Reducer上【40】。
这种技术将处理热键的工作分散到多个Reducer上这样可以使其更好地并行化代价是需要将连接另一侧的输入记录复制到多个Reducer上。 Crunch中的**分片连接sharded join**方法与之类似,但需要显式指定热键而不是使用采样作业。这种技术也非常类似于我们在“[负载倾斜与消除热点](ch6.md#负载倾斜与消除热点)”中讨论的技术,使用随机化来缓解分区数据库中的热点。
这种技术将处理热键的工作分散到多个Reducer上这样可以使其更好地并行化代价是需要将连接另一侧的输入记录复制到多个Reducer上。 Crunch中的**分片连接sharded join**方法与之类似,但需要显式指定热键而不是使用采样作业。这种技术也非常类似于我们在“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”中讨论的技术,使用随机化来缓解分区数据库中的热点。
Hive的偏斜连接优化采取了另一种方法。它需要在表格元数据中显式指定热键并将与这些键相关的记录单独存放与其它文件分开。当在该表上执行连接时对于热键它会使用Map端连接参阅[下一节](#Map端连接))。

View File

@ -434,7 +434,7 @@
虽然传统的事务方法并没有走远,但我也相信在使应用正确而灵活地处理错误方面上,事务并不是最后的遗言。在本节中,我将提出一些在数据流架构中考量正确性的方式。
### 为数据库使用端到端的参数
### 数据库端到端的争论
应用仅仅是使用具有相对较强安全属性的数据系统例如可序列化的事务并不意味着就可以保证没有数据丢失或损坏。例如如果某个应用有个Bug导致它写入不正确的数据或者从数据库中删除数据那么可序列化的事务也救不了你。
@ -710,7 +710,7 @@ COMMIT;
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷每一个软件都没有Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
检查数据系统的完整性,最好是以端到端的方式进行(参阅“[数据库的端到端争论](#数据库的端到端争论)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘,网络,服务,以及算法的正确性检查都隐含在其中了。
检查数据系统的完整性,最好是以端到端的方式进行(参阅“[数据库的端到端 ](#数据库的端到端争论)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘,网络,服务,以及算法的正确性检查都隐含在其中了。
持续的端到端完整性检查可以不断提高你对系统正确性的信心从而使你能更快地进步【70】。与自动化测试一样审计提高了快速发现错误的可能性从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更就可以更好地充分演化一个应用使其满足不断变化的需求。

4
ch2.md
View File

@ -208,13 +208,13 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是“访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。
如果想按新的方式查询数据,你可以声明一个新的索引,查询会自动使用最合适的那些索引。无需更改查询来利用新的索引。(请参阅“[用于数据查询语言](#用于数据查询语言)”。)关系模型因此使添加应用程序新功能变得更加容易。
如果想按新的方式查询数据,你可以声明一个新的索引,查询会自动使用最合适的那些索引。无需更改查询来利用新的索引。(请参阅“[数据查询语言](#数据查询语言)”。)关系模型因此使添加应用程序新功能变得更加容易。
关系数据库的查询优化器是复杂的已耗费了多年的研究和开发精力【18】。关系模型的一个关键洞察是只需构建一次查询优化器随后使用该数据库的所有应用程序都可以从中受益。如果你没有查询优化器的话那么为特定查询手动编写访问路径比编写通用优化器更容易——不过从长期看通用解决方案更好。
#### 与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图2-1]()中的一对多关系,如`positions``education`和`contact_info`),而不是在单独的表中。
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图2-1](img/fig2-1.png)中的一对多关系,如`positions``education`和`contact_info`),而不是在单独的表中。
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为**外键**,在文档模型中称为**文档引用**【9】。该标识符在读取时通过连接或后续查询来解析。迄今为止文档数据库没有走CODASYL的老路。

4
ch3.md
View File

@ -130,7 +130,7 @@ $ cat database
乍一看,只有追加日志看起来很浪费:为什么不更新文件,用新值覆盖旧值?但是只能追加设计的原因有几个:
* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘SSD** 上也是优选的【4】。我们将在第83页的“[比较B-树和LSM-树](#比较B-树和LSM-树)”中进一步讨论这个问题。
* 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘SSD** 上也是优选的【4】。我们将在第83页的“[比较B树和LSM树](#比较B树和LSM树)”中进一步讨论这个问题。
* 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。例如,您不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。
* 合并旧段可以避免数据文件随着时间的推移而分散的问题。
@ -339,7 +339,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
例如全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词忽略单词的语法变体并且搜索在相同文档中彼此靠近的单词的出现并且支持各种其他功能取决于文本的语言分析。为了处理文档或查询中的拼写错误Lucene能够在一定的编辑距离内搜索文本编辑距离1意味着添加删除或替换了一个字母【37】。
正如“[在SSTables中创建LSM树](#在SSTables中创建LSM树)”中所提到的Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引告诉查询需要在排序文件中哪个偏移量查找键。在LevelDB中这个内存中的索引是一些键的稀疏集合但在Lucene中内存中的索引是键中字符的有限状态自动机类似于trie 【38】。这个自动机可以转换成Levenshtein自动机它支持在给定的编辑距离内有效地搜索单词【39】。
正如“[用SSTables制作LSM树](#用SSTables制作LSM树)”中所提到的Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引告诉查询需要在排序文件中哪个偏移量查找键。在LevelDB中这个内存中的索引是一些键的稀疏集合但在Lucene中内存中的索引是键中字符的有限状态自动机类似于trie 【38】。这个自动机可以转换成Levenshtein自动机它支持在给定的编辑距离内有效地搜索单词【39】。
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。有关更多详细信息请参阅信息检索教科书例如【40】。

6
ch4.md
View File

@ -274,7 +274,7 @@ Avro的关键思想是Writer模式和Reader模式不必是相同的 - 他们只
* 通过网络连接发送记录
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议参阅“[通过服务的数据流REST和RPC](#通过服务的数据流REST和RPC)”)就是这样工作的。
当两个进程通过双向网络连接进行通信时,他们可以在连接设置上协商模式版本,然后在连接的生命周期中使用该模式。 Avro RPC协议参阅“[服务中的数据流REST与RPC](#服务中的数据流REST与RPC)”)就是这样工作的。
具有模式版本的数据库在任何情况下都是非常有用的因为它充当文档并为您提供了检查模式兼容性的机会【24】。作为版本号你可以使用一个简单的递增整数或者你可以使用模式的散列。
@ -326,7 +326,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能但是它
这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
* 通过数据库(参阅“[数据库中的数据流](#数据库中的数据流)”)
* 通过服务调用(参阅“[服务中的数据流REST和RPC](#服务中的数据流REST和RPC)”)
* 通过服务调用(参阅“[服务中的数据流REST与RPC](#服务中的数据流REST与RPC)”)
* 通过异步消息传递(参阅“[消息传递中的数据流](#消息传递中的数据流)”)
@ -383,7 +383,7 @@ Web浏览器不是唯一的客户端类型。例如在移动设备或桌面
此外服务器本身可以是另一个服务的客户端例如典型的Web应用服务器充当数据库的客户端。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务这样当一个服务需要来自另一个服务的某些功能或数据时就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为**面向服务的体系结构service-oriented architectureSOA**,最近被改进和更名为**微服务架构**【31,32】。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在[第2章](./ch2.md)中讨论的查询语言进行任意查询但是服务公开了一个特定于应用程序的API它只允许由服务的业务逻辑应用程序代码预定的输入和输出【33】。这种限制提供了一定程度的封装服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在[第2章](ch2.md)中讨论的查询语言进行任意查询但是服务公开了一个特定于应用程序的API它只允许由服务的业务逻辑应用程序代码预定的输入和输出【33】。这种限制提供了一定程度的封装服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如每个服务应该由一个团队拥有并且该团队应该能够经常发布新版本的服务而不必与其他团队协调。换句话说我们应该期望服务器和客户端的旧版本和新版本同时运行因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容——这正是我们在本章所一直在谈论的。

119
ch5.md
View File

@ -10,7 +10,7 @@
[TOC]
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在[第二部分简介](part-ii.md)中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
* 使得数据与用户在地理上接近(从而减少延迟)
* 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
@ -112,7 +112,7 @@
* 如果数据库需要和其他外部存储相协调那么丢弃写入内容是极其危险的操作。例如在GitHub 【13】的一场事故中一个过时的MySQL从库被提升为主库。数据库使用自增ID作为主键因为新主库的计数器落后于老主库的计数器所以新主库重新分配了一些已经被老主库分配掉的ID作为主键。这些主键也在Redis中使用主键重用使得MySQL和Redis中数据产生不一致最后导致一些私有数据泄漏到错误的用户手中。
* 发生某些故障时(见[第8章](ch8.md))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂(split brain)**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(参见“[多领导者复制](#多领导者复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点[^ii]但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
* 发生某些故障时(见[第8章](ch8.md))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂(split brain)**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(参见“[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点[^ii]但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
[^ii]: 这种机制称为 **屏蔽fencing**,充满感情的术语是:**爆彼之头Shoot The Other Node In The Head, STONITH**。
@ -169,7 +169,7 @@
由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统(如数据)这一点很有用例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获change data capture**第11章将重新讲到它。
对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统这一点很有用例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】。 这种技术被称为 **数据变更捕获change data capture**第11章将重新讲到它。
#### 基于触发器的复制
@ -191,7 +191,7 @@
在这种伸缩体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是,这种方法实际上只适用于异步复制——如果尝试同步复制到所有追随者,则单个节点故障或网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置是非常不可靠的。
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性eventually consistency**[^iii]【22,23】
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性eventual consistency**[^iii]【22,23】
[^iii]: 道格拉斯·特里Douglas Terry等人创造了术语最终一致性。 【24】 并经由Werner Vogels 【22】推广成为许多NoSQL项目的口号。 然而不只有NoSQL数据库是最终一致的关系型数据库中的异步复制追随者也有相同的特性。
@ -242,7 +242,7 @@
**图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。**
**单调读Monotonic reads**【23】保证这种异常不会发生。这是一个比 **强一致性strong consistency** 更弱,但比 **最终一致性eventually consistency** 更强的保证。当读取数据时,您可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退,即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。
**单调读Monotonic reads**【23】保证这种异常不会发生。这是一个比 **强一致性strong consistency** 更弱,但比 **最终一致性eventual consistency** 更强的保证。当读取数据时,您可能会看到一个旧值;单调读取仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间后退,即,如果先前读取到较新的数据,后续读取不会得到更旧的数据。
实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取不同的用户可以从不同的副本读取。例如可以基于用户ID的散列来选择副本而不是随机选择副本。但是如果该副本失败用户的查询将需要重新路由到另一个副本。
@ -279,7 +279,7 @@
这是**分区partitioned****分片sharded**数据库中的一个特殊问题将在第6章中讨论。如果数据库总是以相同的顺序应用写入则读取总是会看到一致的前缀所以这种异常不会发生。但是在许多分布式数据库中不同的分区独立运行因此不存在**全局写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在“关系与并发”一节中返回这个主题。
一种解决方案是,确保任何因果相关的写入都写入相同的分区。对于某些无法高效完成这种操作的应用,还有一些显式跟踪因果依赖关系的算法,本书将在“[“此前发生”的关系和并发](#“此前发生”的关系和并发)”一节中返回这个主题。
### 复制延迟的解决方案
@ -297,7 +297,7 @@
本章到目前为止,我们只考虑使用单个领导者的复制架构。 虽然这是一种常见的方法,但也有一些有趣的选择。
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它[^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
[^iv]: 如果数据库被分区见第6章每个分区都有一个领导。 不同的分区可能在不同的节点上有其领导者,但是每个分区必须有一个领导者节点。
@ -333,7 +333,7 @@
有些数据库默认情况下支持多主配置但使用外部工具实现也很常见例如用于MySQL的Tungsten Replicator 【26】用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“冲突解决conflict resolution”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。
由于多主复制在许多数据库中都属于改装的功能所以常常存在微妙的配置缺陷且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等都可能会有麻烦。因此多主复制往往被认为是危险的领域应尽可能避免【28】。
@ -375,7 +375,7 @@
#### 避免冲突
处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个领导者,那么冲突就不会发生。由于多领导者复制处理的许多实现冲突相当不好避免冲突是一个经常推荐的方法【34】。
处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个领导者,那么冲突就不会发生。由于许多的多领导者复制实现在处理冲突时处理得相当不好避免冲突是一个经常推荐的方法【34】。
例如,在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的领导者进行读写。不同的用户可能有不同的“家庭”数据中心(可能根据用户的地理位置选择),但从任何用户的角度来看,配置基本上都是单一的领导者。
@ -391,7 +391,7 @@
实现冲突合并解决有多种途径:
* 给每个写入一个唯一的ID例如一个时间戳一个长的随机数一个UUID或者一个键和值的哈希挑选最高ID的写入作为胜利者并丢弃其他写入。如果使用时间戳这种技术被称为**最后写入胜利LWW, last write wins**。虽然这种方法很流行但是很容易造成数据丢失【35】。我们将在[本章末尾](#检测并发写入)更详细地讨论LWW。
* 给每个写入一个唯一的ID例如一个时间戳一个长的随机数一个UUID或者一个键和值的哈希挑选最高ID的写入作为胜利者并丢弃其他写入。如果使用时间戳这种技术被称为**最后写入胜利LWW, last write wins**。虽然这种方法很流行但是很容易造成数据丢失【35】。我们将在本章末尾的[检测并发写入](#检测并发写入)更详细地讨论LWW。
* 为每个副本分配一个唯一的IDID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中合并的标题可能类似于“B/C”
* 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
@ -410,11 +410,11 @@
当检测到冲突时所有冲突写入被存储。下一次读取数据时会将这些多个版本的数据返回给应用程序。应用程序可能会提示用户或自动解决冲突并将结果写回数据库。例如CouchDB以这种方式工作。
请注意冲突解决通常适用于单个行或文档层面而不是整个事务【36】。因此如果您有一个事务会原子性地进行几次不同的写入请参阅第7章对于冲突解决而言,每个写入仍需分开单独考虑。
请注意冲突解决通常适用于单个行或文档层面而不是整个事务【36】。因此如果您有一个事务会原子性地进行几次不同的写入请参阅[第7章](ch7.md),对于冲突解决而言,每个写入仍需分开单独考虑。
> #### 题外话:自动冲突解决
> #### 自动冲突解决
>
> 冲突解决规则可能很快变得复杂并且自定义代码可能容易出错。亚马逊是一个经常被引用的例子由于冲突解决处理程序令人意外的效果一段时间以来购物车上的冲突解决逻辑将保留添加到购物车的物品但不包括从购物车中移除的物品。因此顾客有时会看到物品重新出现在他们的购物车中即使他们之前已经被移走【37】。
>
@ -435,21 +435,21 @@
其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。
现在还没有一个现成的答案但在接下来的章节中我们将更好地了解这个问题。我们将在第7章中看到更多的冲突示例在[第12章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在[第7章](ch7.md)中看到更多的冲突示例,在[第12章](ch12.md)中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
### 多主复制拓扑
**复制拓扑**replication topology描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者如[图5-7]()所示只有一个合理的拓扑结构领导者1必须把他所有的写到领导者2反之亦然。当有两个以上的领导各种不同的拓扑是可能的。[图5-8]()举例说明了一些例子。
**复制拓扑**replication topology描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者如[图5-7](img/fig5-7.png)所示只有一个合理的拓扑结构领导者1必须把他所有的写到领导者2反之亦然。当有两个以上的领导各种不同的拓扑是可能的。[图5-8](img/fig5-8.png)举例说明了一些例子。
![](img/fig5-8.png)
**图5-8 三个可以设置多领导者复制的示例拓扑。**
最普遍的拓扑是全部到全部([图5-8 [c]]()其中每个领导者将其写入每个其他领导。但是也会使用更多受限制的拓扑例如默认情况下MySQL仅支持**环形拓扑circular topology**【34】其中每个节点接收来自一个节点的写入并将这些写入加上自己的任何写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状[^v]。一个指定的根节点将写入转发给所有其他节点。星型拓扑可以推广到树。
最普遍的拓扑是全部到全部([图5-8 (c)](img/fig5-8.png)其中每个领导者将其写入每个其他领导。但是也会使用更多受限制的拓扑例如默认情况下MySQL仅支持**环形拓扑circular topology**【34】其中每个节点接收来自一个节点的写入并将这些写入加上自己的任何写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状[^v]。一个指定的根节点将写入转发给所有其他节点。星型拓扑可以推广到树。
[^v]: 不要与星型模式混淆(请参阅“[分析模式:星型还是雪花](ch2.md#分析模式:星型还是雪花)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
[^v]: 不要与星型模式混淆(请参阅“[星型和雪花型:分析模式](ch2.md#星型和雪花型:分析模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
在圆形和星形拓扑中写入可能需要在到达所有副本之前通过多个节点。因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环每个节点被赋予一个唯一的标识符并且在复制日志中每个写入都被标记了所有已经过的节点的标识符【43】。当一个节点收到用自己的标识符标记的数据更改时该数据更改将被忽略因为节点知道它已经被处理过。
@ -461,9 +461,9 @@
**图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。**
在[图5-9](img/fig5-9.png)中客户端A向主库1的表中插入一行客户端B在主库3上更新该行。然而主库2可以以不同的顺序接收写入它可以首先接收更新其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。
在[图5-9](img/fig5-9.png)中客户端A向主库1的表中插入一行客户端B在主库3上更新该行。然而主库2可以以不同的顺序接收写入它可以首先接收更新从它的角度来看是对数据库中不存在的行的更新并且仅在稍后接收到相应的插入其应该在更新之前
这是一个因果关系的问题,类似于我们在“[一致前缀读](ch8.md#一致前缀读)”中看到的更新取决于先前的插入所以我们需要确保所有节点先处理插入然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的因为时钟不可能被充分地同步以便在主库2处正确地排序这些事件见[第8章](ch8.md))。
这是一个因果关系的问题,类似于我们在“[一致前缀读](#一致前缀读)”中看到的更新取决于先前的插入所以我们需要确保所有节点先处理插入然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的因为时钟不可能被充分地同步以便在主库2处正确地排序这些事件见[第8章](ch8.md))。
要正确排序这些事件,可以使用一种称为 **版本向量version vectors** 的技术,本章稍后将讨论这种技术(参阅“[检测并发写入](#检测并发写入)”。然而冲突检测技术在许多多领导者复制系统中执行得不好。例如在撰写本文时PostgreSQL BDR不提供写入的因果排序【27】而Tungsten Replicator for MySQL甚至不尝试检测冲突【34】。
@ -477,7 +477,7 @@
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是**无领导的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后它再一次成为数据库的一种时尚架构【37】。 RiakCassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储所以这类数据库也被称为*Dynamo风格*。
[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单引导程序复制。
[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是AWS提供了一个名为DynamoDB的托管数据库产品它使用了完全不同的体系结构它基于单领导者复制。
在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个 **协调者coordinator** 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
@ -523,7 +523,7 @@
在Dynamo风格的数据库中参数nw和r通常是可配置的。一个常见的选择是使n为奇数通常为3或5并设置 $w = r =n + 1/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
> 集群中可能有多于n的节点。集群的机器数可能多于副本数目但是任何给定的值只能存储在n个节点上。 这允许对数据集进行分区,从而支持可以放在一个节点上的数据集更大的数据集。 将在第6章回到分区。
> 集群中可能有多于n的节点。集群的机器数可能多于副本数目但是任何给定的值只能存储在n个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在[第6章](ch6.md)继续讨论分区。
>
法定人数条件$w + r> n$允许系统容忍不可用的节点,如下所示:
@ -538,13 +538,13 @@
**图5-11 如果$w + r > n$读取r个副本至少有一个r副本必然包含了最近的成功写入**
如果少于所需的w或r节点可用则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入)导致节点关闭(崩溃,关闭电源),由于客户端和服务器之间的网络中断节点,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
如果少于所需的w或r节点可用则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为执行操作的错误(由于磁盘已满而无法写入),因为节点关闭(崩溃,关闭电源),由于客户端和服务器节点之间的网络中断,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。
### 法定人数一致性的局限性
如果你有n个副本并且你选择w和r使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。
如果你有n个副本并且你选择w和r使得$w + r> n$,你通常可以期望每个键的读取都能返回最近写入的值。情况就是这样,因为你写入的节点集合和你读取的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。
通常r和w被选为多数超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是法定人数不一定必须是大多数只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的这使得分布式算法的设计有一定的灵活性【45】。
@ -555,50 +555,50 @@
但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
* 如果使用宽松的法定人数(见“[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”w个写入和r个读取落在完全不同的节点上因此r节点和w之间不再保证有重叠节点【46】。
* 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅第171页的“处理写入冲突”)。如果根据时间戳(最后写入胜利)挑选出一个胜者,则由于时钟偏差[35],写入可能会丢失。我们将返回“[检测并发写入](#检测并发写入)”中的此主题。
* 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅“[处理写入冲突](#处理写入冲突)”)。如果根据时间戳(最后写入胜利)挑选出一个胜者,则由于时钟偏差[35],写入可能会丢失。我们将在“[检测并发写入](#检测并发写入)”继续讨论此话题。
* 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值。
* 如果写操作在某些副本上成功而在其他节点上失败例如因为某些节点上的磁盘已满在小于w个副本上写入成功。所以整体判定写入失败但整体写入失败并没有在写入成功的副本上回滚。这意味着如果一个写入虽然报告失败后续的读取仍然可能会读取这次失败写入的值【47】。
* 如果携带新值的节点失败需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复则存储新值的副本数可能会低于w从而打破法定人数条件。
* 即使一切工作正常,有时也会不幸地出现关于**时序timing** 的边缘情况,我们将在第334页上的“[线性化和法定人数](ch9.md#线性化和法定人数)”中看到这点。
* 即使一切工作正常,有时也会不幸地出现关于**时序timing** 的边缘情况,我们将在**“[线性一致性和法定人数](ch9.md#线性一致性和法定人数)”中看到这点。
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取陈旧值的概率但把它们当成绝对的保证是不明智的。
尤其是,通常没有得到“[与延迟有关的问题](#)”(读取您的写入,单调读取或一致的前缀读取)中讨论的保证,因此前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要**事务**或**共识**。我们将在[第七章](ch7.md)和[第九章](ch9.md)回到这些话题。
尤其是,因为通常没有得到“[复制延迟问题](#复制延迟问题)”中讨论的保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要**事务**或**共识**。我们将在[第七章](ch7.md)和[第九章](ch9.md)回到这些话题。
#### 监控陈旧度
从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,您也需要了解复制的健康状况。如果显著落后,应该提醒您,以便您可以调查原因(例如,网络中的问题或超载节点)。
对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入数)。通过从领导者的当前位置中减去随者的当前位置,您可以测量复制滞后量。
对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入数)。通过从领导者的当前位置中减去随者的当前位置,您可以测量复制滞后量。
然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究并根据参数nw和r来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将过时测量值包含在数据库的标准度量标准中是一件好事。最终的一致性是故意模糊的保证,但是对于可操作性来说,能够量化“最终”是很重要的。
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究并根据参数nw和r来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将陈旧测量值包含在数据库的度量标准集中是一件好事。最终一致性是一种有意模糊的保证,但是从可操作性角度来说,能够量化“最终”是很重要的。
### 宽松的法定人数与提示移交
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢因为请求不必等待所有n个节点响应——当w或r节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说这些特性使无主复制的数据库很有吸引力。
然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于可用节点,因此客户端可能无法达到法定人数。
然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端来看,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于w或r因此客户端不再能达到法定人数。
在一个大型的群集中节点数量明显多于n个网络中断期间客户端可能连接到某些数据库节点,而不是为了为特定值组成法定人数的节点们。在这种情况下,数据库设计人员需要权衡一下:
在一个大型的群集中节点数量明显多于n个网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定值的法定人数。在这种情况下,数据库设计人员需要权衡一下:
* 将错误返回给我们无法达到w或r节点的法定数量的所有请求是否更好
* 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常存在的n个节点之间
* 对于所有无法达到w或r节点法定人数的请求是否返回错误是更好的
* 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的n个节点上
后者被认为是一个**宽松的法定人数sloppy quorum**【37】写和读仍然需要w和r成功的响应是那些可能包括不在指定的n个“主”节点中的值。比方说,如果你把自己锁在房子外面,你可能会敲开邻居的门,问你是否可以暂时停留在沙发上。
后者被认为是一个**宽松的法定人数sloppy quorum**【37】写和读仍然需要w和r成功的响应这些响应可能来自不在指定的n个“主”节点中的其它节点。比方说,如果你把自己锁在房子外面,你可能会敲开邻居的门,问你是否可以暂时在沙发上。
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“本地”节点。这就是所谓的**提示移交hinted handoff**。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)
一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“”节点。这就是所谓的**提示移交hinted handoff**。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)
宽松的法定人数对写入可用性的提高特别有用只要有任何w节点可用数据库就可以接受写入。然而这意味着即使当$w + r> n$时也不能确定读取某个键的最新值因为最新的值可能已经临时写入了n之外的某些节点【47】。
因此在传统意义上一个宽松的法定人数实际上不是一个法定人数。这只是一个保证即数据存储在w节点的地方。不能保证r节点的读取直到提示已经完成。
因此在传统意义上一个宽松的法定人数实际上不是一个法定人数。这只是一个保证即数据存储在w节点的地方。不能保证r节点的读取直到提示移交已经完成。
在所有常见的Dynamo实现中宽松的法定人数是可选的。在Riak中它们默认是启用的而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。
#### 运维多个数据中心
我们先前讨论了跨数据中心复制作为多主复制的用例(参阅“[多主复制](#多主复制)”)。无主复制适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
我们先前讨论了跨数据中心复制作为多主复制的用例(参阅“[多主复制](#多主复制)”)。无主复制适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。
Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持副本的数量n包括所有数据中心的节点在配置中您可以指定每个数据中心中您想拥有的副本的数量。无论数据中心如何每个来自客户端的写入都会发送到所有副本但客户端通常只等待来自其本地数据中心内的法定节点的确认从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生尽管配置有一定的灵活性【50,51】。
@ -610,7 +610,7 @@
问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X
* 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。
* 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
* 节点 3 首先接收来自 B 的写入,然后从 A 写入。
@ -622,7 +622,7 @@
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
在“[处理写冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
在“[处理写冲突](#处理写入冲突)”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
#### 最后写入胜利(丢弃并发写入)
@ -632,18 +632,18 @@
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最 **“最近”** 的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利LWW, last write wins**是Cassandra 【53】唯一支持的冲突解决方法也是Riak 【35】中的一个可选特征。
LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们都被报告为客户端成功(因为它们被写入 w 个副本),但只有一个写入将存活而其他写入将被静默丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
LWW实现了最终收敛的目标但以**持久性**为代价如果同一个Key有多个并发写入即使它们报告给客户端的都是成功(因为它们被写入 w 个副本),也只有一个写入将存活而其他写入将被静默丢弃。此外LWW甚至可能会删除不是并发的写入我们将在的“[有序事件的时间戳](ch8.md#有序事件的时间戳)”中讨论。
有一些情况如缓存其中丢失的写入可能是可以接受的。如果丢失数据不可接受LWW是解决冲突的一个很烂的选择。
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个密钥进行并发更新。例如Cassandra推荐使用的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。
与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个进行并发更新。例如Cassandra推荐使用的方法是使用UUID作为键从而为每个写操作提供一个唯一的键【53】。
#### “此前发生”的关系和并发
我们如何判断两个操作是否是并发的?为了建立一个直觉,让我们看看一些例子:
* 在[图5-9](fig5-9.png)中两个写入不是并发的A的插入发生在B的增之前因为B递增的值是A插入的值。换句话说B的操作建立在A的操作上所以B的操作必须有后来发生。我们也可以说B是 **因果依赖causally dependent** 于A
* 另一方面,[图5-12](fig5-12.png)中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在执行操作同样的Key。因此,操作之间不存在因果关系。
* 在[图5-9](fig5-9.png)中两个写入不是并发的A的插入发生在B的增之前因为B递增的值是A插入的值。换句话说B的操作建立在A的操作上所以B的操作必须有后来发生。我们也可以说B是 **因果依赖causally dependent** 于A
* 另一方面,[图5-12](fig5-12.png)中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在执行操作同样的。因此,操作之间不存在因果关系。
如果操作B了解操作A或者依赖于A或者以某种方式构建于操作A之上则操作A在另一个操作B之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上我们可以简单地说如果两个操作都不在另一个之前发生那么两个操作是并发的两个操作都不知道另一个【54】。
@ -655,17 +655,17 @@
>
> 如果两个操作 **“同时”** 发生,似乎应该称为并发——但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否**同时**发生的,这个问题我们将在[第8章](ch8.md)中详细讨论。
>
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作**并发**而不管它们发生的物理时间。人们有时把这个原理和狭义相对论的物理学联系起来【54】它引入了信息不能比光速更快的思想。因此如果事件之间的时间短于光通过它们之间的距离,那么发生一定距离的两个事件不可能相互影响。
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作**并发**而不管它们发生的物理时间。人们有时把这个原理和狭义相对论的物理学联系起来【54】它引入了信息不能比光速更快的思想。因此如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间,那么这两个事件不可能相互影响。
>
> 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是**并行的**。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
> 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是**并行的**。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
#### 捕获"此前发生"关系
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法概括为具有多个副本的无领导者数据库。
来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无领导者数据库。
[图5-13]()显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
[图5-13](img/fig5-13.png)显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入服务器成功存储了它并为其分配版本号1最后将值与版本号一起回送给客户端。
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值**都**反回给客户端 2 ,并附上版本号 2 。
@ -687,10 +687,10 @@
* 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 来自写入请求的响应可以像读取一样,返回所有当前值,这使得我们可以像购物车示例那样连接多个写入。)
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须保持所有值更高版本号(因为这些值与传入的写入同时发生)。
* 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来。)
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与随后的写入是并发的)。
当一个写入包含前一次读取的版本号时,它会告诉我们写入的哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
当一个写入包含前一次读取的版本号时,它会告诉我们写入是基于之前的哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
#### 合并同时写入的值
@ -698,11 +698,11 @@
合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。
以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋同时出现在两个兄弟里,即使他们每个只被写过一次。合并的值可以是[牛奶,面粉,鸡蛋,培根,火腿],没有重复。
然而,如果你想让人们也可以从他们的手推车中**删除**东西而不是仅仅添加东西那么把兄弟求并可能不会产生正确的结果如果你合并了两个兄弟手推车并且只在其中一个兄弟值里删掉了它那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题一个项目在删除时不能简单地从数据库中删除相反系统必须留下一个具有合适版本号的标记以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑tombstone**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。)
然而,如果你想让人们也可以从他们的手推车中**删除**东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果如果你合并了两个兄弟手推车并且只在其中一个兄弟值里删掉了它那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题一个项目在删除时不能简单地从数据库中删除相反系统必须留下一个具有合适版本号的标记以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑tombstone**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。)
因为在应用程序代码中合并兄弟是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,如“[自动冲突解决]()”中讨论的。例如Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并兄弟包括保留删除。
因为在应用程序代码中合并兄弟是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,如“[自动冲突解决](#自动冲突解决)”中讨论的。例如Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并兄弟包括保留删除。
#### 版本向量
@ -710,7 +710,7 @@
[图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。
所有副本的版本号集合称为**版本向量version vector**【56】。这个想法的一些变体正在使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
所有副本的版本号集合称为**版本向量version vector**【56】。这个想法的一些变体正在使用但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
与[图5-13](img/fig5-13.png)中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak将版本向量编码为一个字符串它称为**因果上下文causal context**)。版本向量允许数据库区分覆盖写入和并发写入。
@ -718,7 +718,7 @@
> #### 版本向量和向量时钟
>
> 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙——请参阅参考资料的细节【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。
> 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙——细节请参阅参考资料【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。
>
## 本章小结
@ -743,7 +743,7 @@
尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(甚至不考虑更隐蔽的故障,例如由于软件错误导致的无提示数据损坏)。
尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(甚至不考虑更隐蔽的故障,例如由于软件错误导致的静默数据损坏)。
我们讨论了复制的三种主要方法:
@ -758,11 +758,12 @@
***无主复制***
客户端发送每个写入到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价。
复制可以是同步的,也可以是异步的,在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是在复制滞后增加和服务器故障时要弄清楚会发生什么,这一点很重要。如果一个领导者失败了,并且你推动一个异步更新的追随者成为新的领导者,那么最近承诺的数据可能会丢失。
复制可以是同步的,也可以是异步的,在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是在复制滞后增加和服务器故障时要弄清楚会发生什么,这一点很重要。如果一个领导者失败了,并且你提升了一个异步更新的追随者成为新的领导者,那么最近提交的数据可能会丢失。
我们研究了一些可能由复制滞后引起的奇怪效应,我们讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型:
我们研究了一些可能由复制滞后引起的奇怪效应,我们讨论了一些有助于决定应用程序在复制滞后时的行为的一致性模型:
***写后读***
@ -770,7 +771,7 @@
***单调读***
用户在一个时间点看到数据后,他们不应该在某个早期时间点看到数据。
用户在一个时间点看到数据后,他们不应该在某个更早的时间点看到数据。
***一致前缀读***
@ -778,9 +779,9 @@
最后,我们讨论了多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。我们还谈到了通过合并并发更新来解决冲突的方法。
最后,我们讨论了多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。我们还谈到了通过合并并发更新来解决冲突的方法。
在下一章中,我们将继续研究分布在多个机器上的数据,通过复制的对应方式:将大数据集分割成分区。
在下一章中,我们将继续研究分布在多个机器上的数据,通过复制的同僚:将大数据集分割成分区。

86
ch6.md
View File

@ -11,13 +11,13 @@
[TOC]
在[第5章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区partitions**,也称为**分片sharding**[^i]。
在[第5章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区partitions**,也称为**分片sharding**[^i]。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区net splits** 无关,这是节点之间网络中的一种故障类型。我们将在[第8章](ch8.md)讨论这些错误。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在[第8章](ch8.md)讨论这些错误。
> ##### 术语澄清
>
> 上文中的**分区(partition)**在MongoDBElasticsearch和Solr Cloud中被称为**分片(shard)**在HBase中称之为**区域(Region)**Bigtable中则是 **表块tablet**Cassandra和Riak中是**虚节点vnode)**Couchbase中叫做**虚桶(vBucket)**。但是**分区(partition)** 是约定俗成的叫法。
> 上文中的**分区(partition)**在MongoDBElasticsearch和Solr Cloud中被称为**分片(shard)**在HBase中称之为**区域(Region)**Bigtable中则是 **表块tablet**Cassandra和Riak中是**虚节点vnode)**Couchbase中叫做**虚桶(vBucket)**。但是**分区(partitioning)** 是约定俗成的叫法。
>
通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。
@ -26,15 +26,15 @@
对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。
分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的有些系统则用于分析参阅“[事务处理或分析]”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的有些系统则用于分析参阅“[事务处理还是分析](ch3.md#事务处理还是分析)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[重新平衡分区](#重新平衡分区),如果想要添加或删除群集中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[分区再平衡rebalancing](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
## 分区与复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。
一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1](img/fig6-1.png)所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。
我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
![](img/fig6-1.png)
@ -55,17 +55,17 @@
### 根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
![](img/fig6-2.png)
**图6-2 印刷版百科全书按照关键字范围进行分区**
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2]()中第1卷包含以A和B开头的单词但第12卷则包含以TUVXY和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据分区边界需要依据数据调整。
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2](img/fig6-2.png)中第1卷包含以A和B开头的单词但第12卷则包含以TUVXY和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据分区边界需要依据数据调整。
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[重新平衡分区]()”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略以及其开源等价物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[分区再平衡](#分区再平衡)”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略以及其开源等价物HBase 【2, 3】RethinkDB和2.4版本之前的MongoDB 【4】。
在每个分区中,我们可以按照一定的顺序保存键(参见“[SSTables和LSM-树]()”)。好处是进行范围扫描非常简单,您可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(参阅“[多列索引](#ch2.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
在每个分区中,我们可以按照一定的顺序保存键(参见“[SSTables和LSM树](ch3.md#SSTables和LSM树)”)。好处是进行范围扫描非常简单,您可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(参阅“[多列索引](ch3.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
然而Key Range分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是由于我们在测量发生时将数据从传感器写入数据库因此所有写入操作都会转到同一个分区即今天的分区这样分区可能会因写入而过载而其他分区则处于空闲状态【5】。
@ -75,7 +75,7 @@
由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似它们的散列也会均匀分布在这个数字范围内。
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似它们的散列也会均匀分布在这个数字范围内。
出于分区的目的散列函数不需要多么强壮的加密算法例如Cassandra和MongoDB使用MD5Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数它们用于哈希表但是它们可能不适合分区例如在Java的`Object.hashCode()`和Ruby的`Object#hash`同一个键可能在不同的进程中有不同的哈希值【6】。
@ -85,25 +85,25 @@
**图6-3 按哈希键分区**
这种技术擅长在分区之间分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希consistent hashing**)。
这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希consistent hashing**)。
> #### 一致性哈希
>
> 一致性哈希由Karger等人定义。【7】 用于跨互联网级别的缓存系统例如CDN中是一种能均匀分配负载的方法。它使用随机选择的 **分区边界partition boundaries** 来避免中央控制或分布式一致性的需要。 请注意这里的一致性与复制一致性请参阅第5章或ACID一致性参阅[第7章](ch7.md))无关,而是描述了重新平衡的特定方法。
> 一致性哈希由Karger等人定义。【7】 用于跨互联网级别的缓存系统例如CDN中是一种能均匀分配负载的方法。它使用随机选择的 **分区边界partition boundaries** 来避免中央控制或分布式共识的需要。 请注意,这里的一致性与复制一致性(请参阅[第5章](ch5.md)或ACID一致性参阅[第7章](ch7.md))无关,而是描述了一种重新平衡reblancing的特定方法。
>
> 正如我们将在“[重新平衡分区](#重新平衡分区)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然指的是一致性哈希,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区hash partitioning**。
> 正如我们将在“[分区再平衡](#分区再平衡)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区hash partitioning**。
不幸的是,通过使用Key散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的密钥现在分散在所有分区中所以它们之间的顺序就丢失了。在MongoDB中如果您使用了基于散列的分区模式则任何范围查询都必须发送到所有分区【4】。Riak 【9】Couchbase 【10】或Voldemort不支持主键上的范围查询。
不幸的是,通过使用散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的现在分散在所有分区中所以它们之间的顺序就丢失了。在MongoDB中如果您使用了基于散列的分区模式则任何范围查询都必须发送到所有分区【4】。Riak 【9】Couchbase 【10】或Voldemort不支持主键上的范围查询。
Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据而其他列则被用作Casssandra的SSTables中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表但如果第一列已经指定了固定值则可以对该键的其他列执行有效的范围扫描。
组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为`(user_id, update_timestamp)`,那么您可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
### 负载倾斜与消除热点
### 负载偏斜与热点消除
如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。
这种场景也许并不常见但并非闻所未闻例如在社交媒体网站上一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致大量写入同一个键键可能是名人的用户ID或者人们正在评论的动作的ID。哈希策略不起作用因为两个相同ID的哈希值仍然是相同的。
这种场景也许并不常见但并非闻所未闻例如在社交媒体网站上一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入键可能是名人的用户ID或者人们正在评论的动作的ID。哈希策略不起作用因为两个相同ID的哈希值仍然是相同的。
如今大多数数据系统无法自动补偿这种高度偏斜的负载因此应用程序有责任减少偏斜。例如如果一个主键被认为是非常火爆的一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中。
@ -112,12 +112,12 @@
也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,您需要自己来权衡。
## 分与次级索引
## 分与次级索引
到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。
如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构]()”。辅助索引通常并不能唯一地标识记录而是一种搜索记录中出现特定值的方式查找用户123的所有操作查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。
如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构](ch3.md#其他索引结构)”。辅助索引通常并不能唯一地标识记录而是一种搜索记录中出现特定值的方式查找用户123的所有操作查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。
次级索引是关系型数据库的基础并且在文档数据库中也很普遍。许多键值存储如HBase和Volde-mort为了减少实现的复杂度而放弃了次级索引但是一些如Riak已经开始添加它们因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。
@ -127,9 +127,9 @@
假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区例如分区0中的ID 0到499分区1中的ID 500到999等
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段field**,关系数据库中这些是**列column** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`colorred`的文档ID列表中。
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段field**,关系数据库中这些是**列column** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。
[^ii]: 如果数据库仅支持键值模型则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。
[^ii]: 如果数据库仅支持键值模型则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求](ch7.md#多对象事务的需求)”。
![](img/fig6-4.png)
@ -147,15 +147,15 @@
我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
[图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中`s`到`z`的在分区1。汽车制造商的索引也与之类似分区边界在`f`和`h`之间)。
[图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中`s`到`z`的在分区1。汽车制造商的索引也与之类似分区边界在`f`和`h`之间)。
![](img/fig6-5.png)
**图6-5 基于关键词对二级索引进行分区**
我们将这种索引称为**关键词分区term-partitioned**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`颜色:红色`。**关键词(Term)** 来源于来自全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
我们将这种索引称为**关键词分区term-partitioned**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词(Term)** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据它本身分区对于范围扫描非常有用(例如对于数字,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要**分散/收集**所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
@ -163,11 +163,11 @@
在实践中,对全局二级索引的更新通常是**异步**的也就是说如果在写入之后不久读取索引刚才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB声称在正常情况下其全局次级索引会在不到一秒的时间内更新但在基础架构出现故障的情况下可能会有延迟【20】。
全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库它允许您在本地和全局索引之间进行选择【22】。我们将在[第12章](ch12.md)中涉及实现关键字二级索引的话题。
全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库它允许您在本地和全局索引之间进行选择【22】。我们将在[第12章](ch12.md)中继续关键词分区二级索引实现的话题。
## 分区再平衡
随着时间的推移,数据库会有各种变化
随着时间的推移,数据库会有各种变化
* 查询吞吐量增加所以您想要添加更多的CPU来处理负载。
* 数据集大小增加所以您想添加更多的磁盘和RAM来存储它。
@ -182,7 +182,7 @@
* 节点之间只移动必须的数据以便快速再平衡并减少网络和磁盘I/O负载。
### 平衡策略
### 平衡策略
有几种不同的分区分配方法【23】,让我们依次简要讨论一下。
@ -190,9 +190,9 @@
我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key)<b_0$则将键分配给分区0如果$b_0 hash(key) <b_1$则分配给分区1
也许你想知道为什么我们不使用 ***mod***(许多编程语言中的%运算符)。例如,`hash(key) mod 10`会返回一个介于0和9之间的数字如果我们将散列写为十进制数散列模10将是最后一个数字。如果我们有10个节点编号为0到9这似乎是将每个键分配给一个节点的简单方法。
也许你想知道为什么我们不使用 ***取模(mod***(许多编程语言中的%运算符)。例如,`hash(key) mod 10`会返回一个介于0和9之间的数字如果我们将散列写为十进制数散列模10将是最后一个数字。如果我们有10个节点编号为0到9这似乎是将每个键分配给一个节点的简单方法。
模$N$方法的问题是如果节点数量N发生变化大多数密钥将需要从一个节点移动到另一个节点。例如假设$hash(key)=123456$。如果最初有10个节点那么这个键一开始放在节点6上因为$123456\ mod\ 10 = 6$。当您增长到11个节点时密钥需要移动到节点3$123456\ mod\ 11 = 3$当您增长到12个节点时需要移动到节点0$123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。
N$mod N$方法的问题是如果节点数量N发生变化大多数密钥将需要从一个节点移动到另一个节点。例如假设$hash(key)=123456$。如果最初有10个节点那么这个键一开始放在节点6上因为$123456\ mod\ 10 = 6$。当您增长到11个节点时密钥需要移动到节点3$123456\ mod\ 11 = 3$当您增长到12个节点时需要移动到节点0$123456\ mod\ 12 = 0$)。这种频繁的举动使得重新平衡过于昂贵。
我们需要一种只移动必需数据的方法。
@ -212,21 +212,21 @@
在这种配置中,分区的数量通常在数据库第一次建立时确定,之后不会改变。虽然原则上可以分割和合并分区(请参阅下一节),但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是您可以拥有的最大节点数量,所以您需要选择足够多的分区以适应未来的增长。但是,每个分区也有管理开销,所以选择太大的数字会适得其反。
如果数据集的总大小难以预估(例如,如果它开始很小,但随着时间的推移可能会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小“恰到好处”的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
#### 动态分区
对于使用键范围分区的数据库(参阅“[按键范围分区](#按键范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。
对于使用键范围分区的数据库(参阅“[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。手动重新配置分区边界将非常繁琐。
出于这个原因按键的范围进行分区的数据库如HBase和RethinkDB会动态创建分区。当分区增长到超过配置的大小时在HBase上默认值是10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与B树顶层发生的过程类似参阅“[B树](ch2.md#B树)”)。
出于这个原因按键的范围进行分区的数据库如HBase和RethinkDB会动态创建分区。当分区增长到超过配置的大小时在HBase上默认值是10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与B树顶层发生的过程类似参阅“[B树](ch3.md#B树)”)。
每个分区分配给一个节点每个节点可以处理多个分区就像固定数量的分区一样。大型分区拆分后可以将其中的一半转移到另一个节点以平衡负载。在HBase中分区文件的传输通过HDFS底层分布式文件系统来实现【3】。
每个分区分配给一个节点每个节点可以处理多个分区就像固定数量的分区一样。大型分区拆分后可以将其中的一半转移到另一个节点以平衡负载。在HBase中分区文件的传输通过HDFS底层使用的分布式文件系统来实现【3】。
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据少量的分区就足够了所以开销很小如果有大量的数据每个分区的大小被限制在一个可配置的最大值【23】。
需要注意的是一个空的数据库从一个分区开始因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小直到达到第一个分区的分割点所有写入操作都必须由单个节点处理而其他节点则处于空闲状态。为了解决这个问题HBase和MongoDB允许在一个空的数据库上配置一组初始分区这被称为**预分割pre-splitting**。在键范围分区的情况中预分割需要提前知道键是如何进行分配的【4,26】。
动态分区不仅适用于数据的范围分区而且也适用于散列分区。从版本2.4开始MongoDB同时支持范围和哈希分区,并且都是进行动态分割分区。
动态分区不仅适用于数据的范围分区而且也适用于散列分区。从版本2.4开始MongoDB同时支持范围和散列分区,并且都支持动态分割分区。
#### 按节点比例分区
@ -234,11 +234,11 @@
Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比——换句话说每个节点具有固定数量的分区【23,27,28】。在这种情况下每个分区的大小与数据集大小成比例地增长而节点数量保持不变但是当增加节点数时分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储因此这种方法也使每个分区的大小较为稳定。
当一个新节点加入集群时它随机选择固定数量的现有分区进行拆分然后占有这些拆分分区中每个分区的一半同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割但是平均在更大数量的分区上时在Cassandra中默认情况下每个节点有256个分区新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再分配的算法来避免不公平的分割【29】。
当一个新节点加入集群时它随机选择固定数量的现有分区进行拆分然后占有这些拆分分区中每个分区的一半同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割但是平均在更大数量的分区上时在Cassandra中默认情况下每个节点有256个分区新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再平衡的算法来避免不公平的分割【29】。
随机选择分区边界要求使用基于散列的分区可以从散列函数产生的数字范围中挑选边界。实际上这种方法最符合一致性哈希的原始定义【7】参阅“[一致性哈希](#一致性哈希)”。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
### 运维:手动还是自动平衡
### 运维:手动还是自动平衡
关于再平衡有一个重要问题:自动还是手动进行?
@ -268,9 +268,9 @@
**图6-7 将请求路由到正确节点的三种不同方式。**
这是一个具有挑战性的问题,因为重要的是所有参与者都同意 - 否则请求将被发送到错误的节点,而不是正确处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见[第9章](ch9.md))。
这是一个具有挑战性的问题,因为重要的是所有参与者都同意 - 否则请求将被发送到错误的节点,得不到正确的处理。 在分布式系统中有达成共识的协议,但很难正确地实现(见[第9章](ch9.md))。
许多分布式数据系统都依赖于一个独立的协调服务比如ZooKeeper来跟踪集群元数据如[图6-8](img/fig6-8.png)所示。 每个节点在ZooKeeper中注册自己ZooKeeper维护分区到节点的可靠映射。 其他参与者如路由层或分区感知客户端可以在ZooKeeper中订阅此信息。 只要分区分配发生改变或者集群中添加或删除了一个节点ZooKeeper就会通知路由层使路由信息保持最新状态。
许多分布式数据系统都依赖于一个独立的协调服务比如ZooKeeper来跟踪集群元数据如[图6-8](img/fig6-8.png)所示。 每个节点在ZooKeeper中注册自己ZooKeeper维护分区到节点的可靠映射。 其他参与者如路由层或分区感知客户端可以在ZooKeeper中订阅此信息。 只要分区分配发生改变或者集群中添加或删除了一个节点ZooKeeper就会通知路由层使路由信息保持最新状态。
![](img/fig6-8.png)
@ -278,7 +278,7 @@
例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](img/fig6-8.png)所示的路由层。 HBaseSolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。 MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。
Cassandra和Riak采取不同的方法他们在节点之间使用**流言协议gossip protocol** 来传播群集状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图6-7]()中的方法1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像ZooKeeper这样的外部协调服务的依赖。
Cassandra和Riak采取不同的方法他们在节点之间使用**流言协议gossip protocol** 来传播群集状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图6-7](img/fig6-7.png)中的方法1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像ZooKeeper这样的外部协调服务的依赖。
Couchbase不会自动重新平衡这简化了设计。通常情况下它配置了一个名为moxi的路由层它会从集群节点了解路由变化【32】。
@ -286,21 +286,21 @@
### 执行并行查询
到目前为止,我们只关注读取或写入单个键的非常简单的查询(对于文档分区的二级索引,另外还有分散/聚集查询)。这与大多数NoSQL分布式数据存储所支持的访问级别有关
到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的二级索引场景下的分散/聚集查询)。这也是大多数NoSQL分布式数据存储所支持的访问级。
然而,通常用于分析的**大规模并行处理MPP, Massively parallel processing** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP查询优化器将这个复杂的查询分解成许多执行阶段和分区其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
数据仓库查询的快速并行执行是一个专门的话题由于分析有很重要的商业意义可以带来很多利益。我们将在第10章讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在[第10章](ch10.md)讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
## 本章小结
在本章中,我们探讨了将大数据集划分成更小的子集的不同方法。数据量非常大的时候,在单台机器上存储和处理不再可行,则分区十分必要。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于您的数据的分区方案,并在将节点添加到集群或从集群删除时进行分区。
在本章中,我们探讨了将大数据集划分成更小的子集的不同方法。数据量非常大的时候,在单台机器上存储和处理不再可行,而分区则十分必要。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于您的数据的分区方案,并在将节点添加到集群或从集群删除时进行分区再平衡
我们讨论了两种主要的分区方法:
***键范围分区***
其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。
其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。
在这种方法中,当分区变得太大时,通常将分区分成两个子分区,动态地再平衡分区。

221
ch7.md
View File

@ -29,7 +29,7 @@
怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。尽管乍看事务似乎很简单,但实际上有许多微妙但重要的细节在起作用。
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交****快照隔离**和**可串行化**等隔离级别。
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交read committed****快照隔离snapshot isolation**和**可串行化serializability**等隔离级别。
本章同时适用于单机数据库与分布式数据库;在[第8章](ch8.md)中将重点讨论仅出现在分布式系统中的特殊挑战。
@ -39,15 +39,15 @@
现今,几乎所有的关系型数据库和一些非关系数据库都支持**事务**。其中大多数遵循IBM System R第一个SQL数据库在1975年引入的风格【1,2,3】。40年里尽管一些实现细节发生了变化但总体思路大同小异MySQLPostgreSQLOracleSQL Server等数据库中的事务支持与System R异乎寻常地相似。
2000年以后非关系NoSQL数据库开始普及。它们的目标是通过提供新的数据模型选择参见第2章通过默认包含复制第5章和分区第6章改善关系现状。事务是这种运动的主要原因:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前理解所更弱的一套保证【4】。
2000年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上,通过提供新的数据模型选择(参见[第2章](ch2.md)并默认包含复制第5章和分区第6章进一步提升。事务是这次运动的主要牺牲品:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前所理解的更弱得多的一套保证【4】。
随着这种新型分布式数据库的炒作人们普遍认为事务是可伸缩性的对立面任何大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是**纯粹的夸张**。
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)情况下。
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)情况下。
### ACID的含义
事务所提供的安全保证通常由众所周知的首字母缩略词ACID来描述ACID代表**原子性Atomicity****一致性Consistency****隔离性Isolation**和**持久性Durability**。它由TheoHärder和Andreas Reuter于1983年创建,旨在为数据库中的容错机制建立精确的术语。
事务所提供的安全保证通常由众所周知的首字母缩略词ACID来描述ACID代表**原子性Atomicity****一致性Consistency****隔离性Isolation**和**持久性Durability**。它由TheoHärder和Andreas Reuter于1983年提出,旨在为数据库中的容错机制建立精确的术语。
但实际上不同数据库的ACID实现并不相同。例如我们将会看到关于**隔离性Isolation** 的含义就有许多含糊不清【8】。高层次上的想法很美好但魔鬼隐藏在细节里。今天当一个系统声称自己“符合ACID”时实际上能期待的是什么保证并不清楚。不幸的是ACID现在几乎已经变成了一个营销术语。
@ -61,7 +61,7 @@
相比之下ACID的原子性并**不**是关于 **并发concurrent** 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写 ***I*** 中,即[**隔离性Isolation**](#隔离性Isolation)
ACID的原子性而是描述了当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
ACID的原子性描述了当客户想进行多次写入但在一些写操作处理完之后出现故障的情况。例如进程崩溃网络连接中断磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中并且该事务由于错误而不能完成提交则该事务将被中止并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被**中止abort**,应用程序可以确定它没有改变任何东西,所以可以安全地重试。
@ -72,15 +72,15 @@ ACID原子性的定义特征是**能够在错误时中止事务,丢弃该
一致性这个词被赋予太多含义:
* 在[第5章](ch5.md)中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题(参阅“[复制延迟问题](ch5.md#复制延迟问题)”)。
* [一致性散列Consistency Hash](ch6.md#一致性散列))是某些系统用于重新分区的一种分区方法。
* 在[CAP定理](ch9.md#CAP定理)中,一致性一词用于表示[可线性化](ch9.md#线性化)。
* [一致性哈希Consistency Hashing](ch6.md#一致性哈希))是某些系统用于重新分区的一种分区方法。
* 在[CAP定理](ch9.md#CAP定理)中,一致性一词用于表示[线性一致性](ch9.md#线性一致性)。
* 在ACID的上下文中**一致性**是指数据库在应用程序的特定概念中处于“良好状态”。
很不幸,这一个词就至少有四种不同的含义。
ACID一致性的概念是**对数据的一组特定约束必须始终成立**。即**不变量invariants**。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变量总是满足的。
但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
但是,一致性的这种概念取决于应用程序对不变量的理解,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
原子性隔离性和持久性是数据库的属性而一致性在ACID意义上是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性但这并不仅取决于数据库。因此字母C不属于ACID[^i]。
@ -90,15 +90,15 @@ 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】。
ACID意义上的隔离性意味着**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可串行Serializability**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当多个事务被提交时,结果与它们串行运行一个接一个是一样的尽管实际上它们可能是并发运行的【10】。
![](img/fig7-1.png)
**图7-1 两个客户之间的竞争状态同时递增计数器**
然而实践中很少会使用可序列化隔离因为它有性能损失。一些流行的数据库如Oracle 11g甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做**快照隔离snapshot isolation** 的功能,**这是一种比可序列化更弱的保证**【8,11】。我们将在“[弱隔离级别](#弱隔离级别)”中研究快照隔离和其他形式的隔离。
然而实践中很少会使用可串行的隔离因为它有性能损失。一些流行的数据库如Oracle 11g甚至没有实现它。在Oracle中有一个名为“可串行的”隔离级别,但实际上它实现了一种叫做**快照隔离snapshot isolation** 的功能,**这是一种比可串行化更弱的保证**【8,11】。我们将在“[弱隔离级别](#弱隔离级别)”中研究快照隔离和其他形式的隔离。
#### 持久性Durability
@ -110,7 +110,7 @@ ACID意义上的隔离性意味着**同时执行的事务是相互隔离的**
> #### 复制和持久性
>
> 在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或SSD。最近它已经适应了“复制replication”的新内涵。哪种实现更好一些?
> 在历史上,持久性意味着写入归档磁带。后来它被理解为写入磁盘或SSD。再后来它又有了新的内涵即“复制replication。哪种实现更好一些?
>
> 真相是,没有什么是完美的:
>
@ -123,7 +123,7 @@ ACID意义上的隔离性意味着**同时执行的事务是相互隔离的**
> * 一项关于固态硬盘的研究发现在运行的前四年中30到80的硬盘会产生至少一个坏块【18】。相比固态硬盘磁盘的坏道率较低但完全失效的概率更高。
> * 如果SSD断电可能会在几周内开始丢失数据具体取决于温度【19】。
>
> 在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”
> 在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”
### 单对象和多对象操作
@ -163,23 +163,23 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
[^iii]: 这并不完美。如果TCP连接中断则事务必须中止。如果中断发生在客户端请求提交之后但在服务器确认提交发生之前客户端并不知道事务是否已提交。为了解决这个问题事务管理器可以通过一个唯一事务标识符来对操作进行分组这个标识符并未绑定到特定TCP连接。后续再“[数据库端到端的争论](ch12.md#数据库端到端的争论)”一节将回到这个主题。
另一方面许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API例如键值存储可能具有在一个操作中更新几个键的数个put操作但这并不一定意味着它具有事务语义该命令可能在一些键上成功在其他的键上失败使数据库处于部分更新的状态。
另一方面许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API例如键值存储可能具有在一个操作中更新几个键的multi-put操作但这并不一定意味着它具有事务语义该命令可能在一些键上成功在其他的键上失败使数据库处于部分更新的状态。
#### 单对象写入
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档
- 如果在发送第一个10 KB之后网络连接中断数据库是否存储了不可解析的10KB JSON片段
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象)
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[让B树更可靠](ch3.md#让B树更可靠)”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置CAS, compare-and-set](#比较并设置CAS)** 操作,当值没有被其他并发修改过时,才允许执行写操作。
一些数据库也提供更复杂的原子操作[^iv],例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置CAS, compare-and-set](#比较并设置CAS)** 操作,当值没有被其他并发修改过时,才允许执行写操作。
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”甚至出于营销目的被称为“ACID”【20,21,22】但是这个术语是误导性的。事务通常被理解为**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
[^iv]: 严格地说,**原子自增atomic increment** 这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下它实际上应该被称为 **隔离的isolated** 的或**可串行的serializable** 的增量。 但这就太吹毛求疵了。
[^iv]: 严格地说,**原子自增atomic increment** 这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下它实际上应该被称为 **孤立isolated** 的或**可序列化serializable** 的增量。 但这就太吹毛求疵了
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”甚至出于营销目的被称为“ACID”【20,21,22】但是这个术语是误导性的。事务通常被理解为**将多个对象上的多个操作合并为一个执行单元的机制**
#### 多对象事务的需求
@ -189,17 +189,17 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
* 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章](ch12.md)中探讨其他方法。
#### 处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID数据库基于这样的哲学如果数据库有违反其原子性隔离性或持久性的危险则宁愿完全放弃事务而不是留下半成品。
然而并不是所有的系统都遵循这个哲学。特别是具有[无主复制](ch6.md#无主复制)的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。
然而并不是所有的系统都遵循这个哲学。特别是具有[无主复制](ch5.md#无主复制)的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。
错误发生不可避免但许多软件开发人员倾向于只考虑乐观情况而不是错误处理的复杂性。例如像Rails的ActiveRecord和Django这样的**对象关系映射ORM, object-relation Mapping** 框架不会重试中断的事务—— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。
@ -209,11 +209,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**二阶段提交2PC, two-phase commit** 可以提供帮助(“[原子提交和两阶段提交2PC](ch9.md#原子提交与二阶段提交2PC)”中将讨论这个问题)。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
## 弱隔离级别
@ -223,15 +219,15 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
[^译注i]: 轶事:偶然出现的瞬时错误有时称为***Heisenbug***,而确定性的问题对应地称为***Bohrbugs***
出于这个原因,数据库一直试图通过提供**事务隔离transaction isolation** 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可序列化serializable** 的隔离等级意味着数据库保证事务的效果如同连续运行(即一次一个,没有任何并发)。
出于这个原因,数据库一直试图通过提供**事务隔离transaction isolation** 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可串行的serializable** 隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。
实际上不幸的是:隔离并没有那么简单。**可序列化** 会有性能损失许多数据库不愿意支付这个代价【8】。因此系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。这些隔离级别难以理解并且会导致微妙的错误但是它们仍然在实践中被使用【23】。
实际上不幸的是:隔离并没有那么简单。**可串行的隔离**会有性能损失许多数据库不愿意支付这个代价【8】。因此系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。这些隔离级别难以理解并且会导致微妙的错误但是它们仍然在实践中被使用【23】。
弱事务隔离级别导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】耗费了财务审计人员的调查【26】并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据请使用ACID数据库” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统通常被认为是“ACID”也使用弱隔离级别所以它们也不一定能防止这些错误的发生。
比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
在本节中,我们将看几个在实践中使用的弱(**不可串行化nonserializable**)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行性(请参阅“[可序列化](#可序列化)”)。我们讨论的隔离级别将是非正式的,使用示例。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
在本节中,我们将看几个在实践中使用的弱(**非串行的nonserializable**)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行化(请参阅“[可串行化](#可串行化)”)。我们讨论的隔离级别将是非正式的,通过示例来进行。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
### 读已提交
@ -248,7 +244,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](img/fig7-4.png)所示用户1 设置了`x = 3`但用户2 的 `get x `仍旧返回旧值2 当用户1 尚未提交时)
![](img/fig7-4.png)
@ -267,8 +263,8 @@ 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-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png)以一个二手车销售网站为例Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入网站上的商品列表需要更新以反映买家的购买销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下销售是属于Bob的因为他成功更新了商品列表但发票却寄送给了爱丽丝因为她成功更新了发票表。读已提交会阻止这样的事故。
- 但是,读已提交并不能防止[图7-1](img/fig7-1.png)中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
![](img/fig7-5.png)
@ -280,11 +276,11 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
最常见的情况是,数据库通过使用**行锁row-level lock** 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保在读取进行时,对象不会在脏的、有未提交的值的状态(因为在那段时间锁会被写入该对象的事务持有)。
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
出于这个原因,大多数数据库[^vi]使用[图7-4]()的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
出于这个原因,大多数数据库[^vi]使用[图7-4](img/fig7-4.png)的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
[^vi]: 在撰写本文时,唯一在读已提交隔离级别使用读锁的主流数据库是使用`read_committed_snapshot = off`配置的IBM DB2和Microsoft SQL Server [23,36]。
@ -298,11 +294,11 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
**图7-6 读取偏差Alice观察数据库处于不一致的状态**
爱丽丝在银行有1000美元的储蓄分为两个账户每个500美元。现在一笔事务从她的一个账户转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表不幸地在转账事务完成前看到收款账户余额余额为500美元而在转账完成后看到另一个转出账户已经转出100美元余额400美元。对爱丽丝来说现在她的账户似乎只有900美元——看起来100美元已经消失了。
爱丽丝在银行有1000美元的储蓄分为两个账户每个500美元。现在一笔事务从她的一个账户转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表她可能会碰巧在收款到达前看到收款账户的余额仍然是500美元而在付款产生后看到付款账户的余额已经是400美元。对爱丽丝来说现在她的账户似乎总共只有900美元——看起来有100美元已经凭空消失了。
这种异常被称为**不可重复读nonrepeatable read**或**读取偏差read skew**如果Alice在事务结束时再次读取账户1的余额她将看到与她之前的查询中看到的不同的值600美元。在读已提交的隔离条件下**不可重复读**被认为是可接受的Alice看到的帐户余额时确实在阅读时已经提交了。
> 不幸的是,术语**偏差skew** 这个词是过载的:以前使用它是因为热点的不平衡工作量(参阅“[偏斜的负载倾斜与消除热点](ch6.md#负载倾斜与消除热点)”),而这里偏差意味着异常的时机。
> 不幸的是,术语**偏差skew** 这个词是过载的:以前使用它是因为热点的不平衡工作量(参阅“[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”),而这里偏差意味着异常的时机。
对于Alice的情况这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面她很可能会看到一致的帐户余额。但是有些情况下不能容忍这种暂时的不一致
@ -312,7 +308,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
***分析查询和完整性检查***
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(参阅“[事务处理分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
**快照隔离snapshot isolation**【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的**一致快照consistent snapshot** 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
@ -324,11 +320,11 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅“[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
为了实现快照隔离,数据库使用了我们看到的用于防止[图7-4]()中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为**多版本并发控制MVCC, multi-version concurrency control**。
为了实现快照隔离,数据库使用了我们看到的用于防止[图7-4](img/fig7-4.png)中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为**多版本并发控制MVCC, multi-version concurrency control**。
如果一个数据库只需要提供**读已提交**的隔离级别,而不提供**快照隔离**那么保留一个对象的两个版本就足够了提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现**读已提交**隔离级别。一种典型的方法是**读已提交**为每个查询使用单独的快照,而**快照隔离**对整个事务使用相同的快照。
[图7-7]()说明了如何在PostgreSQL中实现基于MVCC的快照隔离【31】其他实现类似。当一个事务开始时它被赋予一个唯一的永远增长[^vii]的事务ID`txid`。每当事务向数据库写入任何内容时它所写入的数据都会被标记上写入者的事务ID。
[图7-7](img/fig7-7.png)说明了如何在PostgreSQL中实现基于MVCC的快照隔离【31】其他实现类似。当一个事务开始时它被赋予一个唯一的永远增长[^vii]的事务ID`txid`。每当事务向数据库写入任何内容时它所写入的数据都会被标记上写入者的事务ID。
[^vii]: 事实上事务ID是32位整数所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID确保事务ID溢出回卷不会影响到数据。
@ -340,7 +336,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
[^译注ii]: 在PostgreSQL中`created_by` 的实际名称为`xmin``deleted_by` 的实际名称为`xmax`
`UPDATE` 操作在内部翻译为 `DELETE``INSERT` 。例如,在[图7-7]()中事务13 从账户2 中扣除100美元将余额从500美元改为400美元。实际上包含两条账户2 的记录:余额为 \$500 的行被标记为**被事务13删除**,余额为 \$400 的行**由事务13创建**。
`UPDATE` 操作在内部翻译为 `DELETE``INSERT` 。例如,在[图7-7](img/fig7-7.png)中事务13 从账户2 中扣除100美元将余额从500美元改为400美元。实际上包含两条账户2 的记录:余额为 \$500 的行被标记为**被事务13删除**,余额为 \$400 的行**由事务13创建**。
#### 观察一致性快照的可见性规则
@ -351,7 +347,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
3. 由具有较晚事务ID在当前事务开始之后开始的的事务所做的任何写入都被忽略而不管这些事务是否已经提交。
4. 所有其他写入,对应用都是可见的。
这些规则适用于创建和删除对象。在[图7-7]()中当事务12 从账户2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务13 完成的根据规则3事务12 看不到事务13 执行的删除且400美元记录的创建也是不可见的按照相同的规则
这些规则适用于创建和删除对象。在[图7-7](img/fig7-7.png)中当事务12 从账户2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务13 完成的根据规则3事务12 看不到事务13 执行的删除且400美元记录的创建也是不可见的按照相同的规则
换句话说,如果以下两个条件都成立,则可见一个对象:
@ -366,13 +362,13 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
在实践中许多实现细节决定了多版本并发控制的性能。例如如果同一对象的不同版本可以放入同一个页面中PostgreSQL的优化可以避免更新索引【31】。
在CouchDBDatomic和LMDB中使用另一种方法。虽然它们也使用[B树](ch2.md#B树),但它们使用的是一种**仅追加/写时拷贝append-only/copy-on-write** 的变体它们在更新时不覆盖树的页面而为每个修改页面创建一份副本。从父页面直到树根都会级联更新以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制并且保持不变【33,34,35】。
在CouchDBDatomic和LMDB中使用另一种方法。虽然它们也使用[B树](ch3.md#B树),但它们使用的是一种**仅追加/写时拷贝append-only/copy-on-write** 的变体它们在更新时不覆盖树的页面而为每个修改页面创建一份副本。从父页面直到树根都会级联更新以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制并且保持不变【33,34,35】。
使用仅追加的B树每个写入事务或一批事务都会创建一颗新的B树当创建时从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象因为后续写入不能修改现有的B树它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
#### 可重复读与命名混淆
快照隔离是一个有用的隔离级别特别对于只读事务而言。但是许多数据库实现了它却用不同的名字来称呼。在Oracle中称为**可序列Serializable**的在PostgreSQL和MySQL中称为**可重复读repeatable read**【23】。
快照隔离是一个有用的隔离级别特别对于只读事务而言。但是许多数据库实现了它却用不同的名字来称呼。在Oracle中称为**可串行Serializable**的在PostgreSQL和MySQL中称为**可重复读repeatable read**【23】。
这种命名混淆的原因是SQL标准没有**快照隔离**的概念因为标准是基于System R 1975年定义的隔离级别【2】那时候**快照隔离**尚未发明。相反,它定义了**可重复读**,表面上看起来与快照隔离很相似。 PostgreSQL和MySQL称其**快照隔离**级别为**可重复读repeatable read**,因为这样符合标准要求,所以它们可以声称自己“标准兼容”。
@ -382,9 +378,9 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
### 防止丢失更新
到目前为止已经讨论的**读已提交**和**快照隔离**级别,主要保证了**只读事务在并发写入时**可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了[脏写](#脏写),一种特定类型的写-写冲突是可能出现的。
到目前为止已经讨论的**读已提交**和**快照隔离**级别,主要保证了**只读事务在并发写入时**可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了脏写(请参阅“[没有脏写](#没有脏写)”),一种特定类型的写-写冲突是可能出现的。
并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是**丢失更新lost update** 问题,如[图7-1]()所示,以两个并发计数器增量为例。
并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是**丢失更新lost update** 问题,如[图7-1](img/fig7-1.png)所示,以两个并发计数器增量为例。
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入**狠揍clobber** 了前面的写入)这种模式发生在各种不同的情况下:
@ -404,7 +400,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
类似地像MongoDB这样的文档数据库提供了对JSON文档的一部分进行本地修改的原子操作Redis提供了修改数据结构如优先级队列的原子操作。并不是所有的写操作都可以用原子操作的方式来表达例如维基页面的更新涉及到任意文本编辑[^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。参阅“[自动冲突解决](ch5.md#题外话:自动冲突解决)”。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。参阅“[自动冲突解决](ch5.md#自动冲突解决)”。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为**游标稳定性cursor stability**【36,37】。另一个选择是简单地强制所有的原子操作在单一线程上执行。
@ -443,12 +439,12 @@ COMMIT;
#### 比较并设置CAS
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置CAS, Compare And Set**(先前在“[单对象写入]()”中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置CAS, Compare And Set**(先前在“[单对象写入](#单对象写入)”中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
例如为了防止两个用户同时更新同一个wiki页面可以尝试类似这样的方式只有当用户开始编辑页面内容时才会发生更新
```sql
-- 根据数据库的实现情况,这可能也可能不安全
-- 根据数据库的实现情况,这可能安全也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';
```
@ -459,7 +455,7 @@ UPDATE wiki_pages SET content = '新内容'
在复制数据库中(参见[第5章](ch5.md)),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
锁和CAS操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行并异步复制到副本上因此无法保证只有一个最新数据的副本。所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“[线性化](ch9.md#线性化)”中更详细地讨论这个问题。)
锁和CAS操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行并异步复制到副本上因此无法保证只有一个最新数据的副本。所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“[线性一致性](ch9.md#线性一致性)”中更详细地讨论这个问题。)
相反,如“[检测并发写入](ch5.md#检测并发写入)”一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
@ -467,7 +463,7 @@ UPDATE wiki_pages SET content = '新内容'
另一方面最后写入胜利LWW的冲突解决方法很容易丢失更新如“[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))”中所述。不幸的是LWW是许多复制数据库中的默认方案。
#### 写入偏差与幻读
### 写入偏差与幻读
前面的章节中,我们看到了**脏写**和**丢失更新**,当不同的事务并发地尝试写入相同的对象时,会出现这两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止——既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。
@ -489,12 +485,12 @@ UPDATE wiki_pages SET content = '新内容'
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。
我们看到,有各种不同的方法来防止丢失的更新。随着写偏差,我们的选择更受限制:
我们已经看到,有各种不同的方法来防止丢失的更新。但对于写偏差,我们的选择更受限制:
* 由于涉及多个对象,单对象的原子操作不起作用。
* 不幸的是在一些快照隔离的实现中自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读MySQL/InnoDB的可重复读Oracle可序列化或SQL Server的快照隔离级别中都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可序列化隔离(请参见“[可序列化](#可序列化)”)。
* 不幸的是在一些快照隔离的实现中自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读MySQL/InnoDB的可重复读Oracle可串行化或SQL Server的快照隔离级别中都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可串行化隔离(请参见“[可串行化](#可串行化)”)。
* 某些数据库允许配置约束然后由数据库强制执行例如唯一性外键约束或特定值限制。但是为了指定至少有一名医生必须在线需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持但是你可以使用触发器或者物化视图来实现它们这取决于不同的数据库【42】。
* 如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
* 如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
```sql
BEGIN TRANSACTION;
@ -510,7 +506,7 @@ UPDATE doctors
COMMIT;
```
* 和以前一样,`FOR UPDATE`告诉数据库锁定返回的所有行用于更新。
* 和以前一样,`FOR UPDATE`告诉数据库锁定返回的所有行用于更新。
#### 写偏差的更多例子
@ -518,7 +514,7 @@ COMMIT;
***会议室预订系统***
假设你想强制执行,同一时间不能同时在两个会议室预订【43】。当有人想要预订时首先检查是否存在相互冲突的预订即预订时间范围重叠的同一房间如果没有找到则创建会议请参见示例7-2[^ix]。
比如你想要规定不能在同一时间对同一个会议室进行多次的预订【43】。当有人想要预订时首先检查是否存在相互冲突的预订即预订时间范围重叠的同一房间如果没有找到则创建会议请参见示例7-2[^ix]。
[^ix]: 在PostgreSQL中您可以使用范围类型优雅地执行此操作但在其他数据库中并未得到广泛支持。
@ -539,11 +535,11 @@ INSERT INTO bookings(room_id, start_time, end_time, user_id)
COMMIT;
```
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可序列化的隔离级别了。
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
***多人游戏***
在[例7-1]()中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束,否则您很容易发生写入偏差。
在[例7-1]()中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束unique constraint,否则您很容易发生写入偏差。
***抢注用户名***
@ -563,7 +559,7 @@ COMMIT;
3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
这个写入的效果改变了步骤2 中的先决条件。换句话说如果在提交写入后重复执行一次步骤1 的SELECT查询将会得到不同的结果。因为写入改变符合搜索条件的行集现在少了一个医生值班那时候的会议室现在已经被预订了棋盘上的这个位置已经被占据了用户名已经被抢注账户余额不够了
这个写入的效果改变了步骤2 中的先决条件。换句话说如果在提交写入后重复执行一次步骤1 的SELECT查询将会得到不同的结果。因为写入改变符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
这些步骤可能以不同的顺序发生。例如可以首先进行写入然后进行SELECT查询最后根据查询结果决定是放弃还是提交。
@ -579,11 +575,10 @@ COMMIT;
现在,要创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息——它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
这种方法被称为**物化冲突materializing conflicts**因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是弄清楚如何物化冲突可能很难也很容易出错而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因如果没有其他办法可以实现物化冲突应被视为最后的手段。在大多数情况下。**可序列Serializable** 的隔离级别是更可取的。
这种方法被称为**物化冲突materializing conflicts**因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是弄清楚如何物化冲突可能很难也很容易出错而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因如果没有其他办法可以实现物化冲突应被视为最后的手段。在大多数情况下。**可串行Serializable** 的隔离级别是更可取的。
## 可序列化
## 可串行化
在本章中,已经看到了几个易于出现竞争条件的事务例子。**读已提交**和**快照隔离**级别会阻止某些竞争条件,但不会阻止另一些。我们遇到了一些特别棘手的例子,**写入偏差**和**幻读**。这是一个可悲的情况:
@ -591,30 +586,30 @@ COMMIT;
- 光检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,您可能并不知道并发发生的所有事情。
- 没有检测竞争条件的好工具。原则上来说静态分析可能会有帮助【26】但研究中的技术还没法实际应用。并发问题的测试是很难的因为它们通常是非确定性的 —— 只有在倒霉的时机下才会出现问题。
这不是一个新问题从20世纪70年代以来就一直是这样了当时首先引入了较弱的隔离级别【2】。一直以来研究人员的答案都很简单使用**可序列serializable** 的隔离级别!
这不是一个新问题从20世纪70年代以来就一直是这样了当时首先引入了较弱的隔离级别【2】。一直以来研究人员的答案都很简单使用**可串行serializable** 的隔离级别!
**可序列Serializability**隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止**所有**可能的竞争条件。
**可串行Serializability**隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止**所有**可能的竞争条件。
但如果可序列化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可序列化的选项,以及它们如何执行。目前大多数提供可序列化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。
但如果可串行化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可串行化的选项,以及它们如何执行。目前大多数提供可串行化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。
- 字面意义上地串行顺序执行事务(参见“[真的串行执行](#真的串行执行)”)
- **两相锁定2PL, two-phase locking**,几十年来唯一可行的选择。(参见“[两相锁定2PL](#两阶段锁定2PL)”)
- 乐观并发控制技术,例如**可序列化的快照隔离serializable snapshot isolation**(参阅“[可序列化的快照隔离SSI](#序列化快照隔离SSI)”
- **两阶段锁定2PL, two-phase locking**,几十年来唯一可行的选择。(参见“[两阶段锁定2PL](#两阶段锁定2PL)”)
- 乐观并发控制技术,例如**可串行化快照隔离serializable snapshot isolation**(参阅“[可串行化快照隔离SSI](#可串行化快照隔离SSI)”
现在将主要在单节点数据库的背景下讨论这些技术;在[第9章](ch9.md)中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
#### 真的串行执行
### 真的串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可串行化的定义。
尽管这似乎是一个明显的主意但数据库设计人员只是在2007年左右才决定单线程循环执行事务是可行的【45】。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在那么究竟是什么改变致使单线程执行变为可能呢
两个进展引发了这个反思:
- RAM足够便宜了许多场景现在都可以将完整的活跃数据集保存在内存中。参阅“[在内存中存储一切](ch3.md#在内存中存储一切)”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计人员意识到OLTP事务通常很短而且只进行少量的读写操作参阅“[事务处理分析?](ch3.md#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
- 数据库设计人员意识到OLTP事务通常很短而且只进行少量的读写操作参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
串行执行事务的方法在VoltDB/H-StoreRedis和Datomic中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统更好因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程需要与传统形式不同的结构的事务
串行执行事务的方法在VoltDB/H-StoreRedis和Datomic中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统更好因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程需要与传统形式的事务不同的结构。
#### 在存储过程中封装事务
@ -622,7 +617,7 @@ COMMIT;
不幸的是人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入则数据库需要支持潜在的大量并发事务其中大部分是空闲的。大多数数据库不能高效完成这项工作因此几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入以此来保持事务的简短。在Web上这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
即使人类已经找到了关键路径,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
即使已经将人类从关键路径中排除,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
@ -637,7 +632,7 @@ COMMIT;
存储过程在关系型数据库中已经存在了一段时间了自1999年以来它们一直是SQL标准SQL/PSM的一部分。出于各种原因它们的名声有点不太好
- 每个数据库厂商都有自己的存储过程语言Oracle有PL/SQLSQL Server有T-SQLPostgreSQL有PL/pgSQL等。这些语言并没有跟上通用编程语言的发展所以从今天的角度来看它们看起来相当丑陋和陈旧而且缺乏大多数编程语言中能找到的库的生态系统。
- 与应用服务器相比在数据库中运行的管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
- 与应用服务器相比在数据库中运行的代码管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
- 数据库通常比应用服务器对性能敏感的多因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程例如占用大量内存或CPU时间会比在应用服务器中相同的代码造成更多的麻烦。
但是这些问题都是可以克服的。现代的存储过程实现放弃了PL/SQL而是使用现有的通用编程语言VoltDB使用Java或GroovyDatomic使用Java或Clojure而Redis使用Lua。
@ -656,41 +651,41 @@ VoltDB还使用存储过程进行复制但不是将事务的写入结果从
由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。 VoltDB报告的吞吐量大约是每秒1000个跨分区写入比单分区吞吐量低几个数量级并且不能通过增加更多的机器来增加【49】。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调(参阅“[分片与次级索引](ch6.md#分片与次级索引)”)。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调(参阅“[分区与次级索引](ch6.md#分区与次级索引)”)。
#### 串行执行小结
在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。
在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。
- 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢[^x]。
- 写入吞吐量必须低到能在单个CPU核上处理如若不然事务需要能划分至单个分区且不需要跨分区协调。
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
- 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为**反缓存anti-caching**,正如前面在第88页“将所有内容保存在内存”中所述。
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为**反缓存anti-caching**,正如前面在“[在内存中存储一切](ch3.md#在内存中存储一切)”中所述。
### 两阶段锁定2PL
大约30年来在数据库中只有一种广泛使用的序列化算法:**两阶段锁定2PLtwo-phase locking** [^xi]
大约30年来在数据库中只有一种广泛使用的串行化算法:**两阶段锁定2PLtwo-phase locking** [^xi]
[^xi]: 有时也称为**严格两阶段锁定SS2PL, strict two-phase locking**以便和其他2PL变体区分。
[^xi]: 有时也称为**严格两阶段锁定SS2PL, strong strict two-phase locking**以便和其他2PL变体区分。
> #### 2PL不是2PC
>
> 请注意虽然两阶段锁定2PL听起来非常类似于两阶段提交2PC但它们是完全不同的东西。我们将在第9章讨论2PC。
> 请注意虽然两阶段锁定2PL听起来非常类似于两阶段提交2PC但它们是完全不同的东西。我们将在[第9章](ch9.md)讨论2PC。
之前我们看到锁通常用于防止脏写(参阅“[没有脏写](没有脏写)”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
之前我们看到锁通常用于防止脏写(参阅“[没有脏写](#没有脏写)”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定定类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要**独占访问exclusive access** 权限:
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要**独占访问exclusive access** 权限:
- 如果事务A读取了一个对象并且事务B想要写入该对象那么B必须等到A提交或中止才能继续。 这确保B不能在A底下意外地改变对象。
- 如果事务A写入了一个对象并且事务B想要读取该对象则B必须等到A提交或中止才能继续。 (像[图7-1]()那样读取旧版本的对象在2PL下是不可接受的。
- 如果事务A写入了一个对象并且事务B想要读取该对象则B必须等到A提交或中止才能继续。 (像[图7-1](img/fig7-1.png)那样读取旧版本的对象在2PL下是不可接受的。
在2PL中写入不仅会阻塞其他写入也会阻塞读反之亦然。快照隔离使得**读不阻塞写,写也不阻塞读**(参阅“[实现快照隔离](#实现快照隔离)”这是2PL和快照隔离之间的关键区别。另一方面因为2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
在2PL中写入不仅会阻塞其他写入也会阻塞读反之亦然。快照隔离使得**读不阻塞写,写也不阻塞读**(参阅“[实现快照隔离](#实现快照隔离)”这是2PL和快照隔离之间的关键区别。另一方面因为2PL提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
#### 实现两阶段锁
2PL用于MySQLInnoDB和SQL Server中的可序列化隔离级别以及DB2中的可重复读隔离级别【23,36】。
2PL用于MySQLInnoDB和SQL Server中的可串行化隔离级别以及DB2中的可重复读隔离级别【23,36】。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于**共享模式shared mode**或**独占模式exclusive mode**。锁使用如下:
@ -711,11 +706,11 @@ VoltDB还使用存储过程进行复制但不是将事务的写入结果从
因此运行2PL的数据库可能具有相当不稳定的延迟如果在工作负载中存在争用那么可能高百分位点处的响应会非常的慢参阅“[描述性能](ch1.md#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁但在基于2PL实现的可序列化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
基于锁实现的读已提交隔离级别可能发生死锁但在基于2PL实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
#### 谓词锁
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读phantoms**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止**幻读**。
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读phantoms**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防止**幻读**。
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见[例7-2]()),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
@ -753,14 +748,13 @@ WHERE room_id = 123 AND
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
### 可串行化快照隔离SSI
### 序列化快照隔离SSI
本章描绘了数据库中并发控制的黯淡画面。一方面我们实现了性能不好2PL或者伸缩性不好串行执行的可串行化隔离级别。另一方面我们有性能良好的弱隔离级别但容易出现各种竞争条件丢失更新写入偏差幻读等。串行化的隔离级别和高性能是从根本上相互矛盾的吗
本章描绘了数据库中并发控制的黯淡画面。一方面我们实现了性能不好2PL或者伸缩性不好串行执行的可序列化隔离级别。另一方面我们有性能良好的弱隔离级别但容易出现各种竞争条件丢失更新写入偏差幻读等。序列化的隔离级别和高性能是从根本上相互矛盾的吗
也许不是:一个称为**可串行化快照隔离SSI, serializable snapshot isolation** 的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI是相当新的它在2008年首次被描述【40】并且是Michael Cahill的博士论文【51】的主题。
也许不是:一个称为**可序列化快照隔离SSI, serializable snapshot isolation** 的算法是非常有前途的。它提供了完整的可序列化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI是相当新的它在2008年首次被描述【40】并且是Michael Cahill的博士论文【51】的主题。
今天SSI既用于单节点数据库PostgreSQL9.1 以后的可序列化隔离级别和分布式数据库FoundationDB使用类似的算法。由于SSI与其他并发控制机制相比还很年轻还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
今天SSI既用于单节点数据库PostgreSQL9.1 以后的可串行化隔离级别和分布式数据库FoundationDB使用类似的算法。由于SSI与其他并发控制机制相比还很年轻还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
#### 悲观与乐观的并发控制
@ -768,21 +762,21 @@ WHERE room_id = 123 AND
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有“锁”。
相比之下,**序列化快照隔离**是一种**乐观optimistic** 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。
相比之下,**串行化快照隔离**是一种**乐观optimistic** 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
乐观并发控制是一个古老的想法【52】其优点和缺点已经争论了很长时间【53】。如果存在很多**争用contention**(很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
但是,如果有足够的备用容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的要好。可交换的原子操作可以减少争用:例如,如果多个事务同时要增加一个计数器,那么应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要了,所以并发增量可以全部应用且无需冲突。
顾名思义SSI基于快照隔离——也就是说事务中的所有读取都是来自数据库的一致性快照参见“[快照隔离和可重复读取](#快照隔离和可重复读)”。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上SSI添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。
顾名思义SSI基于快照隔离——也就是说事务中的所有读取都是来自数据库的一致性快照参见“[快照隔离和可重复读取](#快照隔离和可重复读)”。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上SSI添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
#### 基于过时前提的决策
先前讨论了快照隔离中的写入偏差(参阅“[写入偏差和幻像](#写入偏差与幻读)”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
先前讨论了快照隔离中的写入偏差(参阅“[写入偏差与幻读](#写入偏差与幻读)”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
换句话说,事务基于一个**前提premise** 采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)。之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可序列化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
@ -791,7 +785,7 @@ WHERE room_id = 123 AND
#### 检测旧MVCC读取
回想一下快照隔离通常是通过多版本并发控制MVCC见[图7-10]()来实现的。当一个事务从MVCC数据库中的一致快照读时它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中事务43 认为Alice的 `on_call = true` 因为事务42修改Alice的待命状态未被提交。然而在事务43想要提交时事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效事务43 的前提不再为真。
回想一下快照隔离通常是通过多版本并发控制MVCC见[图7-10](img/fig7-10.png)来实现的。当一个事务从MVCC数据库中的一致快照读时它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10](img/fig7-10.png)中事务43 认为Alice的 `on_call = true` 因为事务42修改Alice的待命状态未被提交。然而在事务43想要提交时事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效事务43 的前提不再为真。
![](img/fig7-10.png)
@ -799,7 +793,7 @@ WHERE room_id = 123 AND
为了防止这种异常数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样事务必须中止。
为什么要等到提交当检测到陈旧的读取时为什么不立即中止事务43 因为如果事务43 是只读事务则不需要中止因为没有写入偏差的风险。当事务43 进行读取时数据库还不知道事务是否要稍后执行写操作。此外事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交因此读取可能终究不是陈旧的。通过避免不必要的中止SSI 保留快照隔离对从一致快照中长时间运行的读取的支持
为什么要等到提交当检测到陈旧的读取时为什么不立即中止事务43 因为如果事务43 是只读事务则不需要中止因为没有写入偏差的风险。当事务43 进行读取时数据库还不知道事务是否要稍后执行写操作。此外事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交因此读取可能终究不是陈旧的。通过避免不必要的中止SSI 保留了快照隔离从一致快照中长时间读取的能力
#### 检测影响之前读取的写入
@ -807,28 +801,27 @@ WHERE room_id = 123 AND
![](img/fig7-11.png)
**图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。**
**图7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。**
在两阶段锁定的上下文中,我们讨论了[索引范围锁]()(请参阅“[索引范围锁]()”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术除了SSI锁不会阻塞其他事务。
在两阶段锁定的上下文中,我们讨论了索引范围锁(请参阅“[索引范围锁](#索引范围锁)”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术除了SSI锁不会阻塞其他事务。
在[图7-11]()中事务42 和43 都在班次1234 查找值班医生。如果在`shift_id`上有索引则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务指导其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
在[图7-11]()中事务43 通知事务42 其先前读已过时反之亦然。事务42首先提交并成功尽管事务43 的写影响了42 但因为事务43 尚未提交所以写入尚未生效。然而当事务43 想要提交时来自事务42 的冲突写入已经被提交所以事务43 必须中止。
在[图7-11](img/fig7-11.png)中事务43 通知事务42 其先前读已过时反之亦然。事务42首先提交并成功尽管事务43 的写影响了42 但因为事务43 尚未提交所以写入尚未生效。然而当事务43 想要提交时来自事务42 的冲突写入已经被提交所以事务43 必须中止。
#### 可序列化的快照隔离的性能
#### 可串行化快照隔离的性能
与往常一样,许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的**粒度granularity**。如果数据库详细地跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。 PostgreSQL使用这个理论来减少不必要的中止次数【11,41】。
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可串行化的。 PostgreSQL使用这个理论来减少不必要的中止次数【11,41】。
与两阶段锁定相比,可序列化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
与串行执行相比可序列化快照隔离并不局限于单个CPU核的吞吐量FoundationDB将检测到的序列化冲突分布在多台机器上允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区事务也可以在保证可序列化隔离等级的同时读写多个分区中的数据【54】。
中止率显著影响SSI的整体表现。例如长时间读取和写入数据的事务很可能会发生冲突并中止因此SSI要求同时读写的事务尽量短只读长事务可能没问题。对于慢事务SSI可能比两阶段锁定或串行执行更不敏感。
与串行执行相比可串行化快照隔离并不局限于单个CPU核的吞吐量FoundationDB将检测到的串行化冲突分布在多台机器上允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据【54】。
中止率显著影响SSI的整体表现。例如长时间读取和写入数据的事务很可能会发生冲突并中止因此SSI要求同时读写的事务尽量短只读的长事务可能没问题。对于慢事务SSI可能比两阶段锁定或串行执行更不敏感。
## 本章小结
@ -839,7 +832,7 @@ WHERE room_id = 123 AND
如果没有事务处理,各种错误情况(进程崩溃,网络中断,停电,磁盘已满,意外并发等)意味着数据可能以各种方式变得不一致。例如,非规范化的数据可能很容易与源数据不同步。如果没有事务处理,就很难推断复杂的交互访问可能对数据库造成的影响。
本章深入讨论了**并发控制**的话题。我们讨论了几个广泛使用的隔离级别,特别是**读已提交****快照隔离**(有时称为可重复读)和**可序列化**。并通过研究竞争条件的各种例子,来描述这些隔离等级:
本章深入讨论了**并发控制**的话题。我们讨论了几个广泛使用的隔离级别,特别是**读已提交****快照隔离**(有时称为可重复读)和**可串行化**。并通过研究竞争条件的各种例子,来描述这些隔离等级:
***脏读***
@ -859,13 +852,13 @@ WHERE room_id = 123 AND
***写偏差***
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可序列化的隔离才能防止这种异常。
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。
***幻读***
事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
弱隔离级别可以防止其中一些异常情况,此外让应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可序列化的隔离才能防范所有这些问题。我们讨论了实现可序列化事务的三种不同方法:
弱隔离级别可以防止其中一些异常情况,但要求你,也就是应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。我们讨论了实现可串行化事务的三种不同方法:
***字面意义上的串行执行***
@ -873,15 +866,15 @@ WHERE room_id = 123 AND
***两阶段锁定***
数十年来,两阶段锁定一直是实现可序列化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
***可串行化快照隔离SSI***
一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可序列化,事务就会被中止。
一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。
本章中的示例主要是在关系数据模型的上下文中。使用关系数据模型。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是重要的数据库功能。
本章中的示例主要是在关系数据模型的上下文中。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是有价值的数据库功能。
本章主要是在单机数据库的上下文中,探讨了各种概念与想法。分布式数据库中的事务,则引入了一系列新的困难挑战,将在接下来的两章中讨论。
本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,则引入了一系列新的困难挑战,我们将在接下来的两章中讨论。

252
ch8.md
View File

@ -16,7 +16,7 @@
[TOC]
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](#ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch6.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了**副本故障切换**(“[处理节点中断](ch5.md#处理节点宕机)”),**复制延迟**(“[复制延迟问题](ch5.md#复制延迟问题)”)和事务控制(“[弱隔离级别](ch7.md#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西**都会**出错[^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
@ -26,17 +26,16 @@
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。 在[第9章](ch9.md)中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。 但首先,在本章中,我们必须了解我们面临的挑战。
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题(“[无法访问的网络](#无法访问的网络)”); 时钟和时序问题(“[不可靠时钟](#不可靠时钟)”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识,真相和谎言](#知识,真相和谎言)”)。
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。
## 故障与部分失效
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候是“糟糕的一天”(这个问题通常是重新启动的问题),但这主要是软件写得不好的结果。
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有“糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。
单个计算机上的软件没有根本性的不可靠原因:当硬件正常工作时,相同的操作总是产生相同的结果(这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障(例如,内核恐慌,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,而不是介于两者之间。
这是计算机设计中的一个慎重的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情如果您将一些数据写入内存或磁盘那么这些数据将保持不变并且不会被随机破坏。从第一台数字计算机开始*始终正确地计算*这个设计目标贯穿始终【3】。
这是计算机设计中的一个有意的选择:如果发生内部错误,我们宁愿电脑完全崩溃,而不是返回错误的结果,因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现,并呈现出一个理想化的系统模型,并以数学一样的完美的方式运作。 CPU指令总是做同样的事情如果您将一些数据写入内存或磁盘那么这些数据将保持不变并且不会被随机破坏。从第一台数字计算机开始*始终正确地计算*这个设计目标贯穿始终【3】。
当你编写运行在多台计算机上的软件时情况有本质上的区别。在分布式系统中我们不再处于理想化的系统模型中我们别无选择只能面对现实世界的混乱现实。而在现实世界中各种各样的事情都可能会出现问题【4】如下面的轶事所述
@ -52,38 +51,38 @@
关于如何构建大型计算系统有一系列的哲学:
* 规模的一端是高性能计算HPC领域。具有数千个CPU的超级计算机通常用于计算密集型科学计算任务如天气预报或分子动力学模拟原子和分子的运动
* 另一个极端是**云计算cloud computing**云计算并不是一个良好定义的概念【6】但通常与多租户数据中心连接IP网络的商品计算机(通常是以太网),弹性/按需资源分配以及计量计费等相关联。
* 一个极端是高性能计算HPC领域。具有数千个CPU的超级计算机通常用于计算密集型科学计算任务如天气预报或分子动力学模拟原子和分子的运动
* 另一个极端是**云计算cloud computing**云计算并不是一个良好定义的概念【6】但通常与多租户数据中心连接IP网络通常是以太网的商用计算机,弹性/按需资源分配以及计量计费等相关联。
* 传统企业数据中心位于这两个极端之间。
不同的哲学会导致不同的故障处理方式。在超级计算机中作业通常会不时地会将计算的状态存盘到持久存储中。如果一个节点出现故障通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后计算从上一个检查点重新开始【7,8】。因此超级计算机更像是一个单节点计算机而不是分布式系统通过让部分失败升级为完全失败来处理部分失败——如果系统的任何部分发生故障只是让所有的东西都崩溃就像单台机器上的内核恐慌一样
在本书中,我们将重点放在实现互联网服务的系统上,这些系统通常与超级计算机看起来有很大不同
在本书中,我们将重点放在实现互联网服务的系统上,这些系统通常与超级计算机看起来有很大不同
* 许多与互联网有关的应用程序都是**在线online**的,因为它们需要能够随时以低延迟服务用户。使服务不可用(例如,停止群集以进行修复)是不可接受的。相比之下,像天气模拟这样的离线(批处理)工作可以停止并重新启动,影响相当小。
* 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和**远程直接内存访问RDMA**进行通信。另一方面,云服务中的节点是由商机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
* 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和**远程直接内存访问RDMA**进行通信。另一方面,云服务中的节点是由商机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
* 大型数据中心网络通常基于IP和以太网闭合拓扑排列以提供更高的二等分带宽【9】。超级计算机通常使用专门的网络拓扑结构例如多维网格和环面 【10】这为具有已知通信模式的HPC工作负载提供了更好的性能。
* 大型数据中心网络通常基于IP和以太网CLOS拓扑排列以提供更高的对分bisection带宽【9】。超级计算机通常使用专门的网络拓扑结构例如多维网格和Torus网络 【10】这为具有已知通信模式的HPC工作负载提供了更好的性能。
* 系统越大其组件之一就越有可能坏掉。随着时间的推移坏掉的东西得到修复新的东西又坏掉但是在一个有成千上万个节点的系统中有理由认为总是有一些东西是坏掉的【7】。当错误处理策略由简单放弃组成时一个大的系统最终会花费大量时间从错误中恢复而不是做有用的工作【8】。
* 系统越大其组件之一就越有可能坏掉。随着时间的推移坏掉的东西得到修复新的东西又坏掉但是在一个有成千上万个节点的系统中有理由认为总是有一些东西是坏掉的【7】。当错误处理策略由简单放弃组成时一个大的系统最终会花费大量时间从错误中恢复而不是做有用的工作【8】。
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于操作和维护非常有用:例如,可以执行滚动升级(参阅[第4章](ch4.md)),一次重新启动一个节点,而服务继续服务用户不中断。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(参阅[第4章](ch4.md)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
* 在地理位置分散的部署中(保持数据在地理位置上接近用户以减少访问延迟),通信很可能通过互联网进行,与本地网络相比,通信速度缓慢且不可靠。超级计算机通常假设它们的所有节点都靠近在一起。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。 (正如“[可靠性](ch1.md#可靠性)”中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的限。)
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。 (正如“[可靠性](ch1.md#可靠性)”中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的限。)
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,您需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
简单地假设缺陷很罕见,只是希望始终保持最好的状况是不明智的。考虑一系列可能的错误(甚至是不太可能的错误),并在测试环境中人为地创建这些情况来查看会发生什么是非常重要的。在分布式系统中,怀疑,悲观和偏执狂是值得的。
简单地假设缺陷很罕见希望始终保持最好的状况是不明智的。考虑一系列可能的错误(甚至是不太可能的错误),并在测试环境中人为地创建这些情况来查看会发生什么是非常重要的。在分布式系统中,怀疑,悲观和偏执狂是值得的。
> #### 从不可靠的组件构建可靠的系统
>
> 您可能想知道这是否有意义——直观地看来系统只能像其最不可靠的组件最薄弱的环节一样可靠。事实并非如此事实上从不太可靠的潜在基础构建更可靠的系统是计算机领域的一个古老思想【11】。例如
>
> * 纠错码允许数字数据在通信信道上准确传输偶尔会出现一些错误例如由于无线网络上的无线电干扰【12】。
> * **互联网协议Internet Protocol, IP**不可靠:可能丢弃,延迟,复或重排数据包。 传输控制协议Transmission Control Protocol, TCP在互联网协议IP之上提供了更可靠的传输层它确保丢失的数据包被重新传输消除重复并且数据包被重新组装成它们被发送的顺序。
> * **互联网协议Internet Protocol, IP**不可靠:可能丢弃,延迟,复或重排数据包。 传输控制协议Transmission Control Protocol, TCP在互联网协议IP之上提供了更可靠的传输层它确保丢失的数据包被重新传输消除重复并且数据包被重新组装成它们被发送的顺序。
>
> 虽然这个系统可以比它的底层部分更可靠但它的可靠性总是有限的。例如纠错码可以处理少量的单比特错误但是如果你的信号被干扰所淹没那么通过信道可以得到多少数据是有根本性的限制的【13】。 TCP可以隐藏数据包的丢失重复和重新排序但是它不能神奇地消除网络中的延迟。
>
@ -93,16 +92,16 @@
## 不可靠的网络
正如在第二部分的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径——我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
正如在[第二部分](part-ii.md)的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径——我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
**无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络asynchronous packet networks**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示):
1. 请求可能已经丢失(可能有人拔掉了网线)。
2. 请求可能正在排队,稍后将交付(也许网络或收件人超载)。
2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
3. 远程节点可能已经失效(可能是崩溃或关机)。
4. 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;参阅“[暂停进程](#暂停进程)”),但稍后会再次响应。
4. 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;参阅“[进程暂停](#进程暂停)”),但稍后会再次响应。
5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。
6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。
@ -110,9 +109,9 @@
**图8-1 如果发送请求并没有得到响应则无法区分a请求是否丢失b远程节点是否关闭c响应是否丢失。**
发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。这些问题在异步网络中难以区分:您所拥有的唯一信息是,您尚未收到响应。如果您向另一个节点发送请求并且没有收到响应,则无法说明原因。
发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。这些问题在异步网络中难以区分:您所拥有的唯一信息是,您尚未收到响应。如果您向另一个节点发送请求并且没有收到响应,则不可能判断是什么原因。
处理这个问题的通常方法是**超时Timeout**:在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发件人已经放弃了该请求,仍然可能会将其发送给收件人)。
处理这个问题的通常方法是**超时Timeout**:在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
### 真实世界的网络故障
@ -141,60 +140,58 @@
不幸的是,网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,您可能会收到一些反馈信息,明确告诉您某些事情没有成功:
* 如果你可以到运行节点的机器但没有进程正在侦听目标端口例如因为进程崩溃操作系统将通过发送FIN或RST来关闭并重用TCP连接。但是如果节点在处理请求时发生崩溃则无法知道远程节点实际处理了多少数据【22】。
* 如果节点进程崩溃或被管理员杀死但节点的操作系统仍在运行则脚本可以通知其他节点有关该崩溃的信息以便另一个节点可以快速接管而无需等待超时到期。例如HBase做这个【23】。
* 如果您有权访问数据中心网络交换机的管理界面,则可以查询它们以检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果您通过互联网连接,或者如果您处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
* 如果你可以连接到运行节点的机器但没有进程正在侦听目标端口例如因为进程崩溃操作系统将通过发送FIN或RST来关闭并重用TCP连接。但是如果节点在处理请求时发生崩溃则无法知道远程节点实际处理了多少数据【22】。
* 如果节点进程崩溃或被管理员杀死但节点的操作系统仍在运行则脚本可以通知其他节点有关该崩溃的信息以便另一个节点可以快速接管而无需等待超时到期。例如HBase就是这么做的【23】。
* 如果您有权访问数据中心网络交换机的管理界面,则可以通过它们检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果您通过互联网连接,或者如果您处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
* 如果路由器确认您尝试连接的IP地址不可用则可能会使用ICMP目标不可达数据包回复您。但是路由器不具备神奇的故障检测能力——它受到与网络其他参与者相同的限制。
关于远程节点关闭的快速反馈很有用但是你不能指望它。即使TCP确认已经传送了一个数据包应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的你需要应用程序本身的积极响应【24】。
关于远程节点关闭的快速反馈很有用但是你不能指望它。即使TCP确认已经传送了一个数据包应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的你需要应用程序本身的正确响应【24】。
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到一个错误响应,但总的来说,你必须假设你根本就没有得到任何回应。您可以重试几次TCP重试是透明的但是您也可以在应用程序级别重试等待超时过期并且如果在超时时间内没有收到响应则最终声明节点已经死亡。
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到一个错误响应,但总的来说,你必须假设你可能根本就得到任何回应。您可以重试几次TCP重试是透明的但是您也可以在应用程序级别重试等待超时过期并且如果在超时时间内没有收到响应则最终声明节点已经死亡。
### 超时与无穷的延迟
如果超时是检测故障的唯一可靠方法,那么超时应该等待多久?不幸的是没有简单的答案。
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短暂的超时可以更快地检测到故障,但是实际上它只是经历了暂时的减速(例如,由于节点或网络上的负载峰值)而导致错误地宣布节点失效的风险更高
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在“[知识,真相和谎言](#知识,真相和谎言)”以及[第9章](ch9.md)和[第11章](ch11.md)中更详细地讨论这个问题。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在“[知识、真相与谎言](#知识、真相与谎言)”以及[第9章](ch9.md)和[第11章](ch11.md)中更详细地讨论这个问题。
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。尤其是可能发生,节点实际上并没有死亡,而是由于过载导致响应缓慢;将其负载转移到其他节点可能会导致**级联失效cascading failure**(在极端情况下,所有节点都宣告对方死亡,并且所有节点都停止工作)。
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致**级联失效cascading failure**(在极端情况下,所有节点都宣告对方死亡,所有节点都停止工作)。
设想一个虚构的系统,其网络可以保证数据包的最大延迟——每个数据包要么在一段时间内传送,要么丢失,但是传递永远不会比$d$更长。此外,假设你可以保证一个非故障节点总是在一段时间内处理一个请求$r$。在这种情况下,您可以保证每个成功的请求在$2d + r$时间内都能收到响应,如果您在此时间内没有收到响应,则知道网络或远程节点不工作。如果这是成立的,$2d + r$ 会是一个合理的超时设置。
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求(请参阅“[响应时间保证](#响应时间保证)”)。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求(请参阅“[响应时间保证](#响应时间保证)”)。对于故障检测,即使系统大部分时间快速运行是不够的:如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
#### 网络拥塞和排队
在驾驶汽车时由于交通拥堵道路交通网络的通行时间往往不尽相同。同样计算机网络上数据包延迟的可变性通常是由于排队【25】
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 当数据包到达目标机器时如果所有CPU内核当前都处于繁忙状态则来自网络的传入请求将被操作系统排队直到应用程序准备好处理它为止。根据机器上的负载这可能需要一段任意的时间。
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,而另一个虚拟机使用CPU内核。在这段时间内虚拟机不能从网络中消耗任何数据所以传入的数据被虚拟机监视器 【26】排队缓冲进一步增加了网络延迟的可变性。
* TCP执行**流量控制flow control**(也称为**拥塞避免congestion avoidance**或**背压backpressure**其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队。
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用CPU内核。在这段时间内虚拟机不能从网络中消耗任何数据所以传入的数据被虚拟机监视器 【26】排队缓冲进一步增加了网络延迟的可变性。
* TCP执行**流量控制flow control**(也称为**拥塞避免congestion avoidance**或**背压backpressure**),其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着甚至在数据进入网络之前,在发送者处需要进行额外的排队。
![](img/fig8-2.png)
**图8-2 如果有多台机器将网络流量发送到同一目的地则其交换机队列可能会被填满。在这里端口1,2和4都试图发送数据包到端口3**
而且如果TCP在某个超时时间内没有被确认这是根据观察的往返时间计算的则认为数据包丢失丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输但它看到了延迟等待超时到期然后等待重新传输的数据包得到确认
     
> ### TCP与UDP
>
> 一些对延迟敏感的应用程序如视频会议和IP语音VoIP使用UDP而不是TCP。这是在可靠性和和延迟可变性之间的折衷由于UDP不执行流量控制并且不重传丢失的分组所以避免了可变网络延迟的一些原因(尽管它仍然易受切换队列和调度延迟的影响)。
> 一些对延迟敏感的应用程序如视频会议和IP语音VoIP使用UDP而不是TCP。这是在可靠性和和延迟变化之间的折衷由于UDP不执行流量控制并且不重传丢失的分组所以避免了网络延迟变化的一些原因(尽管它仍然易受切换队列和调度延迟的影响)。
>
> 在延迟数据毫无价值的情况下UDP是一个不错的选择。例如在VoIP电话呼叫中可能没有足够的时间重新发送丢失的数据包并在扬声器上播放数据。在这种情况下重发数据包没有意义——应用程序必须使用静音填充丢失数据包的时隙导致声音短暂中断然后在数据流中继续。重试发生在人类层。 (“你能再说一遍吗?声音刚刚断了一会儿。“)
所有这些因素都会造成网络延迟的变化。当系统接近其最大容量时,排队延迟的范围特别广泛:
所有这些因素都会造成网络延迟的变化。当系统接近其最大容量时,排队延迟的变化范围特别大:拥有足够备用容量的系统可以轻松排空队列,而在高利用率的系统中,很快就能积累很长的队列。
拥有足够备用容量的系统可以轻松排空队列,而在高利用率的系统中,很快就能积累很长的队列
在公共云和多租户数据中心中资源被许多客户共享网络链接和交换机甚至每个机器的网卡和CPU在虚拟机上运行时。批处理工作负载如MapReduce参阅[第10章](ch10.md)能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况如果附近的某个人嘈杂的邻居正在使用大量资源则网络延迟可能会发生剧烈变化【28,29】
公共云和多租户数据中心中资源被许多客户共享网络链接和交换机甚至每个机器的网卡和CPU在虚拟机上运行时。批处理工作负载如MapReduce参阅[第10章](ch10.md)可能很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况如果附近的某个人嘈杂的邻居正在使用大量资源则网络延迟可能会发生剧烈抖动【28,29】
这种环境下,您只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定**故障检测延迟**与**过早超时风险**之间的适当折衷
在这种环境下,您只能通过实验方式选择超时:测量延长的网络往返时间和多台机器的分布,以确定延迟的预期可变性。然后,考虑到应用程序的特性,可以确定**故障检测延迟**与**过早超时风险**之间的适当折衷。
更好的一种做法是系统不是使用配置的常量超时时间而是连续测量响应时间及其变化抖动并根据观察到的响应时间分布自动调整超时时间。这可以通过Phi Accrual故障检测器【30】来完成该检测器在例如Akka和Cassandra 【31】中使用。 TCP超时重传机制也同样起作用【27】。
更好的一种做法是系统不是使用配置的常量超时时间而是连续测量响应时间及其变化抖动并根据观察到的响应时间分布自动调整超时时间。这可以通过Phi Accrual故障检测器【30】来完成该检测器在例如Akka和Cassandra 【31】中使用。 TCP的超时重传机制也是以类似的方式工作【27】。
### 同步网络 vs 异步网络
@ -212,19 +209,19 @@
[^ii]: 除了偶尔的keepalive数据包如果TCP keepalive被启用。
如果数据中心网络和互联网是电路交换网络那么在建立电路时就可以建立一个保证的最大往返时间。但是它们并不是以太网和IP是**分组交换协议**,不得不忍受排队的折磨,及其导致的网络无限延迟。这些协议没有电路的概念。
如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立一个保证的最大往返时间。但是它们并不是以太网和IP是**分组交换协议**,不得不忍受排队的折磨,及其导致的网络无限延迟。这些协议没有电路的概念。
为什么数据中心网络和互联网使用分组交换?答案是,它们针对**突发流量bursty traffic**进行了优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。另一方面,请求网页,发送电子邮件或传输文件没有任何特定的带宽要求——我们只是希望它尽快完成。
如果想通过电路传输文件,你得预测一个带宽分配。如果你猜的太低,传输速度会不必要的太慢,导致网络容量闲置。如果你猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,使用用于突发数据传输的电路浪费网络容量并且使传输不必要地缓慢。相比之下TCP动态调整数据传输速率以适应可用的网络容量。
如果想通过电路传输文件,你得预测一个带宽分配。如果你猜的太低,传输速度会不必要的太慢,导致网络容量闲置。如果你猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,将电路用于突发数据传输会浪费网络容量并且使传输不必要地缓慢。相比之下TCP动态调整数据传输速率以适应可用的网络容量。
已经有一些尝试去建立支持电路交换和分组交换的混合网络比如ATM[^iii] InfiniBand有一些相似之处【35】它在链路层实现了端到端的流量控制从而减少了在网络中排队尽管它仍然可能因链路拥塞而受到延迟【36】。通过仔细使用**服务质量quality of service,**QoS数据包的优先级和调度和**准入控制admission control**(限速发送器),可以仿真分组网络上的电路交换,或提供统计上的**有限延迟**【25,32】。
已经有一些尝试去建立同时支持电路交换和分组交换的混合网络比如ATM[^iii]InfiniBand有一些相似之处【35】它在链路层实现了端到端的流量控制从而减少了在网络中排队的需要尽管它仍然可能因链路拥塞而受到延迟【36】。通过仔细使用**服务质量quality of service,**QoS数据包的优先级和调度和**准入控制admission control**(限速发送器),可以在分组网络上模拟电路交换,或提供统计上的**有限延迟**【25,32】。
[^iii]: **异步传输模式Asynchronous TransferMode, ATM**在20世纪80年代是以太网的竞争对手【32】但在电话网核心交换机之外并没有得到太多的采用。与自动柜员机也称为自动取款机无关尽管共用一个缩写词。或许在一些平行的世界里互联网是基于像ATM这样的东西因此它们的互联网视频通话可能比我们的更可靠因为它们不会遭受包和延迟的包裹
[^iii]: **异步传输模式Asynchronous Transfer Mode, ATM**在20世纪80年代是以太网的竞争对手【32】但在电话网核心交换机之外并没有得到太多的采用。与自动柜员机也称为自动取款机无关尽管共用一个缩写词。或许在一些平行的世界里互联网是基于像ATM这样的东西因此它们的互联网视频通话可能比我们的更可靠因为它们不会遭受包的丢失和延迟。
但是,目前在多租户数据中心和公共云或通过互联网[^iv]进行通信时,此类服务质量尚未启用。当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。因此,超时时间没有“正确”的值——它需要通过实验来确定。
[^iv]: 互联网服务提供商之间的对等协议和通过**BGP网关协议BGP**建立路由之间的对等协议,与电路交换本身相比,与电路交换更接近。在这个级别上,可以购买专用带宽。但是,互联网路由在网络级别运行,而不是主机之间的单独连接,而且运行时间要长得多。
[^iv]: 互联网服务提供商之间的对等协议和通过**BGP网关协议BGP**建立的路由与IP协议相比更接近于电路交换。在这个级别上,可以购买专用带宽。但是,互联网路由在网络级别运行,而不是主机之间的单独连接,而且运行时间要长得多。
> ### 延迟和资源利用
>
@ -232,16 +229,15 @@
>
> 假设两台电话交换机之间有一条线路可以同时进行10,000个呼叫。通过此线路切换的每个电路都占用其中一个呼叫插槽。因此您可以将线路视为可由多达10,000个并发用户共享的资源。资源以静态方式分配即使您现在是电话上唯一的电话并且所有其他9,999个插槽都未使用您的电路仍将分配与导线充分利用时相同的固定数量的带宽。
>
> 相比之下,互联网动态分享网络带宽。发送者互相推挤和争夺,以尽可能快地通过网络获得它们的分组,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是它最大限度地利用了电线。电线固定成本,所以如果你更好地利用它,你通过电线发送的每个字节都会更便宜。
> 相比之下,互联网动态分享网络带宽。发送者互相推挤和争夺,以让他们的数据包尽可能快地通过网络,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是它最大限度地利用了电线。电线固定成本,所以如果你更好地利用它,你通过电线发送的每个字节都会更便宜。
>
> CPU也会出现类似的情况如果您在多个线程间动态共享每个CPU内核有一个线程有时必须等待操作系统的运行队列,而另一个线程正在运行,这样线程可以暂停不同的时间长度。但是与为每个线程分配静态数量的CPU周期相比这会更好地利用硬件参阅“[响应时间保证](#响应时间保证)”)。更好的硬件利用率也是使用虚拟机的重要动机。
> CPU也会出现类似的情况如果您在多个线程间动态共享每个CPU内核一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是与为每个线程分配静态数量的CPU周期相比这会更好地利用硬件参阅“[响应时间保证](#响应时间保证)”)。更好的硬件利用率也是使用虚拟机的重要动机。
>
> 如果资源是静态分区的(例如,专用硬件和专用带宽分配),则在某些环境中可以实现**延迟保证**。但是,这是以降低利用率为代价的——换句话说,它是更昂贵的。另一方面,动态资源分配的多租户提供了更好的利用率,所以它更便宜,但它具有可变延迟的缺点。
>
> 网络中的可变延迟不是一种自然规律,而只是成本/收益权衡的结果。
## 不可靠的时钟
时钟和时间很重要。应用程序以各种方式依赖于时钟来回答以下问题:
@ -255,29 +251,29 @@
7. 这个缓存条目何时到期?
8. 日志文件中此错误消息的时间戳是什么?
[例1-4](ch1.md)测量[持续时间]()(例如,发送请求与正在接收的响应之间的时间间隔),而[例5-8](ch5.md)描述**时间点point in time**(在特定日期,特定时间发生的事件)。
[例1-4](ch1.md)测量了**持续时间durations**(例如,请求发送与响应接收之间的时间间隔),而[例5-8](ch5.md)描述**时间点point in time**(在特定日期,特定时间发生的事件)。
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道多少时间。这个事实有时很难确定在涉及多台机器时发生事情的顺序。
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。这个事实导致有时很难确定在涉及多台机器时发生事情的顺序。
而且,网络上的每台机器都有自己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常用的机制是**网络时间协议NTP**它允许根据一组服务器报告的时间来调整计算机时钟【37】。服务器则从更精确的时间源如GPS接收机获取时间。
### 单调钟与时钟
### 单调钟与日历时钟
现代计算机至少有两种不同的时钟:时钟和单调钟。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的目的。
现代计算机至少有两种不同的时钟:日历时钟time-of-day clock和单调钟monotonic clock。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的目的。
#### 时钟
#### 日历时钟
时钟是您直观地了解时钟的依据:它根据某个日历(也称为**挂钟时间wall-clock time**返回当前日期和时间。例如Linux[^v]上的`clock_gettime(CLOCK_REALTIME)`和Java中的`System.currentTimeMillis()`返回自epoch1970年1月1日 午夜 UTC格里高利历)以来的秒数(或毫秒),根据公历日历,不包括闰秒。有些系统使用其他日期作为参考点。
日历时钟是您直观地了解时钟的依据:它根据某个日历(也称为**挂钟时间wall-clock time**返回当前日期和时间。例如Linux上的`clock_gettime(CLOCK_REALTIME)`[^v]和Java中的`System.currentTimeMillis()`返回自epochUTC时间1970年1月1日午夜以来的秒数或毫秒根据公历Gregorian日历,不包括闰秒。有些系统使用其他日期作为参考点。
[^v]: 虽然时钟被称为实时时钟,但它与实时操作系统无关,如第298页上的“[响应时间保证](#响应时间保证)”中所述。
[^v]: 虽然时钟被称为实时时钟,但它与实时操作系统无关,如“[响应时间保证](#响应时间保证)”中所述。
时钟通常与NTP同步这意味着来自一台机器的时间戳理想情况下与另一台机器上的时间戳相同。但是如下节所述时钟也具有各种各样的奇特之处。特别是如果本地时钟在NTP服务器之前太远则它可能会被强制重置看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实使时钟不能用于测量经过时间【38】。
日历时钟通常与NTP同步这意味着来自一台机器的时间戳理想情况下与另一台机器上的时间戳相同。但是如下节所述日历时钟也具有各种各样的奇特之处。特别是如果本地时钟在NTP服务器之前太远则它可能会被强制重置看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实使日历时钟不能用于测量经过时间elapsed time【38】。
时钟还具有相当粗略的分辨率例如在较早的Windows系统上以10毫秒为单位前进【39】。在最近的系统中这已经不是一个问题了。
历史上的日历时钟还具有相当粗略的分辨率例如在较早的Windows系统上以10毫秒为单位前进【39】。在最近的系统中这已经不是一个问题了。
#### 单调钟
单调钟适用于测量持续时间时间间隔例如超时或服务的响应时间Linux上的`clock_gettime(CLOCK_MONOTONIC)`和Java中的`System.nanoTime()`都是单调时钟。这个名字来源于他们保证总是前进的事实(而时钟可以及时跳回)。
单调钟适用于测量持续时间时间间隔例如超时或服务的响应时间Linux上的`clock_gettime(CLOCK_MONOTONIC)`和Java中的`System.nanoTime()`都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)。
你可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。
@ -289,25 +285,24 @@
### 时钟同步与准确性
单调钟不需要同步但是时钟需要根据NTP服务器或其他外部时间源来设置才能有用。不幸的是我们获取时钟的方法并不像你所希望的那样可靠或准确——硬件时钟和NTP可能会变幻莫测。举几个例子
计算机中的石英钟不够精确:它会**漂移drifts**(运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google假设其服务器时钟漂移为200 ppm百万分之一【41】相当于每30秒与服务器重新同步一次的时钟漂移为6毫秒或者每天重新同步的时钟漂移为17秒。即使一切工作正常此漂移也会限制可以达到的最佳准确度。
单调钟不需要同步但是日历时钟需要根据NTP服务器或其他外部时间源来设置才能有用。不幸的是我们获取时钟的方法并不像你所希望的那样可靠或准确——硬件时钟和NTP可能会变幻莫测。举几个例子
* 计算机中的石英钟不够精确:它会**漂移drifts**(运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google假设其服务器时钟漂移为200 ppm百万分之一【41】相当于每30秒与服务器重新同步一次的时钟漂移为6毫秒或者每天重新同步的时钟漂移为17秒。即使一切工作正常此漂移也会限制可以达到的最佳准确度。
* 如果计算机的时钟与NTP服务器的时钟差别太大可能会拒绝同步或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
* 如果某个节点被NTP服务器意外阻塞,可能会在一段时间内忽略错误配置。有证据表明,这在实践中确实发生过。
* 如果某个节点被NTP服务器的防火墙意外阻塞,有可能会持续一段时间都没有人会注意到。有证据表明,这在实践中确实发生过。
* NTP同步只能和网络延迟一样好所以当您在拥有可变数据包延迟的拥塞网络上时NTP同步的准确性会受到限制。一个实验表明当通过互联网同步时35毫秒的最小误差是可以实现的尽管偶尔的网络延迟峰值会导致大约一秒的误差。根据配置较大的网络延迟会导致NTP客户端完全放弃。
* 一些NTP服务器错误或配置错误报告时间已经过去了几个小时【43,44】。 NTP客户端非常强大因为他们查询多个服务器并忽略异常值。尽管如此在互联网上陌生人告诉你的时候你的系统的正确性还是值得担忧的。
* 闰秒导致59分钟或61秒长的分钟这混淆了未设计闰秒的系统中的时序假设【45】。闰秒已经使许多大型系统崩溃【38,46】的事实说明了,关于时钟的假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是通过在一天中逐渐执行闰秒调整(这被称为**拖尾smearing**【47,48】使NTP服务器“撒谎”虽然实际的NTP服务器表现各异【49】。
* 一些NTP服务器是错误的或者配置错误的报告的时间可能相差几个小时【43,44】。还好NTP客户端非常健壮因为他们会查询多个服务器并忽略异常值。无论如何依赖于互联网上的陌生人所告诉你的时间来保证你的系统的正确性这还挺让人担忧的。
* 闰秒导致一分钟可能有59秒或61秒这会打破一些在设计之时未考虑闰秒的系统的时序假设【45】。闰秒已经使许多大型系统崩溃的事实【38,46】说明了关于时钟的错误假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是让NTP服务器“撒谎”在一天中逐渐执行闰秒调整(这被称为**拖尾smearing**【47,48】虽然实际的NTP服务器表现各异【49】。
* 在虚拟机中硬件时钟被虚拟化这对于需要精确计时的应用程序提出了额外的挑战【50】。当一个CPU核心在虚拟机之间共享时每个虚拟机都会暂停几十毫秒与此同时另一个虚拟机正在运行。从应用程序的角度来看这种停顿表现为时钟突然向前跳跃【26】。
* 如果您在未完全控制的设备上运行软件(例如,移动设备或嵌入式设备),则可能完全不能信任该设备的硬件时钟。一些用户故意将其硬件时钟设置为不正确的日期和时间,例如,为了规避游戏中的时间限制,时钟可能会被设置到很远的过去或将来。
* 如果您在没有完整控制权的设备(例如,移动设备或嵌入式设备)上运行软件,则可能完全不能信任该设备的硬件时钟。一些用户故意将其硬件时钟设置为不正确的日期和时间,例如,为了规避游戏中的时间限制,时钟可能会被设置到很远的过去或将来。
如果你足够在乎这件事并投入大量资源就可以达到非常好的时钟精度。例如针对金融机构的欧洲法规草案MiFID II要求所有高频率交易基金在UTC时间100微秒内同步时钟以便调试“闪崩”等市场异常现象并帮助检测市场操纵 【51】。
如果你足够在乎这件事并投入大量资源就可以达到非常好的时钟精度。例如针对金融机构的欧洲法规草案MiFID II要求所有高频率交易基金在UTC时间100微秒内同步时钟以便调试“闪崩”等市场异常现象并帮助检测市场操纵【51】。
通过GPS接收机精确时间协议PTP【52】以及仔细的部署和监测可以实现这种精确度。然而这需要很多努力和专业知识而且有很多东西都会导致时钟同步错误。如果你的NTP守护进程配置错误或者防火墙阻止了NTP通信由漂移引起的时钟误差可能很快就会变大。
### 依赖同步时钟
时钟的问题在于虽然它们看起来简单易用但却具有令人惊讶的缺陷一天可能不会有精确的86,400秒**时钟**可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
时钟的问题在于虽然它们看起来简单易用但却具有令人惊讶的缺陷一天可能不会有精确的86,400秒**日历时钟**可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
本章早些时候,我们讨论了网络丢包和任意延迟包的问题。尽管网络在大多数情况下表现良好,但软件的设计必须假定网络偶尔会出现故障,而软件必须正常处理这些故障。时钟也是如此:尽管大多数时间都工作得很好,但需要准备健壮的软件来处理不正确的时钟。
@ -325,21 +320,21 @@
**图8-3 客户端B的写入比客户端A的写入要晚但是B的写入具有较早的时间戳。**
在[图8-3]()中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的时钟时钟标记一个时间戳。在这个例子中时钟同步是非常好的节点1和节点3之间的偏差小于3ms这可能比你在实践中预期的更好。
在[图8-3](img/fig8-3.png)中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中时钟同步是非常好的节点1和节点3之间的偏差小于3ms这可能比你在实践中预期的更好。
尽管如此,[图8-3](img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为客户端B的增量操作会丢失。
这种冲突解决策略被称为**最后写入胜利LWW**它在多领导者复制和无领导者数据库如Cassandra 【53】和Riak 【54】中被广泛使用参见“[最后写入胜利(丢弃并发写入)](#最后写入胜利(丢弃并发写入))”一节。有些实现会在客户端而不是服务器上生成时间戳但这并不能改变LWW的基本问题
这种冲突解决策略被称为**最后写入胜利LWW**它在多领导者复制和无领导者数据库如Cassandra 【53】和Riak 【54】中被广泛使用参见“[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))”一节。有些实现会在客户端而不是服务器上生成时间戳但这并不能改变LWW的基本问题
* 数据库写入可能会神秘地消失具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃而未向应用报告任何错误。
* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中客户端B的增量操作**一定**发生在客户端A的写入之后和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。
* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中客户端B的增量操作**一定**发生在客户端A的写入之后和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。
* 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的**决胜值tiebreaker**可以简单地是一个大随机数但这种方法也可能会导致违背因果关系【53】。
因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**时钟**,这很可能是不正确的。即使用频繁同步的NTP时钟一个数据包也可能在时间戳100毫秒根据发送者的时钟时发送并在时间戳99毫秒根据接收者的时钟处到达——看起来好像数据包在发送之前已经到达这是不可能的。
因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**日历时钟**,这很可能是不正确的。即使用严格同步的NTP时钟一个数据包也可能在时间戳100毫秒根据发送者的时钟时发送并在时间戳99毫秒根据接收者的时钟处到达——看起来好像数据包在发送之前已经到达这是不可能的。
NTP同步是否能足够准确以至于这种不正确的排序不会发生也许不能因为NTP的同步精度本身受到网络往返时间的限制,除了石英钟漂移这类误差源之外。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
NTP同步是否能足够准确以至于这种不正确的排序不会发生也许不能因为NTP的同步精度本身除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
所谓的**逻辑时钟logic clock**【56,57】是基于递增计数器而不是振荡石英晶体对于排序事件来说是更安全的选择请参见“[检测并发写入](ch5.md#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的**时钟**和**单调钟**也被称为**物理时钟physical clock**。我们将在“[顺序保证](#顺序保证)”中来看顺序问题。
所谓的**逻辑时钟logic clock**【56,57】是基于递增计数器而不是振荡石英晶体对于排序事件来说是更安全的选择请参见“[检测并发写入](ch5.md#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的**日历时钟**和**单调钟**也被称为**物理时钟physical clock**。我们将在“[顺序保证](ch9.md#顺序保证)”中来看顺序问题。
#### 时钟读数存在置信区间
@ -355,23 +350,23 @@
#### 全局快照的同步时钟
在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
在“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
快照隔离最常见的实现需要单调递增的事务ID。如果写入比快照晚写入具有比快照更大的事务ID则该写入对于快照事务是不可见的。在单节点数据库上一个简单的计数器就足以生成事务ID。
但是当数据库分布在许多机器上也许可能在多个数据中心中时由于需要协调跨所有分区全局单调递增的事务ID会很难生成。事务ID必须反映因果关系如果事务B读取由事务A写入的值则B必须具有比A更大的事务ID否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下在分布式系统中创建事务ID成为一个难以防守的瓶颈[^vi]。
但是当数据库分布在许多机器上也许可能在多个数据中心中时由于需要协调跨所有分区全局单调递增的事务ID会很难生成。事务ID必须反映因果关系如果事务B读取由事务A写入的值则B必须具有比A更大的事务ID否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下在分布式系统中创建事务ID成为一个难以处理的瓶颈[^vi]。
[^vi]: 存在分布式序列号生成器例如Twitter的雪花Snowflake其以可伸缩的方式例如通过将ID空间的块分配给不同节点近似单调地增加唯一ID。但是它们通常无法保证与因果关系一致的排序因为分配的ID块的时间范围比数据库读取和写入的时间范围要长。另请参阅“[顺序保证](#顺序保证)”。
[^vi]: 存在分布式序列号生成器例如Twitter的雪花Snowflake其以可伸缩的方式例如通过将ID空间的块分配给不同节点近似单调地增加唯一ID。但是它们通常无法保证与因果关系一致的排序因为分配的ID块的时间范围比数据库读取和写入的时间范围要长。另请参阅“[顺序保证](ch9.md#顺序保证)”。
我们可以使用同步时钟的时间戳作为事务ID吗如果我们能够获得足够好的同步性那么这种方法将具有很合适的属性更晚的事务会有更大的时间戳。当然问题在于时钟精度的不确定性。
Spanner以这种方式实现跨数据中心的快照隔离【5960】。它使用TrueTime API报告的时钟置信区间并基于以下观察结果如果您有两个置信区间每个置信区间包含最早和最晚可能的时间戳 $A = [A_{earliest}, A_{latest}]$ $B=[B_{earliest}, B_{latest}]$),这两个区间不重叠(即:$A_{earliest} < A_{latest} < B_{earliest} < B_{latest}$的话那么B肯定发生在A之后这是毫无疑问的只有当区间重叠时我们才不确定A和B发生的顺序
为了确保事务时间戳反映因果关系在提交读写事务之前Spanner在提交读写事务时会故意等待置信区间长度的时间。通过这样它可以确保任何可能读取数据的事务处于足够晚的时间因此它们的置信区间不会重叠。为了保持尽可能短的等待时间Spanner需要保持尽可能小的时钟不确定性为此Google在每个数据中心都部署了一个GPS接收器或原子钟这允许时钟在大约7毫秒内同步【41】。
为了确保事务时间戳反映因果关系在提交读写事务之前Spanner在提交读写事务时会故意等待置信区间长度的时间。通过这样它可以确保任何可能读取数据的事务处于足够晚的时间因此它们的置信区间不会重叠。为了保持尽可能短的等待时间Spanner需要保持尽可能小的时钟不确定性为此Google在每个数据中心都部署了一个GPS接收器或原子钟这允许时钟同步到大约7毫秒以内【41】。
对分布式事务语义使用时钟同步是一个活跃的研究领域【57,61,62】。这些想法很有趣但是它们还没有在谷歌之外的主流数据库中实现。
### 暂停进程
### 进程暂停
让我们考虑在分布式系统中使用危险时钟的另一个例子。假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
@ -382,33 +377,33 @@
可以想象,请求处理循环看起来像这样:
```java
while(true){
request=getIncomingRequest();
while (true) {
request = getIncomingRequest();
// 确保租约还剩下至少10秒
if (lease.expiryTimeMillis-System.currentTimeMillis() < 10000){
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){
lease = lease.renew();
}
if(lease.isValid()){
if (lease.isValid()) {
process(request);
}
}
```
 这个代码有什么问题首先它依赖于同步时钟租约到期时间由另一台机器设置例如当前时间加上30秒计算到期时间并将其与本地系统时钟进行比较。如果时钟超过几秒不同步,这段代码将开始做奇怪的事情。
 这个代码有什么问题首先它依赖于同步时钟租约到期时间由另一台机器设置例如当前时间加上30秒计算到期时间并将其与本地系统时钟进行比较。如果时钟不同步超过几秒,这段代码将开始做奇怪的事情。
其次,即使我们将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查`System.currentTimeMillis()`和实际执行请求`process(request)`中间的时间间隔非常短。通常情况下这段代码运行得非常快所以10秒的缓冲区已经足够确保**租约**在请求处理到一半时不会过期。
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在`lease.isValid()`行周围停止15秒然后才终止。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 ——到那个时候它可能已经做了一些不安全的处理请求。
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在`lease.isValid()`行周围停止15秒然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 ——到那个时候它可能已经做了一些不安全的处理请求。
假设一个线程可能会暂停很长时间,这是疯了吗?不幸的是,这种情况发生的原因有很多种:
* 许多编程语言运行时如Java虚拟机都有一个垃圾收集器GC偶尔需要停止所有正在运行的线程。这些“**停止所有处理stop-the-world**”GC暂停有时会持续几分钟【64】甚至像HotSpot JVM的CMS这样的所谓的“并行”垃圾收集器也不能完全与应用程序代码并行运行它需要不时地停止所有处理【65】。尽管通常可以通过改变分配模式或调整GC设置来减少暂停【66】但是如果我们想要提供健壮的保证就必须假设最坏的情况发生。
* 在虚拟化环境中,可以**挂起suspend**虚拟机暂停执行所有进程并将内存内容保存到磁盘并恢复恢复内存内容并继续执行。这个暂停可以在进程执行的任何时候发生并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移而不需要重新启动在这种情况下暂停的长度取决于进程写入内存的速率【67】。
* 在最终用户的设备(如笔记本电脑)上,执行也可能被暂停并随意恢复,例如当用户关闭笔记本电脑的盖子时。
* 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可在代码中的任意点处暂停。在虚拟机的情况下在其他虚拟机中花费的CPU时间被称为**窃取时间steal time**。如果机器处于沉重的负载下(即,如果等待运行的线程很长),暂停的线程再次运行可能需要一些时间。
* 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可在代码中的任意点处暂停。在虚拟机的情况下在其他虚拟机中花费的CPU时间被称为**窃取时间steal time**。如果机器处于沉重的负载下(即,如果等待运行的线程队列很长),暂停的线程再次运行可能需要一些时间。
* 如果应用程序执行同步磁盘访问则线程可能暂停等待缓慢的磁盘I/O操作完成【68】。在许多语言中即使代码没有包含文件访问磁盘访问也可能出乎意料地发生——例如Java类加载器在第一次使用时惰性加载类文件这可能在程序执行过程中随时发生。 I/O暂停和GC暂停甚至可能合谋组合它们的延迟【69】。如果磁盘实际上是一个网络文件系统或网络块设备如亚马逊的EBSI/O延迟进一步受到网络延迟变化的影响【29】。
* 如果操作系统配置为允许交换到磁盘(页),则简单的内存访问可能导致**页面错误page fault**要求将磁盘中的页面装入内存。当这个缓慢的I/O操作发生时线程暂停。如果内存压力很高则可能需要将不同的页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为**抖动thrashing**)。为了避免这个问题,通常在服务器机器上禁用页面调度(如果你宁愿干掉一个进程来释放内存,也不愿意冒抖动风险)。
* 如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致**页面错误page fault**要求将磁盘中的页面装入内存。当这个缓慢的I/O操作发生时线程暂停。如果内存压力很高则可能需要将另一个页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为**抖动thrashing**)。为了避免这个问题,通常在服务器机器上禁用页面调度(如果你宁愿干掉一个进程来释放内存,也不愿意冒抖动风险)。
* 可以通过发送SIGSTOP信号来暂停Unix进程例如通过在shell中按下Ctrl-Z。 这个信号立即阻止进程继续执行更多的CPU周期直到SIGCONT恢复为止此时它将继续运行。 即使你的环境通常不使用SIGSTOP也可能由运维工程师意外发送。
所有这些事件都可以随时**抢占preempt**正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时机做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
@ -421,27 +416,27 @@ while(true){
在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是**可以**消除的。
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:飞机主控计算机,火箭,机器人,汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的**截止时间deadline**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的**硬实时hard real-time**系统。
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的**截止时间deadline**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的**硬实时hard real-time**系统。
> #### 实时是真的吗?
>
> 在嵌入式系统中实时是指系统经过精心设计和测试以满足所有情况下的特定时间保证。这个含义与Web上实时术语的模糊使用相反,它描述了服务器将数据推送到客户端以及流处理,而没有严格的响应时间限制(见[第11章](ch11.md))。
> 在嵌入式系统中实时是指系统经过精心设计和测试以满足所有情况下的特定时间保证。这个含义与Web上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见[第11章](ch11.md))。
例如如果车载传感器检测到当前正在经历碰撞你肯定不希望安全气囊释放系统因为GC暂停而延迟弹出。
在系统中提供**实时保证**需要各级软件栈的支持一个实时操作系统RTOS允许在指定的时间间隔内保证CPU时间的分配。库函数必须记录最坏情况下的执行时间动态内存分配可能受到限制或完全不允许实时垃圾收集器存在但是应用程序仍然必须确保它不会给GC太多的负担必须进行大量的测试和测量以确保达到保证。
在系统中提供**实时保证**需要各级软件栈的支持一个实时操作系统RTOS允许在指定的时间间隔内保证CPU时间的分配。库函数必须申明最坏情况下的执行时间动态内存分配可能受到限制或完全不允许实时垃圾收集器存在但是应用程序仍然必须确保它不会给GC太多的负担必须进行大量的测试和测量以确保达到保证。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**”与“**高性能**”不一样——事实上,实时系统可能具有较低的吞吐量,因为他们必须优先考虑及时响应高于一切(另请参见“[延迟和资源利用](#延迟和资源利用)“)。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**”与“**高性能**”不一样——事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参见“[延迟和资源利用](#延迟和资源利用)“)。
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
#### 限制垃圾收集的影响
程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。
程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。
一个新兴的想法是将GC暂停视为一个节点的短暂计划中断并让其他节点处理来自客户端的请求,同时一个节点正在收集其垃圾。如果运行时可以警告应用程序一个节点很快需要GC暂停那么应用程序可以停止向该节点发送新的请求等待它完成处理未完成的请求然后在没有请求正在进行时执行GC。这个技巧隐藏了来自客户端的GC暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
一个新兴的想法是将GC暂停视为一个节点的短暂计划中断在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要GC暂停那么应用程序可以停止向该节点发送新的请求等待它完成处理未完成的请求然后在没有请求正在进行时执行GC。这个技巧向客户端隐藏了GC暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象快速收集并定期在积累大量长寿对象因此需要完整GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从节点移开,就像[滚动升级](ch4.md#滚动升级)一样。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集并定期在积累大量长寿对象因此需要完整GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开,就像[第4章](ch4.md)里描述的滚动升级一样。
这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。
@ -457,9 +452,9 @@ while(true){
幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在[第9章](ch9.md)中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在[第9章](ch9.md)中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
### 真由多数所定义
### 真由多数所定义
设想一个具有不对称故障的网络一个节点能够接收发送给它的所有消息但是来自该节点的任何传出消息被丢弃或延迟【19】。即使该节点运行良好并且正在接收来自其他节点的请求其他节点也无法听到其响应。经过一段时间后其他节点宣布它已经死亡因为他们没有听到节点的消息。这种情况就像梦魇一样**半断开semi-disconnected**的节点被拖向墓地,敲打尖叫道“我没死!” ——但是由于没有人能听到它的尖叫,葬礼队伍继续以坚忍的决心继续行进。
@ -491,11 +486,11 @@ while(true){
**图8-4 分布式锁的实现不正确客户端1认为它仍然具有有效的租约即使它已经过期从而破坏了存储中的文件**
这个问题就是我们先前在“[进程暂停](#进程暂停)”中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入冲突和损坏的文件。
这个问题就是我们先前在“[进程暂停](#进程暂停)”中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
#### 防护令牌
当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护fencing**,如[图8-5]()所示
当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护fencing**,如[图8-5](img/fig8-5.png)所示
![](img/fig8-5.png)
@ -521,55 +516,55 @@ while(true){
> ### 拜占庭将军问题
>
> 拜占庭将军问题是所谓“两将军问题”的概括【78】它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地他们只能通过信使进行沟通信使有时会被延迟或丢失就像网络中的信息包一样。我们将在[第9章](ch9.md)讨论这个共识问题。
> 拜占庭将军问题是对所谓“两将军问题”的泛化【78】它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地他们只能通过信使进行沟通信使有时会被延迟或丢失就像网络中的信息包一样。我们将在[第9章](ch9.md)讨论这个共识问题。
>
> 在这个拜占庭式的问题中有n位将军需要同意他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的因而发出了真实的信息但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人在试图保持未被发现的同时。事先并不知道叛徒是谁。
> 在这个问题的拜占庭版本里有n位将军需要同意他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的因而发出了真实的信息但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人在试图保持未被发现的同时。事先并不知道叛徒是谁。
>
> 拜占庭是后来成为君士坦丁堡的古希腊城市现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现阴谋和阴谋。相反这个名字来源于拜占庭式的过度复杂官僚迂回等意义早在计算机之前就已经在政治中被使用了【79】。Lamport想要选一个不会冒犯任何读者的国家他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为**拜占庭容错Byzantine fault-tolerant**的,在特定场景下,这种担忧在是有意义的:
* 在航空航天环境中计算机内存或CPU寄存器中的数据可能被辐射破坏导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵例如飞机撞毁和炸死船上所有人员或火箭与国际空间站相撞飞行控制系统必须容忍拜占庭故障【81,82】。
* 在多个参与组织的系统中,一些参与者可能会试图欺骗或欺骗他人。在这种情况下,节点仅仅信任另一个节点的消息是不安全的,因为它们可能是出于恶意的目的而被发送的。例如,像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式,而不依赖于中央当局【83】。
* 在多个参与组织的系统中,一些参与者可能会试图欺骗或欺骗他人。在这种情况下,节点仅仅信任另一个节点的消息是不安全的,因为它们可能是出于恶意的目的而被发送的。例如,像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式,而不依赖于中心机构central authority【83】。
然而在本书讨论的那些系统中我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里所有的节点都是由你的组织控制的所以他们可以信任辐射水平足够低内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂【84】而容错嵌入式系统依赖于硬件层面的支持【81】。在大多数服务器端数据系统中部署拜占庭容错解决方案的成本使其变得不切实际。
Web应用程序确实需要预期受终端用户控制的客户端如Web浏览器的任意和恶意行为。这就是为什么输入验证数据清洗和输出转义如此重要例如防止SQL注入和跨站点脚本。但是,我们通常不使用拜占庭容错协议,而只是让服务器决定是否允许客户端行为。在没有这种中心授权的对等网络中,拜占庭容错更为重要。
Web应用程序确实需要预期受终端用户控制的客户端如Web浏览器的任意和恶意行为。这就是为什么输入验证数据清洗和输出转义如此重要例如防止SQL注入和跨站点脚本。然而,我们通常不在这里使用拜占庭容错协议,而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中,拜占庭容错更为重要。
软件中的一个错误可能被认为是拜占庭式的错误,但是如果您将相同的软件部署到所有节点上,那么拜占庭式的容错算法不能为您节省。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作如果有四个节点最多只能有一个故障。要使用这种方法对付bug你必须有四个独立的相同软件的实现并希望一个bug只出现在四个实现之一中。
软件中的一个错误bug可能被认为是拜占庭式的错误,但是如果您将相同的软件部署到所有节点上,那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作如果有四个节点最多只能有一个故障。要使用这种方法对付bug你必须有四个独立的相同软件的实现并希望一个bug只出现在四个实现之一中。
同样,如果一个协议可以保护我们免受漏洞,安全妥协和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能运行相同的软件。因此传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能运行相同的软件。因此传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
#### 弱谎言形式
尽管我们假设节点通常是诚实的,但值得向软件中添加防止“撒谎”弱形式的机制——例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
* 由于硬件问题或操作系统驱动程序路由器等中的错误网络数据包有时会受到损坏。通常内建于TCP和UDP中的校验和会俘获损坏的数据包但有时它们会逃避检测【85,86,87】 。简单的措施通常是采用充分的保护来防止这种破坏,例如应用程序级协议中的校验和。
* 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配拒绝服务。防火墙后面的内部服务可能能够在对输入进行较不严格的检查的情况下逃脱,但是一些基本的理智检查(例如,在协议解析中)是一个好主意。
* NTP客户端可以配置多个服务器地址。同步时客户端联系所有的服务器估计它们的误差并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题一个配置错误的NTP服务器报告的时间会被当成特异值从同步中排除【37】。使用多个服务器使NTP更健壮比起只用单个服务器来
* 由于硬件问题或操作系统、驱动程序、路由器等中的错误网络数据包有时会受到损坏。通常损坏的数据包会被内建于TCP和UDP中的校验和所俘获但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
* 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。
* NTP客户端可以配置多个服务器地址。同步时客户端联系所有的服务器估计它们的误差并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题一个配置错误的NTP服务器报告的时间会被当成特异值从同步中排除【37】。使用多个服务器使NTP更健壮比起只用单个服务器来
### 系统模型与现实
已经有很多算法被设计以解决分布式系统问题——例如,我们将在[第9章](ch9.md)讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
算法的编写方式并不过分依赖于运行的硬件和软件配置的细节。这又要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可能承担的事情。
关于定时假设,三种系统模型是常用的:
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的:
***同步模型***
**同步模型synchronous model**假设网络延迟,进程暂停和和时钟误差都是有界限的。这并不意味着完全同步的时钟或零网络延迟;这只意味着你知道网络延迟暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型因为如本章所讨论的无限延迟和暂停确实会发生。
**同步模型synchronous model**假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟;这只意味着你知道网络延迟暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型因为如本章所讨论的无限延迟和暂停确实会发生。
***部分同步模型***
**部分同步partial synchronous**意味着一个系统在大多数情况下像一个同步系统一样运行但有时候会超出网络延迟进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型大多数情况下网络和进程表现良好否则我们永远无法完成任何事情但是我们必须承认在任何时刻假设都存在偶然被破坏的事实。发生这种情况时,网络延迟暂停和时钟错误可能会变得相当大。
**部分同步partial synchronous**意味着一个系统在大多数情况下像一个同步系统一样运行但有时候会超出网络延迟进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型大多数情况下网络和进程表现良好否则我们永远无法完成任何事情但是我们必须承认在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时,网络延迟暂停和时钟错误可能会变得相当大。
***异步模型***
在这个模型中,一个算法不允许对时做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
在这个模型中,一个算法不允许对时做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时间问题,我们还要考虑**节点失效**。三种最常见的节点系统模型是:
进一步来说,除了时序问题,我们还要考虑**节点失效**。三种最常见的节点系统模型是:
***崩溃-停止故障***
@ -589,7 +584,7 @@ while(true){
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(参阅“[防护令牌](防护令牌)”),我们可能要求算法具有以下属性:
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(参阅“[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性:
***唯一性***
@ -603,7 +598,7 @@ while(true){
请求防护令牌并且不会崩溃的节点,最终会收到响应。
如果一个系统模型中的算法总是满足它在我们假设可能发生的所有情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
#### 安全性和活性
@ -613,8 +608,8 @@ while(true){
安全性通常被非正式地定义为,**没有坏事发生**,而活性通常就类似:**最终好事发生**。但是最好不要过多地阅读那些非正式的定义因为好与坏的含义是主观的。安全性和活性的实际定义是精确的和数学的【90】
* 如果安全属性被违反,我们可以指向一个特定的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌返回的特定操作) 。违反安全属性后,违规行为不能撤销——损失已经发生。
* 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来(即通过接受答复)。
* 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌返回的特定操作)。违反安全属性后,违规行为不能撤销——损失已经发生。
* 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
区分安全性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求**始终**保持安全属性是常见的【88】。也就是说即使所有节点崩溃或者整个网络出现故障算法仍然必须确保它不会返回错误的结果即保证安全性得到满足
@ -624,37 +619,36 @@ while(true){
安全性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而,在实践中实施算法时,现实的混乱事实再一次地让你咬牙切齿,很明显系统模型是对现实的简化抽象。
例如,在故障恢复模型中的算法通常假设稳定存储器中的数据经历了崩溃。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况?
例如,在崩溃-恢复crash-recovery模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况【91】?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况【92】
法定人数算法(参见“[读写法定人数](ch5.md#读写法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多存在崩溃,但有时可能会丢失。但是那个模型就变得更难以推理了。
法定人数算法(参见“[读写法定人数](ch5.md#读写法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
算法的理论描述可以简单宣称一些事在假设上是不会发生的——在非拜占庭式系统中。但实际上我们还是需要对可能发生和不可能发生的故障做出假设,真实世界的实现,仍然会包括处理“假设上不可能”情况的代码,即使代码可能就是`printf("you sucks")`和`exit(666)`,实际上也就是留给运维来擦屁股。(这可以说是计算机科学和软件工程间的一个差异)。
算法的理论描述可以简单宣称一些事是不会发生的——在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理“假设上不可能”情况的代码,即使代码可能就是`printf("Sucks to be you")`和`exit(666)`,实际上也就是留给运维来擦屁股【93】。(这可以说是计算机科学和软件工程间的一个差异)。
这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性降低到一个我们可以推理的可处理的错误是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性总是保持在某个系统模型中。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时间)因为不寻常的情况被打破。理论分析与经验测试同样重要。
这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性提取成一个个我们可以推理的可处理的错误类型是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。
## 本章小结
在本章中,我们讨论了分布式系统中可能发生的各种问题,包括:
* 当您尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否通过
* 节点的时钟可能会与其他节点显著不同步尽管您尽最大努力设置NTP它可能会突然跳转或跳回依靠它是很危险的因为您很可能没有好的测量你的时钟的错误间隔。
* 当您尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否发送成功了
* 节点的时钟可能会与其他节点显著不同步尽管您尽最大努力设置NTP它可能会突然跳转或跳回依靠它是很危险的因为您很可能没有好的方法来测量你的时钟的错误间隔。
* 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为停止所有处理的垃圾收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
这类**部分失效**可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立**部分失效**的容错机制,这样整个系统即使某些组成部分被破坏的情况下,也可以继续运行。
这类**部分失效partial failure**可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立**部分失效**的容错机制,这样整个系统即使某些组成部分被破坏的情况下,也可以继续运行。
为了容忍错误,第一步是**检测**它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠**超时**来确定远程节点是否仍然可用。但是,超时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误【94】千兆网卡可能突然下降到1 Kb/s的吞吐量。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。
为了容忍错误,第一步是**检测**它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠**超时**来确定远程节点是否仍然可用。但是超时无法区分网络失效和节点失效并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外有时一个节点可能处于降级状态例如由于驱动程序错误千兆网卡可能突然下降到1 Kb/s的吞吐量【94】。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
如果你习惯于在理想化的数学完美(同一个操作总能确定地返回相同的结果)的单机环境中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。
如果你习惯于在理想化的数学完美的单机环境同一个操作总能确定地返回相同的结果中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。
但是,正如在[第二部分](part-ii.md)的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择**便宜而不可靠**,而不是**昂贵和可靠**。
在本章中,我们也转换了几次话题,探讨了网络时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择**便宜而不可靠**,而不是**昂贵和可靠**。
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。

6
ch9.md
View File

@ -114,7 +114,7 @@
* $cas(x, v_{old}, v_{new})⇒r$ 表示客户端请求进行原子性的[**比较与设置**](ch7.md#比较并设置CAS)操作。如果寄存器 $x$ 的当前值等于 $v_{old}$ ,则应该原子地设置为 $v_{new}$ 。如果 $x≠v_{old}$ ,则操作应该保持寄存器不变并返回一个错误。 $r$ 是数据库的响应(正确或错误)。
[图9-4]()中的每个操作都在我们认为执行操作的时候用竖线标出(在每个操作的条柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(**每次读取都必须返回最近一次写入设置的值**)。
[图9-4](img/fig9-4.png)中的每个操作都在我们认为执行操作的时候用竖线标出(在每个操作的条柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(**每次读取都必须返回最近一次写入设置的值**)。
线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。
@ -122,7 +122,7 @@
**图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的**
[图9-4]()中有一些有趣的细节需要指出:
[图9-4](img/fig9-4.png)中有一些有趣的细节需要指出:
* 第一个客户端B发送一个读取 `x` 的请求然后客户端D发送一个请求将 `x` 设置为 `0`然后客户端A发送请求将 `x` 设置为 `1`。尽管如此返回到B的读取值为 `1`由A写入的值。这是可以的这意味着数据库首先处理D的写入然后是A的写入最后是B的读取。虽然这不是请求发送的顺序但这是一个可以接受的顺序因为这三个请求是并发的。也许B的读请求在网络上略有延迟所以它在两次写入之后才到达数据库。
@ -238,7 +238,7 @@
在[图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](img/fig9-1.png)
有趣的是通过牺牲性能可以使Dynamo风格的法定人数线性化读取者必须在将结果返回给应用之前同步执行读修复参阅“[读时修复与反熵过程](ch5.md#读时修复与反熵过程)”) 并且写入者必须在发送写入之前读取法定数量节点的最新状态【24,25】。然而由于性能损失Riak不执行同步读修复【26】。 Cassandra在进行法定人数读取时**确实**在等待读修复完成【27】但是由于使用了最后写入胜利的冲突解决方案当同一个键有多个并发写入时将不能保证线性一致性。

View File

@ -292,9 +292,9 @@
### 可序列serializable
### 可串行serializable
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。 请参阅第251页上的“可序列化”。
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。 请参阅第7章的“[可串行化](ch7.md#可串行化)”。