2019独角兽企业重金招聘Python工程师标准>>>
CockroachDB实现了一个无锁的乐观事务模型,事务冲突通过事务重启或者回滚尽快返回客户端,然后由客户端决策下一步如何处理。本文将重点解析CockroachDB的乐观事务模型的实现。
MVCC(多版本并发控制)
在传统的单机数据库中,通常会使用一个单调递增的逻辑ID作为事务ID,同时这个逻辑ID也起着MVCC实现中数据版本号的作用。我们在《CockroachDB事务解密(一)》中提到,CockroachDB使用HLC时间追溯事务发生的先后顺序,为了实现MVCC,CockroachDB同时使用HLC时间戳作为数据的版本号。如下图:
对于已提交的数据,CockroachDB把HLC时间戳Encode到Key的尾部作为版本号,降序存储。对于尚未提交或者刚提交的数据,此时HLC时间不会直接Encode到Key尾部,而是把事务相关的信息和数据一起Encode到Value中,称之为WRITE INTENT,结构如下:
ype MVCCMetadata struct {
Txn *TxnMeta `protobuf:”bytes,1,opt,name=txn” json:”txn,omitempty”`
Timestamp cockroach_util_hlc.Timestamp `protobuf:”bytes,2,opt,name=timestamp” json:”timestamp”`
Deleted bool `protobuf:”varint,3,opt,name=deleted” json:”deleted”`
// The size in bytes of the most recent encoded key.
KeyBytes int64 `protobuf:”varint,4,opt,name=key_bytes,json=keyBytes” json:”key_bytes”`
// The size in bytes of the most recent versioned value.
ValBytes int64 `protobuf:”varint,5,opt,name=val_bytes,json=valBytes” json:”val_bytes”`
RawBytes []byte `protobuf:”bytes,6,opt,name=raw_bytes,json=rawBytes” json:”raw_bytes,omitempty”`
MergeTimestamp *cockroach_util_hlc.Timestamp `protobuf:”bytes,7,opt,name=merge_timestamp,json=mergeTimestamp” json:”merge_timestamp,omitempty”`
}
访问数据时,用当前事务的HLC时间和多版本数据中的HLC时间比较,返回HLC时间小于等于当前事务HLC时间,且HLC时间最大的版本数据。
事务原子性
我们知道,分布式事务可能会涉及到多条记录在多个节点上的写,CockroachDB如何保证写的原子性?CockroachDB首先引入了一个全局事务表(全局事务表的数据亦采用分布式存储,使用随机产生的UUID作为事务记录的唯一标识;但是事务记录没有多版本信息,也就是每个事务记录只有一个版本,而且记录会定期被清理)记录事务执行状态。每个事务启动后,在事务执行第一次写时,同时往全局事务表中写入一条事务记录,记录当前事务状态。事务记录主要结构如下:
UUID:事务记录唯一标识符
Key:事务记录的Key,用来定位事务记录的位置
Timestamp:事务提交时间
Status:事务状态,PENDING、COMMITED、ABORTED
其次,事务写入的数据被封装成上文中提到的WRITE INTENT。WRITE INTENT中包含了指向当前事务记录的索引。事务初始状态是PENDING,事务提交或者回滚只要修改事务记录的状态为COMMITED或者ABORTED,然后返回结果给客户端即可。根据事务状态,遗留的WRITE INTENT会被异步清理:提交成功的数据则转换成前文中的多版本结构,被回滚则直接把INTENT清理掉。
当其他记录遇到WRITE INTENT时,根据WRITE INTENT中的事务记录索引信息反向查找事务记录:
-
如果事务处于PENDING状态,则陷入写写冲突的场景,具体处理方式下文将详细解释;
-
如果事务处于COMMITED,则进一步根据事务中HLC时间决定是返回INTENT中的数据还是返回上一个版本的数据;
-
如果事务处于ABORTED,则返回上一个版本的数据。
同时,当前事务的协调者会为正在执行的事务记录保持心跳(通过定期刷新事务记录的 LastHeartbeat 字段)。即使出现事务协调者down掉,也不会出现事务残留的情况。
简而言之,CockroachDB利用WRITE INTENT和事务记录二者结合,保证事务写入的数据要么一起提交成功,要么一起回滚,实现事务原子操作。
事务隔离性
CockroachDB实现了两种隔离级别:Snapshot Isolation 和 Serializable Snapshot Isolation。
Snapshot Isolation
Snapshot隔离级别解决了脏读、不可重复读、幻读,但是不能解决Write Skew的问题。在上一篇文章《CockroachDB事务解密(一)》中我们阐述了CockroachDB如何实现对已提交数据的Snapshot Read。
对于未提交的数据(WRITE INTENT),在Snapshot隔离级别下,如果写入该WRITE INTENT的事务发生在当前读事务之后(由于Uncertain Read和事务隔离级别没有直接关系,这里暂不考虑Uncertain Read,关于Uncertain Read的处理方式可参考《CockroachDB事务解密(一)》),通过MVCC往前读取合适的版本;
如果写入该WRITE INTENT的事务发生在当前读事务之前且尚未提交,那么读事务会把该写事务的时间戳修改为当前读事务之后,保证不会出现不可重复读和幻读。
对于Snapshot隔离级别的写写冲突,如果当前写事务遇到一个尚未提交的WRITE INTENT,比较当前事物和写入WRITE INTENT的事务的优先级,优先级低的事务被终止并重启;如果当前事务遇到一个已提交的WRITE INTENT,且写入WRITE INTENT的事务发生在当前事务之后,则当前事务终止并重启。
Serializable Snapshot Isolation
Serializable隔离级别在Snapshot隔离级别的基础上进一步解决了Write Skew的问题,但是在多数据库系统中都不支持或者不建议使用Serializable隔离级别,最重要的原因是性能过于低下。CockroachDB为了实现Serializable隔离级别进行了大量的优化,并且把默认隔离级别设置为Serializable隔离级别。CockroachDB提供了一个高性能的Serializable隔离级别。
CockroachDB基于Serializability Graph理论实现Serializable隔离级别。该理论定义了三种冲突(注:三种冲突皆指不同事务操作同一数据引起的冲突):
-
RW: W覆盖了R读到的值
-
WR: R读到了W更新的值
-
WW: W覆盖了第一个W更新的值
若事务T1对事务T2造成上述任意一种冲突,则可认为从T1向T2存在一条冲突关系的有向边;若事务之间的冲突关系形成回环,则意味着这些事务不可串行化。如下图中事务T1、T2、T3则不可串行化:
CockroachDB通过如下约束来保证事务之间的冲突不会形成冲突回环,保证事务的可串行化调度:
1. 同一事务的R/W统一使用事务启动时的时间戳。
2. RW: W的时间戳只能比R的大:
CockroachDB在每个节点会维护一个Read Timestamp Cache,对当前节点所有数据的读时间戳都会被记录下来。当W在Read Timestamp Cache发现R的时间戳更大时,W事务被重启。
3. WR: R只读比自身Timestamp小的最大的版本:
MVCC机制保证R不会去读比自己Timestamp大的数据。其次若R遇到Timstamp比自身小但是未提交的WRITE INTENT,比较二者之间的事务优先级,优先级低的事务被重启。
4. WW:第二个W的Timestamp比第一个W的Timestamp大:
如果W遇到一个比自身Timestamp大且已提交的WRITE INTENT,W以一个更大的时间戳重启事务。如果遇到Timestamp更大但未提交WRITE INTENT,比较二者之间的事务优先级,优先级低的事务被重启。
5. Strict scheduling:读写操作只能作用在已提交的数据之上。
也就是说,只要保证事务只与比自身Timestamp更小的事务冲突,就能保证无环。最终上文中的事务冲突被转换成如下图:
两阶段事务
CockroachDB实现的是一个无锁的两阶段提交事务模型,事务冲突通过事务重启或者回滚尽快返回客户端由客户端决策下一步如何处理。事务重启会以新的HLC时间戳和优先级重新执行,可以复用事务ID,由系统内部自动重新调度。事务回滚则直接废弃当前事务上下文,尽快将控制权返回客户端,客户端重新执行事务时将以新的事务上下文执行。CockroachDB两阶段事务具体执行过程如下所示:
-
产生事务记录,事务状态为PENDING,也就是BeginTransactoin。
-
参与节点以WRITE INTENT的形式写入数据,并返回候选时间戳。
-
比较候选时间戳和事务起始时间戳是否相等,以及事务隔离级别,决定事务状态被修改为COMMITED还是ABORTED。
-
事务提交/回滚之后,残留的WRITE INTENT将被异步清理。
-
通常情况下会选择事务中遇到的第一个写操作的Key作为事务记录的Key,此时才会真正把事务记录持久化到事务记录表中。这样做的好处是,对于只读事务不需要记录事务状态。
一阶段事务
从上文可以看到,CockroachDB的两阶段事务可能需要经过多次的网络交互才能完成事务的提交。为了提升事务处理性能,CockroachDB针对事务所有的写都落在一个Range的场景做了优化,称之为Fast 1PC。
其主要思路是,一次把所有写操作提交到Raft Leader,由Raft来保证这一批写操作的原子性。这样就不需要产生事务记录和INTENT,减少RPC交互。
总结
CockroachDB实现了一个高效的无锁乐观事务模型,相比经典的两阶段实现,在第二阶段只需要修改事务记录状态即可,不需要同步参与者的执行状态以及锁管理,事务提交和回滚代价小;任一节点挂掉仍然能保证事务的一致性。同时不需要中心节点协调事务,任一节点都可临时充当事务协调者。对于数据竞争比较激烈的场景,事务频繁restart的开销会相对较大。