分布式系统中的时钟与一致性解读(上)
● 1 物理时钟与问题
● 2 逻辑时钟 Logicl Clock,LC
● 3 向量时钟 Vector Clock,VC
● 4 TrueTime, TT
● 5 混合逻辑时钟 Hybrid Logical Clocks,HLC
■ 5.1 概念预介绍
■ 5.2 算法介绍
● 6 一致性
■ 6.1 理论中的一致性
■ 6.2 分布式系统中实现的一致性
● 7 多说一点
当我们讨论一致性的时候,我们在讨论什么?
1 物理时钟与问题
世界是处于不断变化中的,佛经上说:“诸行无常”,就是这个意思。只有变化,作为观察者的我们才能感觉到时间,正是变化的世界让我们有了时间的概念。
在古代人们使用滴漏来度量时间,现在有了更具有科技水准的原子钟度量时间,而我们最为熟悉的,还是石英钟(包括你的腕表)显示的时间,也就是称为-墙上时钟(WALL CLOCK)的物理时钟。在日常生活中介绍事情时,首先说明时间早已成为惯例。儒家经典、编年体史书《春秋》,用语极为简练,但对时间的记载却贯穿始终。例如隐公元年的记载:“元年,春,王正月”,通过这段记载,后世史学家便可以很方便的使用西历和后续史书的记载推算出这是公元前722年的春天正月。至于“正月”之前冠“王”字,因为周王为天下权威,一切皆为王有,历法也不例外,所以叫“王正月”。时间与事物的发生高度关联,古已有之。那么在现代,甚至在数据库中,记录事件时包含时钟也就更不足为奇了。
计算机有自己的时钟,它大部分时间和物理时钟相差无几,尤其是配置了NTP(Network Time Protocol)的计算机还会定期向时钟服务器同步来进行时间校正。这保证了在计算机上记录变化的时间是可信的,例如我们查看各种日志,总会参考他的发生时间。但是在分布式环境中,可能会有计算机的物理时钟与其它计算机的时钟存在差异,这将导致在计算机之间同步消息时,出现对消息发生时间有分歧的情况。
考虑这样一个场景,时钟较快的计算机 C1 上在 T1 时间发生了事件 A1,在经过网络传输到另一台计算机 C2 上。不巧 C2 的时钟远远慢于 C1,即 T2 < T1(T2为时间达到C2时,C2 本地时间),那么 C1 传递的消息 A1 发生的时间 T1 就会令 C2 很困惑,对他来讲,这是一个未来时间发生的事情,颇有穿越之感。NTP在一定程度上解决了这个问题,但NTP仍然存在问题,导致时钟在不同计算机中存在不同步的可能。例如在向NTP SERVER同步时钟后,发生了网络阻塞,计算机在获取到NTP SERVER时钟时,这个时钟在NTP SERVER上已经推进了很多。这就导致不同的计算机仍然存在与NTP SERVER存在时钟差异的可能性,这就是时钟不可靠的情况之一。
为什么分布式环境中的计算机对时钟差异这么敏感,尤其是在分布式数据库?主要是我们需要对发生分布式环境中的、在不同计算机上的事件进行排序。而事件的排序决定了后文将要讨论的一致性。准确的对事件按实际发生时间进行排序在单机上非常容易,因为他们共用同一个计时器,可以理解为他们具有同一个观察者,对事件发生的顺序,只要询问这个观察者(本地时钟)就可以了。分布式环境中,每一个计算机都有本地时钟,当对事件的发生时间进行问询时(观察者提问),那么就会出现类似电影《罗生门》的情况,每个节点(计算机)都会根据自己的本地时钟来反馈事件的时间,既然他们的本地时钟不可靠,那他们的反馈又有几分可信呢。如此,自然也就不能就不同计算机上发生的事件根据他们真实发生的顺序(时序)进行排序了。这很难,但并不是没有办法。在分布式系统很难建立全局时钟,在本文中,需要理解:完美的时钟同步是对于分布式系统是不可行的,那么是不是存在其他的方式来实现事件排序呢?
2 逻辑时钟 Logical Clock,LC
为了解决不同机器上的物理时钟难以同步,导致无法区分在分布式系统中多个节点的事件时序问题,也为了区分现实中的物理时钟,Leslie Lamport于1978年在《Time, Clocks and the Ordering of Events in a Distributed System》论文中提出了逻辑时钟的概念,来解决分布式系统中区分事件发生的时序问题。物理时钟不可靠,Lamport逻辑时钟人为构造一个递增序列来为事件排序,替换物理时钟的递增顺序。类似于一个并没有怀表的裁判老师在校运动会赛跑终点人为的把参赛学生排出名次,他只是根据自己看到的学生冲线先后进行排序,并没有依靠物理时钟(这种不计时长,只排先后运动会很常见)。在分布式环境中,逻辑时钟解决的是不同节点对事件发生的顺序达成一致,而不是对时间达成一致。从这个角度理解,我们所处的物理世界的时间也就成了逻辑时钟的一个特例了。
逻辑时钟的算法:
● 捕获因果关系(Happened before relation,符号是:->):a -> b,代表a先于b发生,a, b代表事件
● 逻辑时钟(Logical Clock),用 C 来表示逻辑时钟,逻辑时钟的标准是:
■ [C1]: Ci(a) < Ci(b), [ 说明:Ci -> 代表逻辑时钟, 如果 a 发生在 b 之前, 那么事件a的逻辑时间要小于b的逻辑时间, i 代表在 i 这个特定的进程中 ]
■ [C2]: Ci(a) < Cj(b), [ 说明:代表不同进程发生的事件逻辑时钟的值 Ci(a) 小于 Cj(b) ]
名词参考:
● 进程(Process):Pi
● 事件(Event):Eij,这里 i 是进程号, Eij 是进程号为 i 的第 j 个事件
● tm:消息m的向量时间跨度,表示在进程之间传递
● Ci 向量时钟关联到进程 Pi , 他的第 j 个元素是 Ci[j] ,包含进程 Pi 当前时间的最新值在进程 Pj 中
● d: 漂移时间,通常是1(即每有新事件发生,进程的逻辑时钟递增1)
实现规则(Implementation Rules,aka IR):
● [IR1]: 如果 a -> b [ 说明:在同一个进程中,事件a先于事件b发生 ] , 那么, Ci(b) = Ci(a) + d
● [IR2]: Cj = max(Cj, tm + d) [ 说明:如果存在多进程,在进程之间发送事件,tm 等于 Ci(a) 的值,Cj 等于Cj和 tm + d 中的最大值 ] ]
实现规则补充说明:
实现规则1:在同一个线程内,a -> b 代表事件a先于事件b发生,那么事件 b 的逻辑时钟等于事件a的逻辑时钟加 d,即 Ci(b) = Ci(a) + d,d 就是漂移时间,也就是逻辑递增值,一般为1
实现规则2:在多线程场景中,如果有事件在线程之间发送,那么发送的事件中要携带逻辑时钟,如果线程 i 向线程 j 发送事件 a ,tm 等于需要发送的事件 a 的逻辑时钟 Ci(a),而接收线程 j 则选择 Cj 和 tm + d 中的最大值来更新本地逻辑时钟。
举例说明:
注:e11指在线程1上的第1个事件(event)
在上图中:
● 初始值为1,因为它是第一个事件,并且在起点没有传入值(由其他线程传入的逻辑时钟)
■ e11 = 1
■ e21 = 1
● 下一个点的值将继续增加 d (d = 1),如果没有传入的值,即适用[IR1]
■ e12 = e11 + d = 1 + 1 = 2
■ e13 = e12 + d = 2 + 1 = 3
■ e14 = e13 + d = 3 + 1 = 4
■ e15 = e14 + d = 4 + 1 = 5
■ e16 = e15 + d = 5 + 1 = 6
■ e22 = e21 + d = 1 + 1 = 2
■ e24 = e23 + d = 3 + 1 = 4
■ e26 = e25 + d = 6 + 1 = 7
当有传入值时,则适用规则[IR2],即取 Cj 和 Tm + d 之间的最大值
■ e17 = max(7, 5) = 7, [ e16 + d = 6 + 1 = 7, e24 + d = 4 + 1 = 5, 7与5选择最大值5 ]
■ e23 = max(3, 3) = 3, [ e22 + d = 2 + 1 = 3, e12 + d = 2 + 1 = 3, 3与3选择最大值3 ]
■ e25 = max(5, 6) = 6, [ e24 + 1 = 4 + 1 = 5, e15 + d = 5 + 1 = 6, 5与6选择最大值6 ]
限制
■ 适用规则[IR1]时, 如果 a -> b, 那么 C(a) < C(b) 正确,即在同一个线程中,事件 a 先于事件 b 发生,那么一定 C(a) < C(b)
■ 适用规则[IR2]时, 如果 a -> b, 那么 C(a) < C(b) 可能正确也可能不正确。即在不同线程中,线程1中的事件 a 的逻辑时钟与线程2中的事件 b 的逻辑时钟大小关系不确定,如下图中的e21,e31的逻辑时钟先后无法确定
以上介绍参考了:https://www.geeksforgeeks.org/lamports-logical-clock/
从上面的介绍中可以发现,逻辑时钟是一个正确的算法。MySQL的用户可能觉得逻辑时钟很熟悉,在MySQL的组提交中正是应用了逻辑时钟的方式来解决 binlog 在副本上进行重放时的并发问题。逻辑时钟可以解决事件的偏序问题,但在事件全序上则无法保证。即能够保证存在因果关系的事件的时序,但在不同的节点上不存在因果关系的事件却不符合事件真正发生的顺序。这是因为事件只有在发生因果关系(存在依赖关系)时才会向其他进程发送事件,不存在因果关系的事件之间不存在交互,也就无从知道它们事件的时序。虽然我们从上帝视角可以知道他们的时序,但进程视角的狭隘限制了它。
3 向量时钟 Vector Clock,VC
Lamport逻辑时钟虽然解决了存在依赖关系的事件时序,但是不同节点同时发生的事件不能很好的进行排序。向量时钟对逻辑时钟进行了继承与发展,它的时钟更新逻辑与逻辑时钟一样。不同在于逻辑时钟只有存在依赖关系的事件才会在节点间发送,而向量时钟则是每一个事件发生后,都会向当前所有节点发送(广播),其他节点接收后,更新本地逻辑时钟,这样就解决了所有事件的依赖关系与时序。
向量时钟的算法:
分布式环境中有N个进程,进程有本地的逻辑时间戳。例如,进程 i 的本地逻辑时间Ti,那么:
● 对于进程 i 来说,Ti[i] 是进程 i 本地的逻辑时间
● 当进程 i 有新的事件发生时,Ti[i] = Ti[i] + 1
● 当进程 i 发送消息时将它的向量时间戳(MT=Ti)附带在消息中。
● 接受消息的进程 j 更新本地的向量时间戳:TJ[k] = max(Tj[k], MT[k]), for k = 1 to N。(MT即消息中附带的向量时间戳)
假设有事件 A、B 分别在节点 p、q 上发生,向量时钟分别为 T[A]、T[B]
如果 Tq[B] > Tq[A] 并且 Tp[B] >= Tp[A],则 A 发生于 B 之前,记作 A -> B,此时说明事件 A、B 有因果关系
如果 Tq[B] > Tq[A] 并且 Tp[B] < Tp[A],则认为A、B同时发生,记作 A <-> B
例如下图节点 B 上的第 4 个事件 (A:2,B:4,C:1) 与节点 C 上的第 2 个事件 (B:3,C:2) 没有因果关系,在逻辑上判定为同时发生事件。而 C 节点第 1 个事件 (C=1) 与 B 节点第 1 个事件 (B=1, C=1) 有因果关系,所以 C 节点第 1 个事件 (C=1) 先于 B 节点第 1 个事件 (B=1, C=1) 发生,后者依赖前者,有先后关系。
向量时钟算法利用了向量这种数据结构将全局各个进程的逻辑时间戳广播给各个进程,通过向量时间戳就能够比较任意两个事件的因果关系(先后关系或者同时发生关系)。向量时钟被用于解决数据冲突检测、强制因果通信等需要判断事件因果关系的问题。例如在无中心,多写的数据库亚马逊 Dynamo 中就有应用向量时钟。但是因为进程的事件需要向其他节点广播,随着进程数增加,带来了更多空间的要求,更多的逻辑时钟数据结构需要在进程间进行通信,通信成本不低,从图3中可以看到一些端倪。
4 TrueTime, TT
TrueTime 是 Google 公司提出的概念,用于在分布式系统中提高物理时钟的可靠性。确切的说这是物理时钟 + 算法,是一种偏向硬件的解决方案,可以分配统一的、具有可比性的 timestamp 范围。在 Google 的 Spanner 上得到了应用。时钟硬件由GPS时钟和原子钟组成,因为GPS的天线或者接收故障与原子钟的频率导致漂移的故障原因不交叉,可以提高时钟的可靠性。在部署上支持全球部署,在每个数据中心都会部署若干time master机器,机器之间会通信校验时间,用于物理时间同步,能够保证TrueTime的误差范围 ε 是1ms到7ms 之间。相对于 NTP 的误差范围 100 毫秒大大降低,这是借助硬件和算法实现的。
TrueTime API的直接数据来源是设备上的local clock. 所以TrueTime的误差来源有:
● 从time master同步时的网络延迟, 导致的误差大概是1ms
● local clock的漂移, 校准后的一瞬间是0, 校准前的一瞬间是最大值, 范围在0~6ms
所以TrueTime总的误差在 1~7ms 的范围
TrueTime提供了三个API来操作时间:
使用TrueTime API时,需要搭配下面两个规则:
● Start:提交事务Ti时,事务协调者必须选择一个大于等于TT.now().latest的时间作为提交时间戳si
● Commit Wait:leader必须等待TT.after(si)为true后才能提交数据,也即必须等待si的绝对时间过去了才能提交数据
在具体使用上,举例说明:
分布式系统中有三台服务器S1、S2、S3,使用 Tabs 来指代绝对时间(绝对时间作为参考系,实际很难获得绝对时间。例如我们看到的事物因为光的传播需要时间,在实际上都属于过去式,典型的,我们看到的太阳是8分钟之前的太阳)。在执行分布式事务时,其中一台参与者作为协调者提交事务,提交时使用这次事务所有参与者中最大的时间戳作为事务的提交时间。每台服务器和绝对时间Tabs都有误差,这里做出以下假设:
S1 的时间比绝对时间快5ms,即 T = Tabs + 5
S2 的时间比绝对时间慢4ms,即 T = Tabs - 4
S3 的时间比绝对时间慢2ms,即 T = Tabs - 2
如下图所示
S2 作为协调者发起分布式事务T1,参与者 S1、S2,S1 执行 T1 本地分支事务时间为 15ms (此时 Tabs 为 10ms), S2 执行 T1 本地分支事务的本地时间是 7ms(此时 Tabs 为 11ms),S2作为协调者需要选择参与者中时间最大成员时间作为提交时间,所以选择S1的 15ms 作为提交时间。
S2作为协调者又开启了分布式事务 T2,参与者包括 S2、S3,S3 执行本地分支事务的时间是 13ms(此时 Tabs 为 15ms),S2 执行本地分支事务的时间是 12ms(此时 Tabs 为 16ms)。S2 还是作为协调者,提交事务时选择了 13ms 作为整个事务的执行时间。
在绝对时间上可以看出 T2 是在 T1 之后提交的,但是在事务本身选择的时间上看出 T1 比 T2 晚提交,这违反了事务原本的提交顺序(需要保证T2晚于T1的提交时间这一物理事实),也对一致性产生影响。在上图示例中没有使用 TrueTime API 的两个规则,那么来看看 TrueTime 是如何解决这个问题的呢。
继续使用上面的示例,但需要考虑一个因素,即:假设 TrueTime 误差 ε 为7ms。
S2 作为协调者在提交 T1 选择提交时间 s1 时,根据 Start 规则需要大于所有事务参与者(此时是S1、S2)最大的本地时间,并且需要大于 S2 本地的 TT.now().lastest (即TT.now() + ε),所以 s1 = max(15, 7 + 7) = 15ms。
s1确定后,根据 Commit Wait 规则,还要等待 TT.after(15) 为true后(即在误差时间 7ms 后,才能确认 s1 是过去时间,此时S2本地时间是 23ms)才能提交数据。也就是 TT.now() 是在[16, 30]区间的,S2 的本地时间是 23ms ,绝对时间 27ms 提交数据。
S2 作为协调者在提交 T2 选择提交时间 s2 时,根据 Start 规则得出 s2 = s2 = max(13, 12 + 7) = 19ms。
s2确定后,根据 Commit Wait 规则,还要等待 TT.after(19) 为true后(即在误差时间 7ms 后,才能确认 s2 是过去时间,此时S2本地时间是 27ms)才能提交数据。也就是 TT.now() 是在[20, 34]区间的,S2 的本地时间是 27ms ,绝对时间 31ms 提交数据。
通过加入 Start 和 Commit Wait 两个规则后,无论是事务自己选择的时间戳还是绝对时间,事务 T1、T2 的最终提交顺序都是 T2 晚于 T1,符合他们真实的提交顺序。解决了提交顺序问题,同时满足一致性要求。这里面没有考虑加锁释放锁的逻辑,从中可以看出事务提交额外引入了等待机制,需要等待 TrueTime 时间误差 ε 才能提交,这对性能必然有影响,所以Google一直在致力于提高 TrueTime 的精确度来降低误差 ε 。TrueTime 还是属于物理时钟的解决方案,除了Spanner,TIDB 采用了和 Oracle 相似的 TSO 授时来为事务分配全局唯一的时间戳,也属于物理时钟的一种解决方案。因为时钟精确度以及网络开销问题,TiDB 适合在网络相对有保证的机房内部署(需要向 PD 来请求时钟),而不是像 Spanner 那样,支持全球部署,有 GPS 加持的地方,就可以有 Spanner。详细内容可参考:
http://yang.observer/2020/11/02/true-time/
或论文
Spanner: Google’s Globally-Distributed Database