第九章:一致性与共识

分布式系统最重要的抽象之一就是共识(consensus)就是让所有的节点对某件事达成一致

如果两个节点都认为自己是领导者,这种情况被称为脑裂(split brain),且经常导致数据丢失。正确实现共识有助于避免这种问题。

一致性保证

不一致性是暂时的,最终会自行解决。最终一致性的一个更好的名字可能是收敛(convergence),因为我们预计所有的复本最终会收敛到相同的值。

具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。

分布式一致性模型和我们之前讨论的事务隔离级别的层次结构有一些相似之处。尽管两者有一部分内容重叠,但它们大多是无关的问题:事务隔离主要是为了,避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于,面对延迟和故障时,如何协调副本间的状态。

线性一致性

最终一致的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。如果数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么每个客户端都会有相同的数据视图,且不必担心复制滞后了。

这就是线性一致性(linearizability)(也称为原子一致性(atomic consistency)强一致性(strong consistency)立即一致性(immediate consistency)外部一致性(external consistency ))。

在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。线性一致性是一个新鲜度保证(recency guarantee)

什么使得系统线性一致?

线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。

图 如果读取请求与写入请求并发,则可能会返回旧值或新值

三个客户端在线性一致数据库中同时读写相同的键x。在分布式系统中,x被称为寄存器(register),例如,它可以是键值存储中的一个,关系数据库中的一,或文档数据库中的一个文档

每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。

在这个例子中,寄存器有两种类型的操作:

  • $ read(x)⇒v$表示客户端请求读取寄存器 x 的值,数据库返回值 v
  • $write(x,v)⇒r$ 表示客户端请求将寄存器 x 设置为值 v ,数据库返回响应 r (可能正确,可能错误)。

x 的值最初为 0,客户端C 执行写请求将其设置为 1。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。

  • 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 0
  • 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 1:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。
  • 与写操作在时间上重叠的任何读操作,可能会返回 01 ,因为我们不知道读取时,写操作是否已经生效。这些操作是并发(concurrent)的。

如果与写入同时发生的读取可以返回旧值或新值,那么可能会在写入期间看到数值在旧值和新值之间来回翻转。

为了使系统线性一致,我们需要添加另一个约束。

图 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值

在一个线性一致的系统中,我们可以想象,在 x 的值从0 自动翻转到 1 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 1,即使写操作尚未完成,所有后续读取也必须返回新值。

客户端A 是第一个读取新的值 1 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 1

下面是一个更复杂的例子

图 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的

每个操作都在我们认为执行操作的时候用竖线标出(在每个操作的条柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(每次读取都必须返回最近一次写入设置的值)。

线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖

图 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的

  • 第一个客户端B发送一个读取 x 的请求,然后客户端D发送一个请求将 x 设置为 0,然后客户端A发送请求将 x 设置为 1。返回到B的读取值为 1(由A写入的值)。数据库首先处理D的写入,然后是A的写入,最后是B的读取。虽然这不是请求发送的顺序,但这是一个可以接受的顺序,因为这三个请求是并发的。也许B的读请求在网络上略有延迟,所以它在两次写入之后才到达数据库。
  • 在客户端A从数据库收到响应之前,客户端B的读取返回 1 ,表示写入值 1 已成功。这意味着从数据库到客户端A的正确响应在网络中略有延迟。
  • 不假设有任何事务隔离:另一个客户端可能随时更改值。例如,C首先读取 1 ,然后读取 2 ,因为两次读取之间的值由B更改。可以使用原子比较并设置(cas)操作来检查该值是否未被另一客户端同时更改:B和C的cas请求成功,但是D的cas请求失败(在数据库处理它时,x 的值不再是 0 )。
  • 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的cas写操作并发(它将 x2 更新为 4 )。在没有其他请求的情况下,B的读取返回 2 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 4 ,因此不允许B读取比A更旧的值。

线性一致性与可序列化

可序列化

可序列化(Serializability)是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)。它确保事务的行为,与它们按照某种顺序依次执行的结果相同(每个事务在下一个事务开始之前运行完成)。这种执行顺序可以与事务实际执行的顺序不同。

线性一致性

线性一致性(Linearizability)是读取和写入寄存器(单个对象)的新鲜度保证。它不会将操作组合为事务,因此它也不会阻止写偏差等问题。

一个数据库可以提供可串行性和线性一致性,这种组合被称为严格的可串行性或强的单副本强可串行性(strong-1SR)。基于两阶段锁定的可串行化实现或实际串行执行通常是线性一致性的。

依赖线性一致性

锁定和领导选举

一个使用单主复制的系统,需要确保领导真的只有一个,而不是几个(脑裂)。使用锁选择领导者:每个节点在启动时尝试获取锁,成功者成为领导者。这个锁必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就没用了。

分布式锁也在一些分布式数据库中以更细的粒度使用。 RAC对每个磁盘页面使用一个锁,多个节点共享对同一个磁盘存储系统的访问权限。由于这些线性一致的锁处于事务执行的关键路径上,RAC部署通常具有用于数据库节点之间通信的专用集群互连网络。

约束和唯一性保证

当一个用户注册你的服务时,可以认为他们获得了所选用户名的“锁定”。该操作与原子性的比较与设置非常相似:将用户名赋予声明它的用户,前提是用户名尚未被使用。

一个硬性的唯一性约束(关系型数据库中常见的那种)需要线性一致性。其他类型的约束,如外键或属性约束,可以在不需要线性一致性的情况下实现。

跨信道的时序依赖

图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。

Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性

如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列(步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。

Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的

实现线性一致的系统

最简单的实现就是,真的只用一个数据副本。但是这种方法无法容错:如果持有该副本的节点失效,数据将会丢失,或者至少无法访问,直到节点重新启动。

使系统容错最常用的方法是使用复制:

单主复制(可能线性一致)

在具有单主复制功能的系统中,主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们可能(protential)是线性一致性的。

共识算法(线性一致)

与单领导者复制类似,但是共识协议包含防止脑裂和陈旧副本的措施。共识算法可以安全地实现线性一致性存储。例如,Zookeeper 和etcd 就是这样工作的。

多主复制(非线性一致)

具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。

无主复制(也许不是线性一致的)

对于无领导者复制的系统,有时候人们会声称通过要求法定人数读写( $w + r> n$ )可以获得“强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义。

基于时钟的“最后写入胜利”冲突解决方法几乎可以确定是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。

线性一致性和法定人数

严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件。

图 非线性一致的执行,尽管使用了严格的法定人数

$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 1。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 1 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 0

仲裁条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。

线性一致性的代价

图 网络中断迫使在线性一致性和可用性之间做出选择

如果两个数据中心之间发生网络中断会发生什么?我们假设每个数据中心内的网络正在工作,客户端可以访问数据中心,但数据中心之间彼此无法互相连接。

多主数据库,每个数据中心都可以继续正常运行:由于在一个数据中心写入的数据是异步复制到另一个数据中心的,所以在恢复网络连接时,写入操作只是简单地排队并交换。

单主复制,则主库必须位于其中一个数据中心。任何写入和任何线性一致的读取请求都必须发送给该主库,因此对于连接到从库所在数据中心的客户端,这些读取和写入请求必须通过网络同步发送到主库所在的数据中心。

CAP定理

CP(在网络分区下一致但不可用): 如果应用需要线性一致性,且某些副本因为网络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。请求必须等到网络问题解决,或直接返回错误。

AP(在网络分区下可用但不一致): 如果应用不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以独立处理请求(例如多主复制)。在这种情况下,应用可以在网络问题前保持可用,但其行为不是线性一致的。

CAP定理的正式定义仅限于很狭隘的范围,它只考虑了一个一致性模型(即线性一致性)和一种故障(网络分区,或活跃但彼此断开的节点)。它没有讨论任何关于网络延迟,死亡节点或其他权衡的事。 因此,尽管CAP在历史上有一些影响力,但对于设计系统而言并没有实际价值。

线性一致性和网络延迟

虽然线性一致是一个很有用的保证,但实际上,线性一致的系统惊人的少。例如,现代多核CPU上的内存甚至都不是线性一致的:如果一个CPU核上运行的线程写入某个内存地址,而另一个CPU核上运行的线程不久之后读取相同的地址,并没有保证一定能一定读到第一个线程写入的值(除非使用了内存屏障(memory barrier)围栏(fence))。

这种行为的原因是每个CPU核都有自己的内存缓存和存储缓冲区。默认情况下,内存访问首先走缓存,任何变更会异步写入主存。因为缓存访问比主存要快得多,所以这个特性对于现代CPU的良好性能表现至关重要。但是现在就有几个数据副本,而且这些副本是异步更新的,所以就失去了线性一致性。

许多分布式数据库为了提高性能而选择了牺牲线性一致性,而不是为了容错,线性一致的速度很慢。

顺序保证

因果关系对事件施加了一种顺序:因在果之前;消息发送在消息收取之前。

如果一个系统服从因果关系所规定的顺序,我们说它是因果一致(causally)的。例如,快照隔离提供了因果一致性:当你从数据库中读取到一些数据时,你一定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。

因果顺序不是全序的

全序(total order)允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。例如,自然数集是全序的:给定两个自然数,比如说5和13,那么你可以告诉我,13大于5。

数学集合是偏序(partially order)的:在某些情况下,可以说一个集合大于另一个(如果一个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的。

全序和偏序之间的差异反映在不同的数据库一致性模型中:

线性一致性

在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。

因果性

如果两个操作都没有在彼此之前发生,那么这两个操作是并发的。

如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的

根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系

并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。

线性一致性强于因果一致性

线性一致性隐含着(implies)因果关系:任何线性一致的系统都能正确保持因果性。如果系统中有多个通信通道,线性一致性可以自动保证因果性,系统无需任何特殊操作。

线性一致性并不是保持因果性的唯一途径。一个系统可以是因果一致的,而无需承担线性一致带来的性能折损。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用。

捕获因果关系

为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(happened before。这是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。

检测并发写入中无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题。

为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。

序列号顺序

使用序列号(sequence nunber)时间戳(timestamp)来排序事件。时间戳不一定来自时钟(。它可以来自一个逻辑时钟(logical clock),这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。

序列号或时间戳提供了一个全序关系:也就是说每操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。

可以使用与因果一致(consistent with causality)的全序来生成序列号:如果操作 A 因果后继于操作 B,那么在这个全序中 A 在 B 前( A 具有比 B 更小的序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但也施加了一个比因果性要求更为严格的顺序。

在单主复制的数据库中,复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。

posted on 2023-08-11 23:31  Mr.Tan&  阅读(65)  评论(0编辑  收藏  举报

导航