Lec 17 最终一致性
阅读材料
在本课程中,我们将探讨一系列“NoSQL”数据库系统,这些系统提供与关系数据库系统不同的事务一致性属性、查询语言。上述两篇阅读材料描述了亚马逊的一个有影响力的NoSQL系统——“DynamoDB”,以及亚马逊首席技术官对最终一致性支持的关键思想的讨论
2PC回顾
在分布式系统中,有一个协调者和若干个工作者。如果协调者和一个工作者同时失败,将无法恢复。因为协调者可能已将结果告知失败的工作者,可能会导致这些信息的暴露或不一致的问题。
失效的情况,假设执行到到第5步,协调者已经发送了COMMIT给一部分工作者,协调者永久crash了, 而其他的将永远不会收到结果

案例分析: DynamoDB
亚马逊操作数据库要求
- 高可用,“始终可用”的购物功能
- 即使在出现故障的情况下,也不能影响用户使用
- 没有单点故障
- 低延迟
- 能够同时处理大量订单
- 应对渲染页面需要的大量查询场景
- 无需复杂的分析
- 增量扩展
- 能应对每次扩展一个存储节点的情况,并系统操作员和系统本身的影响最小。
相应的解决方案
- 高可用,“始终可用”的购物功能
- 数据在多个节点上复制
- 优先考虑可用性而非一致性
- 增量扩展
- Key-Value存储
- 支持CRUD语义
- 使用一致性哈希对key进行分区
对比关系型数据库
没有复杂的SQL查询
虽然在 shared-nothing 架构下可以通过添加新节点来扩展系统,但涉及大量数据重分配的 shuffle joins 操作可能不会随着节点数量的增加而得到显著的性能提升
更注重一致性
一些背景知识
复制
复制对容错和性能有很大帮助
对于读而言
- 当发生故障时,读操作能够被重定向到副本上
- 读操作能够被最近的副本处理
那对于写操作?
- 更慢?(更多的节点需要写)
- 可用性更低? (必须写所有的节点,假如有些节点crash了呢?)
可用性
可用性是指,系统能否处理请求?
在大规模系统下,即便非常可靠的节点,故障也会发生。
复制技术可以确保在节点失败时系统仍然可以读取数据。那写操作呢?
看一个写可用性权衡问题
- 如果我们写入所有副本,可用性就会变差
- 如果我们只写入一些副本,系统的可用性会更好,但副本可能会变得过时
- 可用性和一致性是是一种权衡关系

最终一致性能够保证高可用性,当更新操作停止时,副本最终会收敛。强一致性而言,就不具备高可用性。
- 还有更多的关于一致性的模型

CAP理论
IMPORTANT
世界上没有免费的午餐
系统的只能拥有以下3个属性中的其中两个
- 一致性
- 可用性
- 分区容忍
用异步通信系统证明

所有节点上的数据 x 都是相同的,初始状态下都是 4。假设系统出现了分区(例如网络故障),导致节点 n1 和 n2 之间仍然能够通信,而节点 n3 被隔离在一个不同的分区中。我们对n1, n2节点对x进行写操作, x = 5,当我读取节点3的x时候该怎么办?
选项:
- 等待分区恢复(一致性)
- 继续前进:n1 和 n2 处理写操作,之后让 n3 知道这些写操作?(可用性)
NoSQL
- key、value存储
- 按键进行分区和复制
- 不只是强一致性/ACID语义
基于不同的存储方式的数据库分类

一致性哈希
阅读材料
- 一篇理论论文,介绍了一种一致性哈希的工具。是第一篇论文的基础。
背景
这个问题来源在于Web服务性能问题, 使用缓存是一个重要策略,但是面临问题是如何决定在任何给定时间什么内容被缓存在哪里。一致性哈希为多播和目录方案(以前的方案)提供了一种替代方案,并且在负载均衡和容错方面具有其他几个优势。
以往的解决方法
广泛使用缓存也带来了普遍的好处:如果请求被附近的缓存拦截,那么发送到源服务器的请求就会减少。最早的方法,就是为一组用户提供单个共享缓存机器,这种方式存在几个缺点。出现故障,所有用户都会被切断与其的连接;支持的用户数量有限;命中率有限,两个限制,1是存储空间,2是随着用户数量越多,聚合的可能性越低
为了实现容错和可扩展性,采用二级缓存,先通过主缓存,未命中则尝试通过2级缓存定位,仍为命中才会找到内容服务器。这些系统在主缓存未命中时如何定位数据的具体方式,有过几种尝试:
多播查询:通过多播或者UDP广播向其他所有缓存广播查询。除了广播查询会消耗额外带宽外,主缓存必须等待所有协作缓存报告未命中后才能联系内容服务器;这可能会降低二级缓存未命中时的性能。
目录查询:可以是集中式的或重复广播以支持本地查询。目录查询或传输也会消耗带宽,而集中式目录可能成为系统的新故障点。
所有这些系统的另一个问题是缓存之间的数据重复。任何缓存都可能被查询任何数据片段,这将导致它存储一个副本。在二级命中时,将数据复制到另一个缓存会浪费网络带宽和时间。这一方案中的两难问题在于,若更多用户共享同一个缓存,收益会更大,但同时缓存本身可能会过载。
设计思想
它完全不需要缓存之间的通信,却能让缓存协同工作形成一个连贯的系统。通过让客户端自行决定哪个缓存拥有所需数据来消除未命中时的缓存间通信。也就是说,用户浏览器直接联系应该包含所需资源的那一个缓存,而不是联系主缓存。浏览器在哈希函数的帮助下做出决策,该哈希函数将资源(或URL)映射到一个动态变化的可用缓存集合。
相比之前的方案有如下优势:
- 多播 改成了 单播,减少了网络的使用,同时也更快发现缓存未命中。
- 不会为系统带来新的故障点
我们利用了两个用于数据复制的工具,一个是随机缓存树(random cache trees),一个是一致性哈希。
需求
我们系统的目标是让任何客户端都能执行本地计算,将URL映射到包含它的特定缓存。哈希是一个常用于此目的的工具。例如,给定一组编号为0,...,22的23个缓存,我们可能会将URL u哈希到缓存
我们将给定机器所知道的缓存集合称为其视图(view),并观察到在任何时候,系统中都会存在许多不同的视图。这有两个潜在的缺点。由于多视图的存在, 在任何时间,导致一个URL映射到不同的缓存,我们很快就会发现每个URL都存储在所有缓存中 : ( 。此外,有了这些多个视图,很难论证所有缓存都会接收到相同数量的负载——不同的视图可能会将过多的负载引导到一个缓存,即使每个视图单独看起来都能适当地平衡负载。
因此,对于我们的哈希函数来说,一致地映射项目是至关重要的:无论系统存在多个、不断变化的视图,每个项目都应该只映射到少数几台机器,并且以这样一种方式使所有机器获得大致相同的项目负载。
数据模型
随机树
需要引入random tree这个工具,为了简化讲解,我们设计了一个简单的缓存协议。我们对模型进行简化:
- 所有的机器都知道所有的caches的存储在
- 所有请求同时发生
协议
我们为每个页面关联了一个称为抽象树的d叉有根树。每棵树中的节点数等于缓存数量,且树尽可能保持平衡(因此除底层外的所有层都是满的)。我们按照广度优先搜索顺序的排名来引用树的节点。协议被描述为在这些抽象树上运行;为了支持这一点,所有页面请求都采用四元组的形式:
- 请求者的身份
- 所需页面的名称
- 请求应该通过的节点序列
- 请求应该通过的节点的缓存序列
为了确定缓存序列,即给定节点由哪个缓存负责,需要将将节点映射到机器上。树的根节点始终映射该页面的服务器。所有其他节点通过哈希函数
现在,给哈希函数h和参数d和q,我们的协议如下:
- 浏览器: 当浏览器想要一个页面时,他选择一个随机的叶子到根的路径,用h将节点映射到机器,并向叶子节点请求页面。请求包括浏览器名称、页面名称、路径和映射结果。
- 缓存: 当缓存收到请求时,首先检查是否缓存该页面的副本或正在获取要缓存的副本。如果是,就返回给请求者。否则他会增加该页面和它扮演的节点计数器,并向路径上的下一个机器请求页面。如果计数器达到q,他就缓存页面的副本。在任何情况下,缓存都会在获得页面后将其传递给请求者。
- 服务器:当服务器收到请求时,它会向请求者发送页面的副本
分析
分析分为三个部分。首先,我们证明在假设没有服务器被淹没的情况下,处理请求的延迟可能很小。然后我们证明没有机器可能被淹没。最后我们证明协议正常工作不需要任何缓存存储太多页面。对淹没的分析方式类似,只是我们抽象节点上的"权重"现在是到达这些节点的请求数。如上所述,命中一台机器的请求数受限于映射到它的节点权重。
一致性哈希
技术动机: 考虑一个单一的服务器,它有大量其他客户端可能想要访问的对象。在客户端和服务器之间引入一层缓存来减少服务器负载是很自然的做法。在这种方案中,对象应该分布在各个缓存上,使每个缓存负责大致相等的份额。此外,客户端需要知道为特定对象查询哪个缓存。显而易见的方法是使用哈希。服务器可以使用一个哈希函数将对象均匀分布到缓存中。客户端可以使用哈希函数来发现哪个缓存存储了对象。但是活动缓存机器集合发生变化时,会改变原分布
一致性哈希解决了这个不同"视图"的问题。视图是为特定客户端所知道的缓存集合。我们假设虽然视图可能不一致,但它们是实质性的:每台机器都知道当前运行缓存的一个固定比例。客户端使用一致性哈希函数将对象映射到其视图中的某个缓存。我们分析并构造具有以下一致性特性的哈希函数:
首先是"平滑性"特性。当向缓存集合添加或从中移除机器时,需要移动到新缓存的对象的预期比例是维持缓存间负载平衡所需的最小值。其次,在所有客户端视图中,一个对象被分配到的不同缓存的总数是小的。我们称这个特性为"扩散"。类似地,在所有客户端视图中,分配给特定缓存的不同对象的数量是小的。我们称这个特性为"负载"。
因此,一致性哈希解决了上述问题。"扩散"特性意味着即使存在不一致的世界视图,对给定对象的引用也只会指向少量的缓存机器。将对象分发到这个小的缓存集合将确保所有客户端的访问,而不会使用大量存储。"负载"特性意味着没有任何一个缓存被分配不合理数量的对象。"平滑性"特性意味着缓存机器集合的平滑变化会匹配缓存对象位置的平滑演变。
由于有很多方式可以将上述一致性概念形式化,我们不会拘泥于精确的定义。相反,在4.4节中,我们定义了"范围哈希函数",然后精确定义了几个捕捉"一致性"不同方面的量。在4.2节中,我们构造了在某种程度上展现所有四个特性的实用哈希函数。在4.4节中,我们讨论一致性哈希的其他方面,虽然与本文无关,但表明了该理论背后的丰富性。
令
- 数据和节点都映射到环上
- 数据被分配到最近的继承者上
- 当一个节点加入时, 它将接管在它加入范围内的key值
- 无需重哈希所有值
环上加入节点

- 由管理员显式加入/移除节点
- 当一个节点想要加入时,它会与种子节点列表建立连接
- 对于其他已经加入的节点,会定时地学习当前环结构的情况

外部发现服务,由维持种子节点列表,可能包含几个初始的数据库节点地址,这些地址在系统启动时被用来找到节点并建立连接的。种子节点的作用:当一个新节点启动时,它首先会联系种子节点列表中的一个或多个节点。种子节点会告诉新节点当前集群中的节点信息(例如IP地址和端口号)。新节点可以使用这些信息与其他节点建立连接,从而加入集群

读操作
- 每项都会被复制到N个节点上
- 数据读取过程:对数据的key进行hash,根据哈希值,将读取请求发送给该数据的一个副本
- 客户端如何找到这个副本节点的?
- 读取映射
- 使用亚马逊前端服务
- 客户端如何找到这个副本节点的?

客户端首先选择一个节点作为协调者,该节点负责协调该读取的过程。比如上图中协调者E计算key值"hi"的哈希值。协调者节点(E)从多个副本节点(如 D、E、A)读取数据。具体需要读取多少个副本会在稍后详细说明
写操作
- 步骤类似读操作
- 回到我们的可用性难题
- 我们是否需要写入所有的副本?如果其中一个副本故障或不可用怎么办?
- 我们是否只写入一个副本? 我们如何确保其他节点能够读到这个数据?
Dynamo查询接口
- Key/Value 存取
- 所有的key和value都是任意大小的字节数组
- key会被md5生成ID
- get(key)
- put(key, version, value)
- version(或称context上下文),这是一个序列号,是由写操作的协调者生成的。
- 单key原子性
- 即每次读/写操作都是原子的,但仅针对单个键
Dynamo 数据分区和复制
- 所有数据都被复制到N个节点
- 每个节点在环上都有一个地址
- 环的空间代表哈希值的范围, 比如从
- 数据也存储在环上,数据的位置通过哈希计算得出
- 逻辑环只是抽象的表示,用来简化节点和数据的位置管理

Dynamo一致性
仲裁写入
R+W > N
- N = 每个数据项的副本数
- R = 每次读取必须读取到的副本数
- W = 每次写入必须写入的副本数
- 例如R = 2 , W=2,N = 3
R1 R2 R3 v1 v1 v1 v2 v1 v2 将v2写入到3个副本中的2个,则最终任何读取到两个副本的操作都会看到v2
需要某种方式确保写入的节点数少于N个,写入最终会传播到所有及节点
- 如果reader发现副本是旧版本,它会将最新的版本写回。
松散的仲裁(Sloopy Quorum)
- 一般的仲裁方法过于重视一致性,因为
- 持久性降低(希望至少写入N次所有数据)
- 在分区情况下可用性降低
- 解决办法: 松散的仲裁
提示交接Hinted handoff
当系统在写入操作时,发现某个目标节点不可用,他会将数据临时存储到其他节点上,并在这些节点上保存提示信息,指示原目标节点何时需要同步数据。即如果写入成功的副本少于N个,则继续在环上遍历,超越后继节点。

松散仲裁可导致发散
- 如果网络发生分区,提示交接可能导致副本数据不一致。
- 例如假设, N=3,N=2,R=2,网络分区



现在存在两个版本的key k,即k1和k2
vertor clocks
- 每个节点都保持一个单调递增的版本计数器,该计数器在其协调的每次写操作时递增
- 每个数据项都有一个时钟,包含来自每个协调节点的最新版本的列表
过程描述
- 客户端1新建新的key——k。这个操作由节点C进行协调,并且C将这个新的键和他的版本信息写入到其他节点D和F中。
| A | B | C | D | E | F |
|---|---|---|---|---|---|
| 1[C,1] | 1[C,1] | 1[C,1] |

- 客户端1读取key k,请求被发送到C,节点C读取到自身以及节点D和E上的key k的value,节点C返回[C,1],表示键k的当前版本是C协调的第一个版本
- 客户端1发起写入操作,将键k更新为新的版本;节点C生成新的版本[C,2],表示这是由C协调的第二个版本。节点C将[C,2]写入到自身以及其他两个节点A和B中。
| A | B | C | D | E | F |
|---|---|---|---|---|---|
| 1[C,1] | 1[C,1] | 1[C,1] | |||
| 2[C, 2] | 2[C,2] | 2[C,2] |

- 客户端2读取key k,请求被发送到了D,节点D读取了自身以及E,F上的key k的value,节点D返回[C,1],表示键k的当前版本是C协调的第一个版本
- 客户端2发起写入操作,将键k更新为新的版本;节点D生成新的版本[C,1] [D,1],表示这是由D协调的版本,并且包含饿了之前由C协调的第一个版本。节点D将[C,1] [D,1]写入到自身以及其他两个节点E和F中
| A | B | C | D | E | F |
|---|---|---|---|---|---|
| 1[C,1] | 1[C,1] | 1[C,1] | |||
| 2[C, 2] | 2[C,2] | 2[C,2] | |||
| 3[C,1][D,1] | 3[C,1][D,1] | 3 [C,1][D,1] |

C节点的2[C,2]和节点D的 3[C,1] [D,1] 者两个版本无法比较,因为他们没有明确的顺序关系。
读修复
- 客户端可能会读取到两个不可比较的版本
- 需要进行协调,可选方案
- 最新的写入者(时间戳)获胜
- 应用程序特定的协调(例如, 购物车合并)
- 协调之后,执行写回操作,使副本知道最新的状态。
假设有三个副本,R1,R2和R3,对键K进行了三次写操作,产生了三个版本时钟
- V1 = <R1:0 R2:3 R3:3>
- V2 = <R1:1 R2:3 R3:2>
- V3 = <R1:0 R2:0 R3:3>
哪个叙述是正确的
A. 产生 V1 的写入者观察到了 V2。
B. 产生 V2 的写入者观察到了 V1。
C. 产生 V3 的写入者观察到了 V1。
比较 V1 和 V2
- V1 = <R1:0, R2:3, R3:2>
- V2 = <R1:1, R2:3, R3:2>
比较各个组件:
- R1: 0 < 1
- R2: 3 = 3
- R3: 2 = 2
结论: V1 的所有值都小于或等于 V2 的对应值,并且 R1 的值小于 R2 的值。因此,V1 < V2,V2 覆盖 V1。
比较 V1 和 V3
- V1 = <R1:0, R2:3, R3:2>
- V3 = <R1:0, R2:0, R3:3>
比较各个组件:
- R1: 0 = 0
- R2: 3 > 0
- R3: 2 < 3
V1 和 V3 之间没有一个版本的所有值都小于或等于另一个版本的对应值。因此,V1 和 V3 是并列的,不可比较
同理,V2和V3也不能比较。
因此答案是B。
NOTE
版本时钟是包含一个标识符(如R1,R2,R3)和一个对应的版本号。比较规则
- V1 被 V2 覆盖,如果 V1 中所有的值都小于或等于 V2 中对应的值,并且至少有一个值小于,那么 V1 被 V2 覆盖,记作 V1 < V2
- V1 和 V2 是并列的(Incomparable):如果 V1 和 V2 中的值有些相等,有些不等,并且无法完全覆盖对方,则它们是并列的
反熵(Anti-entropy)
分布式系统中的一种数据同步机制。
- 一旦分区恢复或节点恢复,需要一种方法来修复数据
- 可以依赖读修复或提示交接
- Dynamo还会与每个键范围的及节点进行比较
- 比较通过哈希完成,使用一种叫merkle树的技术(一种哈系数结构,用于高效比较和同步数据)

Merkle Trees

整个树与数据一样大,但只需要交换其中不同的部分,即不需要发送图中的浅灰色节点,因为父节点的哈希值都是相同的。
论文阅读:Dynamo, 2007
Dynamo: Amazon’s Highly Available Key-value Store, SIGOPS'07
摘要
大规模可靠性是我们再Amazon遇到的最大挑战之一。作为世界上最大的电子商务运营平台之一,即使是最轻微的服务中断也会带来显著的财务影响并影响客户信任。Amazon.com 平台为全球众多网站提供服务,其实现建立在由分布在世界各地数据中心的数万台服务器和网络组件构成的基础设施之上。在这种规模下,大小组件持续不断地发生故障,而在面对这些故障时如何管理持久化状态,直接决定了软件系统的可靠性和可扩展性。
本文介绍了 Dynamo 的设计和实现,这是一个高可用性的键值存储系统,Amazon 的一些核心服务使用它来提供"永不宕机"的体验。为了达到这种级别的可用性,Dynamo 在某些故障场景下牺牲了一致性。它广泛使用对象版本控制和应用程序辅助的冲突解决机制,为开发人员提供了一个新颖的接口。
1. 引言
我们组织从运营 Amazon 平台中学到的经验之一是,系统的可靠性和可扩展性取决于其应用状态的管理方式。Amazon 使用高度去中心化、松耦合的面向服务架构,由数百个服务组成。在这种环境下,特别需要始终可用的存储技术。例如,即使磁盘发生故障、网络路由不稳定或数据中心被龙卷风摧毁,客户也应该能够查看和添加商品到购物车。因此,负责管理购物车的服务要求它始终能够对其数据存储进行读写操作,并且其数据需要在多个数据中心之间保持可用。
在由数百万组件构成的基础设施中处理故障是我们的标准运营模式;在任何给定时刻,总是有少量但数量可观的服务器和网络组件发生故障。因此,Amazon 的软件系统需要以一种将故障处理视为正常情况的方式构建,同时不影响可用性或性能。
为了满足可靠性和扩展性需求,Amazon 开发了多种存储技术,其中 Amazon Simple Storage Service(在 Amazon 外部也可用,被称为 Amazon S3)可能是最为人所知的。本文介绍了 Dynamo 的设计和实现,这是另一个为 Amazon 平台构建的高可用性和可扩展性分布式数据存储系统。Dynamo 用于管理那些具有很高可靠性要求,并需要在可用性、一致性、成本效益和性能之间进行精确权衡的服务的状态。
Amazon 平台拥有一套非常多样化的应用程序,
- 有些需要足够灵活的存储技术,使应用程序设计者能够基于这些权衡适当配置其数据存储,以最具成本效益的方式实现高可用性和性能保证
- 有些只需要对数据存储进行主键访问。如畅销书排行榜、购物车、客户偏好、会话管理、销售排名和产品目录等,使用关系型数据库会比较低效,并限制扩展性和可用性。Dynamo 提供了K/V满足这些需求
Dynamo 综合运用了多种知名技术来实现可扩展性和可用性:使用一致性哈希 [10] 进行数据分区和复制,并通过对象版本控制 [12] 实现一致性。更新期间副本之间的一致性通过类似仲裁的技术和去中心化的副本同步协议来维护。Dynamo 采用基于八卦(gossip)的分布式故障检测和成员关系协议。Dynamo 是一个完全去中心化的系统,几乎不需要手动管理。存储节点可以在不需要手动分区或重新分配的情况下添加到 Dynamo 中或从中移除。
在过去一年中,Dynamo 一直是 Amazon 电子商务平台中多个核心服务的底层存储技术。它能够在繁忙的假日购物季高效地扩展到极端峰值负载,而不会出现任何停机。例如,维护购物车的服务(购物车服务)在一天内处理了数千万个请求,导致超过 300 万次结账,而管理会话状态的服务则处理了数十万个并发活动会话。
系统假设
- 查询模型:对由唯一键标识的数据项进行简单的读写操作。状态存储为由唯一键标识的二进制对象(即,blob)。没有跨多个数据项的操作,也不需要关系模式。
- ACID属性:在亚马逊的经验表明,提供ACID保证的数据存储往往具有较差的可用性。这一点已被业界和学术界广泛认可。Dynamo面向那些如果较弱的一致性(ACID中的“C”)可以带来高可用性的应用程序。Dynamo不提供任何隔离保证,并且只允许单键更新。
- 效率:通常在分布的第99.9百分位数上测量
服务级别协议(SLA): 一个简单的SLA示例是某个服务保证在500个每秒的峰值客户端负载下,其99.9%的请求将在300毫秒内提供响应

设计考量:
商业系统中使用的数据复制算法通常执行同步副本协调,以提供强一致性的数据访问接口。为了实现这种一致性,这些算法不得不在某些故障场景下牺牲数据的可用性。
对于易受服务器和网络故障影响的系统,可以通过使用乐观复制技术来提高可用性,在这种技术中,允许更改在后台传播到副本,并且容忍并发的、断开的工作。这种方法的挑战在于它可能会导致冲突的更改,这些冲突必须被检测并解决。这个冲突解决过程引入了两个问题:何时解决它们,以及由谁来解决它们。Dynamo 被设计为一个最终一致的数据存储,即所有更新最终都能到达所有副本。
一个重要的设计考量是决定何时执行冲突解决过程,即冲突是应该在读取期间还是写入期间解决。许多传统的数据存储在写入时执行冲突解决,并保持读取复杂性简单 [7]。在这种系统中,如果数据存储在某个时间点无法到达所有(或大多数)副本,写入可能会被拒绝。另一方面,Dynamo 针对的是“始终可写”的数据存储设计空间(即高度可用的写操作数据存储)。对于许多亚马逊的服务来说,拒绝客户更新可能会导致糟糕的客户体验。例如,购物车服务必须允许客户在网络和服务器故障期间添加和删除购物车中的商品。这个需求迫使我们将冲突解决的复杂性推向读取,以确保写入永远不会被拒绝。
下一个设计选择是谁执行冲突解决过程。这可以由数据存储或应用程序完成。如果由数据存储执行冲突解决,其选择是相当有限的。在这种情况下,数据存储只能使用简单的策略,如“最后写入胜出” [22] 来解决冲突的更新。另一方面,由于应用程序了解数据架构,它可以决定最适合其客户体验的冲突解决方法。例如,维护客户购物车的应用程序可以选择“合并”冲突的版本并返回一个统一的购物车。尽管有这种灵活性,一些应用程序开发人员可能不想编写自己的冲突解决机制,并选择将其推给数据存储,后者反过来选择一个简单的策略,如“最后写入胜出”。
其他关键设计原则包括:
- 增量可扩展性:Dynamo 应该能够一次扩展一个存储主机(以下简称“节点”),对系统运营者和系统本身的影响最小。
- 对称性:Dynamo 中的每个节点应该具有与其对等节点相同的职责;不应有承担特殊角色或额外职责的特殊节点。在我们的经验中,对称性简化了系统配置和维护过程。
- 去中心化:对称性的延伸,设计应该偏向于分散的对等技术,而非集中控制。在过去,集中控制导致了停机,目标是尽可能避免这种情况。这导致了一个更简单、更可扩展和更高可用性的系统。
- 异构性:系统需要能够利用其运行的基础设施中的异构性。例如,工作分布必须与单个服务器的能力成比例。这对于添加具有更高容量的新节点而不必一次性升级所有主机至关重要。
系统架构
在生产环境中运行的存储系统架构复杂。除了实际的数据持久化组件外,该系统还需要具备可扩展且可靠的解决方案,以应对负载均衡、成员管理和故障检测、故障恢复、复制同步、过载处理、状态转移、并发和作业调度、请求调度、请求路由、系统监控和报警以及配置管理等问题。由于每个解决方案的详细描述不可能在本文中逐一展开,因此本文主要关注Dynamo中使用的核心分布式系统技术:分区、复制、版本控制、成员管理、故障处理和扩展。表格展示了DynamoDB使用的技术及其优势

系统接口
- get(key):定位与键关联的对象副本,并返回一个单一对象或一个包含冲突版本的对象列表,以及上下文信息
- put(key, context, object): 操作根据关联的键确定对象副本应该放置的位置,并将副本写入磁盘。上下文编码了关于对象的系统元数据,这些元数据对调用者是不可见的,包括对象的版本等信息。上下文信息与对象一起存储,以便系统可以验证在 put 请求中提供的上下文对象的有效性
Dynamo 将调用者提供的键和对象都视为不透明的字节数组。它对键应用 MD5 哈希,生成一个 128 位的标识符,用于确定负责服务该键的存储节点。
分区算法
Dynamo 的一个关键设计要求是它必须具备增量扩展性。这需要一个机制来动态地将数据分区到系统中的节点(即存储主机)上。Dynamo 的分区方案依赖于一致性哈希来分配负载到多个存储主机。在一致性哈希 [10] 中,哈希函数的输出范围被视为一个固定的圆形空间或“环”(即最大的哈希值会回绕到最小的哈希值)。系统中的每个节点在这个空间中被分配一个随机值,表示其在环上的“位置”。每个由键标识的数据项通过对数据项的键进行哈希来确定其在环上的位置,然后沿顺时针方向遍历环以找到第一个位置大于该数据项位置的节点。
因此,每个节点负责环上它与其前驱节点之间的区域。一致性哈希的主要优点是节点的离开或到来仅影响其直接相邻的节点,其他节点不受影响。
基本一致性哈希算法存在一些挑战。首先,每个节点在环上的随机位置分配导致数据和负载分布不均。其次,基本算法忽视了节点性能的异质性。为了解决这些问题,Dynamo使用了一种一致性哈希的变体(类似于[10, 20]中使用的):每个节点在环上被分配多个点。为此,Dynamo引入了“虚拟节点”的概念。虚拟节点在系统中看起来像一个单一节点,但每个节点可以负责多个虚拟节点。实际上,当一个新节点加入系统时,它在环上被分配多个位置(以下简称“令牌”)。Dynamo的分区方案微调过程将后面讨论
使用虚拟节点有以下优点:
- 如果一个节点不可用(由于故障或常规维护),该节点处理的负载将均匀分散到其他可用节点。
- 当一个节点重新可用或一个新节点加入系统时,新节点将从其他可用节点接受大致相等的负载。
- 节点负责的虚拟节点数量可以根据其容量决定,考虑到物理基础设施的异质性。
复制技术
为了实现高可用性和持久性,Dynamo将数据复制到多个主机上。每个数据项在N个主机上复制,其中N是“每实例”配置的参数。每个键k被分配给一个协调节点(在前一节中描述)。协调节点负责其范围内数据项的复制。除了在本地存储其范围内的每个键外,协调节点还将在环上的N-1个顺时针后继节点上复制这些键。这样,每个节点负责环上它与其第N个前驱节点之间的区域。在图2中,节点B除了在本地存储键k外,还将其复制到节点C和D。节点D将存储范围(A, B]、(B, C]和(C, D]内的键。
负责存储特定键的节点列表称为偏好列表。如将在第4.8节中解释的那样,系统设计使得系统中的每个节点都可以确定任何特定键的偏好列表中的节点。为了考虑节点故障,偏好列表包含多个N个节点。需要注意的是,使用虚拟节点时,特定键的前N个后继位置可能由少于N个不同的物理节点拥有(即一个节点可能持有前N个位置中的多个)。为了解决这个问题,键的偏好列表通过跳过环上的位置来构建,以确保列表中仅包含不同的物理节点。
版本管理
Dynamo 提供最终一致性,允许更新异步传播到所有副本。put() 调用可能在更新应用到所有副本之前返回给调用者,这可能导致后续的 get() 操作返回的对象没有最新的更新。如果没有故障,那么更新传播时间是有界的。然而,在某些故障场景下(例如服务器停机或网络分区),更新可能在较长时间内无法到达所有副本。
亚马逊平台上有一类应用可以容忍这种不一致,并且可以在这些条件下运行。例如,购物车应用程序要求“添加到购物车”操作永远不会被忘记或拒绝。如果购物车的最新状态不可用,而用户对旧版本的购物车进行更改,这些更改仍然有意义并且应该被保留。但是同时,它不应该取代当前不可用的购物车状态,因为后者可能包含应该保留的更改。需要注意的是,“添加到购物车”和“从购物车中删除商品”操作都被转换为对 Dynamo 的 put 请求。当客户想要添加商品到购物车(或从购物车中移除商品)而最新版本不可用时,商品会被添加到(或移除自)旧版本,并且分叉的版本稍后会被合并。
为了提供这种保证,Dynamo 将每次修改的结果视为数据的新且不可变的版本。它允许系统中同时存在多个版本的对象。大多数情况下,新版本取代之前的版本,系统本身可以确定权威版本(语法合并)。然而,在故障和并发更新的情况下,可能会发生版本分叉,导致对象的冲突版本。在这些情况下,系统无法合并相同对象的多个版本,客户必须执行合并以将多个数据分支合并为一个(语义合并)。一个典型的合并操作例子是“合并”客户购物车的不同版本。通过这种合并机制,“添加到购物车”操作永远不会丢失。然而,被删除的商品可能会重新出现。
重要的是要理解某些故障模式可能导致系统中存在不止两个版本的相同数据。网络分区和节点故障情况下的更新可能导致对象有不同的版本子历史,系统需要在未来进行合并。这要求我们设计的应用明确承认同一数据可能有多个版本的可能性(以便永远不丢失任何更新)。
Dynamo 使用向量时钟来捕捉相同对象不同版本之间的因果关系。向量时钟实际上是(节点,计数器)对的列表。每个版本的每个对象都关联一个向量时钟。通过检查向量时钟,可以确定对象的两个版本是在并行分支上还是有因果顺序。如果第一个对象时钟上的计数器小于等于第二个时钟上的所有节点,那么第一个是第二个的祖先,可以被遗忘。否则,这两次更改被认为是冲突的,需要进行合并。
在 Dynamo 中,当客户端希望更新对象时,必须指定它正在更新哪个版本。这通过传递从早期读操作中获得的上下文来完成,该上下文包含向量时钟信息。在处理读请求时,如果 Dynamo 访问到多个无法语法合并的分支,它会返回所有叶节点上的对象,并在上下文中包含相应的版本信息。使用此上下文进行的更新被认为已合并了分歧版本,并将分支合并为单个新版本。

为了说明向量时钟的使用,我们考虑图3中显示的示例。一个客户端写入一个新对象。处理该键写入请求的节点(假设是Sx)增加其序列号,并使用它来创建数据的向量时钟。系统现在有对象D1及其关联的时钟[(Sx, 1)]。客户端更新该对象。假设同一个节点也处理了此请求。系统现在还有对象D2及其关联的时钟[(Sx, 2)]。D2继承自D1,因此覆盖了D1,然而,可能在尚未见到D2的节点上仍存在D1的副本。假设同一客户端再次更新对象,且由另一个服务器(假设是Sy)处理请求。系统现在有数据D3及其关联的时钟[(Sx, 2), (Sy, 1)]。
接下来,假设不同的客户端读取D2,然后尝试更新它,另一个节点(假设是Sz)进行写入。系统现在有D4(D2的后代),其版本时钟为[(Sx, 2), (Sz, 1)]。知道D1或D2的节点在接收到D4及其时钟后,可以确定D1和D2被新数据覆盖,可以进行垃圾收集。知道D3的节点在接收到D4时会发现它们之间没有因果关系。换句话说,D3和D4中有未反映在对方中的更改。必须保留并向客户端展示这两个版本的数据(在读取时)以进行语义合并。
现在假设某个客户端读取了D3和D4(上下文将反映读取找到了这两个值)。读取的上下文是D3和D4时钟的摘要,即[(Sx, 2), (Sy, 1), (Sz, 1)]。如果客户端进行合并并且节点Sx协调写入,Sx将更新时钟中的序列号。新数据D5将具有以下时钟:[(Sx, 3), (Sy, 1), (Sz, 1)]。
向量时钟的一个可能问题是,如果许多服务器协调对某个对象的写入,向量时钟的大小可能会增加。实际上,这种情况不太可能发生,因为写入通常由偏好列表中的前N个节点之一处理。在网络分区或多个服务器故障的情况下,写入请求可能由不在偏好列表前N个节点中的节点处理,导致向量时钟的大小增加。在这些情况下,限制向量时钟的大小是可取的。为此,Dynamo采用了以下时钟截断方案:与每个(节点,计数器)对一起,Dynamo存储一个时间戳,指示节点上次更新数据项的时间。当向量时钟中的(节点,计数器)对数量达到阈值(例如10)时,最旧的对被从时钟中移除。显然,这种截断方案可能导致合并中的低效,因为后代关系无法准确推导。然而,这个问题在生产中没有出现,因此尚未被彻底调查。
get和put操作的执行
在 Dynamo 中,任何存储节点都可以接收客户端的 get 和 put 操作请求。为了简化描述,本节介绍在无故障环境下如何执行这些操作,在后续部分将介绍在发生故障时如何执行读写操作。
get 和 put 操作都是使用 Amazon 特定的基础设施请求处理框架通过 HTTP 调用的。客户端可以使用两种策略选择节点:(1)通过通用负载均衡器路由请求,该负载均衡器会根据负载信息选择节点,或(2)使用分区感知的客户端库直接将请求路由到适当的协调节点。第一种方法的优点是客户端不必在其应用程序中链接任何与 Dynamo 相关的代码,而第二种策略可以实现较低的延迟,因为它跳过了潜在的转发步骤。
处理读或写操作的节点称为协调器。通常,这是偏好列表中前 N 个节点中的第一个。如果通过负载均衡器接收请求,则访问某个键的请求可能会被路由到环中的任何随机节点。在这种情况下,如果接收请求的节点不在请求键的偏好列表前 N 个节点中,该节点不会协调请求,而是将请求转发给偏好列表中前 N 个节点中的第一个。
读和写操作涉及偏好列表中前 N 个健康节点,跳过那些已宕机或不可访问的节点。当所有节点都健康时,访问键的偏好列表中前 N 个节点。当节点故障或网络分区时,访问偏好列表中排名较低的节点。
为了在副本之间保持一致性,Dynamo 使用类似于仲裁系统的一致性协议。该协议有两个关键的可配置值:R 和 W。R 是必须参与成功读操作的最少节点数。W 是必须参与成功写操作的最少节点数。设置 R 和 W 使得 R + W > N 会产生一个类似仲裁的系统。在这种模型中,get(或 put)操作的延迟由 R(或 W)副本中最慢的决定。因此,R 和 W 通常配置为小于 N,以提供更好的延迟。
在接收到某个键的 put() 请求后,协调器会生成新版本的向量时钟并在本地写入新版本。然后协调器将新版本(以及新的向量时钟)发送到前 N 个可访问的最高排名节点。如果至少有 W-1 个节点响应,则写操作被认为是成功的。
同样,对于 get() 请求,协调器会请求键的偏好列表中前 N 个可访问节点的所有现有数据版本,然后等待 R 个响应后再将结果返回给客户端。如果协调器最终收集到多个数据版本,它将返回所有被认为因果无关的版本。这些分歧的版本然后进行合并,合并后的版本将取代当前版本。
故障处理: 提示交接(Hinted Handoff)
如果 Dynamo 使用传统的仲裁方法,那么在服务器故障和网络分区期间将不可用,即使在最简单的故障条件下也会降低耐久性。为了解决这个问题,它不强制执行严格的仲裁成员资格,而是使用“松散的仲裁”;所有读写操作都在偏好列表中前 N 个健康节点上执行,这些节点不一定总是遍历一致性哈希环时遇到的前 N 个节点。
考虑图 2 中给出的 Dynamo 配置示例,其中 N=3。在此示例中,如果节点 A 在写操作期间暂时宕机或无法访问,则通常会存储在 A 上的副本现在将发送到节点 D。这是为了维持期望的可用性和耐久性保证。发送到 D 的副本在其元数据中会包含一个提示,表明哪个节点是副本的预期接收者(在本例中为 A)。接收到提示副本的节点将在单独的本地数据库中保存它们,该数据库会定期扫描。检测到 A 恢复后,D 将尝试将副本传递给 A。一旦传输成功,D 可以从其本地存储中删除该对象,而不会减少系统中的副本总数。
通过使用 Hinted Handoff,Dynamo 确保由于临时节点或网络故障不会导致读写操作失败。需要最高可用性级别的应用程序可以将 W 设置为 1,这确保只要系统中的单个节点已将键耐久地写入其本地存储,就接受写操作。因此,只有当系统中的所有节点都不可用时,写请求才会被拒绝。然而,在实际生产中,大多数 Amazon 服务会设置更高的 W 以满足所需的耐久性级别。在第 6 节中,将更详细地讨论 N、R 和 W 的配置。
高度可用的存储系统必须能够处理整个数据中心的故障。数据中心故障可能由于停电、冷却故障、网络故障和自然灾害而发生。Dynamo 被配置为每个对象跨多个数据中心复制。本质上,键的偏好列表被构建为存储节点分布在多个数据中心。这些数据中心通过高速网络连接。跨多个数据中心复制的方案使我们能够在数据中心发生故障时处理数据中断。
永久故障:副本同步
Hinted Handoff 最适合于系统成员更替率低且节点故障是暂时的情况。有些情况下,在提示副本返回到原始副本节点之前,提示副本变得不可用。为了解决这个问题和其他对耐久性的威胁,Dynamo 实施了一种反熵(副本同步)协议来保持副本同步。
为了更快地检测副本之间的不一致性并最小化传输数据量,Dynamo 使用 Merkle 树 [13]。Merkle 树是一种哈希树,其中叶节点是各个键值的哈希。树中更高的父节点是其各自子节点的哈希。Merkle 树的主要优点是树的每个分支可以独立检查,而无需节点下载整个树或整个数据集。此外,Merkle 树有助于减少在检查副本之间的不一致性时需要传输的数据量。例如,如果两棵树的根哈希值相等,则树中叶节点的值相等,节点不需要同步。如果不相等,则意味着某些副本的值不同。在这种情况下,节点可以交换子节点的哈希值,直到到达树的叶节点,主机可以识别“不同步”的键。Merkle 树最大限度地减少了同步所需的数据量,并减少了反熵过程中的磁盘读取次数。
Dynamo 按如下方式使用 Merkle 树进行反熵:每个节点为其托管的每个键范围(虚拟节点覆盖的键集)维护一棵独立的 Merkle 树。这使节点能够比较键范围内的键是否是最新的。在此方案中,两个节点交换其共同托管的键范围对应的 Merkle 树的根。随后,使用上述树遍历方案,节点确定它们是否有任何差异,并执行适当的同步操作。该方案的缺点是,当节点加入或离开系统时,许多键范围会发生变化,从而需要重新计算树。然而,第 6.2 节中描述的改进分区方案解决了这个问题。
成员管理和故障检测
环形成员管理
在 Amazon 的环境中,节点故障(由于失败和维护任务)通常是暂时的,但可能会持续较长时间。节点故障很少表示永久性离开,因此不应该导致分区分配的重新平衡或不可达副本的修复。同样,手动错误可能会导致新 Dynamo 节点的无意启动。由于这些原因,使用明确的机制来启动节点的添加和移除被认为是合适的。管理员可以使用命令行工具或浏览器连接到 Dynamo 节点,并发出将节点加入环或从环中移除节点的命令。处理请求的节点会将成员变更及其发出时间写入持久存储。成员变更形成了历史记录,因为节点可以被多次移除和重新添加。一个基于 gossip 的协议传播成员变更并维持最终一致的成员视图。每个节点每秒联系一个随机选择的对等节点,这两个节点有效地协调它们的持久成员变更历史记录。
当节点第一次启动时,它选择其令牌集(在一致性哈希空间中的虚拟节点)并将节点映射到其相应的令牌集。该映射会持久存储在磁盘上,最初只包含本地节点和令牌集。在不同的 Dynamo 节点之间存储的映射在协调成员变更历史记录的同一通信交换过程中进行协调。因此,分区和放置信息也通过基于 gossip 的协议传播,每个存储节点都知道其对等节点处理的令牌范围。这允许每个节点直接将键的读/写操作转发到正确的节点集合。
外部发现
上述机制可能会暂时导致逻辑上分区的 Dynamo 环。例如,管理员可以先联系节点 A 将其加入环,然后再联系节点 B 将其加入环。在这种情况下,节点 A 和 B 都会认为自己是环的成员,但它们不会立即知道对方。为了防止逻辑分区,一些 Dynamo 节点扮演种子的角色。种子是通过外部机制发现的节点,并且所有节点都知道它们。由于所有节点最终会与种子协调其成员资格,因此逻辑分区的可能性非常小。种子可以从静态配置或配置服务中获得。通常,种子是 Dynamo 环中的完全功能节点。
4.8.3 故障检测
Dynamo 中的故障检测用于避免在进行 get() 和 put() 操作以及传输分区和提示副本时尝试与不可达的对等节点通信。为了避免失败的通信尝试,纯粹的本地故障检测概念完全足够:节点 A 可能会认为节点 B 失败,如果节点 B 没有响应节点 A 的消息(即使 B 对节点 C 的消息有响应)。在 Dynamo 环中,随着客户端请求持续生成节点间通信,节点 A 快速发现节点 B 在 B 未能响应消息时变得不可响应;节点 A 然后使用备用节点来处理映射到 B 的分区的请求;A 定期重试 B 以检查其恢复情况。在没有客户端请求来驱动两个节点之间的流量的情况下,没有节点真正需要知道另一个节点是否可达和响应。
去中心化故障检测协议使用简单的 gossip 风格协议,使系统中的每个节点能够了解其他节点的到达(或离开)。有关去中心化故障检测器和影响其准确性的参数的详细信息,读者可以参考 [8]。Dynamo 的早期设计使用了去中心化故障检测器来维持全局一致的故障状态视图。后来发现,明确的节点加入和离开方法消除了对全局故障状态视图的需求。这是因为节点通过明确的节点加入和离开方法通知永久性节点的添加和移除,而临时节点故障由各个节点在转发请求时未能与其他节点通信时检测到。
添加/移除存储节点
当系统中添加一个新节点(例如 X)时,它会被分配一组在环上随机分布的令牌。对于每个分配给节点 X 的键范围,可能有多个节点(数量小于或等于 N)当前负责处理落在其令牌范围内的键。由于 X 分配了键范围,一些现有节点不再需要管理它们的一部分键,这些节点将这些键转移给 X。让我们考虑一个简单的引导场景,其中节点 X 被添加到图 2 所示的环中,在 A 和 B 之间。当 X 被添加到系统中时,它负责存储范围为 (F, G]、(G, A] 和 (A, X] 的键。因此,节点 B、C 和 D 不再需要存储这些范围内的键。
因此,节点 B、C 和 D 将向 X 提供并在 X 确认后转移相应的键集合。当节点从系统中移除时,键的重新分配则以相反的过程进行。
操作经验表明,这种方法在存储节点之间均匀分配键的负载,这对满足延迟要求和确保快速引导是非常重要的。最后,通过在源节点和目标节点之间增加确认环节,确保了目标节点不会收到给定键范围的重复转移。
实现
平衡前台与后台任务
每个节点执行不同类型的后台任务,如副本同步和数据交接(由于提示交接或是添加/删除节点),此外还要执行正常的前台put/get操作。 前期生产环境中,这些后台任务触发了资源竞争问题导致影响了前台性能。后来就将后台任务集成到准入控制(admission control)机制中,通过不断检测监测前台访问资源的行为(包括磁盘操作的延迟,锁争用,事务超时的等等),控制器利用这些比较来评估前台操作的资源可用性。随后,它决定将多少时间片分配给后台任务。
总结
Dynamo的主要优势在于它提供了通过三个参数(N、R、W),以满足用户的需求。与流行的商业数据存储相比,Dynamo将数据一致性和调解逻辑问题暴露给开发者。对于希望使用Dynamo的新应用程序,在开发的初始阶段需要进行一些分析,以选择合适的冲突解决机制,以适当满足业务需求。最终一致的存储系统可以成为高度可用应用程序的构建块。
Amazon CTO博文:最终一致性
亚马逊首席技术官对最终一致性的关键思想的论述
IMPORTANT
最终一致性——构建可靠的分布式系统在一致性和可用性之间的一种权衡
背景

当一个系统处理大量请求(比如数万亿次)时,通常发生概率较低的事件无限接近与肯定会发生。鉴于高并发的场景,我们广泛使用复制技术以保证一致的性能和高可用性。虽然复制技术使我们的请求就近转到目标服务器,但它不能以完全透明的方式(看起来就像是一个单一的、简单的实体)实现这些目标,并且会给应用带来一些麻烦。
复制技术带来的麻烦之一就是数据一致性问题。在大规模分布式系统中,提供的数据一致性类型会受到复制技术的影响,尤其是最终一致性模型。这意味着数据在某个时间点上可能并不是完全一致的,而是经过一段时间后,最终达到一致的状态。这些权衡影响了亚马逊在全球范围内交付可靠分布式系统的方法。后面会详细说背景如何影响到最终的设计。
历史视角
在理想的世界里,应该只有一种一致性模型:当更新发生时,所有观察者都能看到该更新。第一次发现这一点难以实现是在70年代末的数据库系统中。那个时期的许多系统采取了宁愿让整个系统失败也不愿打破这种透明性的做法。90年代中期,随着更大规模互联网系统的兴起,那时,人们开始认为可用性可能是这些系统最重要的属性,但他们还在挣扎着应该与什么进行权衡。2000年,加州大学伯克利分校的系统教授,同时也是当时Inktomi公司负责人的Eric Brewer在PODC(分布式计算原理)会议的主题演讲中提出了CAP定理,该定理指出共享数据系统的三个属性——数据一致性、系统可用性和网络分区容忍性——中只有两个可以同时实现。
一个不容忍网络分区的系统可以实现数据一致性和可用性,并且通常通过使用事务协议来实现这一点。为了使这项工作有效,客户端和存储系统必须是同一环境;在某些场景下,它们会整体失败,因此客户端无法观察到分区。但要知道,在分布式系统中,网络分区是必然的;因此,一致性和可用性不能同时实现。这意味着有两种选择:松弛的一致性可以使系统在可分区条件下保持高可用性,而优先考虑一致性意味着在某些条件下系统将不可用。
这两种选择都要求客户端开发人员了解系的统需求。如果系统强调一致性,开发人员必须面对系统可能无法接受写入的事实。如果由于系统不可用而导致写入失败,那么开发人员必须处理要写入的数据。如果系统强调可用性,它可能总是接受写入,但在某些条件下,读取不会反映最近完成的写入结果。开发人员需要决定客户端是否需要始终访问最新更新。如果有一系列应用程序可以处理稍微陈旧的数据,它们在这种模型下可以很好地运行。
原则上,事务系统中的一致性属性(如ACID属性中定义的原子性、一致性、隔离性、持久性)是一种不同类型的一致性保证。在ACID中,一致性指的是确保在事务完成后数据库处于一致状态。
一致性
有两种方法来观察一致性。
- 客户端/开发者视角: 如何观察数据更新
- 服务器视角: 更新流如何流经系统,以及系统可以为更新提供哪些保证。
客户端侧一致性
客户端侧, 有以下几种组成部分:
- 一个存储系统: 将他视作黑盒,但是需要保证,它是一种大规模且高度分布式,是用来保证持久性和可用性
- 进程A: 从存储系统中写入或者读出数据的进程
- 进程B、C: 与进程A独立但也是读取、写入数据库的进程。 相互独立意味着,需要通信来共享信息,因为客户端侧一致性要求,观察者(进程A、B、C)何时以及如何看到存储系统中数据对象的更新。
- 强一致性:在更新完成后,任何后续的访问(由 A、B 或 C 进行)都将返回更新后的值。
- 弱一致性:系统不保证后续的访问将返回更新后的值。在返回更新后的值之前需要满足一定的条件。从更新到确保任何观察者都能始终看到更新值的时刻之间的时间段被称为不一致窗口(inconsistsitency window)。
- 最终一致性:这是弱一致性的一种特殊形式;存储系统保证如果对象没有新的更新,则最终所有的访问都将返回最后更新的值。如果没有发生故障,不一致窗口的最大大小可以根据通信延迟、系统负载和复制方案中涉及的副本数量等因素确定。实现最终一致性的最典型的的系统是 DNS。
最终一致性模型的几种重要变体以及它们的特性:
- 因果一致性(Causal consistency):如果进程 A 向进程 B 通信表示已更新数据项,那么进程 B 的后续访问将返回更新后的值,并且写入操作保证会覆盖先前的写入。而对于没有因果关系的进程 C,仍然遵循普通的最终一致性规则。
- 读取自写入一致性(Read-your-writes consistency):在这个模型中,进程 A 在更新数据项后,总是访问更新后的值,永远不会看到旧值。这是因果一致性模型的特殊情况。
- 会话一致性(Session consistency.):这是前一个模型的实用版本,其中一个进程在会话的上下文中访问存储系统。只要会话存在,系统就保证读取自写入一致性。如果会话因某种故障场景而终止,则需要创建新会话,并且保证不会跨会话。
- 单调读一致性(Monotonic read consistency):如果一个进程已经看到了对象的特定值,那么任何后续访问都不会返回任何早期的值。
- 单调写一致性(Monotonic write consistency):系统保证对相同进程的写入操作进行串行化。不保证这种一致性级别的系统通常难以编程。
这些属性可以组合使用。例如,可以将单调读与会话一致性相结合。从实际应用角度看,单调读和读取自写入这两个属性是最终一致性系统中最理想的,但并非总是必需的。这两个属性使开发人员能够更简单地构建应用程序,同时允许存储系统放宽一致性要求并提供高可用性。
这些变体显示了可能发生的许多不同情况。具体取决于特定应用程序是否能够处理其后果。
最终一致性并不是什么很神秘的属性。许多现代关系数据库管理系统(RDBMS)提供主备可靠性,它们在同步和异步模式下实现其复制技术。在同步模式中,副本更新是事务的一部分。在异步模式中,更新以延迟方式到达备份,通常是通过日志传送。在后一种模式中,如果主节点在日志发送之前发生故障,从提升为主服务器的备份读取将产生旧的、不一致的值。为了支持更好的可扩展读性能,RDBMS已经开始提供从备份读取的能力,这是提供最终一致性保证的经典案例,其中不一致窗口取决于日志传输的周期性。
服务端视角的一致性
在服务器端,我们需要更深入地了解更新在系统中的流动方式。
N = 存储数据的副本总数
W = 写入操作需要写入的副本数
R = 读取操作需要读取的副本数
当 W+R > N 时,写和读操作所涉及的节点总数超过了存储数据的所有节点数量,这种情况下可以保证强一致性。在主备份(primary-backup)关系型数据库管理系统(RDBMS)场景:
- 同步复制。 即在进行写操作时,需要等待所有副本都确认接收到更新才能完成写操作。在这种情况下,N=2, W=2, R=1。因为 W+R = 2+1 > N,所以写集合和读集合总是重叠的,从任何一个副本读取都可以保证得到一致的答案。
- 异步复制,假设允许从备份中读取数据。这种情况下,N=2, W=1, 和 R=1。这里 R+W = N,无法保证一致性。
这些配置的问题在于, 由于它们是基于仲裁协议(quorum protocols),当系统因为故障无法写入W个节点时,写操作必须失败,这种情况被认为是系统处于不可用状态(unavailablity)。例如,当N=3且W=3时,如果只有两个节点可用,系统将不得不使写操作失败。
在需要提供高性能和高可用性的分布式存储系统中,副本的数量通常超过两个。专注于容错的系统通常使用N=3(配置为W=2和R=2)读密集型的系统不仅仅满足基本的容错要求,而是会增加更多的副本;N可以是几十上百,R配置为1,这样单次读取就能返回结果。
- 关注一致性的系统将W=N进行更新,这可能会降低写入成功的概率。
- 对于不关注一致性但关注容错的系统,常见的配置是运行W=1,以获得最小的更新持久性,然后依赖于一种懒惰的(传染性)技术来更新其他副本。
如何配置N、W和R取决于常见情况是什么,以及需要优化的性能路径。在R=1和N=W的情况下,我们优化读取操作;而在W=1和R=N的情况下,我们优化非常快速的写入。当然,在后一种情况下,在出现故障时不能保证持久性,而且如果
而当 W+R <= N 时,无法保证强一致性,因为可能存在一部分节点没有被涉及到,导致数据的不一致
最终一致性出现在 R=1,并且通常运用一下两个情况,第一种情况是为了读取扩展而进行的大量复制;第二种情况是数据访问更复杂。在简单的键值模型中,比较版本以确定系统中最新写入的值是很容易的,但在返回对象集合的系统中,确定正确的最新集合要困难得多。
是否能实现读写一致性、会话一致性和单调一致性通常取决于客户端对执行分布式协议的服务器的“粘性”。如果每次都是同一个服务器,那么就比较容易保证读写一致性和单调读取。这使得负载均衡和容错管理稍微复杂一些,但这是一个简单的解决方案。使用会话(它们是粘性的)可以明确这一点,并提供一个客户可以推理的暴露级别。
有时客户端实现读写一致性和单调读取。通过在写操作上添加版本,客户端丢弃读取值的版本先于最后看到的版本。
当系统中的一些节点无法访问其他节点时,就会发生分区,但两边节点都可以被一组客户端访问。如果使用经典的少数服从多数方法,那么包含副本中有 W 个节点的分区可以继续接收更新,而其他分区则变得不可用。读取集合也是如此。因为这两组集合重叠,所以定义上少数集群变得不可用。分区并不常见,但它们确实会发生在数据中心之间,以及数据中心内部。
在某些应用中,任何分区的不可用性都是不可接受的,重要的是能够访问该分区的客户端能够继续进展。在这种情况下,双方会分配一组新的存储节点来接收数据,并在分区修复后执行合并操作。例如,在亚马逊内部,购物车使用这种总是写入的系统;在发生分区时,即使原始购物车位于另一个分区,客户仍然可以继续向购物车中添加物品。一旦分区修复,购物车应用程序会帮助存储系统合并购物车。
总结
在大规模可靠分布式系统中,数据不一致性必须被容忍,原因有两个:在高并发条件下提高读写性能,以及处理分区情况,在这种情况下,少数服从多数模型会导致系统的一部分不可用,即使节点仍在运行。
不一致性是否可接受取决于客户端应用程序。在所有情况下,开发者都需要意识到一致性保证是由存储系统提供的,并且在开发应用程序时需要考虑到这一点。有一些对最终一致性模型的实际改进,例如会话级别一致性和单调读,这些改进为开发者提供了更好的工具。很多时候,应用程序能够轻松处理存储系统的最终一致性保证。一个具体的流行案例是一个网站,我们可以有用户感知一致性的概念。在这种情况下,不一致窗口需要小于客户返回下一次页面加载的预期时间。这允许更新在预期的下一次读取之前传播到系统中。
本文的目的是提高对需要在全球范围内运行的工程系统复杂性的认识,并且这些系统需要仔细调整以确保它们能够提供其应用程序所需的持久性、可用性和性能。系统设计师拥有的工具之一是一致性窗口的长度,在此期间,系统的客户端可能会暴露于大规模系统工程的现实中。