分布式系统的时间

事件的顺序

大家都知道,Linearizability在一些系统(譬如分布式数据库)里面是非常重要的,我们不能允许数据更新之后仍然能读到原先的值,譬如银行转账,用户A有100元,转给用户B 10元,这个操作之后用户A只可能有90元了,但如果A后续发起了另一个转账请求给C转10元的时候,事务里面读到A的余额仍然是100元,这个问题就大了。

一个更简化的说明,假设在某个时间点t我们写入了一条数据,譬如set a = 10,那么在t之后,所有的read a读到的都应该是10,而不是a以前的值。

那么,我们如何确定read a读到的一定是最新的数据呢?在一个系统里面,如果一个事件a发生在另一个事件b的前面,我们就可以认为a happended before b, 可以用 a -> b来表示。

如果a和b是在同一个进程里面执行,(注:这里我们假定进程是顺序执行event的),那么我们可以很容易的就知道a在b之前发生,因为a在进程里面是先执行的。

但在分布式系统里面,a和b可能在不同的进程里面(当然这两个进程一定会相互通讯),这件事情就不是特别容易判断了,我们得找到一个基准用来衡量到底谁先谁后。

我们首先就想到的是时间,我们只要知道a和b发生的时间就能够知道先后顺序了,但是我们都知道,每台机器的时间都不是一致的,虽然能通过NTP协议进行相互校对,但总有误差。所以我们不能直接使用系统时间来判断事件的先后顺序。

Logic Clock

Lamport(这个大牛就不用多介绍了)在上世纪70年代,就提出了使用Logic Clock来确定事件的先后顺序。

我们可以认为Logic Clock就是一个不断增长的number,假设两个进程P1和P2,两个事件a和b,我们用C(a),C(b)来表示这两个事件的logic clock。

如果a和b都是在P1里面发生,如果a -> b,那么我们一定可以知道 C(a) < C(b)。

现在复杂的是a在P1里面发生,但b在P2里面发生,首先我们需要明确P1和P2一定能够相互通讯,a发生之后,P1会给P2发送相关消息,同时会带上C(a),P2接受到这个消息之后再处理b,因为P2明确知道这个消息是从P1发过来的,如果P2当前的clock为C(old)并且小于或者等于C(a),那么会将自己的clock更新为大于C(a)的任意值,不过通常就是C(a) + 1了,这时候在执行b,我们就一定能知道C(b) > C(a)了。

当然,上面a和b必须是相关的事件,也就是a -> b,如果a和b是独立的,那么P1和P2就不需要进行相关的交互了。

可以看到,Logic Vector的原理是非常简单的,但是它因为没有实际的物理时间概念,所以如果我们想根据某一个真实的时间来查询相关事件,这个办不到了。

在Logic Clock之后,人们又引入了Vector Clock,但vector clock也有logic clock同样的问题,不能依据真实的时间来查询,这里就不多介绍vector clock了。

True Time

前面我们说了,NTP是有误差的,而且NTP还可能出现时间回退的情况,所以我们不能直接依赖NTP来确定一个事件发生的时间。在Google Spanner里面,通过引入True Time来解决了分布式时间问题。

Spanner通过使用GPS + Atomic Clock来对集群的机器进行校时,精度误差范围能控制在ms级别,通过提供一套TrueTime API给外面使用。

TrueTime API很简单,只有三个函数:

MethodReturn
TT.now()TTinterval: [earliest, latest]
TT.after(t)true if t has definitely passed
TT.before(t)true if t has definitely not arrived

首先now得到当前的一个时间区间,spanner不能得到精确的一个时间点,只能得到一段区间,但这个区间误差范围很小,也就是ms级别,我们用ε来表示,也就是[t – ε, t + ε]这个范围,

假设事件a发生绝对时间为tt.a,那么我们只能知道tt.a.earliest <= tt.a <= tt.a.latest, 所以对于另一个事件b,只要tt.b.earliest > tt.a.latest,我们就能确定b一定是在a之后发生的,也就是说,我们需要等待大概2ε的事件才能去提交b,这个就是spanner里面说的commit wait time。

可以看到,虽然spanner引入了TrueTime可以得到全球范围的时序一致性,但相关事务在提交的时候会有一个wait时间,只是这个时间很短,而且spanner后续都准备将其优化到 ε < 1ms,也就是对于关联事务,仅仅在上一个事务commit之后等待2ms之后就能执行,性能还是很强悍的。

但spanner有一个最大的问题,TrueTime是基于硬件的,而现在对于很多企业来说,是没有办法搞定这套部署的。所以如果Google能将TrueTime的硬件设计开源,那我觉得更加造福社区了。

Hybrid Logic Clock

既然TrueTime这种硬件方案很多人搞不定,那么我们就采用软件方案了。

Cockroachdb使用了Hybrid Logic Clock(HLC)来解决分布式时间的问题。

HLC是基于NTP的,但它只会读取当前系统时间,而不会去修改,同时HLC又能保证在NTP出现同步问题的时候仍能够很好的进行容错处理。对于一个HLC的时间t来时,它总是大于等于当前的系统时间,并且与其在一个很小的误差范围里面,也就是 |l – pt| < ε。

HLC由两部分组成,physical clock + logic clock,l.j维护的是节点j当前已知的最大的物理时间,c.j则是当前的逻辑时间。那么判断两个事件的先后顺序就很容易了,先判断物理时间pt,在判断逻辑时间ct。

HLC的算法如下,在节点j上面:

  • 初始化: l.j = 0, c.j = 0

  • 给另一个进程发送或者处理自己的事件:

    l'.j = l.j;
    // 跟当前系统时间比较,得到pt
    l.j = max(l'j, pt.j)
    // 如果pt没有变化,则c.j加1,如果有变化,因为这时候
    // 铁定PT变大了,所以我们可以将ct清零
    if (l.j = l'.j) {
        c.j = c.j + 1
    } else {
        c.j = 0
    }
    
    // Timestamp with l.j, c.j
    
  • 接受某一个节点m的消息事件

    l'.j = l.j;
    // 跟当前系统事件以及节点m的pt比较,得到pt
    l.j = max(l'.j, l.m, pt.j)
    if (l.j = l'.j = l.m) {
        // pt一样,获取最大的ct,并加1
        c.j = max(c.j, c.m) + 1
    } else if (l.j = l'j) {
        // 这里表明j原来的pt比m大,只需要增加ct
       c.j = c.j + 1
    } else if (l.j = l.m) {
        // 这里表明m的pt比j原来的要大,所以直接可以用m的ct + 1
        c.j = c.m + 1
    } else {
        // pt变化了,ct清零
        c.j = 0
    }
    
    // Timestamp with l.j, c.j
    

具体的实现算法,可以看cockroachdb的HLC实现

HLC虽然方便,它毕竟是基于NTP的,所以如果NTP出现了问题,可能导致HLC与当前系统pt的时间误差过大,其实已经不怎么精确了,HLC论文提到对于一些out of bounds的message可以直接忽略,然后加个log让人工后续处理,而cockroachdb是直接打印了一个warning log。

Timestamp Oracle

无论上面的Ture Time还是Hybrid Logic Time,都是为了在分布式情况下获取全局唯一时间,如果我们整个系统不复杂,而且没有spanner那种跨全球的需求,有时候一台中心授时服务没准就可以了。

在Google Percolator系统这,他们就提到使用了一个timestamp oracle(TSO)的服务来提供统一的授时服务,为啥叫oracle,我猜想可能底层用的就是oracle数据库。。。

使用TSO的好处在于因为只有一个中心授时,所以我们一定能确定所有时间的时间,但TSO需要关注几个问题:

  • 网络延时,因为所有的事件都需要从TSO获取时间,所以TSO的场景通常都是小集群,不能是那种全球级别的数据库。
  • 性能,TSO是一个非常高频的操作,但鉴于它只干一件事情,就是授时,通常一个TSO每秒都能支持1m+ 以上的QPS,而这个对很多应用来说是绰绰有余的。
  • 容错,TSO是一个单点,所以不得不考虑容错,而这个现在基于zookeeper,etcd也不是特别困难的事情。

所以,如果我们没法实现TrueTime,同时又觉得HLC太复杂,但又想获取全局时间,TSO没准是一个很好的选择,因为它足够简单高效。

我们现在的数据库产品就使用的是TSO方案,但也不排除以后为了支持全球同步,而考虑使用TureTime或者HLC的方案。

最后

在分布式领域,我们很自然的会想到用时间来解决事件的时序问题,但如何保证时间的获取是全局一致并且正确的,并不是一件容易的事情,笔者认为,只要能搞定时间问题,后面编写Linearizability的系统就比较容易了。当然,如果我们还需要支持分布式事务,还有更多的事情需要考虑的,如果后面有时间,在慢慢总结吧。

点赞