今天谈谈分布式事务的时序问题。在说这个问题之前首先说说这为什么是个问题。
对于数据库来说,读到已经commit的数据是最基本的要求。一般来说,为了性能,读写不互相阻塞,现在的数据库系统(Oracle,MySQL,OceanBase,Spanner,CockRoachDB,HBase)几乎无一例外的使用MVCC技术来达到这个目的。说白了,就是数据有多个版本,每次写产生新的更大的版本。读事务可以指定某个版本读,即快照读,数据库返回比指定的版本小的最大的版本的数据。当然也可以不指定,即读最新的已经commit的版本的数据。从时序上来看,越后写的数据,版本号越大,很显然,这个版本号可以通过实现一个单机内单调递增的counter来解决,counter从0开始以1递增。但是这样做,快照读搞不定:查找2015年3月29日1点的最新数据。这是因为这个counter和时间没有任何关系。那么显然,时间戳作为版本号再适合不过了。在单机上,即使出现clock skew(即单机上先后两次调gettimeofday取到的wall time,后面一次取到的wall time反而更小),维护一个单机内单调递增的时间戳很容易办到。可以看出,在单机情况下,满足了Linearizability: T2在T1 commit成功后start,T2的commit timestamp一定大于T1的commit timestamp。下面看看多机的情况。
在多机情况下,如何满足Linearizability。
还是以写事务T1(修改x),T2(修改y)为例,时序上T2在T1 commit之后start,由于不同的服务器的时钟不一样,有些快有些慢,导致T2可能拿到比T1更小的时间戳。
举个例子:
假设机器M1的时钟比M2的时钟快30,T1事务在M1上提交,获得commit timestamp 200,随后T2事务在M2上开始并提交,由于M2时钟更慢30,T2的commit timestamp可能是180。随后来了一个读事务T3,读x和y,分配的读版本号可能是190,结果他只能都到T2的值,不能读到T1 !
问题的根源在于机器之间的时钟不同,没有全局时钟。
Google的Spanner(看 这 和这)和Percolator(看 这 和这)都是搞了一个全局时钟来解决,区别在于Percolator的全局时钟就是基于固定的一台服务器产生,所有的事务获取commit时间戳都问这个全局时钟服务器要,自然保证了单调递增。问题,显而易见,单点,性能,扩展性。Spanner利用原子钟和GPS接收器,实现了一个较为精确的时钟,这个时钟叫做TrueTime,每次调用TrueTime API返回的是一个时间区间,而不是一个具体的值,这个TrueTime保证的是真实时间(absolute time/real time)一定在这个区间内,这个区间范围通常大约14ms,甚至更小。
下面说说Spanner是如何保证Linearizability(external consistency)。
事务的执行过程中,Spanner保证每个事务最后得到的commit timestamp介于这个事务的start和commit之间。基于这个条件,如果T2在T1 commit完成后才start,那么显然,T2的commit timestamp肯定大于T1的timestamp。
Spanner是如何保证每个事务最后得到的commit timestamp介于这个事务的start和commit之间?
在事务开始阶段调用一次TrueTime,返回[t-ε1,t1+ε1],在事务commit阶段时再调用一次TrueTime,返回[t2-ε2,t2+ε2],根据TrueTime的定义,显然,只要t1+ε1<t2-ε2,那么commit timestamp肯定位于start和commit之间。等待的时间大概为2ε,大约14ms左右。可以说,这个延时基本上还可以接受。
至于读请求,直接调用TrueTime API,拿着右界去读即可。
CockRoachDB 是一个前Google员工创业的开源项目,基本上可以认为就是Spanner的开源实现。机器时钟通过NTP同步,基本可以保证机器间误差在150ms左右。
如果按照Spanner的做法,写事务提交时每次都需要等待150ms,性能基本不可接受,当然CockRoachDB可以让客户端选择是否使用这种方案,这种方法实现了Linearizability,可以性能太差,因为时钟误差太大,和Spanner的高精度时钟没法比。
CockRoachDB做了一点work around,同时实现了一种比Linearizability更relax一点的一致性模型,可以保证下面两种情况的Linearizability。
单客户端事务
CockRoachDB 实现了单个客户端的Linearizability,保证同一个客户端先后发出去的两个事务T1和T2,T2的commit timestamp比T1的commit timestamp更大。方法就是T1事务执行完成会将commit timestamp返回给客户端,客户端执行T2时提供一个更大的时间戳给server,告诉server,T2的commit timestamp必须比这个时间戳更大。这样就保证了单个客户端的Linearizability。
相关事务
假设有两个客户端C1和C2,C1先执行写事务T1,请求发送给了机器M1,其中需要修改x,T1 commit后,读事务T2 start,请求发给了机器M2,事务也需要修改x,CockRoachDB可以保证T2分配到的commit timestamp比T1更大。
说这个之前,先看看如何界定两个事件的先后顺序。
通过捕捉两个事件的因果关系可以给两个事件定序,主要基于如下两条规则:
Vector Clock可以用来维护这种因果关系,基本原理就是在一个有N个节点的集群中,每个机器都维护一个大小为N的数组(Vector),数组记作VC,机器i上的VC[k]代表机器i对机器k的clock的认知。每个机器i在发消息m时都会将本地的VC[i]加1(更新本地的clock),然后用它标记消息m,最后把消息发出。每个接收到消息的机器都会取自己的clock和消息中的clock的最大值来更新自己的clock(更新本地的clock)。这个clock实际上就是Logical Clock。Logical Clock越大说明这台机器的"时间"越靠后。在这个思想中,实际上,假设的是机器和机器之间的物理时钟差是无穷大的,只要两台机器之间没有进行过消息交互,那么这两台机器互相之间对对方没有任何知识。那么,显然,由于这种logical clock的实现和物理时间没有任何关系,在真实的系统中,无法满足快照读:读2015年3月29日之前1点的最新数据。
而Spanner的TrueTime API刚好,和上面那种方法是两个极端,完全不捕捉事务之间的因果关系,纯粹的根据TrueTime来对事件进行定序。
而CockRoachDB使用了Hybrid Logical Clock(HLC),它是另外一种Logical Clock的实现,它将Logical Clock和物理时钟(wall time)联系起来,并且他们之间的误差在一个固定的值之内。这个值是由NTP决定的。每台机器更新HLC的算法和上面描述VC的过程大同小异。这种Logical Clock的实现非常简单,这里就不展开,具体看 这篇论文 。实际上,HLC带来了两点好处:
回到这一小节最开始的例子:
假设有两个客户端C1和C2,C1先执行写事务T1,请求发送给了机器M1,其中需要修改x,T1 commit后,读事务T2 start,请求发给了机器M2,事务也需要修改x,CockRoachDB可以保证T2分配到的commit timestamp比T1更大。
那么只要分布式事务的coordinator在确定事务的commit timestamp的过程中询问各个参与者participants的本地HLC,选取其中最大的HLC作为事务的commit timestamp,即可满足Linearizability的要求。
可以看出,CockRoachDB实际上实现了两种一致性级别,第一种就是Linearizability,实现方式和Spanner一样,commit的时候都需要等(实际上,Spanner不是每次都要等,而CockRoachDB每次都需要等),但是由于其时钟误差很大,实际性能很差。第二种就是比Linearizability更宽松一点的一致性,这种一致性级别可以保证同一个客户端的Linearizability和相关事务的Linearizability。
Spanner: Google’s Globally-Distributed Database
分布式事务实现-Spanner
CockRoach design doc
Logical Physical Clocks
and Consistent Snapshots in Globally Distributed Databases
Spanner’s Concurrency Control
Beyond TrueTime: Using AugmentedTime for Improving Spanner
Spencer Kimball on CockroachDB, talk given at Yelp, 9/5/2014