分布式系统中的时钟与一致性解读(下)

ywz888
发布于 2022-8-10 17:50
浏览
0收藏

5 混合逻辑时钟 Hybrid Logical Clocks,HLC
Lamport逻辑时钟解决了分布式环境中物理时钟不能精确同步时,对存在依赖关系的事件进行排序的问题。但在全序上对同时或者无依赖关系的事件却不能准确的进行排序。向量时钟虽然解决了逻辑时钟的全序排序问题,却引入了空间占用较大、通信成本高的问题。单纯使用逻辑时钟或者物理时钟(需要硬件支持)的解决方案都不尽完美。既然逻辑时钟可以解决分布式环境中事件精确的因果关系(这里的因果关系指的是其在真实物理时间上的顺序),物理时钟直观,且可以解决数据库要求的时间点备份恢复问题,那么将逻辑时钟与物理时钟结合起来的方案是否可行。在2014年,五位数据库行业先贤(Sandeep Kulkarni, Murat Demirbas, Deepak Madeppa, Bharadwaj Avva, and Marcelo Leone)发表了论文《Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases》,提出了 Hybrid Logical Clocks 的方案。整合了逻辑时钟与物理时钟,支持对事件进行因果关系排序,同时又有物理时钟直观的特点。

混合逻辑时钟即混合了物理时钟和逻辑时钟,实质上,是对逻辑时钟的增强。物理时钟是机器本地的时钟 PT(Physical Time,例如NTP clocks),系统时间可能会比墙上时钟稍快或稍慢,在一天之内误差可能有毫秒甚至秒级。逻辑时钟是通过 happened-before 关系确定事件的逻辑时钟 LC ,从而确定事件的偏序关系。在分布式场景下,使用物理时钟和逻辑时钟的共同的递增来确定事件的因果关系。后续介绍中更多的引用了论文《Logical Physical Clocks》中的概念与内容。

5.1 概念预介绍
分布式系统由节点(node)集合组成,节点数量会随着时间推移而发生变化,节点从事件角度来看会执行三种类型的事件,接收事件、发送事件、本地事件。时间戳算法目的是为每一个事件分配一个时间戳。在混合逻辑时钟算法实现上,使用 lc.e 来表示分配给事件 e 的逻辑时钟时间戳。和上文介绍的一样,使用 happened before hb 来表示系统中的两个事件的因果关系。按照这个规则,
e hb f 表明:
事件 e 与事件 f 发生在同一个节点,并且事件 e 先于事件 f 发生
或者
事件 e 是发送事件,事件 f 是相应的接收事件
e || f 表明:
事件 e 与事件 f 同时发生(并发)
根据规则,可以推断出:
e hb f ==> lc.e < lc.f
lc.e = lc.f ==> e || f
e hb f <==> vc.e < vc.f


5.2 算法介绍
HLC 的目标是提供类似于 LC的单向的(one-way)因果关系检测,同时保持时钟值始终接近物理时钟。HLC 需要保证能够满足以下四个要求:

 

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区
5.2.1 Naive Algorithm
给定的目标 l.e 的值和 e 事件发生的物理时钟值 pt.e 要接近,在Naive Algorithm算法中我们定义一下规则:对于任何事件 e,它的混合逻辑时钟的物理时钟部分要大于等于本地物理时钟,即 l.e >= pt.e 。设计的算法如下:


●   初始化所有 l 设置为0

●   发送事件或本地事件 f, f 是在节点 j 上创建的,设置 l.f = max(l.e + 1, pt.j), 事件 e 是节点 j 的前一个事件,确保 l.e < l.f 同样确保 l.f >= pt.f

●   接收事件f, f 是在节点 j 上创建的, 设置 l.f = max(l.e + 1, l.m + 1, pt.j), l.e 是节点 j 的前一个事件时间戳,l.m 是发送事件的时间戳,这确保 l.e < l.f 和 l.m < l.f
简化逻辑如下:

●   初始化:l.j = 0
●   发送消息事件或者本地事件:l.j = max(l.j + 1, pt.j)
●   接收 m 消息事件:l.j = max(l.j + 1, l.m + 1, pt.j)
很容易看出算法符合上面需要满足的四个要求中的1和2,但不满足3和4。举例说明,其并不满足 | l.e - pt.e | 在一定范围内。

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

上图是一个计数的例子(counterexample),消息在节点1,2和3之间循环,永久重复,每循环传递回合,逻辑时钟与物理始终之间的漂移(l - pt)将会保持增长。消息再次到节点1时,| l - pt | 为17-4=13,随着事件的继续传递,物理时钟和逻辑时钟的漂移会越来越大,这明显不符合要求3和要求4。这是因为,虽然在Lamport 逻辑时钟的基础上引入了物理时钟,但是却不能分辨出这个值增长究竟是物理时钟导致的还是逻辑时钟增长导致。这样即使物理时钟的增长追赶上了逻辑时钟的增长,我们也没办法重置逻辑时钟部分的值,所以这个算法并不可行。

5.2.2 HLC Algorithm
为了解决上述问题,HLC algorithm算法把Naive Algorithm算法中的 l.j 扩展为逻辑时钟与物理时钟两个部分: l.j 和 c.j 。第一部分 l.j 引入来维护到当前为止最大的物理时钟 pt,第二部分 c.j 则用来表示逻辑时钟部分,当几个事件的物理时钟相等时,使用 c.j 来表示事件之间因果关系(capture causality updates)。对比Naive Algorithm算法,不违反先序关系(hb, happened before)时没有合适的地方来重置逻辑时钟 l ,在HLC Algorithm算法中,当节点本地物理时钟 pt 赶上或超过HLC的物理时钟部分 l.j ,就可以重置逻辑时钟 c.j 了,并把 l.j 更新为新的本地物理时钟。由于 l.j 表示能够感知到的最大物理时钟,而且不随每个事件而递增,在一定的时间范围内,做出以下保证:


●   节点接收消息,使用较大的物理时钟 l ,其 l 已更新,逻辑时钟 c 重置来反映这一点

●   如果节点没有接收其他节点消息, 那么他的混合逻辑时钟的物理时钟部分 l 将会保持不变, 并且他的本地物理时钟 pt 追赶上并更新混合逻辑时钟的物理时钟部分 l, 并且重置逻辑时钟部分 c 。

如下是算法的描述:
初始化时,物理时钟部分 l 逻辑时钟部分 c 设置为 0。当一个发送事件 f 被创建,物理时钟部分 l.j = max(l.e, pt.j), 事件 e 是 j 节点上前一个事件,初始时并不存在,所以第一个事件的混合逻辑时钟的物理时钟部分就是本地物理时钟。类似于naive algorithm算法,这确保 l.j >= pt.j (即HLC的物理时钟部分要大于等于节点本地物理时钟)。因为移除了 “+1” 的自增逻辑,,存在 l.e = l.f 的可能。为解决这种情况, 利用逻辑时钟部分 c.j 。通过增加 c.j ,能够确保( l.e, c.e) < ( l.f, c.f ) 结果为真。如果 l.e 与 l.f 不同,那么 c.j 需要被重置, 这将保证逻辑时钟部分 c 有界,即逻辑时钟是在一个可预测的范围内。当一个接收事件被创建,设置物理时钟部分 l.j = max(l.e, l.m, pt.j) 。现在,取决于 l.j 是否等于 l.e 和 接收的事件 m 的物理时钟部分 l.m , c.j 都会得到设置。简而言之,就是本地HLC时钟会根据本地的事件或者接收的事件,亦或者本地物理时钟来推进。
简化算法逻辑如下图:

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

上图是算法的一个展示。在发送事件的逻辑中, l'.j 指的是 j 节点当前最近执行的事件的物理时钟。当发送事件 f 创建时,物理时钟部分在当前节点本地执行最新的事件 e 的 HLC 的物理时钟部分 l.e 和节点本地最大 物理时钟 pt.j 两个之中选择最大值,设置为发送时间的HLC的物理时钟 l.j 的值。至于发送事件HLC的逻辑时钟部分则取决于事件 e 与发送事件 f 的HLC的物理时钟部分是否相等,相等则发送事件 f 的 HLC 的逻辑部分等于事件 e 的逻辑时钟部分 c.j + 1 ,如果不相等,则发送事件 f 的HLC的逻辑部分则被重置为0。最终得到 HLC 的两部分值与接收事件的逻辑大同小异,详细参考上图中逻辑。下图是计数(counterexample)例子按照HLC算法调整的一个具体的示例:

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

在上图中,当继续在1,2,3节点间的循环,可以看到在节点1上 pt 在追赶并超过 l.j = 10 ,并且重置了逻辑时钟部分 c.j = 0。后续节点间传递消息时,因为节点的 pt 较小,可以看出一直在追赶 HLC 的物理时钟,并且在 HLC 物理始时钟相等时,仅仅增加逻辑时钟部分,直到节点 1,2,3 中任何一个的本地物理时钟值超过 10,这时会将 c.j 重置为 0。

通过算法改进,把物理时钟和逻辑时钟分开来表示,满足了上边的四个要求中的3和4。HLC 仍然需要 NTP。对于任何一个事件 j,pt.j <= l.j ,也即 HLC 的物理时钟部分的值一定大于等于本地物理时钟(通过 NTP 同步)值。假设整个分布式系统中,NTP 协议的时钟误差值为 ε 。新算法中,对于任何一个事件 j,| l.j - pt.j | <= ε,也就是 HLC 物理部分的值和本地物理时钟值的差距不会超过 ε。NTP协议的误差值在局域网内大概 1 毫秒内,广域网可能达到 100ms 或更大。所以一般情况下,使用 HLC 时节点本地物理时钟与 HLC 的物理时钟部分的误差会在 100ms 级别。HLC 虽然没有像 TrueTime 那样需要硬件依赖,但 HLC 仍然有一个 bound(有界) 的要求,需要保证 | l.e - pt.e | 在这个 bound 范围内,如果超过了这个 bound,HLC 就不能正常工作了。这也是需要在分布式系统中部署 NTP 的原因之一。

在工程实践中,HLC 一般会使用 8 字节 64 比特来表示,高位 48 比特是以毫秒为单位的物理时间。低位 16 比特是一个单调增长的整数,最大 65535。目前像 CockRoachDB、 MongoDB 都使用了 HLC 算法,CockRoachDB 在分布式事务中使用了 HLC , MongoDB 则依靠 HLC 支持了因果一致性。

6 一致性
用了很大篇幅在说时钟,时钟解决了事务(或者叫事件)的顺序关系,那么也就提供了一致性保证。那么具体什么是一致性?在不同的语境,一致性可能是不同的。


6.1 理论中的一致性
ACID 之 Consistency
在电影《菊次郎的夏天》中有一幕,放暑假的留守男孩正男与废柴大叔菊次郎在一场治愈之旅中遇到了机车兄弟和章鱼男,一行人做了一个游戏(一二三木头人),这里面的小朋友正男站立不动,其他人在后面慢慢接近,正男每次回头,所有人不能让正男看到移动动作,如果被看到移动动作,则出局。电影的配乐、久石让的Summer灵动活泼,清新自然,让人听来舒心放松,这个游戏也让人联想到事务特性中的一致性,正男作为用户(观察者)不能看到其他人移动的动作(中间状态),虽然几人每次位置(为接近正男)有变化,但其动作不为观察者捕获。也就是从一个一致状态到下一个一致状态,即不允许用户看到不一致的状态。这就是ACID中的一致性。

CAP 之 Consistency
CAP中的Consistency是在一致的状态之外增加了原子性的要求,是一种强一致性要求。它的一致性结果的要求则是线性化的,这在分布式系统中很难实现。异步系统中,可用性不可能满足,主备节点切换总是需要时间。存在网络分区时,无法同时保证可用性与一致性,于是有了 AP 和 CP 系统的艰难选择。所以为了保证可用性,正经分布式数据库很难支持线性一致性。

BASE 之 Eventual Consistency BASE 的基本可用、柔性状态、最终一致性,是对CAP的要求进行了弱化,比较多的场景对性能的要求还是要高于一致性的要求,用户体验是一些应用赖以生存的根本。更新异步的在系统中传播,较强的的一致性延迟较大,明显降低用户体验,所以降低为最终一致性,提升用户体验,占领市场才是硬道理。但是最终是什么时候?一个转账请求,在较弱的一致性场景下,如果是由流处理系统来操作的,那么只有请求在流处理队列中被处理后,用户才能看到,达到最终一致性。现实中转账需要两个小时到账的例子不胜枚举。而这个两个小时就是最终一致性的一种“最终”保证,听起来不可靠,但可以工作得很好。

6.2 分布式系统中实现的一致性
从另一个角度讲,一致性更多的是在描述读一致性。在分布式系统中,采用副本冗余保证高可用是普遍的做法,在向系统写入数据后后,能否承诺立即读取到最新的值,是一致性级别的重要体现。


严格一致性
在这模型下,包含了全局时钟概念,任何进程的写入都可以在写入后立即被其他进程读取,就需要保证写入被复制到所有副本,保证不能被回滚。这只是一个理论模型,限于分布式限制以及事情发生传播的速度,这不可能实现。


可线性化(线性一致性 Linearizability)
要求对分布式系统中不同节点的发生的事件的排序类似在单机系统中是一样的顺序,一样的一致性结果。可线性化是单对象、单操作最强一致性模型。写操作在开始和结束之间的某个时间点,写操作的效果严格一次性对所有的读取者可见。即写入的可见性需要对所有观察者有同样的保证。线性化既保证在进程局部中的操作顺序,也保证其他进程的并行操作的顺序。这个顺序是一致性的,也就是每次读取应该返回在此之前的最新值。即使存在并发,读取也应该以一种看似连续的效果呈现。这里借用《Database Internals: A deep-dive into how distributed data systems work》中的示例来说明。
三个进程,进程1,进程2,进程3各自执行了以下操作

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区
如下图所示,write(x, 1)表示设置寄存器x=1,寄存器初始值为Ø(表示为空集)。read(x)表示读取寄存器x值,这里隐含着三个线程使用了相同的时钟,横向即为时钟演进方向。线段长短代表指令执行时长。

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

线程3的三次读取:


1. 第一次读取时,写入仍在进行中,所以读取的结果并不确定,读取的结果可能是Ø,可能是1,也可能是2。那么在事件排序上,第一次读取可以排在线程1和线程2写操作之前,也可以排在线程1和线程2写操作之间,也可以排在两次写操作之后。
2. 第二次读取时,线程1的写操作已经完成,线程2写操作仍在继续,所以返回值可能是1,也可能是2。
3. 第二次读取时,线程1和线程2的写操作都已经完成,所以只能返回2。这是因为线程2的写操作需要排在线程1的写操作之后。

可线性化体现的就是可见性,操作一旦完成,所有观察者都能够看见。操作虽然不是原子的,但在某个时刻(可线性化点),它一旦变得可见,就是对所有成员而言。正如上文所说,正经分布式数据库很难支持线性化,这是基于代价而言,而不是技术难度。要实现线性化,所有的写入都要同步得到系统中的所有节点后才返回,这带来显著的延迟,就这一项,就很难被用户接受。


即然是全局有序,在这里就可以使用全局时钟来实现线性化,完全可以采用 TrueTime 或者 Oracle 的全局授时算法来对分布式系统中的事务分配时间戳。当然在并行过程中还存在冲突的可能性,需要引入共识算法来解决冲突并排序,来支持线性一致性。


顺序一致性(Sequential Consistency)
虽然在语义上经常与线性一致性混淆,但顺序一致性是对线性一致性放宽要求的一种模型,仍然具有相当强的一致性保证。最早则是用来描述CPU的行为,在默认情况下,多核CPU并不能保证顺序一致性,需要使用内存屏障来确保写入操作对并发线程按顺序可见。CPU 是可以重新排序命令的,如果可以找到一个所有 CPU 执行指令的排序(Order),该排序中每个 CPU 要执行指令的顺序得以保持,且实际的 CPU 执行结果与该指令排序的执行结果一致,则称该次执行达到了顺序一致性。
这里使用多核CPU执行指令的例子说明,这同样可以类比数据库多节点写入的情况:

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

上图中的 write(x, 1) 代表将1写入x,read(x) → 1 代表读取x值为1。横向代表时钟演进方向,即时间。线段长短代表指令执行时长。
对上图的执行结果进行了一次重排序(Reordered):
p1: write(x, 1) -> p3: (read(x) → 1) -> p4: (read(x) → 1) -> p2: write(x, 2) -> p3: (read(x) → 2) -> p4: (read(x) → 2)
排序中各个 CPU 的指令顺序得以保持(如 p3: (read(x) → 1 在 p3: (read(x) → 2之前),这个排序的执行结果与 CPU 分开执行的结果一致,因此该 CPU 的执行是满足顺序一致性的。另外从图中可以看出,p2: (read(x) → 2) 实际上是在 p3: (read(x) → 1) 之前完成的,但是 p3 读到的仍然是 (x → 1) ,这其实是符合实际情况的,毕竟p2的写入传播到 p3 需要时间。从重排序中注意到顺序一致性关心的是 CPU 内部执行指令的顺序,而不关心 CPU 之间的相对顺序。延伸到分布式系统中,每个进程都可以按照自己指定的顺序发出读写请求,任何单线程的程序其实都是一个接一个的执行自己的步骤。从同一个进程传播出来的所有操作都应该按照它的提交顺序出现。来自不同的线程的操作则可以任意排序,从观察者(读者)角度来看,这就是顺序一致性。从上图 CPU p3、p4的操作在重排序后也可以看出来。


Zookeeper就描述它支持顺序一致性,Zookeeper所有的写入都会转发到他的Leader节点,Follower节点从Leader节点复制数据。这与 Raft 类似,Zookeeper的Leader节点充当了状态机的角色,所以Follower节点的写入顺序会和Leader节点一致,但是Zookeeper允许从Follower节点读取数据,在读取Follower节点数据时,Leader节点的数据有可能还没有同步到Follower节点,这样,顺序一致性就得不到保证了。

因果一致性
在全局对所有操作进行排序,成本较高,很多时候也没必要。在一些有依赖关系的操作之间建立关系则是有必要的。虽然没有全局排序的必要,但是需要满足一个要求:在所有的过程(或者叫观察者)中,必须以相同的顺序看到有因果关系的操作。你在网上发出一条微博,有人回复了这条微博。那么第三人看到的顺序应该是,你先发了微博,然后有人回复了微博。而不是先看到一条回复,然后才看见你的微博。发出微博和回复具有明显的依赖关系,其他人也应该以发出微博,然后评论的顺序看到,操作的因果关系对所有的观察者以相同的顺序呈现,这就是因果一致性模型。


如果不满足因果一致性,会产生什么影响?

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

在上图中,进程p1和p2进行了无因果顺序的写入,他们的写入无序的传播到其他进程,进程p3先看值1,在看到值2,而进程p4,却首先看到值2,再看到值1。进程p3与进程p4看到的时间顺序并不一致,如果两个写入操作没有依赖关系,那么这种病情况并无大碍,如果存在依赖关系,操作无序的传播就会引起一致性的问题。


在上图中,如果建立因果关系,就需要在进程p1和p2的写入之间指定一个逻辑时钟,来指定两个写入之间的因果顺序。如下图

分布式系统中的时钟与一致性解读(下)-鸿蒙开发者社区

 

 

在这里可以把x值变化看作是状态的变化。p1线程将x的状态由Ø(空集)设置为1,逻辑时钟此时为t1。进程p2执行的write(x, t1, 2)操作,指定他是在t1之后,与进程p1执行的操作存在按逻辑时钟建立的因果顺序。虽然进程p1的操作和进程p2的操作在传播速度上不同,即使进程p2的操作先传播到进程p3,但是因为它依赖的操作write(x, Ø, 1)暂时并没有到达,所以write(x, t1, 2)的操作是不可见的。这样,使用逻辑时钟就解决了存在依赖关系的操作的排序问题。


使用逻辑时钟解决了因果操作的排序,但是并不能解决数据库通常的按时间点的备份,例如我们要对数据库执行一份一个小时前的备份,这是有一个精确时间点要求的。逻辑时钟只解决了上图进程p1和进程p2的排序,但是没能记录操作具体是什么时间(物理时钟)执行的,在执行精确的时间点备份时,就无法判断备份需要截至到在那个操作了,这就需要混合逻辑时钟了。需要在逻辑时钟之中增加物理时间。


MongoDB就是基于混合逻辑时钟在 MongoDB 3.6.4 支持了因果一致性(Causal Consistency),并骄傲的表示:MongoDB is one of the first commercial databases we know of which provides an implementation [of causal consistency]。详细可以参考论文:Implementation of Cluster-wide Logical Clock and Causal Consistency in MongoDB,在附录部分,给出了混合逻辑时钟的实现证明。


可调一致性
前文说过,分布式系统通常采用副本冗余的方式来维护高可用。采用复制架构的数据库,在主节点写入,数据同步到其他副本则需要时间,一般是异步写入(MySQL的半同步则是写入副本的中继日志)。如果使用读写分离,一致性就很难保证。在数据复制中,引入三个变量来调节写入、读取:
复制副本数:N ,数据副本的数量
写入一致性:W ,写入成功时需要确认的副本数
读取一致性:R ,读取成功时需要响应的副本数
在写入时,选择给客户端返回成功标志的时机对一致性有不同的影响。
写入一个副本(即主节点),写入一致性 W = 1 ,通知客户端写入成功。此时写入尚未同步到其他副本,如果客户端据此向其他副本发出读请求,将不能返回最新值。这是RYW(Read Your Write)的例子。即使此时客户端从主节点读取最新的值,但如果此时,主节点崩溃,其他副本接替崩溃节点的工作。因为崩溃节点的最新值没有同步到其他副本,此时客户端再次发起读请求,在之前尚能读取到的最新值,此时却不见了,仍然违反一致性。所以只写入一个副本就通知客户端成功,是较弱的一致性保证。MySQL的半同步复制在一定程度上解决了这个问题,但只是保证主节点的写入能写入到从节点的磁盘,并没有在实例中应用。


如果写入一致性与读取一致性变量满足 W + R > N ,系统则可以保证每次读取总能够返回最新值,满足RYW(Read Your Write)的要求。因为读取的副本和写入的副本总是存在交集,保证总会读取到包含最新值副本。例如一个含有5副本的数据库,在主节点写入后,在写入同步到3个副本后返回,那么我们从其中的3个副本中读取,至少能够保证其中一个副本中包含最新写入的值。

最好的一致性写入,毫无疑问是等待写入同步到所有节点后再通知客户端写入成功。也就是设置 W = N ,那么读取一致性即使只设置为 R = 1 ,也能保证读取到最新值,即使回滚也没有影响,因为副本中已经全部包含了新写入的值。但是这需要接受较大的延迟,写入一致性变量W越大,则延迟越高,一致性保证越好。不能接受延迟,或者对一致性要求并不高,则可以设置W = 1,或者一个较小的值,配合设置读一致性R为一个较大的值(保证W + R > N)来保证一致性,此时的延迟则加大,因为需要等待数据同步到大部分副本,可以看出这是一个Trade off。

MongoDB实现了可调一致性,通过在写入时设置writeConcern,在读取时设置readConcern来调节一致性,这是一种非常灵巧的方法,而且将选择性交给用户,既然选择强一致性,那么就需要接受较大的延迟。

7 多说一点
不同的数据库对一致性有不同的保证,他们使用了不同的时钟来对操作进行排序,从TrueTime的Spanner,到使用混合逻辑时钟的MongoDB,在实现一致性时都对延迟有所妥协。这种折中、妥协在计算机设计中比比皆是。通过本文的描述,希望能引起大家的兴趣,投入到分布式数据库领域中来,来见证分布式数据库的成长。

分类
标签
已于2022-8-10 17:50:06修改
收藏
回复
举报
回复
    相关推荐