HBase MVCC 机制介绍

关键词:MVCC HBase 一致性

本文最好结合源码进行阅读 

什么是MVCC ?

MVCC(MultiVersionConsistencyControl , 多版本控制协议),是一种通过数据的多版本来解决读写一致性问题的解决方案。在隔离性级别中,MVCC可以解决“可重复读”的隔离(即除了最后一级别的幻读无法解决,幻读只能事务串行化解决),基本是同一份数据并发条件下保证读写一致性的一个理想方案了。

一般情况下MVCC的一种实现思路是类似乐观锁(OCC,又叫乐观并发控制) 的实现机制。乐观锁适用于写冲突不大的并发场景,先执行写入,检查是否有冲突,若有冲突则回滚重来,否则提交写请求成功。MVCC获取最新的版本进行写操作,如果失败则回滚,成功则会将当前的版本作为可读点;读操作只能读大于或小于当前版本的数据。这里用版本概念可能会有点混淆,通常可能是timestamp或seqID。

对于单行数据,MVCC非常美好;但对于多行数据事务的更新操作就有问题了。MVCC是在最后检查才上锁,所以,如果Transaciton1执行理想的MVCC,修改Row1成功,而修改Row2失败,此时需要回滚Row1,但因为Row1没有被锁定,其数据可能又被Transaction2所修改,如果此时回滚Row1的内容,则会破坏Transaction2的修改结果,导致Transaction2违反ACID。因此,一般MVCC实际会配合二阶段锁(2PL)去实施。这样做虽然写事务被迫串行化了,但纯读取的事务不受锁影响且能保证最终的读写一致性。

HBase里的MVCC

HBase里虽然利用了版本这个概念做到了Cell层面的Version,HBase应用侧的version一般使用毫秒级时间戳作为版本,基于LSM的数据修改机制也是利用这个version来实现,包括官方一直未解决的同一毫秒内Delete和Put语义顺序问题(参考:https://yangzhe1991.org/blog/2016/06/hbase-versions-delete-limitations/)。其实这个问题就体现了Version机制的好处,以及由于Version定为毫秒级时间戳不够唯一导致的Version机制崩溃的并发问题。

但HBase真正的MVCC实现则是在HRegion中的写操作的实现。HRegion采用的是一次封锁法(示例代码可参见 HRegion.doMiniBatchMutation(BatchOperationInProgress<?> batchOp) )。

封锁步骤:

1. 对当前事务的所有行获取行锁(其中doMiniBatchMutation不会阻塞获取所有行锁,而是获取多少处理多少,然后在外部循环直至所有mutation操作完成;其他则会阻塞等待行锁)

2. 对region级别的updateLock 进行上读锁。updateLock是个读写锁,并发行级写操作的时候上读锁,region级别的写操作(flush,dropMemStore)的时候上写锁全部写操作阻塞。

MVCC的实现类是MultiVersionConsistencyControl,是个Region级别的MVCC控制。当有写操作来时,MVCC会做如下事情:

1. HRegion级别的seqID自增加一,并且当前 writeNo 设为 seqID + 1000000000。 这个大数的意义是防止别的写操作提交时把readNo提高了,导致当前writeNo成为一个可读状态的id,后面会将其设回正常的seqID (1.1.2 这里貌似有个坑)。

2. 把当前的写操作的一个包含seqID的dummy对象 WriteEntry加进队列。

3. 对于实际写操作本身,先写memstore,再写WAL,如果中间失败则回滚,否则则当做成功继续执行。

4. 不管失败成功,当前这个seqID都是不可再用的了,然后MVCC内排队等待处理当前写请求提交。

5. 写请求提交实际上就是把当前HRegion级别的readNo设为队列中已完成的写请求(包括别的线程的写请求)的seqID最大值,表示seqID以下的写请求都处理完了,可读。

1中提到了设回seqID的坑,正常做法可能是在当前线程cache住AtomicLong自增后的新seqID作为唯一id,但是HBase并没有这样做,而是在较后的build WAL代码中好几个地方调用getSequenceID去设到WALKey里。如果这时候有另一个写请求自增了,然后后续没写请求了,此时两个WriteEntry的seqID是一样的。而且写操作的流程看下来,很可能实际的seqID是NO_SEQUENCE_ID=-1。关于这个问题我翻了一下issue没找到比较相关的,倒是由于一些同步自增的performance问题被人改过(确实挺绕的)。于是我特地去看了1.3.1(1.x 最后的版本)的这部分代码,发现变动很大,MVCC的部分调用逻辑还移到了 WALKey里了。


HBase 1.3.1 的 写操作封锁步骤不变(实际写的步骤也略有变化,把非IO型的构建WALEdit操作从MVCC事务中提出,显然使事务更细粒度), MVCC流程:
1. 去掉HRegion的seqID,writePoint和readPoint统一改由MVCC内维护
2. 开始构建WALEdit,其中在FSWALEntry.stampRegionSequenceId() 方法中会自增writePoint,并WALKey.setWriteEntry。
3. 另一边获取WALKey.getWriteEntry是个异步的过程(用了Disruptor做线程间消息通信),get方法会等待直到第一次set完成才获取出writeEntry。注意此时没有了1.1.2的加一个大数的机制。
4. 获取到writePoint后,开始写memstore和同步WAL(即实际的写操作),如果成功则和1.1.2类似,将readPoint设为已完成的最大writePoint,并调用waitForRead校验readPoint >= currentWritePoint。若失败一样要调整readPoint,只是不用校验。

这里有几个改变点:
1. HBase1.1.2会加上1亿来防止并发的readPoint调整大于当前值,实际在1.3的实现方案中没必要。因为在MVCC的队列中,排在前面的写请求没完成,后面的是无法完成属于自己的WriteEntry的complete操作的,也就是说比自己后添加WriteEntry到队列的写请求是不用担心的。因此1.3.1将自增writePoint和添加队列封装了一个原子方法 MultiVersionConcurrencyControl.begin() ,解决了前文所说的1.1.2每个操作相距甚远同步风险较大的问题。
2. HBase1.1.2的MVCC在complete逻辑是等待之前的写操作完成排到自己,原子操作将队头所有completed的WriteEntry移除,并将它们的最大值作为readPoint。HBase1.3.1的逻辑是严格保证写请求顺序,移除队列头completed的WriteEntries并设最后的那个(就是最大值,因为有序)为readPoint。 waitForRead被单独拎出来作为一个方法,用来解决万一因为同步问题readPoint小于当前的writePoint了,则强制阻塞直到恢复正常的MVCC机制为止。目前还没想到不知道是前面什么操作失败的情况下会出现readPoint<writePoint的情况,但是1.3.1的机制显然更加清晰和安全。

原文地址:https://www.cnblogs.com/lhfcws/p/7828811.html