时间戳与一致性(一):时间戳

时间戳与一致性

时间戳

为什么需要时间戳

数据库遵循ACID的四大特性,在多副本间保证数据的一致性,在分布式架构中,为了在多个用户同时访问数据库时保证这种性质,需要使用并发控制方法,实现并发控制的技术主要有两种:锁(locking)技术和时标(timestamp)技术。

锁技术是用户在对某个数据对象(可以是数据项、记录、数据集以至整个数据库)进行操作之前,必须先向系统发出请求获得相应的锁,以此保证对共享数据段的保护。而时标技术则是对用户的操作顺序进行定序,保证互相冲突的操作不会同时发生。

两种方法的具体实现方式包括了 2PL、MVCC 等,2PL 是最经典的基于读写锁的并发控制,但效率往往较低,已经不被时下的 DBMS 使用,而 MVCC 则是当下主流的并发控制方式。

img
并发控制的几种方法 并发控制的几种方法 并发控制的几种方法
MVCC 的全称是多版本并发控制(Multi-Version Concurrency Control),即同时保存数据项的多个版本,MVCC 通过比较 Snapshot 读取事务ID 和数据上的写入事务 ID,其中最大但不超过读事务ID 的版本,即为可见的版本。事务数据的管理可以以分布式的形式进行管理,也可以集中式的管理,一些数据库使用了这样的方案:

  • MySQL 的 ReadView 实现就是基于事务 ID 大小以及活跃事务列表进行可见性判断。事务 ID 在事务开启时分配,体现了事务 begin 的顺序;提交时间戳 commit_ts 在事务提交时分配,体现了事务 commit 的顺序。

    参考:MySQL高级之MVCC机制详解(七)_贤子磊的博客-CSDN博客

  • 数据库 Postgres-XL 也用了同样的方案,只是将这套逻辑放在全局事务管理器(GTM)中,由 GTM 集中式地维护集群中所有事务状态,并为各个事务生成它们的 Snapshot。

    参考:https://cloud.tencent.com/developer/article/2065492

这种方案将事务的数据和状态集中管理,实现上更简洁,但是制约了数据库的扩展性。

另一套方案是将事务的相关数据交由节点自行管理,同时引入真实的时间戳,只要比较数据的写入时间戳(即写入该数据的事务的提交时间戳)和 Snapshot 的读时间戳,即可判断出可见性。在单机数据库中产生时间戳很简单,用原子自增的整数就能以很高的性能分配时间戳。Oracle 用的就是这个方案。

分布式事务中的时间戳详解 第2张

而在分布式数据库中,最直接的替代方案是引入一个集中式的时间戳分配器,称为TSO(Timestamp Oracle,此 Oracle 非彼 Oracle),由 TSO 提供单调递增的时间戳。TSO 看似还是个单点,但是考虑到各个节点取时间戳可以批量(一次取 K 个),即便集群的负载很高,对 TSO 也不会造成很大的压力。TiDB 用的就是这套方案。

分布式时间戳

时间戳作为天然自增的数列,满足我们对该全序性的要求。但在集群中,要求每台服务器都能在同一时刻得到相同的时间是困难的。如果不能做到这一点,就不能使用服务器上的时间戳作为事务的时间戳,这会导致整个集群无法满足数据的一致性,举例来说,对于集群中的一台服务器,有两个 client 对其发来事务命令,他们都以各自服务器上的时间作为时间戳,理论上来说,这个执行命令的服务器应该按照其携带的时间戳,而非两个命令抵达的先后顺序,但是。假设 client A 所取到的时间戳并非正确的时间戳,其时间戳较正式的时间戳小很多,那么可能我们在一段时间内始终都在优先执行 client A 的命令,这并不是正确的行为。

在这里插入图片描述

同样的事发生在多种场景下,两个client 分别向两个保存相同数据的数据库副本发出执行命令,在这种情况下,命令地抵达顺序将是随机的,我们应该遵循使用时间戳来保证副本数据的一致性,前提是时间戳需要时正确的。

在这里插入图片描述

归根揭底,是我们没有一个可靠的自增序列,来保证全局唯一。但是想要获取这样的时间戳却并不容易,服务器中虽然时钟同步的功能,但是返回的时间与标准时间相比都有一定的误差,基于这种需求,人们提出了许多不同的时间同步方案。

NTP 协议

NTP 协议是最常见的时间同步方案,1985年由特拉华大学的David L. Mills设计提出。NTP协议的目标是将所有计算机的时间同步到几毫秒误差内。实际上广域网可以达到几十毫秒的误差,局域网误差可以在1毫秒内。NTP协议是一种主从式架构协议,使用分层的时钟源系统,每一层称为Stratum,阶层的上限是15,阶层16表示未同步设备。常见的阶层如下:

在这里插入图片描述

除过第一阶层是同0阶层直接连接,其他每一阶层会向上一阶层请求时间,以同步自身系统的时间,由于从发起请求到收到回复需要一定的时间间隔,这也造成了 NTP 系统中的时间误差,具体的同步流程如下所示:

在这里插入图片描述

其导致的时间误差可表示为:
θ = ( ( T 2 − T 1 ) + ( T 3 − T 4 ) ) / 2 θ = ( (T2 - T1) + (T3 - T4) ) / 2 θ=((T2T1)+(T3T4))/2

Lamport Timestamp

由于完全同步时间戳总会收到网络传输等因素的影响而存在误差,而时间戳的存在是为了确立在分布式系统中真实的顺序关系,因此只要能准确表示出事件发生的顺序关系就可以取代真实的时间戳。Leslie Lamport 在1978年的论文《Time, Clocks, and the Ordering of Events in a Distributed System》提出了 Lamport 逻辑时钟,就是基于这样一种思想实现的。

向量时间戳

在 Lamport 逻辑时钟中,存在着这样一个问题:对于任意两个事件 a a a b b b ,如果 a → b a → b ab,那么 C ( a ) < C ( b ) C (a) < C (b) C(a)<C(b),但是反向并不成立, C ( a ) < C ( b ) C(a) < C(b) C(a)<C(b) 推不出来 a → b a→b ab向量时钟(Vector Clocks)是这个问题的一种可行的解决方式,可以保证反向也能成立。

这一设计在1986 年由 Barbara Liskov, Rivka Ladin提出,虽然彼时这个机制还未被正式称为 Vector Clocks(当时是叫做"multipart timestamp")。

向量时钟可以解决 Lamport 逻辑时钟中存在的问题,它的思想是进程间通信的时候,不光同步本进程的时钟值,还同步自己知道的其他进程的时钟值,具体来说:分布式系统中每个进程 P i P_i Pi保存一个本地逻辑时钟向量值 V i V_i Vi,向量的长度是分布式系统中进程的总个数。 V i [ j ] V_i [j] Vi[j] 表示进程 P i P_i Pi在与进程 P j P_j Pj在通讯时记录的本地逻辑时钟值。

V i V_i Vi 的更新算法同 Lamport 逻辑时钟 V i V_i Vi的 类似,仅有细微的不同:

  1. P i P_i Pi在执行一个本地事件之前,对所记录的本地时间戳 V i [ i ] V_i[i] Vi[i]进行自增。

    V[i] = V[i] + 1;
    
  2. 当发生进程间的通讯事件时, P i P_i Pi 会自增本地时间戳,然后将整个向量时间戳 V V V发送给对应的进程 P j P_j Pj

    V[i] = V[i] + 1;
    
  3. 对于事件的接收方, P j P_j Pj会增加所记录的 i i i的时间戳,然后对于检查传递来的向量时间戳 V V V,更新自身时间戳。

    V[i] = V[i] + 1
    V[j] = max(Vmsg[j], V[j]) for j ≠ i
    

现在,对于进程 P i Pi Pi P j Pj Pj上的任意事件,可以通过以下的规则比较事件的发生顺序(假设其时间戳V1,V2):

V1 = V2 , iff V1[i] = V2[i], for all i = 1 to N (i.e, V1 and V2 are equal if and only if all the corresponding values in their vector matches)
V1 ≤ V2 , iff V1[i] ≤ V2[i], for all i = 1 to N 

进而得出两个任意事件间的关系:

  • 顺序发生:

    V1 < V2 , iff  V1 ≤ V2 & there exists a j such that 1 ≤ j ≤ N & V1[j] < V2[j]
    
  • 并发:

    NOT (V1 ≤ V2 ) AND NOT (V2 ≤ V1 )
    

向量时间戳的应用

在这里插入图片描述

这张图展示了在不同事件发生时,不同线程上记录的向量时间戳是如何变化的:

​ 其中 a a a b b b j j j 这些事件都是顺序发生的,因为他们的向量时间戳都是可以比较的,而 b b b l l l 则是并发的事件。

向量时间戳解决了 Lamport 逻辑时钟中时间戳大小无法推导事件发生顺序的问题,但他依然仅仅是一个逻辑时钟,无法对并发事件的定序

wiki 上也列出了其他相关机制:

  • In 1999, Torres-Rojas and Ahamad developed Plausible Clocks,[ref] a mechanism that takes less space than vector clocks but that, in some cases, will totally order events that are causally concurrent.

  • In 2005, Agarwal and Garg created Chain Clocks,[ref] a system that tracks dependencies using vectors with size smaller than the number of processes and that adapts automatically to systems with dynamic number of processes.

  • In 2008, Almeida et al. introduced Interval Tree Clocks.[ref][ref][ref] This mechanism generalizes Vector Clocks and allows operation in dynamic environments when the identities and number of processes in the computation is not known in advance.

  • In 2019, Lum Ramabaja developed Bloom Clocks,[ref] a probabilistic data structure whose space complexity does not depend on the number of nodes in a system. If two clocks are not comparable, the bloom clock can always deduce it, i.e. false negatives are not possible. If two clocks are comparable, the bloom clock can calculate the confidence of that statement, i.e. it can compute the false positive rate between comparable pairs of clocks.

根据介绍可以看到,这些 Vector Clocks 的修改方案大多都是针对由于服务器数量增长而导致的向量时钟增长,包括压缩等等方法,都是为了使用更少的内存空间或者更快的检测。

TrueTime

Vector Clocks 和 Lamport 逻辑时钟都是依赖逻辑时钟解决问题的方式,这也是物理时钟的不可靠所导致的,面对这一问题,Google选择提升物理时钟上的准确性来尝试解决,这个方案首先应用在 Spanner 数据库上(Spanner: Google’s Globally-Distributed Database)。Google Spanner 是一个定位于全球部署的数据库。如果用 TSO 方案则需要横跨半个地球拿时间戳,延迟是较高的。但是 Google 的工程师认为 linearizable 是必不可少的,这就有了 TrueTime。

TrueTime 利用原子钟和 GPS 实现了时间戳的去中心化。之所以使用这两种硬件的原因是因为这两种硬件的故障原因不一样,GPS时钟故障的原因有天线和接收器故障,无线信号干扰等。原子钟可能由于频率问题造成时钟漂移。这两种原因是不相交的,所以能提高整个硬件的可靠性。

即使如此,原子钟和 GPS 提供的时间也是有误差的,TrueTime时钟的误差范围 ε 是1ms到7ms,平均4ms。

在这里插入图片描述

T r u e T i m e 架构 TrueTime架构 TrueTime架构
TrueTime API

TrueTime提供了三个API来操作时间:

MethodReturns
TT.now()TTinterval: [earliest, latest]
TT.after(t)true if t has definitely passed
TT.before(t)true if t has definitely not arrived
  • T T . n o w ( ) TT.now() TT.now() 返回的是当前时间,由于时钟硬件误差的存在,这个当前时间存在一个不确定的范围(uncertainty time),也即一个范围 [ e a r l i e s t , l a t e s t ] [earliest, latest] [earliest,latest],可以保证当前绝对时间一定在这个范围内,上面介绍过,这个间隔范围最大是 7ms。
  • T T . a f t e r ( t ) TT.after(t) TT.after(t) 判断传入的时间戳是否已经是过去的时间,也即 t < T T . n o w ( ) . e a r l i e s t t < TT.now().earliest t<TT.now().earliest
  • T T . b e f o r e ( t ) TT.before(t) TT.before(t) 判断传入的时间戳是否是未来的时间,也即 T T . n o w ( ) . l a t e s t < t TT.now().latest < t TT.now().latest<t

使用TrueTime API时,需要搭配下面两个规则。

  • Start: 提交事务 T i Ti Ti时,leader必须选择一个大于等于 T T . n o w ( ) . l a t e s t TT.now().latest TT.now().latest 的时间作为提交时间戳 t s i tsi tsi
  • Commit Wait: leader必须等待 T T . a f t e r ( t s i ) TT.after(tsi) TT.after(tsi) 为true后才能提交数据,也即必须等待 t s i tsi tsi的绝对时间过去了才能提交数据。

使用这两个规则可以保证:如果事务 T1 提交后 T2 才开始,那么 T2 的提交时间一定晚于 T1 的提交时间。也就是说事务的提交顺序一定和事务发生的绝对时间上的顺序一致。

TrueTime应用

图三

举个例子:分布式事务中有三台服务器 S1,S2,S3。执行分布式事务时,某一台参与者作为协调者提交事务,提交时使用这次事务所有参与者中最大的时间戳作为事务的提交时间。每台服务器和绝对时间 Tabs 都有误差,S1的时间比绝对时间快5ms,即 Tabs + 5,S2 的时间比绝对时间慢4ms,即 Tabs - 4,S3 的时间比绝对时间慢2ms,即 Tabs - 2。

  • 现在有一个事务T1,参与者包括 S1 和 S2,S1 执行分支事务的本地时间是15ms,S2 执行分支事务的本地时间是7ms。S2 作为协调者,提交事务时选择了**15ms **作为整个事务的执行时间。
  • 另外一个事务 T2,参与者包括 S2 和 S3,S3 执行分支事务的本地时间是13ms,S2 执行分支事务的本地时间是12ms。S2 还是作为协调者,提交事务时选择了 13ms 作为整个事务的执行时间。

在绝对时间上事务 T2 比 T1要晚执行,但是提交时间 T2 却比 T1 要早,这显然是错误的。

我们看看使用TrueTime如何解决这个问题。

图四

假设 TrueTime 误差 ε 为7ms:

  • T1 事务协调者选择提交时间 t s 1 ts1 ts1 时,根据 Start 规则,必须大于所有事务参与者中最大的本地时间,还要大于协调者本地 T T . n o w ( ) . l a s t e s t TT.now().lastest TT.now().lastest。计算得出 t s 1 = m a x ( 15 , 7 + 7 ) = 15 m s ts1 = max(15, 7 + 7) = 15ms ts1=max(15,7+7)=15ms

    选择提交时间 t s 1 ts1 ts1后,根据 Commit Wait 规则,还要等待 T T . a f t e r ( 15 ) TT.after(15) TT.after(15) 为true后才能提交数据。也就是 T T . n o w ( ) TT.now() TT.now() 是 [16, 30],S2的本地时间是 23ms,绝对时间 27ms 提交数据。

  • T2 事务协调者选择提交时间 t s 2 ts2 ts2 时,根据 Start 规则,计算得出 t s 2 = m a x ( 13 , 12 + 7 ) = 19 m s ts2 = max(13, 12 + 7) = 19ms ts2=max(13,12+7)=19ms

    选择提交时间 t s 2 ts2 ts2 后,根据 Commit Wait 规则,还要等待 T T . a f t e r ( 19 ) TT.after(19) TT.after(19) 为true后才能提交数据。也就是 T T . n o w ( ) TT.now() TT.now() 是[20, 34],S2 的本地时间是 27ms,绝对时间 31ms 提交的数据。

可以看出 T2 的提交时间要晚于 T1,解决了这个例子中的问题。当然,实际处理时,还会对数据进行加锁等操作,Google Spanner 中详细的事务处理流程。

混合逻辑时钟

无论是物理时钟,包括 NTP协议 和 TrueTime 都属于物理时钟,另一种是逻辑时钟,包括 Lamport逻辑时钟 和 向量时钟(Vector clocks)。两种时钟有各自的优缺点。物理时钟的优点在于直观,使用方便,缺点在于无法做到绝对精确,成本相对高一些。逻辑时钟的优点在于可以做到精确的因果关系,缺点在于依赖节点之间需要通信,而且使用上不如物理时钟直观。

HLC 由 Sandeep Kulkarni, Murat Demirbas等人在2014年的论文《Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases》中提出。目的是为了填补理论(逻辑时钟)和实际(物理时钟)之间的差距,建立起既支持因果关系,同时又有物理时钟的直观特点的时间戳。

给分布式系统中每个事件分配一个HLC,比如 e 事件的HLC记作 l . e l.e l.e,HLC保证能够满足以下四个性质:

  1. 如果 e 事件发生在 f 事件之前(e happened before f),那么 l . e l.e l.e 一定小于 l . f l.f l.f,也就是满足因果关系。
  2. l . e l.e l.e 可以存储在一个整数中,不会随着分布式系统中节点数的增加而增加。(这点和向量时钟不一样,向量时钟会随着节点数的增加而增加)
  3. l . e l.e l.e 的值不会无限增长。(这点和Lamport逻辑时钟不一样,Lamport逻辑时钟会无限增长)
  4. l . e l.e l.e 的值和 e 事件发生的物理时钟值接近, ∣ l . e − p t . e ∣ | l.e - pt.e | l.ept.e的值会小于一定的范围。( p t . e pt.e pt.e 是 e 事件发生的准确的物理时间)

HLC 同时包含了物理时钟和逻辑时钟,这两种时钟不能混为一谈,不同时钟引起的时间增加应该是独立的,不然 HLC 的设计就没有意义了,因此 HLC 分成两部分 l . j l.j l.j c . j c.j c.j l . j l.j l.j 表示事件 j j j 发生时所感知到的最大物理时钟值, c . j c.j c.j 是事件 j j j 的逻辑时钟部分,当几个事件在同一个物理时钟值内发生时, c c c 用于记录事件之间的因果关系。 p t pt pt 依然表示本地NTP协议的物理时钟值。新的算法如下:

  1. 发送消息事件或者本地事件,需要判断 p t . j pt.j pt.j l . j l.j l.j之间的关系,来决定 c . j c.j c.j 是自增还是重新置0。

    if pt.j <= l.j then c.j = c.j + 1
    else c.j = 0; l.j = pt.j
    
  2. 当线程 P i P_i Pi 接收 m 消息事件时:

      // 如果j事件,m消息和本地物理时钟的值相同,增加逻辑时钟部分
      if l.j == l.m == pt.j then c.j = max(c.j, c.m) + 1
      // 如果本地物理时钟没赶上HLC的物理时钟,并且j事件的逻辑时钟更大,更新逻辑时钟的值
      else if pt.j <= l.j and l.m <= l.j then c.j = c.j + 1
      // 如果本地物理时钟没赶上HLC的物理时钟,并且m消息的逻辑时钟更大,更新HLC的逻辑时钟部分和物理时钟部分
      else if pt.j <= l.m and l.j <= l.m then c.j = c.m + 1; l.j = l.m
      else c.j = 0; l.j = pt.j
    

新算法执行的过程中,本地时钟的值通过 NTP 协议更新,HLC 的值并不会修改本地时钟的值。由于分离了物理时钟和逻辑时钟,新的事件发生时,如果物理时钟部分的值没增长,就只增加逻辑时钟部分的值。如果本地的物理时钟赶上了HLC的物理时钟部分的值 l . j l.j l.j,就可以重置逻辑时钟部分的值 c . j c.j c.j,并把 l . j l.j l.j 更新为新的本地物理时钟。这符合之前提出的4个性质,具体数学证明过程参考论文。

对于任何一个事件 j, p t . j < = l . j pt.j <= l.j pt.j<=l.j,也即 HLC 的物理时钟部分的值一定大于等于本地NTP的时钟值。假设整个分布式系统中,NTP协议的时钟误差值为 ε。新算法中,对于任何一个事件 j, ∣ l . j − p t . j ∣ < = ε | l.j - pt.j | <= ε l.jpt.j<=ε,也就是HLC物理部分的值和本地物理时钟值的差距不会超过 ε。这个误差值在局域网内大概1毫秒内,广域网可能达到100毫秒或更大。

HLC应用

HLC可以用于分布式数据库一致性快照读的处理中,很多系统中都使用了HLC,比如HBase和CockRoachDB。

图五

比如,我们要获取 t = 10 这个时间点的数据快照,也即HLC为(l = 10, c = 0),拿这个HLC值去每个节点查找,可以得出上图中黑色的粗线,这条线对应的数据就是系统在 t = 10 的数据快照。

CockRoachDB在分布式事务中使用了HLC。根据 HLC 的性质4, ∣ l . e − p t . e ∣ | l.e - pt.e | l.ept.e的值会小于一定的范围 M a x O f f s e t MaxOffset MaxOffset,CockRoachDB默认这个值为500毫秒, p t . e + M a x O f f s e t pt.e + MaxOffset pt.e+MaxOffset 一定是系统中最大的物理时间。启动事务时会获取本地的 HLC 值 h l c . e hlc.e hlc.e,并且确定一个区间 [ h l c . e , h l c . e + M a x O f f s e t ] [ hlc.e, hlc.e + MaxOffset ] [hlc.e,hlc.e+MaxOffset],然后发往其他节点执行快照读,如果节点上某个数据的HLC值为 h l c . g hlc.g hlc.g,分三种情况考虑:

  1. 如果 h l c . e + M a x O f f s e t < h l c . g hlc.e + MaxOffset < hlc.g hlc.e+MaxOffset<hlc.g,即处于区间的右边,那么e事件肯定发生在g事件之前,不能读取这个数据。
  2. 如果 h l c . e < h l c . g < = h l c . e + M a x O f f s hlc.e < hlc.g <= hlc.e + MaxOffs hlc.e<hlc.g<=hlc.e+MaxOffs,即处于区间之中,由于物理时钟的不确定性,不能分辨出 e 事件和 g 事件的先后关系。这个时候需要重启事务,获取一个更大的 h l c hlc hlc,相当于等待这个不确定的时间过去,推迟事务的执行。
  3. 如果 h l c . g < h l c . e hlc.g < hlc.e hlc.g<hlc.e,那么 g 事件肯定发生在 e 之前,这时可以读取这个数据。(对于这点我有点疑问,由于不确定时间的存在,物理时间可能快,也可能慢,这个区间应该是 [ h l c . e − M a x O f f s e t , h l c . e + M a x O f f s e t ] [ hlc.e - MaxOffset, hlc.e + MaxOffset ] [hlc.eMaxOffset,hlc.e+MaxOffset],为什么这里 h l c . g < h l c . e hlc.g < hlc.e hlc.g<hlc.e 就认为 g 在 e 前面?)
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值