网易视频云技术分享:HBase BlockCache系列 - 性能对比测试报告

来源:互联网 发布:一带一路大数据宣传片 编辑:程序博客网 时间:2024/04/28 21:51

网易视频云是网易公司旗下的视频云服务产品,以Paas服务模式,向开发者提供音视频编解码SDK和开放API,助力APP接入音视频功能。

现在,网易视频云的技术专家给大家分享一篇技术性文章:HBase BlockCache系列 - 性能对比测试报告.


HBase BlockCache系列文章到了终结篇,几个主角的是是非非也该有个了断了,在SlabCache被早早地淘汰之后,站在华山之巅的也就仅剩LRU君(LRUBlockCache)和CBC君(CombinedBlockCache)。谁赢谁输,我说了不算,你说了也不算,那就来让数据说话。这篇文章主要对比LRU君和CBC君(offheap模式)分别在四种场景下几种指标(GC、Throughput、Latency、CPU、IO等)的表现情况。四种场景分别是缓存全部命中、少大部分缓存命中、少量缓存命中、缓存基本未命中。

需要注意的是,本文的所有数据都来自社区文档,在这里分享也只是给大家一个参考,更加详细的测试数据可以阅读文章《Comparing BlockCache Deploys》和 HBASE-11323 附件报告。

说明:本文所有图都以时间为横坐标,纵坐标为对应指标。每张图都会分别显示LRU君和CBC君的四种场景数据,总计八种场景,下面数据表示LRU君的四种场景分布在时间段21:36:39~22:36:40,CBC君的四种场景分布在时间段23:02:16~00:02:17,看图的时候需要特别注意。

LRU君:Tue Jul 22 21:36:39 PDT 2014 run size=32, clients=25 ; lrubc time=1200 缓存全部命中Tue Jul 22 21:56:39 PDT 2014 run size=72, clients=25 ; lrubc time=1200 大量缓存命中Tue Jul 22 22:16:40 PDT 2014 run size=144, clients=25 ; lrubc time=1200 少量缓存命中Tue Jul 22 22:36:40 PDT 2014 run size=1000, clients=25 ; lrubc time=1200 缓存基本未命中

CBC君:Tue Jul 22 23:02:16 PDT 2014 run size=32, clients=25 ; bucket time=1200 缓存全部命中Tue Jul 22 23:22:16 PDT 2014 run size=72, clients=25 ; bucket time=1200 大量缓存命中Tue Jul 22 23:42:17 PDT 2014 run size=144, clients=25 ; bucket time=1200 少量缓存命中Wed Jul 23 00:02:17 PDT 2014 run size=1000, clients=25 ; bucket time=1200 缓存基本未命中

GC

GC指标是HBase运维最关心的指标,出现一次长时间的GC就会导致这段时间内业务方的所有读写请求失败,如果业务方没有很好的容错,就会出现丢数据的情况出现。根据下图可知,只有在‘缓存全部命中’的场景下,LRU君总GC时间25ms比CBC君的75ms短;其他三种场景下,LRU君表现都没有CBC君好,总GC时间基本均是CBC君的3倍左右。

11111

Thoughput

吞吐量可能是所有HBase用户初次使用最关心的问题,这基本反映了HBase的读写性能。下图是随机读测试的吞吐量曲线,在‘缓存全部命中’以及‘大量缓存命中’这两种场景下,LRU君可谓是完胜CBC君,特别是在‘缓存全部命中’的场景下,LRU君的吞吐量甚至是CBC君的两倍;而在‘少量缓存命中’以及‘缓存基本未命中’这两种场景下,两者的表现基本相当;

10001

Latency

读写延迟是另一个用户很关心的指标,下图表示在所有四种情况下LRU君和CBC君都在伯仲之间,LRU君略胜一筹。



10002

IO

接下来两张图是资源使用图,运维同学可能会比较关心。从IO使用情况来看,两者在四种场景下也基本相同。

10003

CPU

再来看看CPU使用情况,在‘缓存全部命中’以及‘大量缓存命中’这两种场景下,LRU君依然完胜CBC君,特别是在‘缓存全部命中’的场景下,CBC君差不多做了两倍于LRU君的工作;而在‘少量缓存命中’以及‘缓存基本未命中’这两种场景下,两者的表现基本相当;

10005

结论

看完了所有比较重要的指标对比数据,我们可以得出以下两点:

1. 在’缓存全部命中’场景下,LRU君可谓完胜CBC君。因此如果总数据量相比JVM内存容量很小的时候,选择LRU君;

2. 在所有其他存在缓存未命中情况的场景下, LRU君的GC性能几乎只有CBC君的1/3,而吞吐量、读写延迟、IO、CPU等指标两者基本相当,因此建议选择CBC。

至此,HBase BlockCache系列文章就结束了,如果大家还有什么交流讨论或者补充的,可以留言或者发邮件至libisthanks@gmail.

Categories:HBase, 性能Tags:blockcache

HBase BlockCache系列 - 探求BlockCache实现机制

April 26th, 2016范欣欣   阅读(0)Comments off

HBase BlockCache系列第一篇文章《走进BlockCache》从全局视角对HBase中缓存、Memstore等作了简要概述,并重点介绍了几种BlockCache方案及其演进过程,对此还不了解的可以点这里。本文在上文的基础上深入BlockCache内部,对各种BlockCache方案具体工作原理进行详细分析。Note:因为SlabCache方案在0.98版本已经不被建议使用,因此本文不针对该方案进行讲解;至于LRU方案和Bucket方案,因为后者更加复杂,本文也会花更多篇幅详细介绍该方案的实现细节。

LRUBlockCache

LRUBlockCache是HBase目前默认的BlockCache机制,实现机制比较简单。它使用一个ConcurrentHashMap管理BlockKey到Block的映射关系,缓存Block只需要将BlockKey和对应的Block放入该HashMap中,查询缓存就根据BlockKey从HashMap中获取即可。同时该方案采用严格的LRU淘汰算法,当Block Cache总量达到一定阈值之后就会启动淘汰机制,最近最少使用的Block会被置换出来。在具体的实现细节方面,需要关注三点:

1. 缓存分层策略

HBase在LRU缓存基础上,采用了缓存分层设计,将整个BlockCache分为三个部分:single-access、mutil-access和inMemory。需要特别注意的是,HBase系统元数据存放在InMemory区,因此设置数据属性InMemory = true需要非常谨慎,确保此列族数据量很小且访问频繁,否则有可能会将hbase.meta元数据挤出内存,严重影响所有业务性能。

2. LRU淘汰算法实现

系统在每次cache block时将BlockKey和Block放入HashMap后都会检查BlockCache总量是否达到阈值,如果达到阈值,就会唤醒淘汰线程对Map中的Block进行淘汰。系统设置三个MinMaxPriorityQueue队列,分别对应上述三个分层,每个队列中的元素按照最近最少被使用排列,系统会优先poll出最近最少使用的元素,将其对应的内存释放。可见,三个分层中的Block会分别执行LRU淘汰算法进行淘汰。

3. LRU方案优缺点

LRU方案使用JVM提供的HashMap管理缓存,简单有效。但随着数据从single-access区晋升到mutil-access区,基本就伴随着对应的内存对象从young区到old区 ,晋升到old区的Block被淘汰后会变为内存垃圾,最终由CMS回收掉(Conccurent Mark Sweep,一种标记清除算法),然而这种算法会带来大量的内存碎片,碎片空间一直累计就会产生臭名昭著的Full GC。尤其在大内存条件下,一次Full GC很可能会持续较长时间,甚至达到分钟级别。大家知道Full GC是会将整个进程暂停的(称为stop-the-wold暂停),因此长时间Full GC必然会极大影响业务的正常读写请求。也正因为这样的弊端,SlabCache方案和BucketCache方案才会横空出世。

BucketCache

相比LRUBlockCache,BucketCache实现相对比较复杂。它没有使用JVM 内存管理算法来管理缓存,而是自己对内存进行管理,因此不会因为出现大量碎片导致Full GC的情况发生。本节主要介绍BucketCache的具体实现方式(包括BucketCache的内存组织形式、缓存写入读取流程等)以及如何配置使用BucketCache。

内存组织形式

下图是BucketCache的内存组织形式图,其中上面部分是逻辑组织结构,下面部分是对应的物理组织结构。HBase启动之后会在内存中申请大量的bucket,如下图中黄色矩形所示,每个bucket的大小默认都为2MB。每个bucket会有一个baseoffset变量和一个size标签,其中baseoffset变量表示这个bucket在实际物理空间中的起始地址,因此block的物理地址就可以通过baseoffset和该block在bucket的偏移量唯一确定;而size标签表示这个bucket可以存放的block块的大小,比如图中左侧bucket的size标签为65KB,表示可以存放64KB的block,右侧bucket的size标签为129KB,表示可以存放128KB的block。

70074

HBase中使用BucketAllocator类实现对Bucket的组织管理:

1. HBase会根据每个bucket的size标签对bucket进行分类,相同size标签的bucket由同一个BucketSizeInfo管理,如上图,左侧存放64KB block的bucket由65KB BucketSizeInfo管理,右侧存放128KB block的bucket由129KB BucketSizeInfo管理。

2. HBase在启动的时候就决定了size标签的分类,默认标签有(4+1)K、(8+1)K、(16+1)K … (48+1)K、(56+1)K、(64+1)K、(96+1)K … (512+1)K。而且系统会首先从小到大遍历一次所有size标签,为每种size标签分配一个bucket,最后所有剩余的bucket都分配最大的size标签,默认分配 (512+1)K,如下图所示:



22222

3. Bucket的size标签可以动态调整,比如64K的block数目比较多,65K的bucket被用完了以后,其他size标签的完全空闲的bucket可以转换成为65K的bucket,但是至少保留一个该size的bucket。

Block缓存写入、读取流程

下图是block写入缓存以及从缓存中读取block的流程示意图,图中主要包括5个模块,其中RAMCache是一个存储blockkey和block对应关系的HashMap;WriteThead是整个block写入的中心枢纽,主要负责异步的写入block到内存空间;BucketAllocator在上一节详细介绍过,主要实现对bucket的组织管理,为block分配内存空间;IOEngine是具体的内存管理模块,主要实现将block数据写入对应地址的内存空间;BackingMap也是一个HashMap,用来存储blockKey与对应物理内存偏移量的映射关系,用来根据blockkey定位具体的block;其中紫线表示cache block流程,绿线表示get block流程。

33333


Block缓存写入流程

1. 将block写入RAMCache。实际实现中,HBase设置了多个RAMCache,系统首先会根据blockkey进行hash,根据hash结果将block分配到对应的RAMCache中;

2. WriteThead从RAMCache中取出所有的block。和RAMCache相同,HBase会同时启动多个WriteThead并发的执行异步写入,每个WriteThead对应一个RAMCache;

3. 每个WriteThead会将遍历RAMCache中所有block数据,分别调用bucketAllocator为这些block分配内存空间;

4. BucketAllocator会选择与block大小对应的bucket进行存放(具体细节可以参考上节‘内存组织形式’所述),并且返回对应的物理地址偏移量offset;

5. WriteThead将block以及分配好的物理地址偏移量传给IOEngine模块,执行具体的内存写入操作;

6. 写入成功后,将类似<blockkey,offset>这样的映射关系写入BackingMap中,方便后续查找时根据blockkey可以直接定位;

Block缓存读取流程

1. 首先从RAMCache中查找。对于还没有来得及写入到bucket的缓存block,一定存储在RAMCache中;

2. 如果在RAMCache中没有找到,再在BackingMap中根据blockKey找到对应物理偏移地址offset;

3. 根据物理偏移地址offset可以直接从内存中查找对应的block数据;

BucketCache工作模式

BucketCache默认有三种工作模式:heap、offheap和file;这三种工作模式在内存逻辑组织形式以及缓存流程上都是相同的,参见上节讲解。不同的是三者对应的最终存储介质有所不同,即上述所讲的IOEngine有所不同。

其中heap模式和offheap模式都使用内存作为最终存储介质,内存分配查询也都使用Java NIO ByteBuffer技术,不同的是,heap模式分配内存会调用byteBuffer.allocate方法,从JVM提供的heap区分配,而后者会调用byteBuffer.allocateDirect方法,直接从操作系统分配。这两种内存分配模式会对HBase实际工作性能产生一定的影响。影响最大的无疑是GC ,相比heap模式,offheap模式因为内存属于操作系统,所以基本不会产生CMS GC,也就在任何情况下都不会因为内存碎片导致触发Full GC。除此之外,在内存分配以及读取方面,两者性能也有不同,比如,内存分配时heap模式需要首先从操作系统分配内存再拷贝到JVM heap,相比offheap直接从操作系统分配内存更耗时;但是反过来,读取缓存时heap模式可以从JVM heap中直接读取,而offheap模式则需要首先从操作系统拷贝到JVM heap再读取,显得后者更费时。

file模式和前面两者不同,它使用Fussion-IO或者SSD等作为存储介质,相比昂贵的内存,这样可以提供更大的存储容量,因此可以极大地提升缓存命中率。

BucketCache配置使用

BucketCache方案的配置说明一直被HBaser所诟病,官方一直没有相关文档对此进行介绍。本人也是一直被其所困,后来通过查看源码才基本了解清楚,在此分享出来,以便大家学习。需要注意的是,BucketCache三种工作模式的配置会有所不同,下面也是分开介绍,并且没有列出很多不重要的参数:

heap模式

<hbase.bucketcache.ioengine>heap</hbase.bucketcache.ioengine>//bucketcache占用整个jvm内存大小的比例<hbase.bucketcache.size>0.4</hbase.bucketcache.size>//bucketcache在combinedcache中的占比<hbase.bucketcache.combinedcache.percentage>0.9</hbase.bucketcache.combinedcache.percentage>

offheap模式

<hbase.bucketcache.ioengine>offheap</hbase.bucketcache.ioengine><hbase.bucketcache.size>0.4</hbase.bucketcache.size><hbase.bucketcache.combinedcache.percentage>0.9</hbase.bucketcache.combinedcache.percentage>

file模式

<hbase.bucketcache.ioengine>file:/cache_path</hbase.bucketcache.ioengine>//bucketcache缓存空间大小,单位为MB<hbase.bucketcache.size>10 * 1024</hbase.bucketcache.size>//高速缓存路径<hbase.bucketcache.persistent.path>file:/cache_path</hbase.bucketcache.persistent.path>

总结

HBase中缓存的设置对随机读写性能至关重要,本文通过对LRUBlockCache和BucketCache两种方案的实现进行介绍,希望能够让各位看官更加深入地了解BlockCache的工作原理。BlockCache系列经过两篇内容的介绍,基本已经解析完毕,那在实际线上应用中到底应该选择哪种方案呢?下一篇文章让我们一起看看HBase社区发布的BlockCache报告!

Categories:BucketCache, HBase, LRUBlockCacheTags:blockcache

HBase – RegionServer宕机案件侦查

April 15th, 2016范欣欣   阅读(0)Comments off

本来静谧的晚上,吃着葡萄干看着球赛,何等惬意。可偏偏一条报警短信如闪电一般打破了夜晚的宁静,线上集群一台RS宕了!于是倏地从床上坐起来,看了看监控,瞬间惊呆了:单台机器的读写吞吐量竟然达到了5w ops/sec!RS宕机是因为这么大的写入量造成的?如果真是这样,它是怎么造成的?如果不是这样,那又是什么原因?各种疑问瞬间从脑子里一一闪过,甭管那么多,先把日志备份一份,再把RS拉起来。接下来还是Bug排查老套路:日志、监控和源码三管齐下,来看看到底发生了什么!

案件现场篇

下图是使用监控工具Ganglia对事发RegionServer当时读写吞吐量的监控曲线,从图中可以看出,大约在19点~21点半的时间段内,这台RS的吞吐量都维持了3w ops/sec左右,峰值更是达到了6w ops/sec。之前我们就线上单台RS能够承受的最大读写吞吐量进行过测定,基本也就维持在2w左右,主要是因为网络带宽瓶颈。而在宕机前这台RS的读写吞吐量超出这么多,直觉告诉我RS宕机原因就是它!


接着就赶紧把日志拉出来看,满屏的responseTooSlow,如下图所示:

002

很显然,这种异常最大可能原因就是Full GC,果然,经过耐心地排查,可以看到很多如下所示的Full GC日志片段:

2016-04-14 21:27:13,174 WARN  [JvmPauseMonitor] util.JvmPauseMonitor: Detected pause in JVM or host machine (eg GC): pause of approximately 20542msGC pool 'ParNew' had collection(s): count=1 time=0msGC pool 'ConcurrentMarkSweep' had collection(s): count=2 time=20898ms2016-04-14 21:27:13,174 WARN  [regionserver60020.periodicFlusher] util.Sleeper: We slept 20936ms instead of 100ms, this is likely due to a long garbage collecting pause and it's usually bad, see http://hbase.apache.org/book.html#trouble.rs.runtime.zkexpired

可以看出,HBase执行了一次CMS GC,导致整个进程所有线程被挂起了20s。通过对MemStore的监控也可以看出这段时间GC力度之大,如下图所示:


GC时间长最明显的危害是会造成上层业务的阻塞,通过日志也可以看出些许端倪:

java.io.IOException: Connection reset by peer        at sun.nio.ch.FileDispatcherImpl.read0(Native Method)        at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:39)        at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)        at sun.nio.ch.IOUtil.read(IOUtil.java:197)        at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:384)        at org.apache.hadoop.hbase.ipc.RpcServer.channelRead(RpcServer.java:2246)        at org.apache.hadoop.hbase.ipc.RpcServer$Connection.readAndProcess(RpcServer.java:1496)....2016-04-14 21:32:40,173 WARN  [B.DefaultRpcServer.handler=125,queue=5,port=60020] ipc.RpcServer: RpcServer.respondercallId: 7540 service: ClientService methodName: Multi size: 100.2 K connection: 10.160.247.139:56031: output error2016-04-14 21:32:40,173 WARN  [B.DefaultRpcServer.handler=125,queue=5,port=60020] ipc.RpcServer: B.DefaultRpcServer.handler=125,queue=5,port=60020: caught a ClosedChannelException, this means that the server was processing a request but the client went away. The error message was: null

上述日志表示HBase服务端因为Full GC导致一直无法响应用户请求,用户客户端程序在一定时间过后就会SocketTimeout并断掉此Connection。连接断掉之后,服务器端就会打印如上日志。然而,这些和我们的终极目标好像并没有太大关系,别忘了我们的目标是找到RS宕机的原因哦!

破案铺垫篇

经过对案件现场的排查,唯一有用的线索就是HBase在宕机前经历了很严重、很频繁的Full GC,从下面日志可以进一步看出,这些Full GC都是在 concurrent mode failure模式下发生的,也就是虚拟机还未执行完本次GC的情况下又来了大量数据导致JVM内存不够,此时虚拟机会将所有用户线程挂起,执行长时间的Full GC!

(concurrent mode failure): 45876255K->21800674K(46137344K), 10.0625300 secs] 48792749K->21800674K(49283072K), [CMS Perm : 43274K->43274K(262144K)], 10.2083040 secs] [Times: user=12.02 sys=0.00, real=10.20 secs]2016-04-14 21:22:43,990 WARN  [JvmPauseMonitor] util.JvmPauseMonitor: Detected pause in JVM or host machine (eg GC): pause of approximately 10055msGC pool 'ParNew' had collection(s): count=2 time=244msGC pool 'ConcurrentMarkSweep' had collection(s): count=1 time=10062ms

上文提到Full GC会对上层业务产生很严重的影响,那有没有可能会对下层依赖方也产生很大的影响呢?事实是Yes!而且,RS宕机的大部分原因也要归咎于此!

进一步查看日志,发现HBase日志中出现下述异常:

2016-04-14 21:22:44,006 WARN  [ResponseProcessor for block BP-632656502-10.160.173.93-1448595094942:blk_1073941840_201226] hdfs.DFSClient: DFSOutputStream ResponseProcessor exception  for block BP-632656502-10.160.173.93-1448595094942:blk_1073941840_201226
java.io.IOException: Bad response ERROR for block BP-632656502-10.160.173.93-1448595094942:blk_1073941840_201226 from datanode 10.160.173.93:50010at org.apache.hadoop.hdfs.DFSOutputStream$DataStreamer$ResponseProcessor.run(DFSOutputStream.java:732)

从日志内容来看应该是hbase调用DFSClient从datanode加载block数据”BP-632656502-10.160.173.93-1448595094942:blk_1073941840_201226″,但是datanode返回失败。具体失败原因需要查看datanode节点日志,如下所示:

2016-04-14 21:22:43,789 INFO org.apache.hadoop.hdfs.server.datanode.DataNode: opWriteBlock BP-632656502-10.160.173.93-1448595094942:blk_1073941840_201226 received exception java.net.SocketTimeoutException: 10000 millis timeout while waiting for channel to be ready for read. ch : java.nio.channels.SocketChannel[connected local=/10.160.173.94:50010 remote=/10.160.173.94:30110]2016-04-14 21:22:43,779 ERROR org.apache.hadoop.hdfs.server.datanode.DataNode: hz-hbase4.photo.163.org:50010:DataXceiver error processing WRITE_BLOCK operation  src: /10.160.173.94:30123 dest: /10.160.173.94:50010java.net.SocketTimeoutException: 10000 millis timeout while waiting for channel to be ready for read. ch : java.nio.channels.SocketChannel[connected local=/10.160.173.94:50010 remote=/10.160.173.94:30123]

 

很显然,从日志可以看出,datanode一直在等待来自客户端的read请求,但是直至SocketTimeout,请求都没有过来,此时datanode会将该连接断开,导致客户端收到上述”Bad response ERROR ***”的异常。

那这和Full GC有什么关系呢?很简单,就是因为Full GC导致HBase所有内部线程挂起,因此发往datanode的read请求也被挂起了,datanode就等啊等,左等右等都等不到,万不得已才将连接断掉。

查看Hadoop客户端源码可知,如果DFSClient发生上述异常,DFSClient会将一个全局标志errorIndex设为一个非零值。具体可参见DFSOutputStream类中如下代码片段:

004

破案结局篇

上述铺垫篇最后的结果就是Hadoop客户端会将一个全局标志errorIndex设为一个非零值,那这到底和最终RS宕掉有什么关系呢?来继续往下看。下图HBase日志相关片段截图,记录了比较详细的RS宕机异常信息,我们就以这些异常信息作为切入点进行分析,可以看出至少三条有用的线索,如下图所示:



005

线索一:RS宕机最直接的原因是因为系统在关闭LogWriter(之后会重新开启一个新的HLog)的时候失败

线索二:执行LogWriter关闭失败的原因是”writing trailer”时发生IOException异常

线索三:而发生IOException异常的原因是”All datanodes *** are bad”

到这里为止,我们能够获得的最靠谱的情报就是RS宕机本质是因为”All datanodes *** are bad”造成的,看字面意思就是这台datanode因为某种原因坏掉了,那我们赶紧去看看datanode的日志,看看那个时间段有没有相关的异常或者错误日志。

然而很遗憾,datanode日志在那个时间点没有打印任何异常或者错误日志,而且显示所有服务都正常,信息如下所示:

2016-04-14 21:32:38,893 INFO org.apache.hadoop.hdfs.server.datanode.DataNode.clienttrace: src: 127.0.0.1, dest: 127.0.0.1, op: REQUEST_SHORT_CIRCUIT_FDS, blockid: 1073941669, srvID: DS-22834907-10.160.173.94-50010-1448595406972, success: true2016-04-14 21:32:38,894 INFO org.apache.hadoop.hdfs.server.datanode.DataNode.clienttrace: src: 127.0.0.1, dest: 127.0.0.1, op: REQUEST_SHORT_CIRCUIT_FDS, blockid: 1073941669, srvID: DS-22834907-10.160.173.94-50010-1448595406972, success: true...

看到这里,是不是有点蒙圈:HBase日志里面明明打印说这台datanode坏掉了,但是实际datanode日志显示服务一切正常。这个时候就得翻翻源码了,看看HBase在哪里打印的”All datanodes *** are bad“,通过查看源码,可以看出最终元凶就是上文提到的errorIndex,如下图所示:

006

终于拨开天日了,再不完结就要晕了!上文铺垫篇铺垫到最后就得出来因为Full GC最终导致DFSClient将一个全局标志errorIndex设为一个非零值,在这里终于碰头了,简直泪流满面!

案件梳理篇

整个流程走下来本人都有点晕晕的,涉及的方方面面太多,因此有必要把整个流程完整的梳理一遍,下面简单画了一个示意图:



007

经过对整个案件的整理分析,一方面再次锻炼了如何通过监控、日志以及源码定位排查问题的功底,另一方面在HBase运维过程中也需要特别关注如下几点:

1. Full GC不仅会严重影响上层业务,造成业务读写请求的卡顿。另外还有可能造成与HDFS之间数据请求的各种异常,这种异常严重的时候甚至会导致RegionServer宕机。

2. 上文中提到Full GC基本是由于Concurrent Mode Failure造成,这种Full GC场景比较少见,通常可以通过减小 JVM 参数XX:CMSInitiatingOccupancyFraction来避免,这个参数用来设置CMS垃圾回收时机,假如此时设置为60,表示JVM内已使用内存占到总内存的60%的时候就会进行垃圾回收,减少该值可以使得垃圾回收更早进行

3. 一定要严格限制业务层面的流量。一方面需要和业务方交流,由业务方进行限制,另一方面可以探索HBase业务资源隔离的新方案;

Categories:HBase, RegionServer, 宕机Tags:

HBase BlockCache系列 – 走进BlockCache

April 14th, 2016fan xinxin   阅读(191)No comments

和其他数据库一样,优化IO也是HBase提升性能的不二法宝,而提供缓存更是优化的重中之重。最理想的情况是,所有数据都能够缓存到内存,这样就不会有任何文件IO请求,读写性能必然会提升到极致。然而现实是残酷的,随着请求数据的不断增多,将数据全部缓存到内存显得不合实际。幸运的是,我们并不需要将所有数据都缓存起来,根据二八法则,80%的业务请求都集中在20%的热点数据上,因此将这部分数据缓存起就可以极大地提升系统性能。

HBase在实现中提供了两种缓存结构:MemStore和BlockCache。其中MemStore称为写缓存,HBase执行写操作首先会将数据写入MemStore,并顺序写入HLog,等满足一定条件后统一将MemStore中数据刷新到磁盘,这种设计可以极大地提升HBase的写性能。不仅如此,MemStore对于读性能也至关重要,假如没有MemStore,读取刚写入的数据就需要从文件中通过IO查找,这种代价显然是昂贵的!BlockCache称为读缓存,HBase会将一次文件查找的Block块缓存到Cache中,以便后续同一请求或者邻近数据查找请求,可以直接从内存中获取,避免昂贵的IO操作。MemStore相关知识可以戳这里,本文将重点分析BlockCache。

在介绍BlockCache之前,简单地回顾一下HBase中Block的概念,详细介绍戳这里。 Block是HBase中最小的数据存储单元,默认为64K,在建表语句中可以通过参数BlockSize指定。HBase中Block分为四种类型:Data Block,Index Block,Bloom Block和Meta Block。其中Data Block用于存储实际数据,通常情况下每个Data Block可以存放多条KeyValue数据对;Index Block和Bloom Block都用于优化随机读的查找路径,其中Index Block通过存储索引数据加快数据查找,而Bloom Block通过一定算法可以过滤掉部分一定不存在待查KeyValue的数据文件,减少不必要的IO操作;Meta Block主要存储整个HFile的元数据。

BlockCache是Region Server级别的,一个Region Server只有一个Block Cache,在Region Server启动的时候完成Block Cache的初始化工作。到目前为止,HBase先后实现了3种Block Cache方案,LRUBlockCache是最初的实现方案,也是默认的实现方案;HBase 0.92版本实现了第二种方案SlabCache,见HBASE-4027;HBase 0.96之后官方提供了另一种可选方案BucketCache,见HBASE-7404

这三种方案的不同之处在于对内存的管理模式,其中LRUBlockCache是将所有数据都放入JVM Heap中,交给JVM进行管理。而后两者采用了不同机制将部分数据存储在堆外,交给HBase自己管理。这种演变过程是因为LRUBlockCache方案中JVM垃圾回收机制经常会导致程序长时间暂停,而采用堆外内存对数据进行管理可以有效避免这种情况发生。

LRUBlockCache

HBase默认的BlockCache实现方案。Block数据块都存储在 JVM heap内,由JVM进行垃圾回收管理。它将内存从逻辑上分为了三块:single-access区、mutil-access区、in-memory区,分别占到整个BlockCache大小的25%、50%、25%。一次随机读中,一个Block块从HDFS中加载出来之后首先放入signle区,后续如果有多次请求访问到这块数据的话,就会将这块数据移到mutil-access区。而in-memory区表示数据可以常驻内存,一般用来存放访问频繁、数据量小的数据,比如元数据,用户也可以在建表的时候通过设置列族属性IN-MEMORY= true将此列族放入in-memory区。很显然,这种设计策略类似于JVM中young区、old区以及perm区。无论哪个区,系统都会采用严格的Least-Recently-Used算法,当BlockCache总量达到一定阈值之后就会启动淘汰机制,最少使用的Block会被置换出来,为新加载的Block预留空间。

SlabCache

为了解决LRUBlockCache方案中因为JVM垃圾回收导致的服务中断,SlabCache方案使用Java NIO DirectByteBuffer技术实现了堆外内存存储,不再由JVM管理数据内存。默认情况下,系统在初始化的时候会分配两个缓存区,分别占整个BlockCache大小的80%和20%,每个缓存区分别存储固定大小的Block块,其中前者主要存储小于等于64K大小的Block,后者存储小于等于128K Block,如果一个Block太大就会导致两个区都无法缓存。和LRUBlockCache相同,SlabCache也使用Least-Recently-Used算法对过期Block进行淘汰。和LRUBlockCache不同的是,SlabCache淘汰Block的时候只需要将对应的bufferbyte标记为空闲,后续cache对其上的内存直接进行覆盖即可。

线上集群环境中,不同表不同列族设置的BlockSize都可能不同,很显然,默认只能存储两种固定大小Block的SlabCache方案不能满足部分用户场景,比如用户设置BlockSize = 256K,简单使用SlabCache方案就不能达到这部分Block缓存的目的。因此HBase实际实现中将SlabCache和LRUBlockCache搭配使用,称为DoubleBlockCache。一次随机读中,一个Block块从HDFS中加载出来之后会在两个Cache中分别存储一份;缓存读时首先在LRUBlockCache中查找,如果Cache Miss再在SlabCache中查找,此时如果命中再将该Block放入LRUBlockCache中。

经过实际测试,DoubleBlockCache方案有很多弊端。比如SlabCache设计中固定大小内存设置会导致实际内存使用率比较低,而且使用LRUBlockCache缓存Block依然会因为JVM GC产生大量内存碎片。因此在HBase 0.98版本之后,该方案已经被不建议使用。

BucketCache

SlabCache方案在实际应用中并没有很大程度改善原有LRUBlockCache方案的GC弊端,还额外引入了诸如堆外内存使用率低的缺陷。然而它的设计并不是一无是处,至少在使用堆外内存这个方面给予了阿里大牛们很多启发。站在SlabCache的肩膀上,他们开发了BucketCache缓存方案并贡献给了社区。

BucketCache通过配置可以工作在三种模式下:heap,offheap和file。无论工作在那种模式下,BucketCache都会申请许多带有固定大小标签的Bucket,和SlabCache一样,一种Bucket存储一种指定BlockSize的数据块,但和SlabCache不同的是,BucketCache会在初始化的时候申请14个不同大小的Bucket,而且即使在某一种Bucket空间不足的情况下,系统也会从其他Bucket空间借用内存使用,不会出现内存使用率低的情况。接下来再来看看不同工作模式,heap模式表示这些Bucket是从JVM Heap中申请,offheap模式使用DirectByteBuffer技术实现堆外内存存储管理,而file模式使用类似SSD的高速缓存文件存储数据块。

实际实现中,HBase将BucketCache和LRUBlockCache搭配使用,称为CombinedBlockCache。和DoubleBlockCache不同,系统在LRUBlockCache中主要存储Index Block和Bloom Block,而将Data Block存储在BucketCache中。因此一次随机读需要首先在LRUBlockCache中查到对应的Index Block,然后再到BucketCache查找对应数据块。BucketCache通过更加合理的设计修正了SlabCache的弊端,极大降低了JVM GC对业务请求的实际影响,但也存在一些问题,比如使用堆外内存会存在拷贝内存的问题,一定程度上会影响读写性能。当然,在后来的版本中这个问题也得到了解决,见HBASE-11425

本文是HBase BlockCache系列文章的第一篇,主要概述了HBase中MemStore和BlockCache,再分别对三种BlockCache方案进行了基本介绍。接下来第二篇文章会主要对LRUBlockCache和BucketCache两种方案进行详细的介绍,敬请期待!

Categories:HBaseTags:blockcache, hbase

MySQL X Plugin来也

April 12th, 2016AskInside   阅读(0)Comments off

MySQL 5.7.12版本发布,虽然之前5.7已经GA,但这个版本依然承上启下,举足轻重,因为MySQL X Plugin来了。

X Plugin extends MySQL Server to be able to function as a d[......]

阅读全文

Categories:MySQLTags:

HBase BlockCache系列 – 走进BlockCache

April 8th, 2016范欣欣   阅读(0)Comments off

和其他数据库一样,优化IO也是HBase提升性能的不二法宝,而提供缓存更是优化的重中之重。最理想的情况是,所有数据都能够缓存到内存,这样就不会有任何文件IO请求,读写性能必然会提升到极致。然而现实是残酷的,随着请求数据的不断增多,将数据全部缓存到内存显得不合实际。幸运的是,我们并不需要将所有数据都缓存起来,根据二八法则,80%的业务请求都集中在20%的热点数据上,因此将这部分数据缓存起就可以极大地提升系统性能。

HBase在实现中提供了两种缓存结构:MemStore和BlockCache。其中MemStore称为写缓存,HBase执行写操作首先会将数据写入MemStore,并顺序写入HLog,等满足一定条件后统一将MemStore中数据刷新到磁盘,这种设计可以极大地提升HBase的写性能。不仅如此,MemStore对于读性能也至关重要,假如没有MemStore,读取刚写入的数据就需要从文件中通过IO查找,这种代价显然是昂贵的!BlockCache称为读缓存,HBase会将一次文件查找的Block块缓存到Cache中,以便后续同一请求或者邻近数据查找请求,可以直接从内存中获取,避免昂贵的IO操作。MemStore相关知识可以戳这里,本文将重点分析BlockCache。

在介绍BlockCache之前,简单地回顾一下HBase中Block的概念,详细介绍戳这里。 Block是HBase中最小的数据存储单元,默认为64K,在建表语句中可以通过参数BlockSize指定。HBase中Block分为四种类型:Data Block,Index Block,Bloom Block和Meta Block。其中Data Block用于存储实际数据,通常情况下每个Data Block可以存放多条KeyValue数据对;Index Block和Bloom Block都用于优化随机读的查找路径,其中Index Block通过存储索引数据加快数据查找,而Bloom Block通过一定算法可以过滤掉部分一定不存在待查KeyValue的数据文件,减少不必要的IO操作;Meta Block主要存储整个HFile的元数据。

BlockCache是Region Server级别的,一个Region Server只有一个Block Cache,在Region Server启动的时候完成Block Cache的初始化工作。到目前为止,HBase先后实现了3种Block Cache方案,LRUBlockCache是最初的实现方案,也是默认的实现方案;HBase 0.92版本实现了第二种方案SlabCache,见HBASE-4027;HBase 0.96之后官方提供了另一种可选方案BucketCache,见HBASE-7404

这三种方案的不同之处在于对内存的管理模式,其中LRUBlockCache是将所有数据都放入JVM Heap中,交给JVM进行管理。而后两者采用了不同机制将部分数据存储在堆外,交给HBase自己管理。这种演变过程是因为LRUBlockCache方案中JVM垃圾回收机制经常会导致程序长时间暂停,而采用堆外内存对数据进行管理可以有效避免这种情况发生。

LRUBlockCache

HBase默认的BlockCache实现方案。Block数据块都存储在 JVM heap内,由JVM进行垃圾回收管理。它将内存从逻辑上分为了三块:single-access区、mutil-access区、in-memory区,分别占到整个BlockCache大小的25%、50%、25%。一次随机读中,一个Block块从HDFS中加载出来之后首先放入signle区,后续如果有多次请求访问到这块数据的话,就会将这块数据移到mutil-access区。而in-memory区表示数据可以常驻内存,一般用来存放访问频繁、数据量小的数据,比如元数据,用户也可以在建表的时候通过设置列族属性IN-MEMORY= true将此列族放入in-memory区。很显然,这种设计策略类似于JVM中young区、old区以及perm区。无论哪个区,系统都会采用严格的Least-Recently-Used算法,当BlockCache总量达到一定阈值之后就会启动淘汰机制,最少使用的Block会被置换出来,为新加载的Block预留空间。

SlabCache

为了解决LRUBlockCache方案中因为JVM垃圾回收导致的服务中断,SlabCache方案使用Java NIO DirectByteBuffer技术实现了堆外内存存储,不再由JVM管理数据内存。默认情况下,系统在初始化的时候会分配两个缓存区,分别占整个BlockCache大小的80%和20%,每个缓存区分别存储固定大小的Block块,其中前者主要存储小于等于64K大小的Block,后者存储小于等于128K Block,如果一个Block太大就会导致两个区都无法缓存。和LRUBlockCache相同,SlabCache也使用Least-Recently-Used算法对过期Block进行淘汰。和LRUBlockCache不同的是,SlabCache淘汰Block的时候只需要将对应的bufferbyte标记为空闲,后续cache对其上的内存直接进行覆盖即可。

线上集群环境中,不同表不同列族设置的BlockSize都可能不同,很显然,默认只能存储两种固定大小Block的SlabCache方案不能满足部分用户场景,比如用户设置BlockSize = 256K,简单使用SlabCache方案就不能达到这部分Block缓存的目的。因此HBase实际实现中将SlabCache和LRUBlockCache搭配使用,称为DoubleBlockCache。一次随机读中,一个Block块从HDFS中加载出来之后会在两个Cache中分别存储一份;缓存读时首先在LRUBlockCache中查找,如果Cache Miss再在SlabCache中查找,此时如果命中再将该Block放入LRUBlockCache中。

经过实际测试,DoubleBlockCache方案有很多弊端。比如SlabCache设计中固定大小内存设置会导致实际内存使用率比较低,而且使用LRUBlockCache缓存Block依然会因为JVM GC产生大量内存碎片。因此在HBase 0.98版本之后,该方案已经被不建议使用。

BucketCache

SlabCache方案在实际应用中并没有很大程度改善原有LRUBlockCache方案的GC弊端,还额外引入了诸如堆外内存使用率低的缺陷。然而它的设计并不是一无是处,至少在使用堆外内存这个方面给予了阿里大牛们很多启发。站在SlabCache的肩膀上,他们开发了BucketCache缓存方案并贡献给了社区。

BucketCache通过配置可以工作在三种模式下:heap,offheap和file。无论工作在那种模式下,BucketCache都会申请许多带有固定大小标签的Bucket,和SlabCache一样,一种Bucket存储一种指定BlockSize的数据块,但和SlabCache不同的是,BucketCache会在初始化的时候申请14个不同大小的Bucket,而且即使在某一种Bucket空间不足的情况下,系统也会从其他Bucket空间借用内存使用,不会出现内存使用率低的情况。接下来再来看看不同工作模式,heap模式表示这些Bucket是从JVM Heap中申请,offheap模式使用DirectByteBuffer技术实现堆外内存存储管理,而file模式使用类似SSD的高速缓存文件存储数据块。

实际实现中,HBase将BucketCache和LRUBlockCache搭配使用,称为CombinedBlockCache。和DoubleBlockCache不同,系统在LRUBlockCache中主要存储Index Block和Bloom Block,而将Data Block存储在BucketCache中。因此一次随机读需要首先在LRUBlockCache中查到对应的Index Block,然后再到BucketCache查找对应数据块。BucketCache通过更加合理的设计修正了SlabCache的弊端,极大降低了JVM GC对业务请求的实际影响,但也存在一些问题,比如使用堆外内存会存在拷贝内存的问题,一定程度上会影响读写性能。当然,在后来的版本中这个问题也得到了解决,见HBASE-11425

本文是HBase BlockCache系列文章的第一篇,主要概述了HBase中MemStore和BlockCache,再分别对三种BlockCache方案进行了基本介绍。接下来第二篇文章会主要对LRUBlockCache和BucketCache两种方案进行详细的介绍,敬请期待!。

Categories:BucketCache, HBase, LRUBlockCache, SlabCacheTags:blockcache

开源MySQL多线程逻辑导入工具myloader原理与改进

April 5th, 2016wzpywzh   阅读(0)Comments off

在上一篇中,介绍了多线程备份工具mydumper的实现及网易对其所做的优化,本篇聊聊与mydumper配合使用的myloader工具。

myloader是MySQL领域少有的多线程的恢复工具,为了能够更好的理解其如何进行工作,有必要对mydumper[……]

阅读全文

Categories:MySQLTags:

支持redis节点高可用的twemproxy

April 5th, 2016何李夫   阅读(419)No comments

原生twemporxy

twemproxy支持一个proxy实例同时代理多个分布式集群(server pools),每个集群使用不同的网络端口实现数据流的隔离,下图中port1应用于cluster1代理,port2应用于cluster2代理:

今天要介绍的是twemproxy对redis节点高可用的支持,拿上图的其中一个分布式集群进行示例,逻辑结构如下:

客户端client流入的请求,在proxy上进行路由分片,然后转发到后端的redis节点上存储或者读取。事实上,大家已经注意到后端的redis节点只有一个点,在出现异常情况下,是很容易掉线的。按twemproxy的设计,它可以自动识别失效节点并将其剔除,同时落在原来节点上的请求会分摊到其余的节点上。这是分布式缓存系统的一种通用做法,但需要忍受这个失效节点上的数据丢失,这种情况是否可以接受?

在业内,redis虽然被定位为缓存系统,但事实上,无论哪种业务场景(我们接触过的)都不愿意接受节点掉线带来的数据丢失,因为那样对他们系统的影响实在太大了,更有甚者在压力大的时候引起后端数据库被击穿的风险。所以,我们打算改造twemproxy,前后总共有几个版本,下面分享给各位的是我们目前线上在跑的版本。

定制化改造

在上图的基础上,我们增加了与manager交互的模块、增加了与sentinel(redis-sentinel)交互的模块,修改了redis连接管理模块,图中三个红色虚线框所示:

manager交互模块

增加连接manager的客户端交互模块,用于发送心跳消息,从心跳应答包里获取group名称列表和sentinel列表(IP/PORT信息),即整个分布式集群的配置信息,其中心跳消息带有版本信息,发送间隔可配置。

sentinel交互模块

增加与sentinel客户端交互模块(IP/PORT信息来自于manager),发送group名称给sentinel获取redis主节点的IP/PORT信息,一个group对应一个主节点。取到所有主节点后,订阅主从切换频道,获取切换消息用于触发proxy和主节点间的连接切换。这里需要解析sentinel的响应消息,会比较繁琐一些。当proxy开始与sentinel节点的交互过程,需要启动定时器,用以控制交互结果,当定时器超时交互未结束(或者proxy未正常工作),proxy将主动切换到下一个sentinel节点,并启动新的交互过程。考虑到proxy与sentinel之间网络连接的重要性(连接假死,proxy收不到主从切换消息,不能正常切换),增加了定时心跳机制,确保这条TCP链路的可用性。

redis连接管理模块

原先redis节点的IP/PORT信息来自于静态配置文件,是固定的,而改造以后这些信息是从sentinel节点获取。为了确保获取到的IP/PORT信息的准确性,需要向IP/PORT对应的节点验证是否是主节点的逻辑,只有返回确认是主节点,才认为是合法的。整个过程,按官方指导实现,不存在漏洞。

详细消息流

为了清晰的描述proxy的内部处理逻辑,制作了如下消息流图:

绿色为业务通道,用于透传业务层数据;

紫色为命令通道(红线的细化),用于初始化和节点主从切换:

  • 箭头1:manager heartbeat req;
  • 箭头2:manager heartbeat rsp;
  • 箭头3:sentinel get-master-addr-by-name req;
  • 箭头4:sentinel get-master-addr-by-name rsp;
  • 箭头5:redis auth & role req;
  • 箭头6:redis auth & role rsp;
  • 箭头7:sentinel psubscribe +switch-master req;
  • 箭头8:sentinel psubscribe +switch-master rsp;
  • 箭头9:sentinel pmessage;
  • 命令通道命令顺序按数字1-8进行,7/8是proxy与sentinel的心跳消息,9是主从切换消息;

高可用影响面分析

  • 在sentinel节点切换的过程中,存在proxy正在对外提供业务服务的状态,这时候正在处理的数据将继续处理,不会受到影响,而新接入的客户端连接将会被拒绝,已有的客户端连接上的新的业务请求数据也会被拒绝。sentinel节点切换,对系统的影响是毫秒级别,前面的设计对业务系统来讲会显得比较友好、不那么粗鲁;
  • 而redis节点的主从切换对系统的影响,主要集中在proxy发现主节点异常到sentinel集群做出主从切换这个过程,这段时间内落在该节点上的业务都将失败,而该时间段的长度主要依赖在sentinel节点上的down-after-milliseconds配置字段;

经验总结

  • 作为代理中间件,支持pipeline的能力有限,容易产生消息积压,导致客户端大量超时,所以慎用pipeline功能;
  • 高负荷下容易吃内存,struct msg和struct mbuf对象会被大量缓存在进程内(内存池化);
  • zero copy,对于多个连续请求(TCP粘包)进行拆分,拷贝是无法避免的,但是有优化空间;
问卷调查

Read more…

Categories:redisTags:

HBase – 探索HFile索引机制

April 3rd, 2016范欣欣   阅读(0)Comments off

HFile索引结构解析

HFile中索引结构根据索引层级的不同分为两种:single-level和mutil-level,前者表示单层索引,后者表示多级索引,一般为两级或三级。HFile V1版本中只有single-level一种索引结构,V2版本中引入多级索引。之所以引入多级索引,是因为随着HFile文件越来越大,Data Block越来越多,索引数据也越来越大,已经无法全部加载到内存中(V1版本中一个Region Server的索引数据加载到内存会占用几乎6G空间),多级索引可以只加载部分索引,降低内存使用空间。上一篇文章 《HBase-存储文件HFile结构解析》,我们提到Bloom Filter内存使用问题是促使V1版本升级到V2版本的一个原因,再加上这个原因,这两个原因就是V1版本升级到V2版本最重要的两个因素。

V2版本Index Block有两类:Root Index Block和NonRoot Index Block,其中NonRoot Index Block又分为Intermediate Index Block和Leaf Index Block两种。HFile中索引结构类似于一棵树,Root Index Block表示索引数根节点,Intermediate Index Block表示中间节点,Leaf Index block表示叶子节点,叶子节点直接指向实际数据块。

HFile中除了Data Block需要索引之外,上一篇文章提到过Bloom Block也需要索引,索引结构实际上就是采用了single-level结构,文中Bloom Index Block就是一种Root Index Block。

对于Data Block,由于HFile刚开始数据量较小,索引采用single-level结构,只有Root Index一层索引,直接指向数据块。当数据量慢慢变大,Root Index Block满了之后,索引就会变为mutil-level结构,由一层索引变为两层,根节点指向叶子节点,叶子节点指向实际数据块。如果数据量再变大,索引层级就会变为三层。

下面就针对Root index Block和NonRoot index Block两种结构进行解析,因为Root Index Block已经在上面一篇文章中分析过,此处简单带过,重点介绍NonRoot Index Block结构(InterMediate Index Block和Ieaf Index Block在内存和磁盘中存储格式相同,都为NonRoot Index Block格式)。

Root Index Block

Root Index Block表示索引树根节点索引块,可以作为bloom的直接索引,也可以作为data索引的根索引。而且对于single-level和mutil-level两种索引结构对应的Root Index Block略有不同,本文以mutil-level索引结构为例进行分析(single-level索引结构是mutual-level的一种简化场景),在内存和磁盘中的格式如下图所示:

22

其中Index Entry表示具体的索引对象,每个索引对象由3个字段组成,Block Offset表示索引指向数据块的偏移量,BlockDataSize表示索引指向数据块在磁盘上的大小,BlockKey表示索引指向数据块中的第一个key。除此之外,还有另外3个字段用来记录MidKey的相关信息,MidKey表示HFile所有Data Block中中间的一个Data Block,用于在对HFile进行split操作时,快速定位HFile的中间位置。需要注意的是single-level索引结构和mutil-level结构相比,就只缺少MidKey这三个字段。

Root Index Block会在HFile解析的时候直接加载到内存中,此处需要注意在Trailer Block中有一个字段为dataIndexCount,就表示此处Index Entry的个数。因为Index Entry并不定长,只有知道Entry的个数才能正确的将所有Index Entry加载到内存。

NonRoot Index Block

当HFile中Data Block越来越多,single-level结构的索引已经不足以支撑所有数据都加载到内存,需要分化为mutil-level结构。mutil-level结构中NonRoot Index Block作为中间层节点或者叶子节点存在,无论是中间节点还是叶子节点,其都拥有相同的结构,如下图所示:

23

和Root Index Block相同,NonRoot Index Block中最核心的字段也是Index Entry,用于指向叶子节点块或者数据块。不同的是,NonRoot Index Block结构中增加了block块的内部索引entry Offset字段,entry Offset表示index Entry在该block中的相对偏移量(相对于第一个index Entry),用于实现block内的二分查找。所有非根节点索引块,包括Intermediate index block和leaf index block,在其内部定位一个key的具体索引并不是通过遍历实现,而是使用二分查找算法,这样可以更加高效快速地定位到待查找key。

HFile数据完整索引流程

了解了HFile中数据索引块的两种结构之后,就来看看如何使用这些索引数据块进行数据的高效检索。整个索引体系类似于MySQL的B+树结构,但是又有所不同,比B+树简单,并没有复杂的分裂操作。具体见下图所示:

24

图中上面三层为索引层,在数据量不大的时候只有最上面一层,数据量大了之后开始分裂为多层,最多三层,如图所示。最下面一层为数据层,存储用户的实际keyvalue数据。这个索引树结构类似于InnoSQL的聚集索引,只是HBase并没有辅助索引的概念。

图中红线表示一次查询的索引过程(HBase中相关类为HFileBlockIndex和HFileReaderV2),基本流程可以表示为:

1. 用户输入rowkey为fb,在root index block中通过二分查找定位到fb在’a’和’m’之间,因此需要访问索引’a’指向的中间节点。因为root index block常驻内存,所以这个过程很快。

2. 将索引’a’指向的中间节点索引块加载到内存,然后通过二分查找定位到fb在index ‘d’和’h’之间,接下来访问索引’d’指向的叶子节点。

3. 同理,将索引’d’指向的中间节点索引块加载到内存,一样通过二分查找定位找到fb在index ‘f’和’g’之间,最后需要访问索引’f’指向的数据块节点。

4. 将索引’f’指向的数据块加载到内存,通过遍历的方式找到对应的keyvalue。

上述流程中因为中间节点、叶子节点和数据块都需要加载到内存,所以io次数正常为3次。但是实际上HBase为block提供了缓存机制,可以将频繁使用的block缓存在内存中,可以进一步加快实际读取过程。所以,在HBase中,通常一次随机读请求最多会产生3次io,如果数据量小(只有一层索引),数据已经缓存到了内存,就不会产生io。

索引块分裂

上文中已经提到,当数据量少、文件小的时候,只需要一个root index block就可以完成索引,即索引树只有一层。当数据不断写入,文件变大之后,索引数据也会相应变大,索引结构就会由single-level变为mulit-level,期间涉及到索引块的写入和分裂,本节来关注一下数据写入是如何引起索引块分裂的。

如果大家之前看过HBase系列另一篇博文《HBase数据写入之Memstore Flush》,可以知道memstore flush主要分为3个阶段,第一个阶段会讲memstore中的keyvalue数据snapshot,第二阶段再将这部分数据flush的HFile,并生成在临时目录,第三阶段将临时文件移动到指定的ColumnFamily目录下。很显然,第二阶段将keyvalue数据flush到HFile将会是关注的重点(flush相关代码在DefaultStoreFlusher类中)。整个flush阶段又可以分为两阶段:

1. append阶段:memstore中keyvalue首先会写入到HFile中数据块

2. finalize阶段:修改HFlie中meta元数据块,索引数据块以及Trailer数据块等

append流程

具体keyvalue数据的append以及finalize过程在HFileWriterV2文件中,其中append流程可以大体表征为:

25

a. 预检查:检查key的大小是否大于前一个key,如果大于则不符合HBase顺序排列的原理,抛出异常;检查value是否是null,如果为null也抛出异常

b. block是否写满:检查当前Data Block是否已经写满,如果没有写满就直接写入keyvalue;否则就需要执行数据块落盘以及索引块修改操作;

c. 数据落盘并修改索引:如果DataBlock写满,首先将block块写入流;再生成一个leaf index entry,写入leaf Index block;再检查该leaf index block是否已经写满需要落盘,如果已经写满,就将该leaf index block写入到输出流,并且为索引树根节点root index block新增一个索引,指向叶子节点(second-level index)

d. 生成一个新的block:重新reset输出流,初始化startOffset为-1

e. 写入keyvalue:将keyvalue以流的方式写入输出流,同时需要写入memstoreTS;除此之外,如果该key是当前block的第一个key,需要赋值给变量firstKeyInBlock

finalize阶段

memstore中所有keyvalue都经过append阶段输出到HFile后,会执行一次finalize过程,主要更新HFile中meta元数据块、索引数据块以及Trailer数据块,其中对索引数据块的更新是我们关心的重点,此处详细解析,上述append流程中c步骤’数据落盘并修改索引’会使得root index block不断增多,当增大到一定程度之后就需要分裂,分裂示意图如下图所示:

26

上图所示,分裂前索引结构为second-level结构,图中没有画出Data Blocks,根节点索引指向叶子节点索引块。finalize阶段系统会对Root Index Block进行大小检查,如果大小大于规定的大小就需要进行分裂,图中分裂过程实际上就是将原来的Root Index Block块分割成4块,每块独立形成中间节点InterMediate Index Block,系统再重新生成一个Root Index Block(图中红色部分),分别指向分割形成的4个interMediate Index Block。此时索引结构就变成了third-level结构。

总结

这篇文章是HFile结构解析的第二篇文章,主要集中介绍HFile中的数据索引块。首先分Root Index Block和NonRoot Index Block两部分对HFile中索引块进行了解析,紧接着基于此介绍了HBase如何使用索引对数据进行检索,最后结合Memstore Flush的相关知识分析了keyvalue数据写入的过程中索引块的分裂过程。希望通过这两篇文章的介绍,能够对HBase中数据存储文件HFile有一个更加全面深入的认识。

Categories:HBaseTags:HFile, 索引

新一代列式存储格式Parquet

March 28th, 2016feng yu   阅读(387)No comments

Apache Parquet是Hadoop生态圈中一种新型列式存储格式,它可以兼容Hadoop生态圈中大多数计算框架(Hadoop、Spark等),被多种查询引擎支持(Hive、Impala、Drill等),并且它是语言和平台无关的。Parquet最初是由Twitter和Cloudera(由于Impala的缘故)合作开发完成并开源,2015年5月从Apache的孵化器里毕业成为Apache顶级项目,最新的版本是1.8.1。

Parquet是什么

Parquet的灵感来自于2010年Google发表的Dremel论文,文中介绍了一种支持嵌套结构的存储格式,并且使用了列式存储的方式提升查询性能,在Dremel论文中还介绍了Google如何使用这种存储格式实现并行查询的,如果对此感兴趣可以参考论文和开源实现Apache Drill。

嵌套数据模型

在接触大数据之前,我们简单的将数据划分为结构化数据和非结构化数据,通常我们使用关系数据库存储结构化数据,而关系数据库中使用数据模型都是扁平式的,遇到诸如List、Map和自定义Struct的时候就需要用户在应用层解析。但是在大数据环境下,通常数据的来源是服务端的埋点数据,很可能需要把程序中的某些对象内容作为输出的一部分,而每一个对象都可能是嵌套的,所以如果能够原生的支持这种数据,这样在查询的时候就不需要额外的解析便能获得想要的结果。例如在Twitter,在他们的生产环境中一个典型的日志对象(一条记录)有87个字段,其中嵌套了7层,如下图:
Twitter日志格式

另外,随着嵌套格式的数据的需求日益增加,目前Hadoop生态圈中主流的查询引擎都支持更丰富的数据类型,例如Hive、SparkSQL、Impala等都原生的支持诸如struct、map、array这样的复杂数据类型,这样也就使得诸如Parquet这种原生支持嵌套数据类型的存储格式也变得至关重要,性能也会更好。

列式存储

列式存储,顾名思义就是按照列进行存储数据,把某一列的数据连续的存储,每一行中的不同列的值离散分布。列式存储技术并不新鲜,在关系数据库中都已经在使用,尤其是在针对OLAP场景下的数据存储,由于OLAP场景下的数据大部分情况下都是批量导入,基本上不需要支持单条记录的增删改操作,而查询的时候大多数都是只使用部分列进行过滤、聚合,对少数列进行计算(基本不需要select * from xx之类的查询)。列式存储可以大大提升这类查询的性能,较之于行是存储,列式存储能够带来这些优化:

1、由于每一列中的数据类型相同,所以可以针对不同类型的列使用不同的编码和压缩方式,这样可以大大降低数据存储空间。
2、读取数据的时候可以把映射(Project)下推,只需要读取需要的列,这样可以大大减少每次查询的I/O数据量,更甚至可以支持谓词下推,跳过不满足条件的列。
3、由于每一列的数据类型相同,可以使用更加适合CPU pipeline的编码方式,减小CPU的缓存失效。

Parquet的组成

Parquet仅仅是一种存储格式,它是语言、平台无关的,并且不需要和任何一种数据处理框架绑定,目前能够和Parquet适配的组件包括下面这些,可以看出基本上通常使用的查询引擎和计算框架都已适配,并且可以很方便的将其它序列化工具生成的数据转换成Parquet格式。

查询引擎: Hive, Impala, Pig, Presto, Drill, Tajo, HAWQ, IBM Big SQL
计算框架: MapReduce, Spark, Cascading, Crunch, Scalding, Kite
数据模型: Avro, Thrift, Protocol Buffers, POJOs

项目组成

Parquet项目由以下几个子项目组成:

parquet-format项目由java实现,它定义了所有Parquet元数据对象,Parquet的元数据是使用Apache Thrift进行序列化并存储在Parquet文件的尾部。
parquet-mr项目由java实现,它包括多个模块,包括实现了读写Parquet文件的功能,并且提供一些和其它组件适配的工具,例如Hadoop Input/Output Formats、Hive Serde(目前Hive已经自带Parquet了)、Pig loaders等。
parquet-compatibility项目,包含不同编程语言之间(JAVA和C/C++)读写文件的测试代码。
parquet-cpp项目,它是用于用于读写Parquet文件的C++库。
下图展示了Parquet各个组件的层次以及从上到下交互的方式。
Parquet组成

数据存储层定义了Parquet的文件格式,其中元数据在parquet-format中定义,包括Parquet原始类型定义、Page类型、编码类型、压缩类型等等。
对象转换层完成其他对象模型与Parquet内部数据模型的映射和转换,Parquet的编码方式使用的是striping and assembly算法。
对象模型层定义了如何读取Parquet文件的内容,这一层转换包括Avro、Thrift、PB等序列化格式、Hive serde等的适配。并且为了帮助大家理解和使用,Parquet提供了org.apache.parquet.example包实现了java对象和Parquet文件的转换。

数据模型

Parquet支持嵌套的数据模型,类似于Protocol Buffers,每一个数据模型的schema包含多个字段,每一个字段又可以包含多个字段,每一个字段有三个属性:重复数、数据类型和字段名,重复数可以是以下三种:required(出现1次),repeated(出现0次或多次),optional(出现0次或1次)。每一个字段的数据类型可以分成两种:group(复杂类型)和primitive(基本类型)。例如Dremel中提供的Document的schema示例,它的定义如下:

message Document {
required int64 DocId;
optional group Links {
repeated int64 Backward;
repeated int64 Forward;
}
repeated group Name {
repeated group Language {
required string Code;
optional string Country;
}
optional string Url;
}
}

可以把这个Schema转换成树状结构,根节点可以理解为repeated类型,如下图:
嵌套Schema

可以看出在Schema中所有的基本类型字段都是叶子节点,在这个Schema中一共存在6个叶子节点,如果把这样的Schema转换成扁平式的关系模型,就可以理解为该表包含六个列。Parquet中没有Map、Array这样的复杂数据结构,但是可以通过repeated和group组合来实现这样的需求。在这个包含6个字段的表中有以下几个字段和每一条记录中它们可能出现的次数:

DocId int64 只能出现一次
Links.Backward int64 可能出现任意多次,但是如果出现0次则需要使用NULL标识
Links.Forward int64 同上
Name.Language.Code string 同上
Name.Language.Country string 同上
Name.Url string 同上
由于在一个表中可能存在出现任意多次的列,对于这些列需要标示出现多次或者等于NULL的情况,它是由Striping/Assembly算法实现的。

Striping/Assembly算法

上文介绍了Parquet的数据模型,在Document中存在多个非required列,由于Parquet一条记录的数据分散的存储在不同的列中,如何组合不同的列值组成一条记录是由Striping/Assembly算法决定的,在该算法中列的每一个值都包含三部分:value、repetition level和definition level。

Repetition Levels

为了支持repeated类型的节点,在写入的时候该值等于它和前面的值在哪一层节点是不共享的。在读取的时候根据该值可以推导出哪一层上需要创建一个新的节点,例如对于这样的一个schema和两条记录。

message nested {
repeated group leve1 {
repeated string leve2;
}
}
r1:[[a,b,c,] , [d,e,f,g]]
r2:[[h] , [i,j]]

计算repetition level值的过程如下:
value=a是一条记录的开始,和前面的值(已经没有值了)在根节点(第0层)上是不共享的,所以repeated level=0.
value=b它和前面的值共享了level1这个节点,但是level2这个节点上是不共享的,所以repeated level=2.
同理value=c, repeated level=2.
value=d和前面的值共享了根节点(属于相同记录),但是在level1这个节点上是不共享的,所以repeated level=1.
value=h和前面的值不属于同一条记录,也就是不共享任何节点,所以repeated level=0.
根据以上的分析每一个value需要记录的repeated level值如下:
repetition level计算

在读取的时候,顺序的读取每一个值,然后根据它的repeated level创建对象,当读取value=a时repeated level=0,表示需要创建一个新的根节点(新记录),value=b时repeated level=2,表示需要创建一个新的level2节点,value=d时repeated level=1,表示需要创建一个新的level1节点,当所有列读取完成之后可以创建一条新的记录。本例中当读取文件构建每条记录的结果如下:
构造记录

可以看出repeated level=0表示一条记录的开始,并且repeated level的值只是针对路径上的repeated类型的节点,因此在计算该值的时候可以忽略非repeated类型的节点,在写入的时候将其理解为该节点和路径上的哪一个repeated节点是不共享的,读取的时候将其理解为需要在哪一层创建一个新的repeated节点,这样的话每一列最大的repeated level值就等于路径上的repeated节点的个数(不包括根节点)。减小repeated level的好处能够使得在存储使用更加紧凑的编码方式,节省存储空间。

Definition Levels

有了repeated level我们就可以构造出一个记录了,为什么还需要definition levels呢?由于repeated和optional类型的存在,可能一条记录中某一列是没有值的,假设我们不记录这样的值就会导致本该属于下一条记录的值被当做当前记录的一部分,从而造成数据的错误,因此对于这种情况需要一个占位符标示这种情况。

definition level的值仅仅对于空值是有效的,表示在该值的路径上第几层开始是未定义的,对于非空的值它是没有意义的,因为非空值在叶子节点是定义的,所有的父节点也肯定是定义的,因此它总是等于该列最大的definition levels。例如下面的schema。


message ExampleDefinitionLevel {
optional group a {
optional group b {
optional string c;
}
}
}

它包含一个列a.b.c,这个列的的每一个节点都是optional类型的,当c被定义时a和b肯定都是已定义的,当c未定义时我们就需要标示出在从哪一层开始时未定义的,如下面的值:
definition level计算

由于definition level只需要考虑未定义的值,而对于repeated类型的节点,只要父节点是已定义的,该节点就必须定义(例如Document中的DocId,每一条记录都该列都必须有值,同样对于Language节点,只要它定义了Code必须有值),所以计算definition level的值时可以忽略路径上的required节点,这样可以减小definition level的最大值,优化存储。

一个完整的例子

本节我们使用Dremel论文中给的Document示例和给定的两个值r1和r2展示计算repeated level和definition level的过程,这里把未定义的值记录为NULL,使用R表示repeated level,D表示definition level。
document schema

首先看DocuId这一列,对于r1,DocId=10,由于它是记录的开始并且是已定义的,所以R=0,D=0,同样r2中的DocId=20,R=0,D=0。

对于Links.Forward这一列,在r1中,它是未定义的但是Links是已定义的,并且是该记录中的第一个值,所以R=0,D=1,在r1中该列有两个值,value1=10,R=0(记录中该列的第一个值),D=2(该列的最大definition level)。

对于Name.Url这一列,r1中它有三个值,分别为url1=’http://A’,它是r1中该列的第一个值并且是定义的,所以R=0,D=2;value2=’http://B’,和上一个值value1在Name这一层是不相同的,所以R=1,D=2;value3=NULL,和上一个值value2在Name这一层是不相同的,所以R=1,但它是未定义的,而Name这一层是定义的,所以D=1。r2中该列只有一个值value3=’http://C’,R=0,D=2.

最后看一下Name.Language.Code这一列,r1中有4个值,value1=’en-us’,它是r1中的第一个值并且是已定义的,所以R=0,D=2(由于Code是required类型,这一列repeated level的最大值等于2);value2=’en’,它和value1在Language这个节点是不共享的,所以R=2,D=2;value3=NULL,它是未定义的,但是它和前一个值在Name这个节点是不共享的,在Name这个节点是已定义的,所以R=1,D=1;value4=’en-gb’,它和前一个值在Name这一层不共享,所以R=1,D=2。在r2中该列有一个值,它是未定义的,但是Name这一层是已定义的,所以R=0,D=1.

Parquet文件格式

Parquet文件是以二进制方式存储的,所以是不可以直接读取的,文件中包括该文件的数据和元数据,因此Parquet格式文件是自解析的。在HDFS文件系统和Parquet文件中存在如下几个概念。

HDFS块(Block):它是HDFS上的最小的副本单位,HDFS会把一个Block存储在本地的一个文件并且维护分散在不同的机器上的多个副本,通常情况下一个Block的大小为256M、512M等。
HDFS文件(File):一个HDFS的文件,包括数据和元数据,数据分散存储在多个Block中。
行组(Row Group):按照行将数据物理上划分为多个单元,每一个行组包含一定的行数,在一个HDFS文件中至少存储一个行组,Parquet读写的时候会将整个行组缓存在内存中,所以如果每一个行组的大小是由内存大的小决定的,例如记录占用空间比较小的Schema可以在每一个行组中存储更多的行。
列块(Column Chunk):在一个行组中每一列保存在一个列块中,行组中的所有列连续的存储在这个行组文件中。一个列块中的值都是相同类型的,不同的列块可能使用不同的算法进行压缩。
页(Page):每一个列块划分为多个页,一个页是最小的编码的单位,在同一个列块的不同页可能使用不同的编码方式。

文件格式

通常情况下,在存储Parquet数据的时候会按照Block大小设置行组的大小,由于一般情况下每一个Mapper任务处理数据的最小单位是一个Block,这样可以把每一个行组由一个Mapper任务处理,增大任务执行并行度。Parquet文件的格式如下图所示。
Parquet文件格式

上图展示了一个Parquet文件的内容,一个文件中可以存储多个行组,文件的首位都是该文件的Magic Code,用于校验它是否是一个Parquet文件,Footer length了文件元数据的大小,通过该值和文件长度可以计算出元数据的偏移量,文件的元数据中包括每一个行组的元数据信息和该文件存储数据的Schema信息。除了文件中每一个行组的元数据,每一页的开始都会存储该页的元数据,在Parquet中,有三种类型的页:数据页、字典页和索引页。数据页用于存储当前行组中该列的值,字典页存储该列值的编码字典,每一个列块中最多包含一个字典页,索引页用来存储当前行组下该列的索引,目前Parquet中还不支持索引页,但是在后面的版本中增加。

在执行MR任务的时候可能存在多个Mapper任务的输入是同一个Parquet文件的情况,每一个Mapper通过InputSplit标示处理的文件范围,如果多个InputSplit跨越了一个Row Group,Parquet能够保证一个Row Group只会被一个Mapper任务处理。

映射下推(Project PushDown)

说到列式存储的优势,映射下推是最突出的,它意味着在获取表中原始数据时只需要扫描查询中需要的列,由于每一列的所有值都是连续存储的,所以分区取出每一列的所有值就可以实现TableScan算子,而避免扫描整个表文件内容。

在Parquet中原生就支持映射下推,执行查询的时候可以通过Configuration传递需要读取的列的信息,这些列必须是Schema的子集,映射每次会扫描一个Row Group的数据,然后一次性得将该Row Group里所有需要的列的Cloumn Chunk都读取到内存中,每次读取一个Row Group的数据能够大大降低随机读的次数,除此之外,Parquet在读取的时候会考虑列是否连续,如果某些需要的列是存储位置是连续的,那么一次读操作就可以把多个列的数据读取到内存。

谓词下推(Predicate PushDown)

在数据库之类的查询系统中最常用的优化手段就是谓词下推了,通过将一些过滤条件尽可能的在最底层执行可以减少每一层交互的数据量,从而提升性能,例如”select count(1) from A Join B on A.id = B.id where A.a > 10 and B.b < 100″SQL查询中,在处理Join操作之前需要首先对A和B执行TableScan操作,然后再进行Join,再执行过滤,最后计算聚合函数返回,但是如果把过滤条件A.a > 10和B.b < 100分别移到A表的TableScan和B表的TableScan的时候执行,可以大大降低Join操作的输入数据。

无论是行式存储还是列式存储,都可以在将过滤条件在读取一条记录之后执行以判断该记录是否需要返回给调用者,在Parquet做了更进一步的优化,优化的方法时对每一个Row Group的每一个Column Chunk在存储的时候都计算对应的统计信息,包括该Column Chunk的最大值、最小值和空值个数。通过这些统计值和该列的过滤条件可以判断该Row Group是否需要扫描。另外Parquet未来还会增加诸如Bloom Filter和Index等优化数据,更加有效的完成谓词下推。

在使用Parquet的时候可以通过如下两种策略提升查询性能:1、类似于关系数据库的主键,对需要频繁过滤的列设置为有序的,这样在导入数据的时候会根据该列的顺序存储数据,这样可以最大化的利用最大值、最小值实现谓词下推。2、减小行组大小和页大小,这样增加跳过整个行组的可能性,但是此时需要权衡由于压缩和编码效率下降带来的I/O负载。

性能

相比传统的行式存储,Hadoop生态圈近年来也涌现出诸如RC、ORC、Parquet的列式存储格式,它们的性能优势主要体现在两个方面:1、更高的压缩比,由于相同类型的数据更容易针对不同类型的列使用高效的编码和压缩方式。2、更小的I/O操作,由于映射下推和谓词下推的使用,可以减少一大部分不必要的数据扫描,尤其是表结构比较庞大的时候更加明显,由此也能够带来更好的查询性能。
存储空间对比

上图是展示了使用不同格式存储TPC-H和TPC-DS数据集中两个表数据的文件大小对比,可以看出Parquet较之于其他的二进制文件存储格式能够更有效的利用存储空间,而新版本的Parquet(2.0版本)使用了更加高效的页存储方式,进一步的提升存储空间。
impala测试对比

上图展示了Twitter在Impala中使用不同格式文件执行TPC-DS基准测试的结果,测试结果可以看出Parquet较之于其他的行式存储格式有较明显的性能提升。
Hive测试结果

上图展示了criteo公司在Hive中使用ORC和Parquet两种列式存储格式执行TPC-DS基准测试的结果,测试结果可以看出在数据存储方面,两种存储格式在都是用snappy压缩的情况下量中存储格式占用的空间相差并不大,查询的结果显示Parquet格式稍好于ORC格式,两者在功能上也都有优缺点,Parquet原生支持嵌套式数据结构,而ORC对此支持的较差,这种复杂的Schema查询也相对较差;而Parquet不支持数据的修改和ACID,但是ORC对此提供支持,但是在OLAP环境下很少会对单条数据修改,更多的则是批量导入。

项目发展

自从2012年由Twitter和Cloudera共同研发Parquet开始,该项目一直处于高速发展之中,并且在项目之初就将其贡献给开源社区,2013年,Criteo公司加入开发并且向Hive社区提交了向hive集成Parquet的patch(HIVE-5783),在Hive 0.13版本之后正式加入了Parquet的支持;之后越来越多的查询引擎对此进行支持,也进一步带动了Parquet的发展。

目前Parquet正处于向2.0版本迈进的阶段,在新的版本中实现了新的Page存储格式,针对不同的类型优化编码算法,另外丰富了支持的原始类型,增加了Decimal、Timestamp等类型的支持,增加更加丰富的统计信息,例如Bloon Filter,能够尽可能得将谓词下推在元数据层完成。

总结

本文介绍了一种支持嵌套数据模型对的列式存储系统Parquet,作为大数据系统中OLAP查询的优化方案,它已经被多种查询引擎原生支持,并且部分高性能引擎将其作为默认的文件存储格式。通过数据编码和压缩,以及映射下推和谓词下推功能,Parquet的性能也较之其它文件格式有所提升,可以预见,随着数据模型的丰富和Ad hoc查询的需求,Parquet将会被更广泛的使用。

更多技术交流,请关注网易视频云进行咨询与交流哦。
0 0
原创粉丝点击