本文是阅读论文Efficient Optimistic Concurrency Control Using Loosely Synchronized Clocks的读书笔记.
本文是mit 6.824 Schedule: Spring 2016的第10课,前面课程内容可以在分布式找到,更多详细资料可以到:distributed-system查看。
概述
论文是在1995年发表的,当时分布式数据系统中怎么实现分布式事务这个方向就是一个热门的领域,这么多年过去了,一直到现在,当时提出的OCC(Optimistic Concurrency Control)仍然是当今的一个热门个研究方向。
人们一直都希望能够实现一个高效、可扩展、稳定的持久化存储系统,而本文提出的OCC正是用来解决这个问题的,其特点是:
- 数据在client端本地缓存,在server端持久化
- 对提交的事务提供了serializability and external consistency的保证
- 通过松散同步的时钟获取global serialization
OCC支持并发事务,但是没有像传统方法那样对每个数据都保存着并发控制的信息,而是只保存了一个版本号,保证了内存消耗尽量的少,并且低存储消耗的情况下,也保证了性能。
介绍
本文介绍的OCC适用的场景是什么?
分布式面向对象数据库系统,数据持久化由server负责,client为了提高性能会对数据进行cache。
为什么叫乐观并发控制?
乐观是相对比悲观算法来说的,为了保证事务的external consistency,一个简单的方法就是通过锁,将所有事务串行化,但是这样子肯定会使得性能很差,那解决方法就是去掉锁,只有当冲突发生的时候才采取措施,因此乐观是相对于悲观的加锁算法来说的。
什么是external consistency?
external consistency(有些文章叫Linearizability或者strict serializability) 是指:后开始的事务一定可以看到先提交的事务的修改
乐观的代价
由于我们不采用锁,因此OCC有其使用的限制:适合冲突少的场景,如果大量事务都发生冲突,那OCC会有非常糟糕的性能,因为:
悲观算法只会延迟事务的执行,乐观算法一旦冲突,会直接终止事务执行。
乐观的实现
乐观算法本质上只是将冲突的检测延后了,当发生冲突后进行恢复,因此核心解决的问题有两个:
- 冲突检测
- 冲突恢复
环境
本文提出的OCC已经在面向对象数据库Thor中实现,下面给出Thor的模型图
此处每个应用直接运行在client上,数据持久化在server上,为了提高性能,每个client本地对数据都进行了cache,应用可以直接在client上执行,然后操作本地cache中的数据,最后提交时才与后端servers进行通信。
在数据提交的时候,会带上两部分信息:
- validation information:表示本次事务T中涉及到的数据的读写类型
- installation information:修改后的数据
client发送commit请求给后端server,如果这些数据是server自己拥有的,则进行提交操作,否则,server转换为Coordinate角色,和拥有数据的participants一起完整事务的提交操作。
此时coordinate和participants之间会涉及到2-phase protocol,下面简要的描述下。
第一阶段如下:
1.1 coordinate发送prepare msg给各个参与者,消息包含了validation information和installation information
1.2 参与者验证通过后,将 installation information记录到磁盘,回复ok
1.3 coordinate收到所有参与者回复ok后,记录一条commit消息到本地磁盘,然后回复给客户端说ok
第二阶段是异步执行的
2.1 coordinate发送commit消息给各个参与者
2.2 参与者将installation消息中的新值覆盖掉老值,并在本地记录一条commit日志,回复给coordinate说ok
当server提交了事务后,需要发送invalidation messages给除了客户端之外的其他持有缓存数据的客户端,那怎么找到这些客户端呢?
server这边对每个客户端都存着一个cached set,这些invalidation messages不要求正确,但是需要满足下面两点:
- 如果client收到invalidation messages,当前执行中的transaction还没读到旧数据了,那将本地cache中的数据失效
- 如果当前transaction已经读到旧数据了,则立即终止当前transaction
当客户端处理完invalidation messages消息后,回复给server,server将其从失效集合(invalid set)中移除。
高效的验证规则
算法保证了两种一致性:
- Serializability:所有提交的事务都可以排个序,实际执行的效果跟按照这个序依次执行事务一致
- External consistency:后开始的事务一定可以看到先提交的事务的修改
验证发生在一个事务请求提交的时候,验证分两种:
- Forward validation:和所有正在执行的事务进行冲突检查
- Backward validation:和所有已经验证成功的(validated)事务进行冲突检查
我们通过一个个例子来推演出验证的规则:
例子1
初始化时:x=0 y=0 z=0
T1: Rx0 Wx1
T2: Rz0 Wz9
T3: Ry1 Rx1
T4: Rx0 Wy1
验证时,对上述的4个事务找到一个顺序,能够让所有读写都成立。
一个可行的顺序是:T4, T1, T3, T2
此时我们假设了事务在验证时能够看到未提交的数据,因为四个事务都并行的执行,没有一个在validation的时候提交了,因此他们显然看到了彼此的写。
为了性能,我们希望分布式的验证规则,即数据x,y,z可能分布在不同的机器上,于是有了下面的例子:
例子2
初始化时:x=0 y=0 z=0
T1: Rx0 Wx1
T2: Rx0 Wy1
T3: Ry0 Rx1
S1只验证x的信息
T1: Rx0 Wx1
T2: Rx0
T3: Rx1
此时 T2 T1 T3 是ok的所以S1回答yes
S2只验证y:
T2: Wy1
T3: Ry0
此时T3 T2 是ok的,所以S2回答yes,但是实际上上述的事务是无法通过检查的,那出错的原因是什么呢?
验证必须需要选择 一致的 顺序
于是就有了下面的要求:我们希望通过每个client提交时读取本地时钟时间戳,以此为排序基准,这样子,每个server都可以以相同的顺序进行验证了。
那如果我们以ts为顺序进行验证,会有什么问题吗?
例子3
T1@100: Rx0 Wx1
T2@50: Rx1 Wx2
上面T1先于T2到来,T1已经提交了,此时T2才来,T2读到了x=1,写了x=2,按照timestamp排序,是通不过验证的,但是这种情况可能是客户端时钟相差比较大,如果T1的时钟超前,T2的落后,所以:要求TS order会造成不必要的事务终止。
此时我们每次提交的时候,都会带读数据的原值,这个值可能很大,造成不必要的浪费,因此优化如下:可以通过每个对象一个版本号来检查读到的数据是否是之前事务写的最新数据,在版本号的选择上,可以选择写数据时的ts作为版本号。
例子4
初始值x=y=0,ts=0,x,y分别在S1和S2上
T1@100: Rx@0 Wx
T2@110: Rx@0 Wy
T3@105: Ry@0 Rx@100
S1只验证x的信息,按ts排序:
T1@100: Rx@0 Wx
T3@105: Rx@100
T2@110: Rx@0
此处T2读到的不是最新的,应该是100才对。
此处我们通过版本号来确定数据是不是最新的,相比较直接用值得方式,有什么缺点吗?
可能version不同,但是值相同
T1@100: Wx1
T2@110: Wx2
T3@120: Wx1
T4@130: Rx1@100
根据versions,应该终止T4,T4应该读到T3写的版本,但是其实T4读到了正确的值x=1,T3和T1都是相同的值。
讲完上面的例子,我们再来看下一些具体的问题
全局序
全局序是通过每台机器上的时间戳来获取的,但是每个机器的时钟会存在不同步,因此会带来一些偏差,于是谷歌的[Spanner][2]通过在数据中心配备原子钟和GPS接收器来解决这个误差范围不确定的问题,进而解决分布式事务时序这个问题,本文提出的算法假设是这种时钟不一致时可控的。
在coordinate收到commit请求后,会读取本地时钟的时间戳,并赋值给事务T.ts,coordinate发送给参与者的prepare msg包含:
- T.ts:事务T的时间戳
- T.ReadSet:T读到数据的IDs
- T.WriteSet:T写数据的IDs
- T运行的客户端id
此处T.ts = <timestamp,server-id>
每个server会对验证通过的事务放入a validation queue, or VQ
检查ts靠后的事务
考虑场景:S是一个已经验证通过的事务,而此时来了T要求验证,根据T和S的时间戳顺序,会有不同的验证规则,我们先看S的时间戳晚于T。
此处为什么会出现S的时间戳晚,但是反而先提交了呢?这可能就是因为不同机器之间的时钟不同步的原因了。
对于这种情况,我们检查规则是:
对于每个已经验证通过,并且时间戳大于T的事务,我们检查T没有读取任何S修改过的数据,也没有更新任何S读取的数据。我们称这种检查为:
later-conflict
检查
检查ts靠前的事务
对于已经验证通过的而且时间戳早于T的事务S,我们考虑:
- S读取了x,T修改了x,此时我们没必要检查
- S修改了x,T读取了x,此时我们需要保证T读取到的时候修改后的x,此时又分为两种情况
如果S还没有提交了,那中断T,因为T不可能读取到还没提交的数据
如果S已经提交了,此时取决于T读取到的x的version了
下面具体说下version-check。为了要实现version-check,一般的做法是给每一个object关联一个version,这个version可以是每次提交写操作事务的时间戳,满足了单调递增的需求,但是这样会造成不必要的空间浪费,于是本文提供了一种叫current-verison-check
的方法:
检查T已经读到了x的最新值
具体是怎么做到的呢?我们先来简单论证下current-verison-check
和version-check
是一致的,假设T读取了x,并且已经过了later-conflict
检查,说明在T之后已经验证通过的事务没有更新x的了,如果此时T通过了version-check
,说明T读到x是之前更改过x之后最新的值,那此时T读到的x当然是最新的,是当前版本。
我们之前提过server对每个client都保存了一个invalid set
,此时我们只要去看下T读到的x是否在client的invalid set
之中,就可以知道x是否是最新的了。
我们接着考虑下:为什么x不在client的invalid set
之中,就表示最新的
使用失效集合的列子:
Client C2:
T2 ... Rx ... client/TC about to send out prepare msgs
Server:
T1 wrote x, validated, committed
x in C2's invalid set on S
server has sent invalidation message to C2
server is waiting for ack from C2
3个cases:
-
inval消息在C2发送prepare消息前到达C2
C2 aborts T2
-
inval在C2发送完prepare后,等待 yes/no的回答时
C2 aborts T2
-
inval丢失或者延迟了(so C2 doesn't know to abort T2)
S没有收到C2的ack
C2还在S上的x的inval set
S会对T2的prepare消息回复说no
通过上面的分析,如果此时进行current-verison-check
的时候,x不在invalidate set
中,那client肯定是已经收到x过期的消息了,如果此时x的值不是最新的,那肯定是在上面3个case中的case2中,即在发送prepare消息后,此时即使server回复ok了,事务也终止了,没什么问题。
本节sever上需要有个内存中的数据:
- cached sets
- invalid sets
- VQ
其中前两个都不大,但是VQ如果不清除的话,会越来越大,下节就介绍怎么对VQ进行截断。
截断(Truncation)
VQ中存储着所有验证通过的事务,如果我们不去清理,会越来越长,那我们清理应该清理掉哪些事务呢?
-
已经提交了的事务
由于invalid set中保存着已提交事务的影响,所以可以删除
-
只读的事务
对于已经移除的只读事务,我们怎么知道它读到了什么数据呢?我们维持了一个
threshold timestamp
,这个threshold
大于等于所有已经从VQ中移除的transaction的时间戳。
我们在整个过程中,维持着下面的不变量:
VQ中保存着所有未提交的read-write事务,以及所有大于threshold的事务
于是有了threshold check,所有小于threshold的都验证失败,因为没有足够的信息来进行later-conflict
,而通过threshold check的检查,会有足够信息来进行earlier check。
那有了threshold的概念,那怎么设置呢?设置的太高,会导致事务在检查threshold check就失败,设置的太低,会导致VQ队列太长。
我们假设消息的传输延迟是msg delay,而时钟时间的误差是skew,则当消息从coordinate发出到达到participant这个延迟是:msg delay + skew,那如果此时participant的时间是t1,则有可能已经发出,但是未到的事务的时间 t 应该是 t > t1 - (msg delay + skew),我们将msg delay + skew称之为Threshold Interval。
此时我们总结下我们目前所有的验证规则是:
-
Threshold Check
所有小于Threshold都失败
-
Checks Against Earlier Transactions
对于VQ中未提交的,时间戳小于T的事务S,如果T中有读取到了S中写的数据,返回失败
-
Current-Version Check
对于T中每个读的数据x,如果x在invalid set中,则返回失败
-
Checks Against Later Transactions
对于VQ中时间戳大于T的事务S,只要T中读的数据在S中被修改了,或者T中写的数据在S中被读取了,都返回失败
崩溃后的恢复
当server从崩溃中恢复过来后,需要保证在崩溃前验证的事务要保证和恢复后验证的事务还是满足验证规则,因此一个自然的想法就是将VQ和Threshold记录到磁盘上。
对于读写事务,在prepare的时候,本来就需要记录installation信息,此时记录VQ不会带来额外的开销,但是对于只读事务,在prepare的时候,是不需要记录installation,如果此时记录VQ,会带来性能的损耗,因此我们的做法是不进行记录。
如果我们对只读的事务不进行记录,那当crash后恢复,则会丢失这部分信息,但是如果我们将Threshold设置为大于服务器上最后一个验证通过的事务,那就不担心只读数据的丢失了。
另外cached set也没有进行持久化存储,作为替代的,server存储了cache着数据的client地址。crash恢复后,server和client进行通信,进行cached set的恢复。
最后Invalid set通过记录的installation info和cached set进行恢复,但是这可能由于丢失client的ack,而多出一些不必要的项。怎么解决呢?当一个事务引发invalidation msg的时候,server会产生一个invalidation number,和提交日志一起存储,而且invalidation number保证单调递增,当发送invalidation msg的时候,会将invalidation number带上,此时client在收到后将invalidation number存储起来,当恢复的时候,客户端会将invalidation number和cached set一起带过来,server就能依据invalidation number来重建正确的Invalid set了。
实验
省略
总结
caching减少了client/servr之间的数据fetch,所以快
分布式OCC避免了clien/server的锁争用
- 松散的时间同步帮助servers对顺序达成一致,达到检测的目的
- 分布式OCC在20年后仍然是一个热门领域
这是6.824: Distributed Systems的第10课,你的鼓励是我继续写下去的动力,期待我们共同进步。