From 9746baf3c054fec44f32dbf36bde421018e28290 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 09:23:46 +0800 Subject: [PATCH] bugfix: file move structure --- zh-cn/ch1.md | 26 +++++++------- zh-cn/ch10.md | 22 ++++++------ zh-cn/ch11.md | 48 +++++++++++++------------- zh-cn/ch12.md | 12 +++---- zh-cn/ch2.md | 42 +++++++++++------------ zh-cn/ch3.md | 68 ++++++++++++++++++------------------- zh-cn/ch4.md | 32 +++++++++--------- zh-cn/ch5.md | 78 +++++++++++++++++++++--------------------- zh-cn/ch6.md | 34 +++++++++---------- zh-cn/ch7.md | 50 +++++++++++++-------------- zh-cn/ch8.md | 28 +++++++-------- zh-cn/ch9.md | 88 ++++++++++++++++++++++++------------------------ zh-cn/part-ii.md | 4 +-- 13 files changed, 266 insertions(+), 266 deletions(-) diff --git a/zh-cn/ch1.md b/zh-cn/ch1.md index 42555e35..463317ba 100644 --- a/zh-cn/ch1.md +++ b/zh-cn/ch1.md @@ -1,6 +1,6 @@ # 第一章:可靠性,可扩展性,可维护性 -![](img/ch1.png) +![](../img/ch1.png) > 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗? > @@ -40,9 +40,9 @@ ​ 其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。 -​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 +​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](../img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 -![](img/fig1-1.png) +![](../img/fig1-1.png) **图1-1 一个可能的组合使用多个组件的数据系统架构** @@ -174,7 +174,7 @@ 大体上讲,这一对操作有两种实现方式。 -1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: +1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](../img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: ```sql SELECT tweets.*, users.* @@ -183,13 +183,13 @@ JOIN follows ON follows.followee_id = users.id WHERE follows.follower_id = current_user ``` - ![](img/fig1-2.png) + ![](../img/fig1-2.png) **图1-2 推特主页时间线的关系型模式简单实现** -2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 +2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](../img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 - ![](img/fig1-3.png) + ![](../img/fig1-3.png) **图1-3 用于分发推特至关注者的数据流水线,2012年11月的负载参数【16】** @@ -220,9 +220,9 @@ ​ 即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。 -​ 在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 +​ 在[图1-4](../img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 -![](img/fig1-4.png) +![](../img/fig1-4.png) **图1-4 展示了一个服务100次请求响应时间的均值与百分位数** @@ -232,7 +232,7 @@ ​ 如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第50百分位点,有时缩写为p50。注意中位数是关于单个请求的;如果用户同时发出几个请求(在一个会话过程中,或者由于一个页面中包含了多个资源),则至少一个请求比中位数慢的概率远大于50%。 -​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。 +​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](../img/fig1-4.png)所示。 ​ 响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。 @@ -246,13 +246,13 @@ > #### 实践中的百分位点 > -> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 +> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](../img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 > > ​ 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。 > > ​ 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。 -![](img/fig1-5.png) +![](../img/fig1-5.png) **图1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求** @@ -376,7 +376,7 @@ ​ 不幸的是,使应用可靠、可扩展或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。 -​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子) +​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](../img/fig1-1.png)中的例子) diff --git a/zh-cn/ch10.md b/zh-cn/ch10.md index d039ab24..58de73f4 100644 --- a/zh-cn/ch10.md +++ b/zh-cn/ch10.md @@ -1,6 +1,6 @@ # 10. 批处理 -![](img/ch10.png) +![](../img/ch10.png) > 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。 > @@ -247,11 +247,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(参阅“[MapReduce查询](ch2.md#MapReduce查询)”)。 -​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。 +​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](../img/fig10-1.png)中的m1,m2和m3标记)。 ​ 每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。 -![](img/fig10-1.png) +![](../img/fig10-1.png) **图10-1 具有三个Mapper和三个Reducer的MapReduce任务** @@ -297,9 +297,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 示例:分析用户活动事件 -​ [图10-2](img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 +​ [图10-2](../img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 -![](img/fig10-2.png) +![](../img/fig10-2.png) **图10-2 用户行为日志与用户档案的连接** @@ -313,9 +313,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 排序合并连接 -​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。 +​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](../img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](../img/fig10-3.png)所示。 -![](img/fig10-3.png) +![](../img/fig10-3.png) **图10-3 在用户ID上进行的Reduce端连接。如果输入数据集分区为多个文件,则每个分区都会被多个Mapper并行处理** @@ -375,11 +375,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。 -​ 例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 +​ 例如,假设在[图10-2](../img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 [^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。 -​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 +​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](../img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 ​ 这种简单有效的算法被称为**广播散列连接(broadcast hash join)**:**广播**一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),**散列**一词反映了它使用一个散列表。 Pig(名为“**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用【41】。 @@ -387,7 +387,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 分区散列连接 -​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 +​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](../img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 ​ 如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。 @@ -612,7 +612,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 > 像Spark,Flink和Tez这样的数据流引擎(参见“[中间状态的物化](#中间状态的物化)”)通常将算子作为**有向无环图(DAG)**的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱! -​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 +​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](../img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 ​ 可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种“重复至完成”的想法不能用普通的MapReduce来表示,因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现: diff --git a/zh-cn/ch11.md b/zh-cn/ch11.md index 97309aad..983e63e0 100644 --- a/zh-cn/ch11.md +++ b/zh-cn/ch11.md @@ -1,6 +1,6 @@ # 11. 流处理 -![](img/ch11.png) +![](../img/ch11.png) > 有效的复杂系统总是从简单的系统演化而来。 反之亦然:从零设计的复杂系统没一个能有效工作的。 > @@ -94,7 +94,7 @@ #### 多个消费者 -当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示: +当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](../img/fig11-1.png)所示: ***负载均衡(load balance)*** @@ -104,7 +104,7 @@ ​ 每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。 -![](img/fig11-1.png) +![](../img/fig11-1.png) **图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。** @@ -116,9 +116,9 @@ ​ 如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样) -​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 +​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](../img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 -![](img/fig11-2.png) +![](../img/fig11-2.png) **图11-2 在处理m3时消费者2崩溃,因此稍后重传至消费者1** @@ -142,11 +142,11 @@ ​ 同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。 -​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。 +​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](../img/fig11-3.png)所示。 -​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 +​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](../img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 -![](img/fig11-3.png) +![](../img/fig11-3.png) **图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件** @@ -223,9 +223,9 @@ ​ 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是**双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。 -​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 +​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](../img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 -![](img/fig11-4.png) +![](../img/fig11-4.png) **图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达** @@ -233,7 +233,7 @@ ​ 双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。 -​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 +​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](../img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 ​ 如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗? @@ -245,9 +245,9 @@ ​ 最近,人们对**变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。 -​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。 +​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](../img/fig11-5.png)所示。 -![](img/fig11-5.png) +![](../img/fig11-5.png) **图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统** @@ -255,7 +255,7 @@ ​ 我们可以将日志消费者叫做**衍生数据系统**,正如在第三部分的[介绍](part-iii.md)中所讨论的:存储在搜索索引和数据仓库中的数据,只是**记录系统**数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。 -​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。 +​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](../img/fig11-2.png)的重新排序问题)。 ​ 数据库触发器可用来实现变更数据捕获(参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如应对模式变更。 @@ -275,7 +275,7 @@ ​ 如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)** 提供了一个很好的备选方案。 -​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 +​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](../img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 ​ 在日志结构存储引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。 @@ -299,7 +299,7 @@ ​ 与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上: -* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 +* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](../img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 * 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。 事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。 @@ -345,12 +345,12 @@ ​ 无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(change log)**,表示了随时间演变的状态。 -​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 +​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](../img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 $$ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ stream(t) = \frac{d\ state(t)}{dt} $$ -![](img/fig11-6.png) +![](../img/fig11-6.png) **图11-6 应用当前状态与事件流之间的关系** @@ -372,7 +372,7 @@ $$ #### 从同一事件日志中派生多个视图 -​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 +​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](../img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 ​ 添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。 @@ -412,7 +412,7 @@ $$ 剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项: -1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 +1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](../img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。 3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项1或2)。 @@ -507,9 +507,9 @@ $$ [^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。 -​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](img/fig11-7.png))。 +​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](../img/fig11-7.png))。 -![](img/fig11-7.png) +![](../img/fig11-7.png) **图11-7 按处理时间分窗,会因为处理速率的变动引入人为因素** @@ -580,7 +580,7 @@ $$ #### 流表连接(流扩展) -​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 +​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](../img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 ​ 要执行此联接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在“[示例:分析用户活动事件](ch10.md#示例:分析用户活动事件)”一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。 @@ -615,7 +615,7 @@ GROUP BY follows.follower_id ​ 流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当基础表发生变化时都会更新[^iii]。 -[^iii]: 如果你将流视作表的衍生物,如[图11-6](img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 +[^iii]: 如果你将流视作表的衍生物,如[图11-6](../img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 #### 连接的时间依赖性 diff --git a/zh-cn/ch12.md b/zh-cn/ch12.md index 63b3fd0d..99496056 100644 --- a/zh-cn/ch12.md +++ b/zh-cn/ch12.md @@ -1,6 +1,6 @@ # 12. 数据系统的未来 -![](img/ch12.png) +![](../img/ch12.png) > 如果船长的终极目标是保护船只,他应该永远待在港口。 > @@ -42,7 +42,7 @@ ​ 例如,你可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 -​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 +​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](../img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 ​ 如果您可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。 这是状态机复制方法的一个应用,我们在“[全序广播](ch9.md#全序广播)”中看到。无论您使用变更数据捕获还是事件源日志,都不如仅对全局顺序达成共识更重要。 @@ -328,9 +328,9 @@ ### 观察衍生数据状态 -​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了一个更新搜索索引的例子。 +​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](../img/fig12-1.png)显示了一个更新搜索索引的例子。 -![](img/fig12-1.png) +![](../img/fig12-1.png) **图12-1 在搜索索引中,写(文档更新)遇上读(查询)** @@ -338,7 +338,7 @@ ​ 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。 -​ 如[图12-1](img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 +​ 如[图12-1](../img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 #### 物化视图和缓存 @@ -454,7 +454,7 @@ ​ 除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP使用数据包上的序列号,在接收方将它们正确排序。并确定网络上是否有数据包丢失或重复。任何丢失的数据包都会被重新传输,而在将数据交付应用前,TCP协议栈会移除任何重复数据包。 -​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](img/fig8-1.png))。 +​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](../img/fig8-1.png))。 **例12-1 资金从一个账户到另一个账户的非幂等转移** diff --git a/zh-cn/ch2.md b/zh-cn/ch2.md index 4fbc1796..ed2dbbfd 100644 --- a/zh-cn/ch2.md +++ b/zh-cn/ch2.md @@ -1,6 +1,6 @@ # 2. 数据模型与查询语言 -![](img/ch2.png) +![](../img/ch2.png) > 语言的边界就是思想的边界。 > @@ -65,13 +65,13 @@ 像ActiveRecord和Hibernate这样的 **对象关系映射(ORM object-relational mapping)** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。 -![](img/fig2-1.png) +![](../img/fig2-1.png) **图2-1 使用关系型模式来表示领英简介** -例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: +例如,[图2-1](../img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: -* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](img/fig2-1.png)所示。 +* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](../img/fig2-1.png)所示。 * 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。JSON数据类型也得到多个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。 * 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。 @@ -119,11 +119,11 @@ 有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。 -JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 +JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 -从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。 +从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](../img/fig2-2.png))。 -![](img/fig2-2.png) +![](../img/fig2-2.png) **图2-2 一对多关系构建了一个树结构** @@ -157,18 +157,18 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 ***组织和学校作为实体*** -在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。 +在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](../img/fig2-3.png),来自LinkedIn的一个例子)。 ***推荐*** 假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何建议都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。 -![](img/fig2-3.png) +![](../img/fig2-3.png) **图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)** -[图2-4](img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 +[图2-4](../img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 -![](img/fig2-4.png) +![](../img/fig2-4.png) **图2-4 使用多对多关系扩展简历** @@ -178,7 +178,7 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用和维护,运行在IBM大型机的OS/390上【14】。 -IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](img/fig2-2.png)的JSON结构。 +IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](../img/fig2-2.png)的JSON结构。 同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。 @@ -226,7 +226,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数 #### 哪个数据模型更方便写代码? -如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 +如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](../img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。 @@ -274,7 +274,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 查询的数据局部性 -文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 +文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](../img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。 @@ -533,9 +533,9 @@ db.observations.aggregate([ 在刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人,网页或交叉路口)。不过,图并不局限于这样的同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到和用户的评论;边缘表示哪些人是彼此的朋友,哪个签到发生在何处,谁评论了哪条消息,谁参与了哪个事件,等等【35】。 -在本节中,我们将使用[图2-5](img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 +在本节中,我们将使用[图2-5](../img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 -![](img/fig2-5.png) +![](../img/fig2-5.png) **图2-5 图数据结构示例(框代表顶点,箭头代表边)** @@ -586,7 +586,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动。(这就是为什么[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) 3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。 -这些特性为数据建模提供了很大的灵活性,如[图2-5](img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 +这些特性为数据建模提供了很大的灵活性,如[图2-5](../img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 你可以想象延伸图还能包括许多关于Lucy和Alain,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图表在可演化性是富有优势的:当向应用程序添加功能时,可以轻松扩展图以适应应用程序数据结构的变化。 @@ -594,7 +594,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); Cypher是属性图的声明式查询语言,为Neo4j图形数据库而发明【37】。(它是以电影“黑客帝国”中的一个角色来命名的,而与密码术中的密码无关【38】。) -[例2-3]()显示了将[图2-5](img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 +[例2-3]()显示了将[图2-5](../img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 **例2-3 将图2-5中的数据子集表示为Cypher查询** @@ -608,7 +608,7 @@ CREATE (Lucy) -[:BORN_IN]-> (Idaho) ``` -当[图2-5](img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 +当[图2-5](../img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 [例2-4]()展示了如何在Cypher中表达这个查询。在MATCH子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`边的任意两个顶点。该边的尾节点被绑定了变量`person`,头节点则未被绑定。 @@ -896,9 +896,9 @@ Cypher和SPARQL使用SELECT立即跳转,但是Datalog一次只进行一小步 2. 数据库存在`within(usa, namerica)`,在上一步骤中生成`within_recursive(namerica, 'North America')`,故运用规则2。它会产生`within_recursive(usa, 'North America')`。 3. 数据库存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故运用规则2。它产生`within_recursive(idaho, 'North America')`。 -通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](img/fig2-6.png)所示。 +通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](../img/fig2-6.png)所示。 -![](img/fig2-6.png) +![](../img/fig2-6.png) **图2-6 使用示例2-11中的Datalog规则来确定爱达荷州在北美。** diff --git a/zh-cn/ch3.md b/zh-cn/ch3.md index 8cbf6fed..881e8281 100644 --- a/zh-cn/ch3.md +++ b/zh-cn/ch3.md @@ -1,6 +1,6 @@ # 3. 存储与检索 -![](img/ch3.png) +![](../img/ch3.png) > 建立秩序,省却搜索 > @@ -83,9 +83,9 @@ $ cat database 键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢? -假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 +假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](../img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 -![](img/fig3-1.png) +![](../img/fig3-1.png) **图3-1 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。** @@ -93,15 +93,15 @@ $ cat database 像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。 -直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 +直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](../img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 -![](img/fig3-2.png) +![](../img/fig3-2.png) **图3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值** -而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 +而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](../img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 -![](img/fig3-3.png) +![](../img/fig3-3.png) **图3-3 同时执行压缩和分段合并** @@ -148,30 +148,30 @@ $ cat database ### SSTables和LSM树 -在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 +在[图3-3](../img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。 我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势: -1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 +1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](../img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 - ![](img/fig3-4.png) + ![](../img/fig3-4.png) ##### 图3-4 合并几个SSTable段,只保留每个键的最新值 如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。 -2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 +2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](../img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 - ![](img/fig3-5.png) + ![](../img/fig3-5.png) **图3-5 具有内存索引的SSTable** 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。 -3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](../img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 [^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) @@ -217,25 +217,25 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使 我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。 -每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。 +每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](../img/fig3-6.png)所示。 -![](img/fig3-6.png) +![](../img/fig3-6.png) **图3-6 使用B树索引查找一个键** 一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。 -在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 +在[图3-6](../img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。 -在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 +在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](../img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 -如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。 +如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](../img/fig3-7.png)所示[^ii]。 [^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 -![](img/fig3-7.png) +![](../img/fig3-7.png) **图3-7 通过分割页面来生长B树** @@ -299,7 +299,7 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载 到目前为止,我们只讨论了关键值索引,它们就像关系模型中的**主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。 -有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 +有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](../img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。 @@ -398,9 +398,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 这些OLTP系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以DBA会密切关注他们的OLTP数据库,他们通常不愿意让业务分析人员在OLTP数据库上运行临时分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时执行的事务的性能。 -相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8)所示。 +相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](../img/fig3-8)所示。 -![](img/fig3-8.png) +![](../img/fig3-8.png) **图3-8 ETL至数据仓库的简化提纲** @@ -424,7 +424,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 -![](img/fig3-9.png) +![](../img/fig3-9.png) **图3-9 用于数据仓库的星型模式的示例** @@ -432,7 +432,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。 -例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 +例如,在[图3-9](../img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 即使日期和时间通常使用维度表来表示,因为这允许对日期(诸如公共假期)的附加信息进行编码,从而允许查询区分假期和非假期的销售。 @@ -469,13 +469,13 @@ GROUP BY 我们如何有效地执行这个查询? -在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。 +在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](../img/fig3-1.png)的CSV例子中看到这个。 为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。 -面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。 +面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](../img/fig3-10.png)所示。 -![](img/fig3-10.png) +![](../img/fig3-10.png) **图3-10 使用列存储关系型数据,而不是行** @@ -489,9 +489,9 @@ GROUP BY 除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。 -看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。 +看看[图3-10](../img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](../img/fig3-11.png)所示。 -![](img/fig3-11.png) +![](../img/fig3-11.png) **图3-11 压缩位图索引存储布局** @@ -536,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3 相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。 -第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 +第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](../img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 -排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 +排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](../img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。 @@ -568,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3 当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。 -物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。 +物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](../img/fig3-12.png)显示了一个例子。 -![](img/fig3-12.png) +![](../img/fig3-12.png) **图3-12 数据立方的两个维度,通过求和聚合** -想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 +想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](../img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。 diff --git a/zh-cn/ch4.md b/zh-cn/ch4.md index d047edd1..f45c9aff 100644 --- a/zh-cn/ch4.md +++ b/zh-cn/ch4.md @@ -1,6 +1,6 @@ # 4. 编码与演化 -![](img/ch4.png) +![](../img/ch4.png) > 唯变所适 > @@ -113,7 +113,7 @@ JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事 在下面的章节中,能达到比这好得多的结果,只用32个字节对相同的记录进行编码。 -![](img/fig4-1.png) +![](../img/fig4-1.png) **图4-1 使用MessagePack编码的记录(例4-1)** @@ -141,9 +141,9 @@ message Person { ``` Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。您的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。 -用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](img/fig4-2.png)所示【19】。 +用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](../img/fig4-2.png)所示【19】。 -![](img/fig4-2.png) +![](../img/fig4-2.png) **图4-2 使用Thrift二进制协议编码的记录** @@ -151,17 +151,17 @@ Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用 与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。 -与[图4-1](img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 +与[图4-1](../img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 -Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 +Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](../img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 -![](img/fig4-3.png) +![](../img/fig4-3.png) **图4-3 使用Thrift压缩协议编码的记录** -最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 +最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 -![](img/fig4-4.png) +![](../img/fig4-4.png) **图4-4 使用Protobuf编码的记录** @@ -183,7 +183,7 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4 如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被扼杀。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。 -Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 +Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](../img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。 @@ -217,11 +217,11 @@ record Person { } ``` -首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。 +首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](../img/fig4-5.png)所示。 如果您检查字节序列,您可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。 -![](img/fig4-5.png) +![](../img/fig4-5.png) **图4-5 使用Avro编码的记录** @@ -235,11 +235,11 @@ record Person { 当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能是从该模式生成的。 -Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。 +Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](../img/fig4-6.png)所示。 例如,如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。 -![](img/fig4-6.png) +![](../img/fig4-6.png) **图4-6 一个Avro Reader解决读写模式的差异** @@ -344,7 +344,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它 解决这个问题不是一个难题,你只需要意识到它。 -![](img/fig4-7.png) +![](../img/fig4-7.png) **图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。** @@ -474,7 +474,7 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承 消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。 -如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。 +如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](../img/fig4-7.png))。 #### 分布式的Actor框架 diff --git a/zh-cn/ch5.md b/zh-cn/ch5.md index e7499fd7..ed4405ba 100644 --- a/zh-cn/ch5.md +++ b/zh-cn/ch5.md @@ -1,6 +1,6 @@ # 5. 复制 -![](img/ch5.png) +![](../img/ch5.png) > 与可能出错的东西比,'不可能'出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。 > @@ -36,7 +36,7 @@ [^i]: 不同的人对**热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)**指的是能接受客户端读请求的副本。而**温备(warm standby)**只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。 -![](img/fig5-1.png) +![](../img/fig5-1.png) **图5-1 基于领导者(主-从)的复制** ​ 这种复制模式是许多关系数据库的内置功能,如PostgreSQL(从9.0版本开始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库,包括MongoDB,RethinkDB和Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如DRBD这样的块复制设备也与之类似。 @@ -47,9 +47,9 @@ ​ 想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。 -[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 +[图5-2](../img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 -![](img/fig5-2.png) +![](../img/fig5-2.png) **图5-2 基于领导者的复制:一个同步从库和一个异步从库** ​ 在[图5-2]()的示例中,从库1的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库1的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的:主库发送消息,但不等待从库的响应。 @@ -205,7 +205,7 @@ ​ 但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。 -![](img/fig5-3.png) +![](../img/fig5-3.png) **图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常** @@ -236,9 +236,9 @@ ​ 从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流(moving backward in time)**。 -​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 +​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](../img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 -![](img/fig5-4.png) +![](../img/fig5-4.png) **图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。** @@ -260,7 +260,7 @@ 这两句话之间有因果关系:Cake夫人听到了Poons先生的问题并回答了这个问题。 -​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容: +​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](../img/fig5-5.png))。 于是,这个观察者会听到以下内容: > *Mrs. Cake* > ​ 通常约十秒钟,Mr. Poons. @@ -271,7 +271,7 @@ 对于观察者来说,看起来好像Cake夫人在Poons先生发问前就回答了这个问题。 这种超能力让人印象深刻,但也会把人搞糊涂。【25】。 -![](img/fig5-5.png) +![](../img/fig5-5.png) **图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。** @@ -311,9 +311,9 @@ ​ 假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 -​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 +​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](../img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 -![](img/fig5-6.png) +![](../img/fig5-6.png) **图5-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)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 ​ 由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免【28】。 @@ -361,9 +361,9 @@ ​ 多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。 -​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 +​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](../img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 -![](img/fig5-7.png) +![](../img/fig5-7.png) **图5-7 两个主库同时更新同一记录引起的写入冲突** @@ -385,7 +385,7 @@ ​ 单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。 -​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 +​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](../img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 ​ 如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库1的C和主库2的B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种**收敛(convergent)**的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。 @@ -393,7 +393,7 @@ * 给每个写入一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为**最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在[本章末尾](#检测并发写入)更详细地讨论LWW。 * 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。 -* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 +* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](../img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 * 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。 @@ -431,7 +431,7 @@ #### 什么是冲突? -​ 有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 +​ 有些冲突是显而易见的。在[图5-7](../img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 ​ 其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。 @@ -443,7 +443,7 @@ ​ **复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如[图5-7]()所示,只有一个合理的拓扑结构:领导者1必须把他所有的写到领导者2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图5-8]()举例说明了一些例子。 -![](img/fig5-8.png) +![](../img/fig5-8.png) **图5-8 三个可以设置多领导者复制的示例拓扑。** @@ -455,13 +455,13 @@ ​ 循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。 -​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。 +​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](../img/fig5-9.png)所示。 -![](img/fig5-9.png) +![](../img/fig5-9.png) **图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))。 @@ -485,9 +485,9 @@ ​ 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(参阅「[处理节点宕机](#处理节点宕机)」)。 -​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 +​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](../img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 -![](img/fig5-10.png) +![](../img/fig5-10.png) **图5-10 法定写入,法定读取,并在节点中断后读修复。** @@ -503,7 +503,7 @@ ***读修复(Read repair)*** -​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 +​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](../img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 ***反熵过程(Anti-entropy process)*** @@ -513,7 +513,7 @@ #### 读写的法定人数 -​ 在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? +​ 在[图5-10](../img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? ​ 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。 @@ -531,10 +531,10 @@ * 如果$w n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入** @@ -544,7 +544,7 @@ ### 法定人数一致性的局限性 -​ 如果你有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】。 @@ -608,17 +608,17 @@ ​ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**提示移交**期间也可能会产生冲突。 -​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: +​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](../img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: * 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 * 节点 3 首先接收来自 B 的写入,然后从 A 写入。 -![](img/fig5-12.png) +![](../img/fig5-12.png) **图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。** -​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 +​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](../img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 ​ 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。 @@ -628,7 +628,7 @@ ​ 实现最终融合的一种方法是声明每个副本只需要存储最**“最近”**的值,并允许**“更旧”**的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。 -​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 +​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](../img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 ​ 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入胜利(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。 @@ -673,13 +673,13 @@ 4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿] 5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。 -![](img/fig5-13.png) +![](../img/fig5-13.png) **图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。** -​ [图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 +​ [图5-13](../img/fig5-13.png)中的操作之间的数据流如[图5-14](../img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 -![](img/fig5-14.png) +![](../img/fig5-14.png) **图5-14 图5-13中的因果依赖关系图。** @@ -698,7 +698,7 @@ ​ 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。 -​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 +​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](../img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 ​ 然而,如果你想让人们也可以从他们的手推车中**删除**东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果:如果你合并了两个兄弟手推车,并且只在其中一个兄弟值里删掉了它,那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有合适版本号的标记,以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑(tombstone)**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。) @@ -706,13 +706,13 @@ #### 版本向量 -​ [图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? +​ [图5-13](../img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? -​ [图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 +​ [图5-13](../img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 ​ 所有副本的版本号集合称为**版本向量(version vector)**【56】。这个想法的一些变体正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。 -​ 与[图5-13](img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 +​ 与[图5-13](../img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 ​ 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。 diff --git a/zh-cn/ch6.md b/zh-cn/ch6.md index 4dfd6531..c2acbc21 100644 --- a/zh-cn/ch6.md +++ b/zh-cn/ch6.md @@ -1,6 +1,6 @@ # 6. 分区 -![](img/ch6.png) +![](../img/ch6.png) > 我们必须跳出电脑指令序列的窠臼。 叙述定义、描述元数据、梳理关系,而不是编写过程。 > @@ -37,7 +37,7 @@ ​ 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 -![](img/fig6-1.png) +![](../img/fig6-1.png) **图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。** @@ -57,7 +57,7 @@ ​ 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 -![](img/fig6-2.png) +![](../img/fig6-2.png) **图6-2 印刷版百科全书按照关键字范围进行分区** @@ -79,9 +79,9 @@ ​ 出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于哈希表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。 -​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 +​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](../img/fig6-3.png)所示。 -![](img/fig6-3.png) +![](../img/fig6-3.png) **图6-3 按哈希键分区** @@ -125,19 +125,19 @@ ### 基于文档的二级索引进行分区 -​ 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 +​ 假设你正在经营一个销售二手车的网站(如[图6-4](../img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 ​ 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 [^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。 -![](img/fig6-4.png) +![](../img/fig6-4.png) **图6-4 基于文档的二级索引进行分区** ​ 在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库(添加,删除或更新文档),只需处理包含您正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引(local index)**(而不是将在下一节中描述的**全局索引(global index)**)。 -​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 +​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](../img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 ​ 这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使二级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区二级索引。大多数数据库供应商建议您构建一个能从单个分区提供二级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个二级索引时(例如同时需要按颜色和制造商查询)。 @@ -147,9 +147,9 @@ ​ 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 -​ [图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) +![](../img/fig6-5.png) **图6-5 基于关键词对二级索引进行分区** @@ -188,7 +188,7 @@ #### 反面教材:hash mod N -​ 我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key) ​ 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。 > @@ -90,11 +90,11 @@ 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】。 -![](img/fig7-1.png) +![](../img/fig7-1.png) **图7-1 两个客户之间的竞争状态同时递增计数器** @@ -137,7 +137,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的** 同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。 -这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: +这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](../img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: ```sql SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -145,17 +145,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。 -在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 +在[图7-2](../img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 [^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。 -![](img/fig7-2.png) +![](../img/fig7-2.png) **图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。** -[图7-3](img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 +[图7-3](../img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 -![](img/fig7-3.png) +![](../img/fig7-3.png) **图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致** @@ -175,7 +175,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。 -一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 +一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](../img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv] @@ -190,7 +190,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象: * 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。 -* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 +* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](../img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 * 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。 这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。 @@ -250,14 +250,14 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。 -![](img/fig7-4.png) +![](../img/fig7-4.png) **图7-4 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。** 为什么要防止脏读,有几个原因: -- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 -- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 +- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](../img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 +- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](../img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 #### 没有脏写 @@ -267,10 +267,10 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 通过防止脏写,这个隔离级别避免了一些并发问题: -- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 +- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](../img/fig7-5.png),[图7-5](../img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](../img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 - 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。 -![](img/fig7-5.png) +![](../img/fig7-5.png) **图7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起** @@ -292,9 +292,9 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许**中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混合。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。 -但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](img/fig7-6.png)说明了读已提交时可能发生的问题。 +但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](../img/fig7-6.png)说明了读已提交时可能发生的问题。 -![](img/fig7-6.png) +![](../img/fig7-6.png) **图7-6 读取偏差:Alice观察数据库处于不一致的状态** @@ -332,7 +332,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true [^vii]: 事实上,事务ID是32位整数,所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。 -![](img/fig7-7.png) +![](../img/fig7-7.png) **图7-7 使用多版本对象实现快照隔离** @@ -475,9 +475,9 @@ UPDATE wiki_pages SET content = '新内容' 首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作【40,41】。 -现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](img/fig7-8.png)说明了接下来的事情。 +现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](../img/fig7-8.png)说明了接下来的事情。 -![](img/fig7-8.png) +![](../img/fig7-8.png) **图7-8 写入偏差导致应用程序错误的示例** @@ -626,9 +626,9 @@ COMMIT; ​ 在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。 -​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 +​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](../img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 -![](img/fig7-9.png) +![](../img/fig7-9.png) **图7-9 交互式事务和存储过程之间的区别(使用图7-8的示例事务)** @@ -793,7 +793,7 @@ WHERE room_id = 123 AND 回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43 认为Alice的 `on_call = true` ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。 -![](img/fig7-10.png) +![](../img/fig7-10.png) **图7-10 检测事务何时从MVCC快照读取过时的值** @@ -803,9 +803,9 @@ WHERE room_id = 123 AND #### 检测影响之前读取的写入 -第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](img/fig7-11.png)所示。 +第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](../img/fig7-11.png)所示。 -![](img/fig7-11.png) +![](../img/fig7-11.png) **图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。** diff --git a/zh-cn/ch8.md b/zh-cn/ch8.md index d047d162..382487de 100644 --- a/zh-cn/ch8.md +++ b/zh-cn/ch8.md @@ -1,6 +1,6 @@ # 第八章:分布式系统的麻烦 -![](img/ch8.png) +![](../img/ch8.png) > 邂逅相遇 > @@ -97,7 +97,7 @@ ​ **无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。 -​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示): +​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](../img/fig8-1.png)所示): 1. 请求可能已经丢失(可能有人拔掉了网线)。 2. 请求可能正在排队,稍后将交付(也许网络或收件人超载)。 @@ -106,7 +106,7 @@ 5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。 6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。 -![](img/fig8-1.png) +![](../img/fig8-1.png) **图8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。** @@ -168,12 +168,12 @@ ​ 在驾驶汽车时,由于交通拥堵,道路交通网络的通行时间往往不尽相同。同样,计算机网络上数据包延迟的可变性通常是由于排队【25】: -* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 +* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](../img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 * 当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。 * 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,而另一个虚拟机使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。 * TCP执行**流量控制(flow control)**(也称为**拥塞避免(congestion avoidance)**或**背压(backpressure)**),其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队。 -![](img/fig8-2.png) +![](../img/fig8-2.png) **图8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口1,2和4都试图发送数据包到端口3** @@ -319,20 +319,20 @@ ​ 让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近? -​ [图8-3](img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 +​ [图8-3](../img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](../img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 -![](img/fig8-3.png) +![](../img/fig8-3.png) **图8-3 客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。** ​ 在[图8-3]()中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的时钟时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点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的增量操作会丢失。 +​ 尽管如此,[图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的基本问题: * 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝【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毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。 @@ -485,9 +485,9 @@ while(true){ ​ 如果一个节点继续表现为**天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。 -​ 例如,[图8-4](img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 +​ 例如,[图8-4](../img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 -![](img/fig8-4.png) +![](../img/fig8-4.png) **图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件** @@ -495,15 +495,15 @@ while(true){ #### 防护令牌 -​ 当使用锁或租约来保护对某些资源(如[图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) **图8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全** ​ 我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。 -​ 在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 +​ 在[图8-5](../img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 ​ 如果将ZooKeeper用作锁定服务,则可将事务标识`zxid`或节点版本`cversion`用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。 diff --git a/zh-cn/ch9.md b/zh-cn/ch9.md index 0936689a..85317a33 100644 --- a/zh-cn/ch9.md +++ b/zh-cn/ch9.md @@ -1,6 +1,6 @@ # 9. 一致性与共识 -![](img/ch9.png) +![](../img/ch9.png) > 好死不如赖活着 > —— Jay Kreps, 关于Kafka与 Jepsen的若干笔记 (2013) @@ -60,11 +60,11 @@ ​ 在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。 -![](img/fig9-1.png) +![](../img/fig9-1.png) **图9-1 这个系统是非线性一致的,导致了球迷的困惑** -​ [图9-1 ](img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 +​ [图9-1 ](../img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 ​ 如果Alice和Bob在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而Bob是在听到Alice惊呼最后得分**之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。 @@ -72,13 +72,13 @@ ​ 线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。 -​ [图9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 +​ [图9-2](../img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 -![](img/fig9-2.png) +![](../img/fig9-2.png) **图9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值** -​ 为了简单起见,[图9-2](img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] +​ 为了简单起见,[图9-2](../img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] [^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。即使真实的系统通常没有准确的时钟(参阅“[不可靠的时钟](ch8.md#不可靠的时钟)”),但这种假设是允许的:为了分析分布式算法,我们可以假设一个精确的全局时钟存在,不过算法无法访问它【47】。算法只能看到由石英振荡器和NTP产生的实时逼近。 @@ -89,7 +89,7 @@ * $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。 -在[图9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? +在[图9-2](../img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? * 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`。 * 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。 @@ -99,16 +99,16 @@ [^ii]: 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为**常规寄存器(regular register)**【7,25】 -为了使系统线性一致,我们需要添加另一个约束,如[图9-3](img/fig9-3.png)所示 +为了使系统线性一致,我们需要添加另一个约束,如[图9-3](../img/fig9-3.png)所示 -![](img/fig9-3.png) +![](../img/fig9-3.png) **图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。** ​ 在一个线性一致的系统中,我们可以想象,在 `x` 的值从`0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。 -​ [图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) +​ [图9-3](../img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) -​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](img/fig9-4.png)显示了一个更复杂的例子【10】。 +​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](../img/fig9-4.png)显示了一个更复杂的例子【10】。 在[图9-4]()中,除了读写之外,还增加了第三种类型的操作: @@ -118,7 +118,7 @@ ​ 线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。 -![](img/fig9-4.png) +![](../img/fig9-4.png) **图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的** @@ -130,7 +130,7 @@ * 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,C首先读取 `1` ,然后读取 `2` ,因为两次读取之间的值由B更改。可以使用原子**比较并设置(cas)**操作来检查该值是否未被另一客户端同时更改:B和C的**cas**请求成功,但是D的**cas**请求失败(在数据库处理它时,`x` 的值不再是 `0` )。 -* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。 +* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同。 这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。 @@ -182,17 +182,17 @@ #### 跨信道的时序依赖 -​ 注意[图9-1](img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 +​ 注意[图9-1](../img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 -​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](img/fig9-5.png)所示。 +​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](../img/fig9-5.png)所示。 ​ 图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的(参阅[第11章](ch11.md))。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。 -![](img/fig9-5.png) +![](../img/fig9-5.png) **图9-5 Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性。** -​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 +​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](../img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 -​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 +​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](../img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 ​ 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在Alice和Bob的例子),则可以使用在“[读己之写](ch5.md#读己之写)”讨论过的备选方法,不过会有额外的复杂度代价。 @@ -230,13 +230,13 @@ #### 线性一致性和法定人数 -​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](img/fig9-6.png)所示。 +​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](../img/fig9-6.png)所示。 -![](img/fig9-6.png) +![](../img/fig9-6.png) **图9-6 非线性一致的执行,尽管使用了严格的法定人数** -​ 在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 +​ 在[图9-6](../img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 ​ 法定人数条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]()) @@ -252,9 +252,9 @@ ​ 一些复制方法可以提供线性一致性,另一些复制方法则不能,因此深入地探讨线性一致性的优缺点是很有趣的。 -​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](img/fig9-7.png)说明了这种部署的一个例子。 +​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](../img/fig9-7.png)说明了这种部署的一个例子。 -![](img/fig9-7.png) +![](../img/fig9-7.png) **图9-7 网络中断迫使在线性一致性和可用性之间做出选择。** @@ -311,7 +311,7 @@ ## 顺序保证 -​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。 +​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](../img/fig9-4.png)中的顺序。 **顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它**顺序**曾经出现过的上下文: @@ -325,12 +325,12 @@ **顺序**反复出现有几个原因,其中一个原因是,它有助于保持**因果关系(causality)**。在本书中我们已经看到了几个例子,其中因果关系是很重要的: -* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 -* [图5-9](img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 +* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](../img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 +* [图5-9](../img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 * 在“[检测并发写入](ch5.md#检测并发写入)”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B**并发**。这种**此前发生(happened before)**关系是因果关系的另一种表述:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是**并发**的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。 -* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](img/fig7-6)所示)。 -* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 -* 在爱丽丝和鲍勃看球的例子中([图9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 +* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](../img/fig7-6)所示)。 +* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](../img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 +* 在爱丽丝和鲍勃看球的例子中([图9-1](../img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 因果关系对事件施加了一种**顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。 @@ -350,7 +350,7 @@ ***线性一致性*** -​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](img/fig9-4.png)中以时间线表示。 +​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](../img/fig9-4.png)中以时间线表示。 ***因果性*** @@ -358,13 +358,13 @@ ​ 因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。 -​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 +​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](../img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 ​ 如果你熟悉像Git这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个**提交(Commit)**发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并(Merge)**会在这些并发创建的提交相融合时创建。 #### 线性一致性强于因果一致性 -​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 +​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](../img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 ​ 线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。 @@ -384,7 +384,7 @@ ​ 用于确定*哪些操作发生在其他操作之前* 的技术,与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。 -​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 +​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](../img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 @@ -417,7 +417,7 @@ * 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。 -* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] +* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](../img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] [^viii]: 可以使物理时钟时间戳与因果关系保持一致:在“[用于全局快照的同步时钟](#用于全局快照的同步时钟)”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这中方法确保了实际上靠后的事务会有更大的时间戳。 但是大多数时钟不能提供这种所需的不确定性度量。 @@ -429,9 +429,9 @@ ​ 尽管刚才描述的三个序列号生成器与因果不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。 -​ [图9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 +​ [图9-8](../img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 -![](img/fig9-8.png) +![](../img/fig9-8.png) **图9-8 Lamport时间戳提供了与因果关系一致的总排序。** @@ -440,7 +440,7 @@ ​ 迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大**计数器**值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。 -​ 这如 [图9-8](img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 +​ 这如 [图9-8](../img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 ​ 只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。 @@ -504,7 +504,7 @@ #### 使用全序广播实现线性一致的存储 -​ 如 [图9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 +​ 如 [图9-4](../img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 [^x]: 从形式上讲,线性一致读写寄存器是一个“更容易”的问题。 全序广播等价于共识【67】,而共识问题在异步的崩溃-停止模型【68】中没有确定性的解决方案,而线性一致的读写寄存器**可以**在这种模型中实现【23,24,25】。 然而,支持诸如**比较并设置(CAS, compare-and-set)**,或**自增并返回(increment-and-get)**的原子操作使它等价于共识问题【28】。 因此,共识问题与线性一致寄存器问题密切相关。 @@ -601,7 +601,7 @@ * 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。 * 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。 -如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 +如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](../img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 ​ 事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了**读已提交**隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了**已提交却又被追溯声明不存在数据**的事务也必须回滚。 @@ -611,9 +611,9 @@ ​ **两阶段提交(two-phase commit)**是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,也以**XA事务**的形式对应用可用【76,77】(例如Java Transaction API支持)或以SOAP Web服务的`WS-AtomicTransaction` 形式提供给应用【78,79】。 -[ 图9-9](img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 +[ 图9-9](../img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 -![](img/fig9-9.png) +![](../img/fig9-9.png) **图9-9 两阶段提交(2PC)的成功执行** @@ -653,9 +653,9 @@ ​ 如果协调者在发送**准备**请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)**的或**不确定(uncertain)**的。 -​ 情况如[图9-10](img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 +​ 情况如[图9-10](../img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 -![](img/fig9-10.png) +![](../img/fig9-10.png)  **图9-10 参与者投赞成票后,协调者崩溃。数据库1不知道是否提交或中止** ​ 没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。 @@ -718,7 +718,7 @@ ​ 问题在于**锁(locking)**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常获取待修改的行上的**行级排他锁**,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。 -​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 +​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](../img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 ​ 当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。 diff --git a/zh-cn/part-ii.md b/zh-cn/part-ii.md index 197bfc05..e81ef7b1 100644 --- a/zh-cn/part-ii.md +++ b/zh-cn/part-ii.md @@ -59,9 +59,9 @@ ​ 将一个大型数据库拆分成较小的子集(称为**分区(partitions)**),从而不同的分区可以指派给不同的**节点(node)**(亦称**分片(shard)**)。 [第六章](ch6.md)将讨论分区。 -复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。 +复制和分区是不同的机制,但它们经常同时使用。如[图II-1](../img/figii-1.png)所示。 -![](img/figii-1.png) +![](../img/figii-1.png) **图II-1 一个数据库切分为两个分区,每个分区都有两个副本**