一个HBase查询问题引发的思考,作为HBase使用者这个问题你知道答案吗?

前言

讲解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<WriteEntry> 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.Public
public 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的问题定位以及性能优化上有好的发挥。

最后,如果想一起大数据的小伙伴,欢迎点赞转发加关注,下次学习不迷路,我们在大数据的路上共同前进!

挂个公众号二维码,公众号的文章是最新的,CSDN的会有些滞后,想追更的朋友欢迎大家关注公众号,谢谢大家支持。 

公众号地址:

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值