初探分布式一致性模型

165 阅读46分钟

浅谈分布式一致性模型

前言

为什么需要使用分布式?单点机器宕机,单点机器性能不足等。

分布式使用多台机器一起协作完成事情,怎么保证每台机器都能get到最新的数据?其中一台挂了怎么办?一般需要使用到复制(一般为异步复制)。

复制带来的具体「好处」主要是体现在两个方面:

1.容错 (fault tolerance)。即使某些网络节点发生故障,由于原本保存着在故障节点上的数据在正常节点上还有备份,所以整个系统仍然可能是可用的。这也是我们期待分布式系统能够提供的「高可用」特性。

2.提升吞吐量。将一份数据复制多份并保存在多个副本节点上,还顺便带来一个好处:对于同一个数据对象的访问请求(至少是读请求)可以由多个副本节点分担,从而使得整个系统可以随着请求量的增加不断扩展。

一方面,复制带来了诸多好处;另一方面,它也带来了很多挑战,其中最重要的一个就是数据的一致性问题。由于同一份数据保存在了多个副本节点上,它们之间就存在数据不一致的风险。

因此接下来就来讨论不同一致性的概念和定义,以及涉及的相关知识。

这次讨论涉及的一致性类型主要有:线性一致性,因果一致性,顺序一致性,单调一致性,最终一致性。

题外话:

这部分参考一本Vonng/ddia - 设计数据密集型应用(github.com/Vonng/ddia)…

随即又查了一些具体的各个一致性的定义,主要参考了一篇zhangtielei.com/posts/distr…

什么是一致性?

ACID数据库事务中的一致性

ACID是数据库事务的四个特性,分别是原子性 (Atomicity)、一致性 (Consistency)、隔离性 (Isolation)和持久性 (Durability)。

ACID中的「一致性」,是对于整个数据库的「一致」状态的维持。抽象来看,对数据库每进行一次事务操作,它的状态就发生一次变化。这相当于把数据库看成了状态机,只要数据库的起始状态是「一致」的,并且每次事务操作都能保持「一致性」,那么数据库就能始终保持在「一致」的状态上 (Consistency Preservation)。

所谓状态是不是「一致」,其实是由业务层规定的。比如转账的例子,“转账前后账户总额保持不变”,这个规定只对于「转账」这个特定的业务场景有效。如果换一个业务场景,「一致」的概念就不是这样规定了。所以说,ACID中的「一致性」,其实是体现了业务逻辑上的合理性,并不是由数据库本身的技术特性所决定的。

ACID中的一致性,是个很偏应用层的概念。这跟ACID中的原子性、隔离性和持久性有很大的不同。原子性、隔离性和持久性,都是数据库本身所提供的技术特性;而一致性,则是由特定的业务场景规定的。要真正做到ACID中的一致性,它是要依赖数据库的原子性和隔离性的(应对错误和并发)。ACID中的一致性,甚至跟分布式都没什么直接关系。它跟分布式的唯一关联在于,在分布式环境下,它所依赖的数据库原子性和隔离性更难实现。

CAP与线性一致性

CAP的三个字母分别代表了分布式系统的三个特性:一致性(Consistency)、可用性(Availability)和分区容错性(Partition-tolerance)。

在证明CAP定理的原始论文中,C指的是linearizable consistency,也就是「线性一致性」。

网上对于CAP的一致性的通俗解释,通常有两种:

1.一致性是指:在分布式系统完成某写操作后的任何读操作,都应该获取到该写操作写入的那个最新的值。显然,如果系统“表现得像只有一个副本”一样,这个描述是成立的。不过这只是描述了线性一致性的一个特例而已,有以偏概全的嫌疑。

2.一致性是指:保持所有节点在同一个时刻具有相同的、逻辑一致的数据。显然这种解释并不是从观察者的角度来描述的,而是试图从系统内部的行为(内部实现)来描述的。「所有节点」,可能指的是「所有副本」;至于“在同一个时刻具有相同的、逻辑一致的数据”这个说法,则似乎离线性一致性的本来含义偏离太远了。从逻辑上说,“表现得像只有一个副本”,并不一定需要系统“在同一个时刻具有相同的、逻辑一致的数据”。线性一致性可能有很多种实现方式,而这种解释规定了一种具体的系统实现,同样有以偏概全的嫌疑。

个人理解 CAP中的C指的是线性一致性,而分布式理论中的一致性则是各种一致性模型的统称。

线性一致性(Linearizable Consistency)

概念:

也叫做强一致性(Strong consistency)/原子一致性(Atomic Consistency)

基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。

在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象,系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。

一个非线性一致性的例子(举一个更搞笑的事情:某一次年会抽奖 奖品中有一个同事A特别在意的滑板车,大家都在各自终端上看年会直播抽奖,但是因为网络延迟卡顿等外部问题产生了奇怪的现象:同事B同事C都感慨没有抽中滑板车而难受,同事A高呼终于到了滑板车抽奖时间(笑^_^))

图 9-1 一个非线性一致性的例子。

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

上图中的客户端操作。如果与写入同时发生的读取可以返回旧值或新值,那么读者可能会在写入期间看到数值在旧值和新值之间来回翻转。这不是我们所期望的仿真 “单一数据副本” 的系统。所以需要加入另外一个约束。

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

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

下图新增一个原子性生效的操作。

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

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

上图的四个注意点:

1.客户端B读取的是客户端D写入后客户端A写入的1

2.客户端B读取的是客户端A写入的值,但是A写入还未收到响应

3.不假设任何事务隔离。例如B客户端的CAS操作成功了,使得C客户端连续读到了不同值

4.客户端最后一次读取是非线形一致的。操作和C的写入并发,但是因为A读到了4,所以B不再允许读到旧值

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

线性一致性定义

这里感觉虽然涉及了线性一致性的举例描述但是还是缺少准确定义线性一致性的概念。

另一篇文章中提及了:zhangtielei.com/posts/blog-…

而刚刚描述的线性一致性的关键点也是符合另一篇文章中的关于线性一致性的定义:

试图把所有读写操作重排成一个全局线性有序的序列,并满足下述三个条件:

条件I: 重排后的序列中每一个读操作返回的值,必须等于前面对同一个数据对象的最近一次写操作所写入的值。

条件II: 原来每个进程中各个操作的执行先后顺序,在这个重排后的序列中必须保持一致。

条件III: 不同进程的操作,如果在时间上不重叠,那么它们的执行先后顺序,在这个重排后的序列中必须保持一致。

依赖线性一致性的场景:

1.锁定和领导选举

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

2.约束和唯一性保证

唯一性约束在数据库中很常见。如果要在写入数据时强制执行此约束(例如,如果两个人试图同时创建一个具有相同名称的用户或文件,其中一个将返回一个错误),则需要线性一致性。一个硬性的唯一性约束(关系型数据库中常见的那种)需要线性一致性。其他类型的约束,如外键或属性约束,可以不需要线性一致性

3.跨信道的时序依赖

例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。

图像缩放器需要明确的指令来执行尺寸缩放作业,指令是 Web 服务器通过消息队列发送的。

图 9-5 Web 服务器和图像缩放器通过文件存储和消息队列进行通信,打开竞争条件的可能性。

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

实现线性一致性的系统

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

在具有单主复制功能的系统中,主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们 可能(potential) 是线性一致性的 。然而,实际上并不是每个单主数据库都是线性一致性的,无论是因为设计的原因(例如,因为使用了快照隔离)还是因为在并发处理上存在错误。

2.共识算法(线性一致)

共识算法包含防止脑裂和陈旧副本的措施。正是由于这些细节,共识算法可以安全地实现线性一致性存储。例如,Zookeeper 和 etcd 。

3.多主复制(非线性一致)

具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突。这种冲突是因为缺少单一数据副本所导致的。

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

对于无主复制的系统(Dynamo 风格),有时候人们会声称通过要求法定人数读写( w+r>n )可以获得 “强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。即使使用严格的法定人数,非线性一致的行为也是可能的,如下图所示。

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

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

法定人数条件满足( w+r > n ),但是这个执行是非线性一致的:B 的请求在 A 的请求完成后开始,但是 B 返回旧值,而 A 返回新值。 (又一次,如同 Alice 和 Bob 的例子)

最安全的做法是:假设采用 Dynamo 风格无主复制的系统不能提供线性一致性。

线性一致性的代价

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

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

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

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

在单主配置的条件下,如果数据中心之间的网络被中断,则连接到从库数据中心的客户端无法联系到主库,因此它们无法对数据库执行任何写入,也不能执行任何线性一致的读取。它们仍能从从库读取,但结果可能是陈旧的(非线性一致)。如果应用需要线性一致的读写,却又位于与主库网络中断的数据中心,则网络中断将导致这些应用不可用。

顺序保证

顺序与因果关系(因果一致性Causally Consistent)

因果关系对事件施加了一种 顺序:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。

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

因果一致性的定义

这里感觉虽然涉及了因果一致性的描述但是还是缺少准确定义因果一致性的概念。

另一篇文章中提及了:zhangtielei.com/posts/blog-…

举一个例子:下面的故事发生在一个类似Facebook的社交网站上:

有一天,小白的儿子失踪了。小白找不到她的儿子,很着急,于是在社交网站上发布了一条状态:“我儿子丢了!”

过了一会,儿子自己跑出去玩然后回家了。小白长出一口气,又重新修改了刚才发布的状态:“谢天谢地,虚惊一场!儿子原来是跑出去玩了。”

小白的一个朋友,叫小红,看到了她发的最新的状态,在社交网站上回复了她:“太好了,总算松了一口气!”

假如这家社交网站的数据库系统没能保证因果一致性,那么我们就可能看到比较奇怪的事件次序。假设小白的另外一个朋友,叫小黄,也在浏览这个社交网站。可能由于系统延迟,数据还未收敛到一致的状态,小黄可能会看到小白发的第一条状态和小红的回复,但却看不到小白发的第二条状态。于是,在小黄看来:

小白说:“我儿子丢了!” 小红回复:“太好了,总算松了一口气!” 小黄可能会错误地认为,小红满心希望小白的儿子丢了!

之所以发生这样的问题,就是因为因果倒置了。考虑两个事件:事件A,小白发布第二条状态(称自己的儿子找到了);事件B,小红回复小白表示安慰。显然,事件B是由事件A引发的,也就是说,事件A是事件B的「因」,事件B是事件A的「果」。但在小黄看来,却只看到了事件B,没有看到事件A,这违反了因果规律。

如果只是满足最终一致性的系统,是没法总是保持因果关系的。但是,如果一个系统满足因果一致性,那么我们可以放心地认为,事件的因果关系是能够得到保证的。

一个因果一致性的case

为此我们首先需要定义清楚一个关键概念——因果顺序 (causality order),它表明了两个不同操作之间的排序是怎样规定的。

因果顺序的定义:如果两个操作o1和o2满足下面三个条件之一,那么它们就是满足因果顺序的,记为o1→o2:

(1) o1和o2属于同一个进程,且o1在o2前面执行。

(2) o1是个写操作,o2是个读操作,且o2读到的值是由o1写入的。

(3) 存在一个操作o’满足o1→o’o2。

结合上图的例子,我们解释一下这三个条件:

(1) 同一个进程内部先后执行的两个操作,不管他们是读操作还是写操作,都是满足因果顺序的。比如上图中P1进程的 A –> w1(x) 和 B –> w1(x) 两个操作就是满足因果顺序的;P2进程的 r2(x) –> BC –> w2(y) 也是满足因果顺序的。这一条件表明,因果顺序遵从了进程的执行顺序。

(2) 如果一个读操作读到的值是由另一个写操作写入的(肯定是针对同一个数据对象),那么不管它们是不是属于同一个进程,这个写操作和读操作就是满足因果关系的。比如上图中的 B –> w1(x) 和 r2(x) –> B 就是满足因果顺序的;C –> w2(y) 和 r3(y) –> C 也是满足因果顺序的。这个条件反映了读写操作之间的因果依赖关系。

(3) 这个条件表明因果顺序“→”满足传递关系 (transitive relation)。

因果一致性定义:在一个 并发 执行过程中,站在其中任意一个进程 Pi 的视角上,考虑这个进程的所有读、写操作和所有其它进程的所有写操作(注意不包含读操作),得到一个操作序列。如果每个视角的这个序列满足以下两个条件,那么这个并发执行过程就是满足因果一致性的

条件I: 重排后的序列中每一个读操作返回的值,必须等于前面对同一个数据对象的最近一次写操作所写入的值。

条件II: 重排后的序列遵从前面定义的因果顺序“→”。

以前面图示的并发执行过程为例,我们先以P1的视角,需要考虑把P1的所有读写操作和P2、P3的所有写操作进行重排,可以得到如下的有序序列:

D –> w3(x) 【 A –> w1(x) B –> w1(x) C –> w2(y) 】

再以P2的视角,需要考虑把P2的所有读写操作和P1、P3的所有写操作进行重排,可以得到如下的有序序列:

D –> w3(x) 【 A –> w1(x) B –> w1(x) r2(x) –> B C –> w2(y) 】

最后以P3的视角,需要考虑把P3的所有读写操作和P1、P2的所有写操作进行重排,可以得到如下的有序序列:

D –> w3(x) 【 A –> w1(x) B –> w1(x) C –> w2(y) r3(y) –> C r3(x) –> B

而且【D –> w3(x) r3(y) –>C r3(x) –> B】也包含因果顺序关系

我们可以依次检查三个重排序列,会发现因果一致性的 条件I和条件II都是满足的,所以前面图中的并发执行过程是满足因果一致性的。

你可能会觉得因果一致性的定义有些复杂,那么它的设计初衷是什么呢?

我们通过分析两个问题来做初步的解读:

为什么因果一致性是站在各个进程的视角对部分操作进行排序,而不是对所有进程的操作进行全局排序?这是因为,因果顺序是一种偏序关系,这就允许站在不同进程的视角去观察各自所关心的部分操作,从而得到不同的观察结果(排序序列)且同时不违反因果律。假如因果顺序不是一种偏序,而是一种全局关系,那么就可以把所有操作按照同一个次序排序起来,那就变成跟顺序一致性一样了,每个进程也可以看到完全一样的排序序列了。

所以说,这里隐含着一个结论:因果一致性是比顺序一致性更弱的一类一致性模型,而顺序一致性也意味着遵从了因果一致性。另外,也只有当站在不同进程的视角有不同的观察结果时,才可能在发生网络分区的时候,同时提供可用性。想象当一个节点同系统其它部分隔开了,这个节点不需要等待与其它节点联系,仍然可以使用旧版本的数据提供服务,同时不违反因果顺序即可。而如果像顺序一致性或者线性一致性那样,维持一个统一的全局排序,则需要在各个节点之间充分交换完数据才能达成一致。

为什么站在一个进程的视角要考虑所有其它进程的写操作呢?因为对于因果顺序来说,所有写操作都是潜在的「因」,而当前进程的读操作则代表了它的「看法」。进程的局部看法的形成,需要考虑所有的「因」,才能保证不违反因果律。

不满足因果一致性的case

因果顺序不是全序的,因果顺序不能有循环依赖

全序(total order) 允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。

例如,自然数集是全序的:给定两个自然数,比如说 5 和 13,那么你可以告诉我,13 大于 5。然而数学集合并不完全是全序的:{a, b} 比 {b, c} 更大吗?好吧,你没法真正比较它们,因为二者都不是对方的子集。

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

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

线性一致性

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

因果一致性

我们说过,如果两个操作都没有在彼此 之前发生,那么这两个操作是并发的。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的(一般就没有因果相关 ps:因果一致的模型中的操作并不是都存在因果顺序),则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的。

比如上图满足因果一致性的图中的 A –> w1(x) 和 D –> w3(x) 这两个操作之间的关系,就不符合因果顺序三个条件中的任何一个。r2(x) –> BD –> w3(x) 之间也同样如此,它们之间不存在因果顺序。

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

线性一致性 隐含着(implies) 因果关系:任何线性一致的系统都能正确保持因果性。特别是,如果系统中有多个通信通道(如 图 9-5 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。

一个系统可以是因果一致的,而无需承担线性一致带来的性能折损(尤其对于 CAP 定理不适用的情况)。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用。在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。

捕获因果关系

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

序列号顺序

虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用中,客户端在写入内容之前会先读取大量数据,我们无法弄清写入因果依赖于先前全部的读取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。

但还有一个更好的方法:我们可以使用 序列号(sequence nunber) 或 时间戳(timestamp) 来排序事件。时间戳不一定来自日历时钟(或物理时钟)。它可以来自一个 逻辑时钟(logical clock),这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。它提供了一个全序关系:也就是说每个操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。

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

个人理解 使用序列号相当于给每一个操作加上了一个唯一标识,这样因果依赖可以很明确,不然就像这里说的,一个果可能依赖之前读取的大量数据,每个数据都可能是因。

非因果序列号生成器

如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:

1.每个节点都可以生成自己独立的一组序列号。2.可以将日历时钟(物理时钟)的时间戳附加到每个操作上。3.可以预先分配序列号区块。

这三个选项都比单一主库的自增计数器表现要好,并且更具可伸缩性。它们为每个操作生成一个唯一的,近似自增的序列号。然而它们都有同一个问题:生成的序列号与因果不一致。有一个简单的方法来产生与因果关系一致的序列号,即兰伯特时间戳。

兰伯特时间戳

兰伯特时间戳就是计数器和节点 ID的简单组合: (计数器C,节点 Node) 。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID,每个时间戳都是唯一的。它提供了一个全序:如果你有两个时间戳,则 计数器 值大者是更大的时间戳。如果计数器值相同,则节点 ID 越大的,时间戳越大。

关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大 计数器 值,并在每个请求中包含这个最大计数器值。 当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。

图 9-8 Lamport 时间戳提供了与因果关系一致的全序。

这如 图 9-8 所示,其中客户端 A 从节点 2 接收计数器值 5 ,然后将最大值 5 发送到节点 1 。此时,节点 1 的计数器仅为 1 ,但是它立即前移至 5 ,所以下一个操作的计数器的值为 6 。只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。

虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。

考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中时间戳早的应该成功,另一个时间戳晚的应该失败。但这是在一个两个请求都已经收到的情况下判断,而在请求来的那个时刻,节点并不知道是否存在其他节点正在 并发 执行创建同样用户名的操作。为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,你必须检查其它每个节点,明显不太可行。

总之:为了实现诸如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定,不然还不如单主复制给力。如何确定全序关系已经尘埃落定,这将在 全序广播 中明确。

全序广播

单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个 CPU 核上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效(“处理节点宕机”),如何处理故障切换。在分布式系统文献中,这个问题被称为 全序广播(total order broadcast) 或 原子广播(atomic broadcast)。

全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性:

1.可靠交付(reliable delivery :没有消息丢失,如果消息被传递到一个节点,它将被传递到所有节点。

2.全序交付(totally ordered delivery :消息以相同的顺序传递给每个节点。

正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。

全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为 状态机复制(state machine replication)。

全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳排序更强。

考量全序广播的另一种方式是,这是一种创建日志的方式(如在复制日志、事务日志或预写式日志中):传递消息就像追加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。

全序广播和线性一致的差异:全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达(所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是新鲜性的保证:读取一定能看见最新的写入值。

使用全序广播实现线性一致的存储

例如,你可以确保用户名能唯一标识用户帐户。假设对于每一个可能的用户名,你都可以有一个带有 CAS 原子操作的线性一致寄存器(后面实现)。每个寄存器最初的值为空值(表示未使用该用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行 CAS 操作,在先前寄存器值为空的条件,将其值设置为用户的账号 ID。如果多个用户试图同时获取相同的用户名,则只有一个 CAS 操作会成功,因为其他用户会看到非空的值(由于线性一致性)。

可以通过将全序广播当成仅追加日志的方式来实现这种线性一致的 CAS 操作:

1.在日志中追加一条消息,试探性地指明你要声明的用户名。(个人理解需要主节点广播你要尝试申明这个用户名的消息)

2.读日志,并等待你刚才追加的消息被读回。(个人理解从主节点陆续广播的顺序消息中读取到自己的消息)

3.检查是否有任何消息声称目标用户名的所有权。(个人理解 全序广播保证了全序和可靠)如果这些消息中的第一条就是你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。

由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入(个人理解 多个人并发声明同一个用户名),则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。

尽管这一过程保证写入是线性一致的(按照主节点日志消息写入),但它并不保证读取也是线性一致的(不同节点接受消息有时间差) —— 如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。 (精确地说,这里描述的过程提供了 顺序一致性(sequential consistency),有时也称为 时间线一致性(timeline consistency),比线性一致性稍微弱一些的保证)。(后面有关于顺序一致性的详细定义)

为了使读取也线性一致,有几个选项:

1.你可以通过在日志中追加一条消息,然后读取日志,直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点(etcd 的法定人数读取有些类似这种情况【16】)。

2.如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待该位置前的所有消息都传达到你,然后执行读取。 (这是 Zookeeper sync() 操作背后的思想【15】)。

3.你可以从同步更新的副本中进行读取,因此可以确保结果是最新的(这种技术用于链式复制(chain replication)【63】;请参阅 “关于复制的研究”)。

使用线性一致性存储实现全序广播

最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返回操作。或者原子 CAS 操作也可以完成这项工作。

该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增并返回操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号依序传递(deliver)消息

请注意,与兰伯特时间戳不同,通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 —— 事实上,这是全序广播和时间戳排序间的关键区别。

实现一个带有原子性 自增并返回操作的线性一致寄存器有多困难? 像往常一样,如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的网络连接中断时的情况,并在该节点失效时能恢复这个值。因此需要共识算法

顺序一致性的定义

这里感觉虽然涉及了顺序一致性的描述但是还是缺少准确定义顺序一致性的概念。

参考另一篇文章中提及了:zhangtielei.com/posts/blog-…

结合上面提到的全序广播实现线性一致性的例子解释一下。

把这里的read(x)=>2移动到前面即可

这里个人非常难理解 什么叫做写入是线性一致的读取却不是线性一致的?线性一致性还分读写?而这种过程就是顺序一致性?

个人理解写入是线性一致的是:假设 并发 多个 CAS 只有一个成功,写操作是原子的而且是全序的。

而读取不是线性一致的:文中解释因为读取到了延迟较高的副本的旧值,即数据已经写入,但是读取却未读到最新值。

和前面的线性一致性的定义的三个条件一一判断

条件I: 重排后的序列中每一个读操作返回的值,必须等于前面对同一个数据对象的最近一次写操作所写入的值。(符合)

条件II: 原来每个进程中各个操作的执行先后顺序,在这个重排后的序列中必须保持一致。(符合)

条件III: 不同进程的操作,如果在时间上不重叠,那么它们的执行先后顺序,在这个重排后的序列中必须保持一致。(符合)

顺序一致性的定义:如果一个并发执行过程所包含的所有读写操作能够重排成一个全局线性有序的序列,并且这个序列满足条件一二,那么这个并发执行过程就是满足顺序一致性的。而在此基础上满足条件三,即这个执行过程是满足线性一致的。

顺序一致性为什么会这样定义呢?这个定义的初衷是什么?

我们可以试着这样理解:首先,重排成一个全局线性有序的序列,相当于系统对外表现出了一种「假象」,原本多进程并发执行的操作,好像是顺序执行的一样。本文前面提到过,理想情况下,分布式系统应该“表现得像只有一个副本”一样。顺序一致性正是遵循了这种「系统假象」,系统对外表现就好像在操作一个单一的副本,执行顺序也必然是可以看做顺序执行的。而条件I规定了系统的表现是合理的(即合乎逻辑的);条件II则保证了以任何进程的视角来看,它所发起的操作执行顺序都是符合它原本的预期的。

比较一下顺序一致性和线性一致性:

1.它们都试图让系统“表现得像只有一个副本”一样。

2.它们都保证了程序执行顺序不会被打乱。体现在条件II对于进程内各个操作的排序保持上。

3.线性一致性考虑了时间先后顺序,而顺序一致性没有。

4.满足线性一致性的执行过程,肯定都满足顺序一致性;反之不一定。

比较一下顺序一致性和因果一致性:

1.顺序一致性是对所有进程的所有读写操作进行统一的重排,而因果一致性是站在每个进程的视角各自进行局部重排。这表示顺序一致性要求系统的所有进程都对操作排序达成一致的看法,而因果一致性允许每个进程对操作的排序有不同的看法。

2.顺序一致性的条件II只是要求遵从进程的执行顺序,而因果一致性则有更强的要求——遵从因果顺序(而进程的执行顺序只是因果顺序的一部分)。

再举一个顺序一致性的case以及不是顺序一致性的case:

顺序一致性的case

重排之后的结果是:A –> w1(x) r3(x) –> A C –> w2(x) r3(x) –> C B –> w1(x) r3(x) –> B (满足顺序一致性的要求)

非顺序一致性的case

无论怎么重排,P1对应的先写A再写B的顺序需要保证,和P3的先读到B再读到A的顺序怎么也无法排在一起。

读己之写

许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给主库,但是当用户查看数据时,可以通过从库进行读取。但对于异步复制,问题就来了。如 图 5-3 所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。从异步从库读取时的第一个问题就是数据丢失。

图 5-3 用户写入后从旧副本中读取数据。需要写后读 (read-after-write) 的一致性来防止这种异常

在这种情况下,我们需要 写后读一致性(read-after-write consistency),也称为 读己之写一致性(read-your-writes consistency) 。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺:其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。

如何在基于领导者的复制系统中实现写后读一致性?有各种可能的技术,这里说一些:

对于用户 可能修改过 的内容,总是从主库读取;这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则就是:总是从主库读取用户自己的档案,如果要读取其他用户的档案就去从库。

如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(读伸缩就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。

客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟。

另一种复杂的情况发生在同一位用户从多个设备(例如桌面浏览器和移动 APP)请求服务的时候。这种情况下可能就需要提供跨设备的写后读一致性:如果用户在一个设备上输入了一些信息,然后在另一个设备上查看,则应该看到他们刚输入的信息。

如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心。(例如,用户的台式计算机使用家庭宽带连接,而移动设备使用蜂窝数据网络,则设备的网络路由可能完全不同)。如果你的方法需要读主库,可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心。

单调读

从异步从库读取时可能发生的异常的问题是 时光倒流

如果用户从不同从库进行多次读取,就可能发生这种情况。例如,图 5-4 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页时每个请求都被路由到一个随机的服务器,这种情况就很有可能发生)。第一个查询返回了最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取到该写入内容。实际上可以认为第二个查询是在比第一个查询更早的时间点上观察系统。

图 5-4 用户首先从新副本读取,然后从旧副本读取。时间看上去回退了。为了防止这种异常,我们需要单调的读取。

单调读(monotonic reads)(个人理解就是单调一致性) 可以保证这种异常不会发生。这是一个比 强一致性(strong consistency) 更弱,但比 最终一致性(eventual consistency) 更强的保证。当读取数据时,你可能会看到一个旧值;单调读仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间回退,也就是说,如果已经读取到较新的数据,后续的读取不会得到更旧的数据。

实现单调读的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。

最终一致性

最终一致性的设计思路,不再试图提供单一系统视图 (SSI),即不再试图让系统“表现得像只有一个副本”一样。它允许读到旧版本的数据。

虽然最终一致性和本文前面讨论的线性一致性或顺序一致性在命名上非常相似,但它的定义却与后两者存在非常大的差别。深层的原因在于,它们其实属于不同类别的系统属性 ( property )线性一致性和顺序一致性属于safety property (安全性);而最终一致性属于liveness property (活性)。

一个并发程序或者一个分布式系统,它们的执行所展现出来的系统属性,可以分为两大类:

safety:它表示「坏事」永远不会发生。比如,一个系统如果遵守线性一致性或顺序一致性,那么就永远不会出现违反三个(对于顺序一致性来说是两个)条件的执行过程。而一旦系统出现问题,safety被违反了,我们也能明确指出是在哪个时间点上出现意外的。

liveness:它表示「好事」最终会发生。这种属性听起来会比较神奇:在任何一个时间点,你都无法判定liveness被违反了。因为,即使你期望的「好事」还没有发生,也不代表它未来不会发生。就像最终一致性一样,即使当前系统处于不一致的状态,也不代表未来系统就不会达到一致的状态。而只要系统存在“在未来某个时刻达到一致状态”的可能性,最终一致性就没有被违反。另外,可用性 (availability) 也属于liveness属性。

实际上,最终一致性有点名不副实,它更好的名字可能是收敛性 (convergence),表示所有副本最终都会收敛到相同的值。