HBase读写原理以及rowKey设计

一、HBase基本知识

1.1、HBase的数据模型

  • 表和区域(Table&Region):当表随着记录数不断增加而变大后,会逐渐分裂成多份,成为区域,一个区域是对表的水平划分,不同的区域会被Master分配给相应的RegionServer进行管理
  • 行健(Row Key):表的主键,表中的记录默认按照行健升序排序
  • 列族(Column Family):表在水平方向有一个或者多个列族组成,一个列族中可以由任意多个列组成,列族支持动态扩展,无需预先定义列的数量以及类型,所有列均以二进制格式存储,用户需要自行进行类型转换。所有的列族成员的前缀是相同的,例如“abc:a1”和“abc:a2”两个列都属于abc这个列族。
  • 单元格(Cell):表存储数据的单元。由{行健,列(列族:列),时间戳}唯一确定,其中的数据是没有类型的,以二进制的形式存储。
  • 时间戳(Timestamp):每次数据操作对应的时间戳,可以看作是数据的版本号
    HBase存储结构
    通过json表示一个HBase结构

1.2、HBase物理存储

1.2.1、table与region的关系

HBase中使用.META内部表(hbase:meta)存储region的分布情况以及每个region的详细信息。内部表中记录了region的rowkey范围,region所在的服务器等等。集群通过region server提供数据访问服务,region server可以管理多个region,来自不同region server上的region组合成表格的整体逻辑视图,如下图。
图1 table和region之间的关系

1.2.2、RegionService物理结构图

  • RegionService:包括一个HLog、一个BlockCache和多个Region,一般是一个单点服务器,提供对外的读写以及Region的管理。
  • HLog:也称为预写日志,或WAL,是面对故障为HBase提供数据持久性的功能。对HBase的每次写入都记录在HLog中,并写入HDFS。
  • BlockCache:是​​内存的一部分,HBase会在第二次读取的时候,直接读取从磁盘缓存的这部分数据。
  • Region:是表按照RowKey范围划分的不同的部分,相当于DBMS中的分区。同时Region也是表在集群中分布的最小单位,可以被分配到某一个Region Server上。随着数据不断插入表,region不断增大,当region的某个列族(store)达到一个阈值(默认256M),会拆分成两个region。包括多个Store。
  • Store:对应存储着列族。包括一个MemStore和多个StoreFile。
  • MemStore:当写入数据时,会先将数据写入MemStore,等到MemStore缓存到一定值的时候,才会批量将数据写入到StoreFile中。
  • StoreFile:磁盘上的数据由StoreFile管理,并且以HFile格式存储。
  • HFile:对应着Hadoop文件存储格式HDFS。
    图2 RegionSerer物理存储图

1.3、读取数据流程图

1.3.1、hbase读取数据顺序

Client调用HBase Api接口,访问HBase集群,根据rowkey定位region(有可能有多个),根据region查询对应的Column Family,通过列名和时间戳定位实际的值。
图3 HBase读取数据流程

1.3.2、Client-Server交互逻辑

  • Client获取zk中/ < hbase-rootdir > /meta-region-server节点信息,该节点信息存储HBase元数据(hbase:meta)表所在的RegionServer地址以及访问端口等信息。
  • Client访问存有hbase:meta表的RegionServer,并且缓存hbase:meta表数据,根据hbase:meta表定位需要查询rowkey的RegionServer相关信息。
  • Clinet发送请求给rowkey所在的RegionServer,将查询需求交由它处理,并等待返回结果。
    Client与Server交互流程

client与server只有第一次交互的时候才会是三个步骤。如果hbase:meta表并没有发生变化的话,client将会直接访问目标rowkey所在的RegionServer;如果hbase:meta表发生改变(一般为删除了某些region,新增了一些region),这个时候client访问会报错,这样才会重新去保存最新hbase:meta表的RegionServer缓存新的hbase:meta表。

1.3.3、region中的读取流程

  1. 查询memstore队列的那些等待被修改的数据中是否存在;
  2. 查询BlockCache中是否缓存;
  3. 访问硬盘HFile中的真实值,如果需要查询多列,则需要查询多个HFile。
    图4 HBase读取数据与组件的具体交互

这里有一个要注意的地方,就是如果我们设计表的时候,有很多的列簇,那么查询的时候最好指定对应的列簇,不然的会默认情况下会获取所有的列下面的数据,这样就会访问region下的所有store,对应的也要经历多次上面的3流程。

二、HBase查询数据底层实现

2.1、scan客户端设计原理

HBase中scan并不像大家想象的一样直接发送一个命令过去,服务器就将满足扫描条件的所有数据一次性返回给客户端。

下图右侧是HBase scan的客户端代码,其中for循环中每次遍历ResultScanner对象获取一行记录,实际上在客户端层面都会调用一次next请求。
客户端读取result流程

  1. next请求首先会检查客户端缓存中是否存在还没有读取的数据行,如果有就直接返回,否则需要将next请求给HBase服务器端(RegionServer)。
  2. 如果客户端缓存已经没有扫描结果,就会将next请求发送给HBase服务器端。默认情况下,一次next请求仅可以请求100行数据(或者返回结果集总大小不超过2M),可以设置单次请求的返回总行数(cashing),设计单次返回数据字节总大小(maxResultSize)。
  3. 服务器端接收到next请求之后就开始从BlockCache、HFile以及memcache中一行一行进行扫描,扫描的行数达到100行之后就返回给客户端,客户端将这100条数据缓存到内存并返回一条给上层业务。

这里大家可以思考下一个问题,就是为什么HBase不设计成一次性返回所有数据?

2.2、Scanner体系的构建

RegionService在接收到客户端的get/scan请求的时候,会构建一个Scanner体系(做一些查询的前期准备工作),下面详细了解下Scanner体系的构建流程以及get/scan操作的具体过程。

Scanner体系的构建中有许多的成员对象,一共包括三层:RegionScanner、StoreScanner、StoreFileScanner和MemstoreScanner。他们之间的关系:

  • 一个RegionScanner由多个StoreScanner构成,一个表中有多少个列簇,就会构建多少个StoreScanner,每一个StoreScanner都会负责对应的Store;
  • 一个StoreScanner由一个MemstoreScanner和多个StoreFileScanner构成,StoreScanner会对当前的每一个StoreFile创建一个StoreFileScanner,针对Memstore创建一个MemstoreScanner,StoreFileScanner负责对应的HFile数据检索,MemstoreScanner负责内存中MemStore的数据检索。

其中实际在执行查询操作的是StoreFileScanner和MemstoreScanner,RegionScanner与StoreScanner并不负责实际的查询操作。下图表示构建的流程图:
scanner体系构建图

  • 1.1 构建StoreFileScanner:每个StoreScanner会为当前该Store中每个HFile构造一个StoreFileScanner,用于实际执行对应文件的检索。同时会为对应Memstore构造一个MemstoreScanner,用于执行该Store中Memstore的数据检索。
  • 1.2 过滤淘汰StoreFileScanner:根据Time Range、RowKey Range以及布隆过滤器对StoreFileScanner以及MemstoreScanner进行过滤,淘汰肯定不存在待检索结果的Scanner。图中StoreFile3因为检查RowKeyRange不存在待检索Rowkey所以被淘汰。
  • 1.3 Seek rowkey:所有StoreFileScanner开始做准备工作,在负责的HFile中定位到满足条件的起始Row。Seek过程也是一个很核心的步骤。
  • 1.4 StoreFileScanner合并构建最小堆:将该Store中所有StoreFileScanner和MemstoreScanner合并形成一个heap(最小堆),所谓heap是一个优先级队列,队列中元素是所有scanner,排序规则按照scanner seek到的keyvalue大小由小到大进行排序。

这里我们需要研究下三个问题:首先为什么这些Scanner需要由小到大排序,其次keyvalue是什么样的结构,最后,keyvalue谁大谁小是如何确定的。

  • 为什么这些Scanner需要由小到大排序?

最直接的解释是scan的结果需要由小到大输出给用户,当然,这并不全面,最合理的解释是只有由小到大排序才能使得scan效率最高。举个简单的例子,HBase支持数据多版本,假设用户只想获取最新版本,那只需要将这些数据由最新到最旧进行排序,然后取队首元素返回就可以。那么,如果不排序,就只能遍历所有元素,查看符不符合用户查询条件。这就是排队的意义。

  • HBase中KeyValue是什么样的结构?

HBase中KeyValue并不是简单的KV数据对,而是一个具有复杂元素的结构体,其中Key由RowKey,ColumnFamily,Qualifier ,TimeStamp,KeyType等多部分组成,Value是一个简单的二进制数据。Key中元素KeyType表示该KeyValue的类型,取值分别为Put/Delete/Delete Column/Delete Family四种。KeyValue可以表示为如下图所示:
在这里插入图片描述

  • 不同KeyValue之间如何进行大小比较?

上面提到KeyValue中Key由RowKey,ColumnFamily,Qualifier ,TimeStamp,KeyType等5部分组成,HBase设定Key大小首先比较RowKey,RowKey越小Key就越小;RowKey如果相同就看CF,CF越小Key越小;CF如果相同看Qualifier,Qualifier越小Key越小;Qualifier如果相同再看Timestamp,Timestamp越大表示时间越新,对应的Key越小。如果Timestamp还相同,就看KeyType,KeyType按照DeleteFamily -> DeleteColumn -> Delete -> Put 顺序依次对应的Key越来越大。

2.3、scan查询

构建Scanner体系是为了更好地执行scan查询。scan查询总是一行一行查询的,先查第一行的所有数据,再查第二行的所有数据,但每一行的查询流程却没有什么本质区别。所以实际上我们只需要关注其中一行数据是如何查询的就可以。

对于一行数据的查询,又可以分解为多个列族的查询,比如RowKey=row1的一行数据查询,首先查询列族1上该行的数据集合,再查询列族2里该行的数据集合。所以我们也只需要关注某一行某个列族的数据是如何查询的就可以。

还记得Scanner体系构建的最终结果是一个由StoreFileScanner和MemstoreScanner组成的heap(最小堆)么,这里就派上用场了。下图是一张表的逻辑视图,该表有两个列族cf1和cf2(我们只关注cf1),cf1只有一个列name,表中有5行数据,其中每个cell基本都有多个版本。cf1的数据假如实际存储在三个区域,memstore中有r2和r4的最新数据,hfile1中是最早的数据。现在需要查询RowKey=r2的数据,按照上文的理论对应的Scanner指向就如图所示:
Scanner指向HFile图
这三个Scanner组成的heap为<MemstoreScanner,StoreFileScanner2, StoreFileScanner1>,Scanner由小到大排列。查询的时候首先pop出heap的堆顶元素,即MemstoreScanner,得到keyvalue = r2:cf1:name:v3:name23的数据,拿到这个keyvalue之后,需要进行如下判定:

  1. 检查该KeyValue的KeyType是否是Deleted / DeletedColumn / DeletedFamily,如果是就直接忽略该列所有其他版本,跳到下列(列族)
  2. 检查该KeyValue的Timestamp是否在用户设定的Timestamp Range范围,如果不在该范围,忽略
  3. 检查该KeyValue是否满足用户设置的各种filter过滤器,如果不满足,忽略
  4. 检查该KeyValue是否满足用户查询中设定的版本数,比如用户只查询最新版本,则忽略该cell的其他版本;反之如果用户查询所有版本,则还需要查询该cell的其他版本。

现在假设用户查询所有版本而且该keyvalue检查通过,此时当前的堆顶元素需要执行next方法去检索下一个值,并重新组织最小堆。即图中MemstoreScanner将会指向r4,重新组织最小堆之后最小堆将会变为<StoreFileScanner2, StoreFileScanner1, MemstoreScanner>,堆顶元素变为StoreFileScanner2,得到keyvalue=r2:cf1:name:v2:name22,进行一系列判定,再next,再重新组织最小堆…
不断重复这个过程,直至一行数据全部被检索得到。继续下一行…

这里引入一个思考问题,Memstore在flush的时候会不会将Blockcache中的数据update?如果不update的话不就会产生脏读,读到以前的老数据?答案见问题2

三、rowkey设计

3.1、重要性

3.1.1、rowkey在查询中的作用

rowkey是作为hbase中唯一标识一行数据的字段,hbase中的查询方式总共有三种:

  1. 通过get指定的rowkey查询
  2. 通过scan一个rowkey的startRowkey,endRowkey来查询
  3. 全表扫描查询,查询全表中的所有数据

我们通过rowkey正则表达式查询,实际上是属于第二种。然而,通过过滤字段条件查询,属于上述的第三种。可见,如果需要高效的快速的查询hbase中的数据,只能够通过rowkey。

3.1.2、rowkey在region中的作用

region中会保存一个startRowkey和endRowkey,用于定义这个region的大小范围,当客户端对集群进行读写操作的时候,需要通过定位rowkey来确定这行数据应该写在那个region(或者从哪个region读)。在region分裂的时候也是通过rowkey,将一个region分裂为多个region。

除了对于region的作用,在HFile文件中是按照rowkey的字典排序的,在Memstore中也是按rowkey的字段排序的。

3.2、遵循法则

在hbase中rowkey在数据检索和数据存储方面都有重要的作用,一个好的rowkey设计会影响到数据在hbase中的分布,还会影响我们查询效率,所以一个好的rowkey的设计方案是多么重要。下面我们来聊聊设计rowkey需要遵循的一些法则。

3.2.1、唯一性法则

rowkey是唯一标识hbase中一行数据的字段,所以在同一张表中rowkey一定要保证唯一性。
唯一性法则是rowkey必须要遵循的法则,这个原因就不必多说了。

3.2.2、长度法则

在设计rowkey的时候,需要尽可能的将rowkey的长度设计的简短,最好是能够设计成8字节的整数倍,推荐长度为10~100字节。
主要原因如下:

  1. 数据的持久化文件HFile中是按照KeyValue存储的,如果Rowkey过长比如100个字节,1000万列数据光Rowkey就要占用100*1000万=10亿个字节,将近1G数据,这会极大影响HFile的存储效率;
  2. MemStore将缓存部分数据到内存,如果Rowkey字段过长内存的有效利用率会降低,系统将无法缓存更多的数据,这会降低检索效率。因此Rowkey的字节长度越短越好。
  3. 目前操作系统是都是64位系统,内存8字节对齐。控制在16个字节,8字节的整数倍利用操作系统的最佳特性。

3.2.3、散列原则

设计的rowkey要均匀的分布,不管是某个时间段内数据的增量写入,还是全量数据的存储,都需要保证rowkey均匀的分布在每一个region中。
主要原因如下:

  1. 用户在读取hbase的时候是通过rowkey来获取数据的,如果数据存储的不够分散,将会导致某个regionService服务的访问过于频繁,出现数据热点问题,到时候单体机器的io等资源将会成为整个hbase集群服务的上线。
  2. 如果rowkey不够散列,就会将最新的一批数据都写入同一个region中,也会造成数据热点问题。

3.3、region热点问题

3.2.3提到了数据热点问题,那么我们来讨论下如何尽量避免这个问题。
首先确定下为什么会发生这个问题,主要的原因是因为我们设计rowkey的时候并没有将rowkey打散,导致读写hbase集群的时候有些regionServer过于“闲”,有些regionServer过于“忙”,想要解决这个问题也很简单,就是将rowkey尽量打撒,这个在hbase的官网中Rowkey Design也提到了具体的实现方式有三种:Salting、Hashing和Reversing the Key

3.3.1、Salting

Salting中文翻译为加盐,核心思想就是为rowkey加上一个随机的前缀,这样能够很好的将数据均匀的散列在每一个region中。但是这种方式也是有缺点的,缺点就是:由于此分配是随机的,因此,如果要按字典顺序检索行,则需要做更多的工作。所以,加盐会增加写入的吞吐量,但是会增加读取的成本。
例如:salt(0~n)-rowkey
a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002

3.3.2、Hashing

我们可以将原来的rowkey进行hash加密,这样也能够将rowkey散列在所有的regionService中。这种加密可以是单向,也可以是双向的,只要保证每一次加密的原文和密文一一对应就可以。
hash的缺点很明显,与加盐的方式一样,就是会打乱原来rowkey的排序,对于一些scan操作不是很友好。
例如:MD5(rowkey)
f47f92898435790e
ebb2797d54aa059a
3660c44f7e5b96d9
19547ad362b31d74

3.3.3、Reversing the Key

防止热点的第三个常见技巧是反转固定宽度或数字行键,以便最频繁更改(最低有效数字)的部分位于第一位。 这有效地使行键随机化,但牺牲了行排序属性。

反转的时候需要注意下的就是,如果rowkey长度不一,可以先预估rowkey最大长度,然后将rowkey进行翻转,再在翻转之后的字符串后面补0(至最大长度);如果长度固定,直接进行翻转即可。

最经典的案例就是rowkey为电话号码时,因为电话号码的第一位都是1,这样很容易造成热点问题,将11位的电话号码反转一下,那么就能够很好的避免这个问题。

实际上这三种方式都有一个缺点,就是打乱的时候,会导致原本排好序的rowkey又变成乱序的,这样对于scan操作并不友好,为了尽量避免这个问题,我们下面会通过一些技巧来避免这个问题。

3.4、rokey设计实例

3.4.1、不同场景下设计技巧

  1. 查询某用户在某应用中的操作记录
    function(userid) + appid + timestamp
  2. 查询某用户在某应用中的操作记录(优先展现最近的数据)
    function(userid) + appid + (Long.Max_Value - timestamp)
  3. 查询某用户在某段时间内所有应用的操作记录
    function(userid) + timestamp + appid
  4. 查询某用户的基本信息
    function(userid)
  5. 查询某eventid记录信息
    function(eventid) + timestamp

其中function可以选择上诉解决方案中的任意一个方案。一般来说rowkey都是由多个部分组成,我们将最前面这个不需要支持排序的关键值进行打散,将需要排序的字段放在后面,这样的话,我们既能够比较好的支持scan操作,又能够避免热点问题。

设计rowkey还有两个需要注意的地方,

  • 我们要尽可能的根据业务场景来设计rowkey。
    我们要清楚业务最频繁的查询场景,根据这些最频繁的查询条件,将这个查询条件设计为rowkey的组成成分,并且将使用频繁程度按照rowkey从左到右开始排序,这个排序的原因是因为,我们的rowkey在底层的排序是按照字典顺序(从第一值的ASCII开始一直比较)排序的,这样的排序更方便scan操作进行;
  • rowkey设计一开始就定义好rowkey的长度,以及每一个组成rowkey字段的长度。如果rowkey的长度不一致,那么就有可能产生意想不到的排序方式,我们就以第六种场景来看,5232020020608,这一个rowkey就有两种解释,第一种为:id = 52320,timestamp = 20020608,第二种:id = 523,timestamp = 2020020608(2020年2月6号8点)。当然这点不是必要的,其实我们只要在中间加上分隔符就能够区分开来,但是我们却浪费了一个字符长度。

3.4.2、统计相关场景设计实例

背景

我们需要提供一个查询或修改一个楼盘的统计数据,未来有可能会有其他纬度(经纪人等)的数据与楼盘相关的统计数据出现,统计数据纬度有可能是天、周、月,统计具体指标不确定,但是一定是与楼盘相关的。

分析

这个场景中经常需要查询的场景为:楼盘id+统计日期、楼盘id+其他纬度+统计日期,所以我们需要设计rowkey的主要字段为楼盘id、其他纬度、统计时间。
  • 楼盘id:是一个明确的值,并且是一定会出现的的值,所以我们可以将它设置为rowkey的第一个字段(最左端)
  • 其他纬度:可能是经纪人等等的数据,我们需要用一个枚举来顶一个其他纬度,用枚举code+纬度id设计该字段,确保唯一性
  • 统计时间:也可能是通过不同纬度来统计的,我们需要设计一个时间纬度枚举,用于确定统计时间的唯一性,纬度code+时间


设计方案

首先我们来看看设计的最终方案: reversing(hex(楼盘id)) + hex(其他纬度Code) + hex(纬度id) + hex(统计纬度code) + (999999 - format(时间,yyMMdd)) reversing:反转函数,位数不够0补充 hex:转换为16进制数,位数不够0补充 format:格式化时间
  • 楼盘id:将楼盘id作为最左端,由于id是自增的,所以需要将它打散。这里我们选择打散的方式为反转,由于id的长度并不是固定的,所以不够的位置需要用0补齐。这里我们还发现,楼盘id是Long型,这样他的最大值为18位数,正常情况下id的值不会超过千万级别,但是我们预留了一些空间,让其能够达到10位数大小,由于位数太大,我们考虑使用16进制来表示该id,最终确定楼盘id的长度为8位数,最大值为0xffffffff;
  • 其他纬度:该纬度的总量预估不会超过两位数,我们设计枚举code的位数为两位,也是设置为16进制,最大值为0xff,纬度id做法与楼盘id相似,但是不做反转操作,不够的话前缀补充0;
  • 统计时间:该字段由两部分组成,统计纬度code+时间,统计纬度目前想到的是月、周、日,以后还可能出现10天统计,20天统计等等,这里使用一个枚举code来表示,也将使用两位16进制的数标识,最大值为0xff。时间类型,因为考虑到用时间戳将会是一个很长的字符,我们这里使用yyMMdd(200608,2020年06月08号)格式来尽可能的缩短时间占用格式,由于我们比较想要获取最近的数据,所以这里会用999999(6个9)去减去时间,让时间保持一个倒序排列。

四、总结

在hbase的使用过程,设计rowkey是一个很重要的一个环节。我们在进行rowkey设计的时候可参照如下步骤:

  1. 结合业务场景特点,选择合适的字段来做为rowkey,并且按照查询频次来放置字段顺序;
  2. 通过设计的rowkey能尽可能的将数据打散到整个集群中,均衡负载,避免热点问题;
  3. 设计的rowkey应尽量简短;
  4. 需要明确组成每一个rowkey字段的长度,防止出现异常的排序方式;
  5. rowkey使用字符串格式,方便数据维护。







引用链接:
【Nick Dimiduk】http://www.n10k.com/blog/hbase-for-architects/ —2013.05.29
【wuchanming】https://wuchanming.gitbooks.io/hbase/content/ – 2017
【范欣欣博客】http://hbasefly.com/tag/hbase/ – 2017.6.16
【胡争、范欣欣】HBase原理与实践,——北京:机械工业出版社,2019.8
【刘_威】https://www.jianshu.com/p/9ad59c0e65ec – 2017.07.19
【java精汇总】https://juejin.im/post/5d10bb5051882546e072cb45 – 2019.06.24
【晋心】https://www.cnblogs.com/kxdblog/p/43 – 2015.03.10

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值