MVCC是一种通过数据的多版本来解决读写一致性问题的解决方案。HBase在保证写数据一致性的同时,还保证读取的高性能。这一机制的实现就是通过MVCC来控制的。HBase为什么需要并发控制?可能存在多个客户端,可能同时在操作某一个Region的某一行的某一列。HBase的MVCC是在HRegion中的写操作的实现。
MVCC实现类
MVCC的实现类是MultiVersionConsistencyControl,是个Region级别的MVCC控制。当有写操作来时,MVCC会做如下事情:
- HRegion级别的seqID自增加一,并且当前 writeNo 设为 seqID + 1亿。 这个大数的意义是防止别的写操作提交时把readNo提高了,导致当前writeNo成为一个可读状态的id,后面会将其设回正常的seqID
- 把当前的写操作的一个包含seqID的dummy对象 WriteEntry加进队列
- 对于实际写操作本身,如果写WAL失败则回滚,否则则当做成功继续执行
- 不管失败成功,当前这个seqID都是不可再用的了,然后MVCC内排队等待处理当前写请求提交
- 写请求提交实际上就是把当前HRegion级别的readNo设为队列中已完成的写请求的seqID最大值,表示seqID以下的写请求都处理完了,可读
HBase 1.3.1 的 写操作封锁步骤不变, MVCC流程:
- 去掉HRegion的seqID,writePoint和readPoint统一改由MVCC内维护
- 开始构建WALEdit,其中在FSWALEntry.stampRegionSequenceId() 方法中会自增writePoint,并WALKey.setWriteEntry
- 另一边获取WALKey.getWriteEntry是个异步的过程,get方法会等待直到第一次set完成才获取出writeEntry。注意此时没有了1.1.2的加一个大数的机制
- 获取到writePoint后,开始写memstore和同步WAL,如果成功则和1.1.2类似,将readPoint设为已完成的最大writePoint,并调用waitForRead校验readPoint >= currentWritePoint。若失败一样要调整readPoint,只是不用校验
这里有几个改变点:
- HBase1.1.2会加上1亿来防止并发的readPoint调整大于当前值,实际在1.3的实现方案中没必要。因为在MVCC的队列中,排在前面的写请求没完成,后面的是无法完成属于自己的WriteEntry的complete操作的,也就是说比自己后添加WriteEntry到队列的写请求是不用担心的。因此1.3.1将自增writePoint和添加队列封装了一个原子方法 MultiVersionConcurrencyControl.begin() ,解决了前文所说的1.1.2每个操作相距甚远同步风险较大的问题
- HBase1.1.2的MVCC在complete逻辑是等待之前的写操作完成排到自己,原子操作将队头所有completed的WriteEntry移除,并将它们的最大值作为readPoint。HBase1.3.1的逻辑是严格保证写请求顺序,移除队列头completed的WriteEntries并设最后的那个(就是最大值,因为有序)为readPoint。 waitForRead被单独拎出来作为一个方法,用来解决万一因为同步问题readPoint小于当前的writePoint了,则强制阻塞直到恢复正常的MVCC机制为止。目前还没想到不知道是前面什么操作失败的情况下会出现readPoint<writePoint的情况,但是1.3.1的机制显然更加清晰和安全
MultiVersionConsistencyControl用于控制Memstore中读写的一致性,其中维护两个long型的变量:
1)memstoreRead:用于记录当前全局可读的readPoint,同时为了每个客户端读请求能够记录自己发起请求时刻的readPoint,还有一个ThreadLocal的perThreadReadPoint变量以及相关的set和get方法
2)memstoreWrite:用于记录当前全局最大的writePoint,根据它为下个事务生成新的writePoint
MultiVersionConsistencyControl中关键的实现方法如下:
1)WriteEntry beginMemstoreInsert():开始一个更新操作,将memstoreWrite加1,创建writeQueue并插入到writeQueue,并返回WriteEntry对象
2)void completeMemstoreInsert(WriteEntry e):完成当前更新操作,将WriteEntry对象标记为可读,具体分两步:
- boolean advanceMemstore(WriteEntry e):从头开始遍历writeQueue,移除所有已完成的WriteEntry对象,最后将memstoreRead更新为最新已完成的memstoreWrite
- void waitForRead(WriteEntry e):阻塞当前线程,直到memstoreRead等于当前WriteEntry的memstoreWrite,至此表明当前WriteEntry之前的所有更新事务都已经完成
写操作
假设有两个程序同时往HBase某表中同一行的同一个Family(Info)的两个Qualifier(Company、Role)写入数据,一般情况下,这两个写入请求会被HBase RegionServer接收后封装成两个Call,然后被两个Handler(线程)分别处理,即将请求中的数据写入列簇Company的MemStore中,对于MemStore来说,这些数据是被并发的线程写入的。
对并发写操作进行同步,最简单的一种方案是在对某一行进行操作之前,首先显式对该行进行加锁操作,加锁成功后才进行相应操作,否则只能等待获取锁,此时,写入流程如下:
(1) 获取行锁
(2) 写WAL文件
(3) 更新MemStore:将每个cell写入到MemStore
(4) 释放行锁
引入行锁的机制后,就可以避免并发情景下,对同一行数据进行操作(写入或更新)时出现数据交错的情况。
读操作
假设没有为HBase的读操作引入任何的并发控制策略,在两次写入请求的同时,再发起一个读取请求,这三个请求都是针对同一行进行读写操作的。可见需要对读和写也进行并发控制,不然会得到不一致的数据。HBase使用了MVCC来避免读取的锁操作。
原理:
无论读写,每一次都被给一个Number,写操作叫做WriteNumber,读操作叫做memstoreRead。一般情况下,为了确保数据一致性,在对数据进行写操作的时候,需要等到前面的操作完成,不然可能造成数据丢失。所以当一个写操作来的时候,会把这个请求写入链表末尾writeQueue,完成的操作标记为完成,并且从链表头部删除。而最大的writeNumber会记录在memstoreRead,从而告知其他的所有的等待者,现在操作的最新sequenceId是多少,保证每一个等待着看到的数据都是最新的。
HBase读写怎么和mvcc结合起来
1.获取相关的锁,由于HBase要确保行一级的原子性,所以获取锁的时候获取的是整个rowkey的锁而不是单个cell的锁;
也只有当至少获取一个锁的时候,这个方法才会继续,否则直接返回。
2.更新cell中的时间戳(timestamp)以及获取mvcc相关参数,其中timestamp可以在客户端自己手动指定,
所以在一致性上不能用来做参考,也许正是因此才会引入一个叫做sequenceId的概念来完成mvcc。
3.将这些put操作写入memstore,虽然数据库系统中写日志永远比写数据重要,但是这里可以认为当前“事务”尚未提交,即使现在挂了没有日志恢复也不要紧,因为这个“事务”是没有提交的。
4.构建walEdit,这一步主要是为了构建WALEdit类型的walEdit变量,这个变量主要是以list的形式聚合了很多HBase里面cell的概念,以后会写入到HLog中。
5.追加刚才构建好的walEdit:首先构造一个walKey,注意这里的walKey的sequenceId为默认值-1,到后面才会修改为跟region挂钩的唯一递增id;接着调用wal的append方法并返回一个递增数值(txid),用来表示这个追加到wal内存中日志条目的编号,在第七步中这个数值将会作为参数传入,确保该数值之前的日志信息都被写入到HLog日志文件中,而且在append方法中会保证walKey的sequenceId变成了region的sequenceId(也是一个递增序列)。
6.释放获取的锁。
7.将wal写入磁盘,正如第五步所说,这里保证txid以及之前的日志条目都被写入到日志文件中了,一旦写完便可以认为这个“事务”成功了,这里跟MySQL里面的auto commit很像。
8.提交本次操作,让put操作对读可见,核心步骤就是增加对应memstore的readpoint,使得以前讲的MemStoreScanner可以看见put过来的数据,这根后面讲的mvcc有关。
MVCC对于写操作:
- (w1) 获取行锁后,每个写操作都立即分配一个写序号
- (w2) 写操作在保存每个数据cell时都要带上写序号
- (w3) 写操作需要申明以这个写序号来完成本次写操作
对于读操作:
- (r1) 每个读操作开始都分配一个读序号,也称为读取点
- (r2) 读取点的值是所有的写操作完成序号中的最大整数(所有的写操作完成序号<=读取点)
- (r3) 对某个(row,column)的读取操作r来说,结果是满足写序号为“写序号<=读取点这个范围内”的最大整数的所有cell值的组合
采用MVCC后,每一次写操作都有一个写序号(即w1),每个cell数据写memstore操作都有一个写序号(w2)并且每次写操作完成也是基于这个写序号(w3)。这样就实现了以无锁的方式读取到一致的数据了。
总结:
引入MVCC后写入操作流程如下:
(0) 获取行锁
(0a) 获取写序号
(1) 写WAL文件
(2) 更新MemStore:将每个cell写入到memstore
(2a) 以写序号完成操作
(3) 释放行锁
HRegion.java中通过private ConcurrentHashMap<RegionScanner, Long> scannerReadPoints;维护各个查询请求的readPoint。
以get或scan请求为例,最终会通过getScanner方法需要构造RegionScannerImpl对象:
org.apache.hadoop.hbase.regionserver.HRegion.RegionScannerImpl:
1)根据Scan对象构造时设置好readPoint,scan.getIsolationLevel()分为READ_UNCOMMITTED和READ_COMMITTED,
只有当READ_COMMITTED时根据MultiVersionConsistencyControl.resetThreadReadPoint(mvcc);设置当前scanner线程的readPoint,并插入到scannerReadPoints维护起来。
2)根据scan需要读取的column family,创建StoreScanner(根据bloom filter、time range、ttl筛选需要的MemStoreScanner和StoreFileScanner),
添加到scanners中,并最终根据scanners构造出一个KeyValueHeap。
下面看下RegionScannerImpl中的next方法是每次查询时需要调用的函数:
boolean org.apache.hadoop.hbase.regionserver.HRegion.RegionScannerImpl.next(List<KeyValue> outResults, int limit) throws IOException
而上述方法会通过KeyValueHeap的next方法读取下一条数据:先定位到当前KeyValueScanner(即之前构造KeyValueHeap时传入的MemStoreScanner或StoreScanner),然后调用next方法。
StoreFileScanner和MemStoreScanner均为KeyValueScanner,通过其中的next()接口方法,
分别调用到StoreFileScanner.java的skipKVsNewerThanReadpoint方法、Memstore.java中MemStoreScanner对象的getNext方法。
本文详细介绍了HBase中的MVCC(多版本并发控制)机制,如何在保证写数据一致性的同时实现高读取性能。内容包括MVCC的实现类MultiVersionConsistencyControl及其在写操作和读操作中的具体流程,以及如何通过写序号和读序号来确保数据一致性。
3728

被折叠的 条评论
为什么被折叠?



