前言
讲解HBase事务的文章很多,这里就不过多赘述了,大家应该都知道是通过MVCC实现的。但是今天这篇文章的背景是一个同事和我讨论一个问题引发的,这个问题使我重新梳理下这块内容并作为记录和大家分享。
下面先来看看这个问题:
HBase的查询流程是:先查询MemStore,查不到则查询BlockCache,还没有则查询HFile,再将查询到的数据放入BlockCache。
请问是不是存在这么一种情况,假如有一条数据id=1,name='张三',当被查询时,数据被放入blockCache中,后来数据更新了,id=1,name='李四',数据存入memstore中,达到刷写机制,写入hfile中了。用户查询这条数据,发现blockCache中有这条数据,但是数据是旧的,name='张三'。于是取到了旧的数据
其实这是个很常见的场景,我当时第一反应是肯定不会查到脏数据,但是到底怎么实现的,我还真的一时有点拿不准了,作为自己最熟悉的组件,这个问题还是需要弄清楚的。
其实上面的问题仔细分析下,可以分解为下面几个问题:
BlockCache中存的是什么?
肯定有数据标识标记数据的版本使得取数据不会出现问题,那这个标识是什么?
HBase是如何保持数据一致性的?
下面就从这几个问题展开并解答上面的那个问题。
正文
BlockCache中存的是什么
这个问题不是今天这个问题的核心内容,但是当做准备知识顺便说一下还是可以的。
BlockCache说明
BlockCache称为读缓存,主要是加速HBase读取数据的速度的。与之对应的是HBase的写缓存,即MemStore,用以加速HBase的写操作。
HBase会将一次文件查找的Block块缓存到Cache中,以便后续同一请求或者邻近数据查找请求,可以直接从内存中获取,避免昂贵的IO操作。
BlockCache缓存的数据
看完BlockCache的作用后,下面来看看BlockCache里面到底存了什么?
其实答案很简单,都叫BlockCache了,里面肯定存的是Block了。这里要区分两个概念,由于HBase底层使用HDFS存储,而HDFS也有Block的概念,所以要区分这两个Block概念。
HDFS的Block是HDFS维度的概念,这个Block的大小默认是128M;
HBase的Block是HBase维度的概念,这个Block的大小默认是64K;
从大小比较就能看出来两者的区别,如果使用HDFS的Block粒度来做缓存,那么缓存不了几个Block,BlockCache就满了,显然不合适。而HBase的Block默认的64K大小是一个基于HBase的两种查询操作scan和get效率的一个折中。如果get操作居多,可以适当调小Block的大小;反之如果scan操作居多,则可以适当调大Block的大小。如果两者数量差不多,那妥妥的默认即可。
那么HBase都有哪些Block呢?主要分为下面四种:
Data Block:用于存储实际数据,通常情况下每个Data Block可以存放多条KeyValue数据对,这些数据都是查询之后包含结果的Block在BlockCache中的缓存,用以加速查询。
Index Block:用于存储硬盘上数据的索引文件,通过存储索引数据加快数据查找
Bloom Block:用于存储Hfile中rowkey的布隆过滤器,用于过滤掉部分一定不存在KeyValue的数据查询,减少不必要的IO操作。
Meta Block:存储整个HFile的元数据,包含HFile的基本信息,布隆过滤器的元数据,HFile的数据以及索引的原信息等。
BlockCache分类
这个简单说说,当前HBase主流的BlockCache使用方式就是将BucketCache和LruBlockCache搭配使用,称为CombinedBlockCache即CBC。至于BucketCache和LruBlockCache具体说明大家不清楚的可以去查一下,网上很多,这里我简单说说这两者的特点:
LruBlockCache中主要存储Index Block和Bloom Block,采用LRU算法进行缓存淘汰。而Meta Block以及被设置为IN_MEMORY => 'true'的内存表不参与LRU淘汰过程而常驻内存。
BucketCache中主要存储Data Block
一次随机读需要首先在LruBlockCache中查到对应的Index Block,然后再到BucketCache查找对应数据块
Bucket Cache缓存中有3种模式:heap模式和offheap模式file模式,常规的heap模式不过多介绍,offheap模式因为内存属于操作系统,所以基本不会产生各种GC,尤其是产生毛刺的Full GC。而file模式借助于SSD以及Alluxio等存储也可以实现高速查询。
HBase是如何避免脏读的
首先先纠正下上面问题的读取数据流程。上面数据读取数据流程其实是错误的,那么正确的流程是什么样子的呢?其实HBase的查询操作分为Get和Scan操作两种(虽然Get操作也是被当做Scan来处理)流程如下:
首先确实需要查询Memstore,但是并不是查询到数据就返回,而是在查询Memstore的同时也会去文件中查询,最后会将结果进行合并筛选。
至于查hfile,则是先根据rowkey段进行筛选,选出符合条件的HFile(如果是get操作,布隆过滤器应该会起作用,能直接筛选出更精确的文件)。然后判断hfile对应的block是否在blockcache中,如果存在就直接读取blockcache的数据,不存在就加载对应block到blockcache中并查询对应的数据。
所以针对上面的场景,如果数据所在的region没有发生compact的话,应该会返回两个结果,一个在BlockCache中;另一个在文件中,被加载到BlockCache中后被查询出来。
然后关键的地方来了,其实在查询的时候scan是带有读序号的,而数据存储中也是带有写序号的,最后会按rowkey将收集来的所有结果分组,然后根据读序号和写序号的关系来选取唯一符合条件的值。筛选条件就是Max(写序号<=读序号的所有值)
上面就是HBase避免脏读的处理手段。乍看起来信息量有点大,大家可能有点懵,什么是读序号,什么是写序号,这两者之间是什么关系以及如何同步的?这就是涉及到了HBase事务实现机制MVCC的一些细节,下文详细详解。
HBase是如何保持数据一致性的
上文讲了HBase是如何避免脏读的,下面就来看看上面的那一些专有名词以及HBase是如何保持查询一致以及数据一致的。
首先MVCC相关的基础知识我这里就不赘述了,大家可以去网上查查,资料很多,我这里主要讲讲MVCC这个组件在HBase中是如何工作,以及读序号和写序号是如何关联以及更新的,进而就可以回答上述的那个问题。先看下图:
首先MVCC有三个主要组成部分:
writePoint:写序号,AtomicLong类型
readPoint:读序号,AtomicLong类型
LinkedList
writeQueue:存储写操作状态的list,之所以选用LinkedList,是因为这个list需要频繁在两头插入和删除WriteEntry。MVCC每个region都有一个实例。这三个属性通过规则联动,HBase读写该region的数据都会从这里获取读写序号,然后进行相关的操作。
下面来看看这三个属性的联动规则:
1.当一个client写入数据时,首先lock住MVCC控制中心的写入队列writeQueue,并向其插入一个新的entry,并将之前的writePoint+1赋予entry的writeNumber(writePoint+1也是同步操作),表示发起了一个新的写入事务。completed值此时为False,表名目前事务还未完成,数据还在写入过程中。图中的write client1和write client3就处于这个阶段。
2.第二步client将数据写入memstore和WAL,此时认为数据已经持久化,可以结束该事务。此处需要注意,这里只是事务结束,但是并没有返回客户端写入成功,还需要有下面MVCC相关的操作。
3.client调用MVCC控制中心的complete(WriteEntry writeEntry)方法,该方法对writeQueue采用synchronized关键字,将该num对应的entry的completed设置为True,表示该entry对应的事务完成。但是单单将completed设置为True是不够的,我们的最终目的是要让scan能够看到最新写入完成的数据,也就是说还需要更新readPoint。
/** * Mark the {@link WriteEntry} as complete and advance the read point as much as possible. * Call this even if the write has FAILED (AFTER backing out the write transaction * changes completely) so we can clean up the outstanding transaction. * * How much is the read point advanced? * * Let S be the set of all write numbers that are completed. Set the read point to the highest * numbered write of S. * * @param writeEntry * * @return true if e is visible to MVCC readers (that is, readpoint >= e.writeNumber) */ public boolean complete(WriteEntry writeEntry) { synchronized (writeQueue) { writeEntry.markCompleted(); long nextReadValue = NONE; boolean ranOnce = false; while (!writeQueue.isEmpty()) { ranOnce = true; WriteEntry queueFirst = writeQueue.getFirst(); if (nextReadValue > 0) { if (nextReadValue + 1 != queueFirst.getWriteNumber()) { throw new RuntimeException("Invariant in complete violated, nextReadValue=" + nextReadValue + ", writeNumber=" + queueFirst.getWriteNumber()); } } if (queueFirst.isCompleted()) { nextReadValue = queueFirst.getWriteNumber(); writeQueue.removeFirst(); } else { break; } } if (!ranOnce) { throw new RuntimeException("There is no first!"); } if (nextReadValue > 0) { synchronized (readWaiters) { readPoint.set(nextReadValue); readWaiters.notifyAll(); } } return readPoint.get() >= writeEntry.getWriteNumber(); } }
4.更新readPoint:同样在complete(WriteEntry writeEntry)方法中完成,每一个client将其对应的entry的completed设置为True后,都会去按照队列顺序,从readPoint开始遍历,假如遍历到的entry的completed为True,则将readPoint更新至此位置,直到遇到completed为False的位置时停止。也就是说每个client写入之后,都会尽力去将readPoint更新到目前最大连续的已经完成的事务的点(因为是有可能后开始的事务先于之前的事务完成)。
看到这里,可能大家会想了,那假如事务A先于事务C,事务A还未完成,但事务C已经完成,事务C也只能将readPoint更新到事务A之前的位置,如果此时事务C返回写入成功,那按道理来说scan是应该能够查到事务C的数据,但是由于readPoint没有更新到C,就会造成一个现象就是:事务C明明提示执行成功,但是查询的时候却看不到。
所以上面说的第4步其实还并没有完,client在执行complete(WriteEntry writeEntry)后,如果方法返回的值为false,还会执行一个waitForRead(WriteEntry e)方法,参数的entry就是该事务对应的entry,下面是源码逻辑:
/** * Complete a {@link WriteEntry} that was created by {@link #begin()} then wait until the * read point catches up to our write. * * At the end of this call, the global read point is at least as large as the write point * of the passed in WriteEntry. Thus, the write is visible to MVCC readers. */ public void completeAndWait(WriteEntry e) { if (!complete(e)) { waitForRead(e); } }
该方法会一直等待readPoint大于等于该entry的writeNumber时才会返回,这样保证了事务有序完成。此时客户端才会最终返回写入成功,即下次查询就会查询到最新的数据。下面是源码逻辑:
/** * Wait for the global readPoint to advance up to the passed in write entry number. */ void waitForRead(WriteEntry e) { boolean interrupted = false; int count = 0; synchronized (readWaiters) { while (readPoint.get() < e.getWriteNumber()) { if (count % 100 == 0 && count > 0) { long totalWaitTillNow = READPOINT_ADVANCE_WAIT_TIME * count; LOG.warn("STUCK for : " + totalWaitTillNow + " millis. " + this); } count++; try { readWaiters.wait(READPOINT_ADVANCE_WAIT_TIME); } catch (InterruptedException ie) { // We were interrupted... finish the loop -- i.e. cleanup --and then // on our way out, reset the interrupt flag. interrupted = true; } } } if (interrupted) { Thread.currentThread().interrupt(); } }
再回到上面那个图,当前write client 2在等待write client 3写入成功后readPoint追上来,所以write client 2处于写入成功等待readPoint追上来的阶段。此时的readPoint是6,查询的时候只能查询到writePoint <= 6的数据,然后返回其中writePoint最大的数据。而writePoint = 8的数据虽然写入成功,但是客户端并没有收到写入成功的状态,数据不可见也符合一般认知。
以上就是HBase写入时MVCC的工作流程,scan就比较好理解了,每一个scan请求都会申请一个readPoint,保证了该readPoint之后的事务不会被检索到。
另外,上述的HBase查询机制是基于HBase默认的事务级别即read committed级别。同时HBase也同样支持read uncommitted级别,也就是我们在查询的时候将scan的mvcc值设置为一个超大的值,大于目前所有申请的MVCC值,那么查询时同样会返回正在写入的数据。
/** * Specify Isolation levels in Scan operations. * <p> * There are two isolation levels. A READ_COMMITTED isolation level * indicates that only data that is committed be returned in a scan. * An isolation level of READ_UNCOMMITTED indicates that a scan * should return data that is being modified by transactions that might * not have been committed yet. */@InterfaceAudience.Publicpublic enum IsolationLevel { READ_COMMITTED(1), READ_UNCOMMITTED(2); IsolationLevel(int value) {} public byte [] toBytes() { return new byte [] { toByte() }; } public byte toByte() { return (byte)this.ordinal(); } public static IsolationLevel fromBytes(byte [] bytes) { return IsolationLevel.fromByte(bytes[0]); } public static IsolationLevel fromByte(byte vbyte) { return IsolationLevel.values()[vbyte]; }}
总结
最后回到最上面的问题,查询的client会查询到张三和李四两条数据,但是由于李四是小于等于readPoint所有数据中writePoint最大的数据,所以最终返回客户端的数据是李四,结果符合预期,没有问题,上面的问题就是这样的。
回顾全文可以看出,HBase一条查询API后面执行的业务逻辑还是相当复杂的。如果作为初级人员,调用API即可,剩下的事情HBase就帮你做了。但是如果要进阶到HBase的高级阶段的话,这些原理性的东西还是需要了解和掌握的,只有掌握了这些原理,才会在HBase的问题定位以及性能优化上有好的发挥。
最后,如果想一起大数据的小伙伴,欢迎点赞转发加关注,下次学习不迷路,我们在大数据的路上共同前进!
原文:https://juejin.cn/post/7098232975623946247