HBase总结
为什么使用HBase
HBase是开源版的BigTable。
- 高性能的列式存储
- 高可靠的弹性伸缩
HBase VS RDBMS(传统关系数据库)
- 数据类型
RDBMS:关系模型,丰富的数据类型和存储方式
HBase存的数据都是字符串,用户根据自己的需要解析字符串 - 数据操作
RDBMS:丰富的CRUD操作,多表连接
HBase:不存在复杂的表与表之间的关系,只有简单的插入、查询、删除、清空等 - 存储模式
RDBMS:基于行模式存储,行被连续存储在磁盘页,在读取时需要顺序扫描行并筛选,如果每一行只有少量数据值对于查询是有用的,那么基于行模式的存储就会浪费许多磁盘空间和内存带宽;
HBase:基于列存储,每个列族都由几个文件保存,不同列族的文件是分离的,可以支持更大并发的查询,因为仅需处理查询所需的列,而不需要像RDBMS那样处理整行;同一个列族的数据会被一起进行压缩,由于同一列族内的数据相似度较高,因此可以获得较高的压缩比。 - 数据索引
RDBMS会根据需要构建多个索引
HBase只有一个索引:行键 - 数据维护
RDBMS更新后老数据会被替换
HBase更新只会生成一个新版本,老版本数据仍然保留。 - 可伸缩性
RDBMS很难实现横向扩展。
HBase可以灵活地水平扩展。 - 事务
HBase不支持事务,不能实现跨行更新的原子性。
使用HBase
- Native Java API
- HBase Shell
- Thrift Gateway
- REST Gateway
- Pig
- Hive
启动HBase
从官网下载HBase:https://hbase.apache.org/
注意兼容性:http://hbase.apache.org/book.html#hadoop
Hadoop安装后只包含HDFS和MapReduce,并不包含HBase,需要在Hadoop之上继续安装HBase。
编辑配置文件conf/hbase-site.xml
,可以修改数据写入目录:
1 | <configuration> |
将 DIRECTORY 替换成期望写文件的目录. 默认 hbase.rootdir 是指向 /tmp/hbase-${user.name} ,重启时数据会丢失。
编辑环境变量conf/hbase-env.sh
:
1 | # 如果JAVA_HOME已经有了就不用设置了 |
启动hbase:
1 | ./bin/start-hbase.sh |
关闭hbase:
1 | ./bin/stop-hbase.sh |
查看日志
如果启动失败后者后续的命令执行失败了,可以查看根目录下的日志:
1 | vim logs/hbase-hgc-master-hgc-X555LD.log |
Shell
使用shell连接HBase:
1 | ./bin/hbase shell |
1 | # 查看命令列表,要注意的是表名,行和列需要加引号 |
create - 创建表、列族:
1 | # 创建表 |
list - 查询表信息
1 | > list |
put - 向表、行、列指定的单元格添加数据:
1 | # 向表t1中的行row1和列f1:c1所对应的单元格中添加数据value1,时间戳为1421822284898 |
get - 获取单元格数据
1 | # 从表t1获取数据,行row1、列f1,时间范围为TIMERANGE,版本号为1的数据 |
Java API
原理
数据模型
- 表
HBase使用表来组织数据,表由行和列组成,列又划分为若干列族。 - 行
每个表由若干行组成,每个行由行键标识。
访问表中的行只能通过:单个行键、行键区间、全表扫描实现。 - 列族
列族是基本的访问控制单元。
列族里的数据通过列限定符或列来定位。 - 单元格
在表中,可以通过行、列族和列限定符确定一个单元格Cell。
单元格中存储的数据没有数据类型,总是被视为字节数组。
每个单元格中可以保存一个数据的多个版本,每个版本对应一个不同的时间戳。 - 时间戳
单元格中的数据通过时间戳进行索引,每次对一个单元格执行增删改操作都会隐式生成并存储一个时间戳。 - 数据坐标
HBase中可以根据<行键, 列族, 列限定符, 时间戳>的四元组来确定一条数据。
面向列存储
- 行式数据库
行式存储将每一行连续地存储在磁盘页中,要找一行数据就要连续地扫描磁盘。
如果每行只有少量属性的值对查询有用,那么行式存储就会浪费非常多的磁盘空间和内存带宽。
行式数据库主要适合于小批量的数据处理,如联机事务型数据处理,常见实现如MySQL。 - 列式数据库
以列为单位进行存储,关系中多个元组的同一列值会被存储到一起,而同一个元组中不同列则通常会被分别存储到不同的磁盘页中。
列式数据库主要适用于批量数据处理和Ad-Hoc Query,优点是可以降低IO开销,支持大量并发用户查询,因为仅需要处理可以回答这些查询的列,而不是分类整理与特定查询无关的数据行;具有较高的数据压缩比。
列式数据库主要用于数据挖掘、决策支持和地理信息系统等查询密集型系统中,因为一次查询就可以得出结果,而不必每次都要遍历所有的数据库。
缺点1:连接操作效率低,执行连接操作时需要昂贵的元组重构代价,因为一个元组的不同属性被分散到不同磁盘页中存储,当需要一个完整的元组时,就要从多个磁盘页中读取相应字段的值来重新组合得到原来的一个元组。
缺点2:不适合频繁更新同一行元组的场景,理由同上,因为一个元组的不同属性分散到不同的磁盘页,因此写操作频繁会导致不能很好命中缓冲。因此HBase更适合数据被存储后不会发生修改的场景。
HBase架构
HBase的实现包含3个主要的功能组件:
- 一个Master主服务器
Master负责管理HBase表的分区(Region)信息
比如一个表包含哪些Region,这些Region被划分到哪台Region服务器上。
同时也负责维护Region服务器列表,实时监测集群中的Region服务器,把特定的Region分配到可用的Region服务器上,并确保整个集群内部不同Region服务器之间的负载均衡。
负责Region集群的故障转移,当某个Region服务器因出现故障而失效时,Master会把故障服务器上存储的Region重新分配给其他可用的Region服务器。
负责模式变化,如表和列族的创建。 - 许多个Region服务器
Region服务器负责存储和维护分配给自己的Region,处理来自客户端的读写请求。 - 库函数
链接到每个客户端
客户端并不是直接从Master上读取数据,而是先获取Region的存储位置后再直接从Region服务器上读取数据。而且需要注意的是客户端不直接和Master交互,而是从ZooKeeper上获取Region信息,这可以保证Master的负载尽可能小。
客户端
客户端会访问HBase的服务端接口,并缓存已经访问过的Region位置信息,用来提高后续访问数据的速度。
ZooKeeper服务器
- Master将Region服务器的状态注册到ZooKeeper
- Master选举。
- 保存-ROOT-表和Master的地址,然后客户端可以根据-ROOT-表来一级一级找到所需的数据
Master服务器
- 管理对表的CRUD操作
- 实现不同Region之间的负载均衡
- 在Region分裂或合并后,重新调整Region的分布
- 将发生故障失效的Region迁移到其他Region服务器
Region服务器
Region服务器的主要职责:
- 维护分配给自己的Region
- 响应用户的读写请求
Region一般采用HDFS作为底层文件存储系统,并依赖HDFS来实现数据复制和维护数据副本的功能。
Region的定位 - 如何找到一个Region
每个Region都有一个RegionID来标识它的唯一性,要定位一个Region可以使用<表名, 开始主键, RegionID>的三元组。
HBase还会维护一张<Region标识符, Region服务器>的映射表,被称为元数据表,又名 .META.表。
如果一个HBase表中的Region特别多,一个服务器存不下.META.表,则.META.表也会被分区存储到不同的服务器上,并用一张根数据表来维护所有元数据的具体位置,又名 -ROOT-表,-ROOT-表是不能被分割的,永远只会被存储到一个唯一的Region中。
Region与行
HBase中的行是根据行键的字典序进行维护的,表中包含的行的数量可能非常大,需要通过行键对表中的行进行分区(Region)。
Region包含了位于某个值区间内的所有数据,它是负载均衡和数据分发的基本单位,这些Region会被Master分发到不同的Region服务器上。
每个Region的默认大小是100MB200MB,当一个Region包含的数据达到一个阈值时,会被自动分裂成两个新的Region,通常一个Region服务器上会放置101000个Region。
Region服务器的存储结构
Region服务器内部维护了一系列Region对象和一个HLog文件
每个Region由多个Store组成,每个Store对应了表中的一个列族的存储。
每个Store又包含一个MemStore和若干StoreFile,前者是内存缓存,后者是磁盘文件,使用B树结构组织,底层实现方式是HDFS的HFile(会对内容进行压缩)。HLog是磁盘上的记录文件,记录着所有的更新操作。
每个Store对应了表中一个一个列族,包含了一个MemStore和若干个StoreFile;
其中,MemStore是在内存中的缓存,保存最近更新的数据;StoreFile由HDFS的HFile实现,底层是磁盘中的文件,这些文件都是B树结构,方便快速读取,而且HFile的数据块通常采用压缩方式存储,可以大大减少网络和磁盘IO。
流程 - 用户读写数据
- 写入流程
用户写入 -> 路由Region服务器 -> HLog -> MemStore -> commit()返回给客户端HLog是WAL(Write Ahead Log),因此在MemStore之前写入
- 读取流程
用户读取 -> 路由Region服务器 -> MemStore -> StoreFile
流程 - 缓存刷新
- 周期性刷新
周期性调用Region.flushcache() -> 将MemStore缓存中的内容写到磁盘StoreFile中 -> 清空缓存 -> 在HLog中写入一个标记表示缓存已刷到StoreFile
每次缓存刷新都会在磁盘上生成一个新的StoreFile文件,因此每个Store会包含多个StoreFile文件 - 启动刷新
启动时检查HLog -> 确认最后一次刷新后是否还有发生写入 -> 如果有发生则将这些更新写入MemStore -> 刷新缓存写入到StoreFile -> 删除旧的HLog文件 -> 开始为用户提供数据访问服务如果最后一次刷新后没有新数据,说明所有数据已经被永久保存。
流程 - StoreFile合并
如《缓存刷新》流程所述,每次MemStore刷新都会在磁盘上生成一个新的StoreFile,这样系统中每个Store都会有多个StoreFile,要找到Store中某个值就必须查找所有这些StoreFile文件,非常耗时。
因此,为了减少耗时,系统会调用Store.compact()把多个StoreFile合并成一个大文件。
这个合并操作比较耗费资源,因此只会在StoreFile文件的数量达到一个阈值时才会触发合并操作。
Store的工作原理
如《Region存储结构》所示,Region服务器是HBase的核心模块,而Store是Region服务器的核心,每个Store对应了表中的一个列族的存储,每个Store包含一个MemStore缓存和若干个StoreFile文件。
- 写入数据优先写入MemStore,写满时刷新到StoreFile
- 随着StoreFile数量不断增加,达到阈值时触发文件合并操作
- 当StoreFile文件越来越大,达到阈值时,会触发文件分裂操作,同时当前的一个父Region会被分裂成2个子Region,父Region会下线,新分裂出的2个子Region会被Master分配到相应的Region服务器上。
HLog的工作原理
在分布式环境下,系统出错可能导致数据丢失,比如Region故障导致MemStore缓存中的数据被清空了。HBase采用HLog来保证系统故障时的恢复。
- HLog是每个Region服务器仅配置一个
一个Region服务器包含多个Region,这些一台Region服务器上的Region会共用一个HLog
这样做的好处是:一台Region服务器不需要打开多个日志文件,减少磁盘寻址次数,提高写操作性能。
这样的坏处是:如果一个Region服务器发生故障,为了恢复其上的Region对象,需要按所属Region对HLog进行拆分,然后分发到其他Region服务器上执行恢复操作。 - HLog是WAL(Write Ahead Log)
用户数据需要先写HLog才能写入MemStore,并且直到MemStore缓存内容对应的日志已经被写入磁盘,该缓存内容才会被刷新到磁盘。 - 数据恢复
ZooKeeper会实时监测每个Region服务器的状态,当某个Region服务器发生故障,ZooKeeper会通知Master。
Master会处理该故障服务器上的HLog文件,注意HLog会包含来自多个Region对象的日志记录,系统会根据每条日志所属的Region对象对HLog数据进行拆分,分别放到对应Region对象的目录下,然后再将失效的Region重新分配到可用的Region服务器中,并把与该Region对象相关的HLog日志记录也发送给相应的Region服务器。
Region服务器领取到分配给自己的Region对象以及与之相关的HLog日志记录以后,会重演一遍日志记录中的操作,把日志记录中的数据写入MemStore缓存,然后刷新到磁盘的StoreFile文件中,完成数据恢复。
ES5_3IngestNode
Ingest Node
Ingest Node提供了一种类似Logstash的功能:
- 预处理能力,可拦截Index或Bulk API的请求
- 对数据进行转换,并重新返回给Index或Bulk API
比如为某个字段设置默认值、重命名某个字段的字段名、对字段值进行Split操作
支持设置Painless脚本,对数据进行更加复杂的加工。
相对Logstash来说:
- | Logstash | Ingest Node |
---|---|---|
数据输入与输出 | 支持从不同的数据源读取,并写入不同的数据源 | 支持从ES REST API获取数据,并且写入ES |
数据缓冲 | 实现了简单的数据队列,支持重写 | 不支持缓冲 |
数据处理 | 支持大量插件、支持定制开发 | 内置插件,支持开发Plugin(但是添加Plugin需要重启) |
配置和使用 | 增加了一定的架构复杂度 | 无需额外部署 |
构建Ingest Node - Pipeline & Processor
- Pipeline
管道会对通过的数据(文档),按照顺序进行加工 - Processor
对加工的行为进行抽象封装
创建pipeline
为ES添加一个Pipeline:
1 | PUT _ingest/pipeline/blog_pipeline |
查看Pipeline:
1 | GET _ingest/pipeline/blog_pipeline |
测试Pipeline:
1 | POST _ingest/pipeline/blog_pipeline/_simulate |
- 可以看到tags被拆分成了数组
- 最终文档中新增了一个views字段
使用Pipeline更新文档:
1 | PUT tech_blogs/_doc/2?pipeline=blog_pipeline |
但是使用_update_by_query更新文档时可能会报错:
1 | POST /tech_blogs/_update_by_query?pipeline=blog_pipeline |
是因为对已经拆分过的字段再用split processor拆分,相当于要对数组类型的字段做字符串切分操作。
为了避免这种情况,可以通过加条件来忽略已经处理过的文档:
1 | POST tech_blogs/_update_by_query?pipeline=blog_pipeline |
构建pipeline
processor的种类比较多,这里列出一部分。
字段拆分 - split
ES的_ingest命令可以分析pipeline:
1 | POST _ingest/pipeline/_simulate |
- pipeline中只有一个processor,它将文档的tags字段按”,”拆分为数组
- 文档有一个tags字段,但是原始值中多个标签被拼成了一个字符串
字段值重置 - set
1 | POST _ingest/pipeline/_simulate |
- 添加文档时,使用processor set来增加一个新字段views
ES5_4Painless
内置脚本语言。
ES5_1数据建模
文档关联
范式化
关系数据库一般会考虑Normalize数据,而在Elasticsearch中,往往考虑Denormalize。
- 查询性能好
- 无需表连接
- 无需行锁
关联
ES不擅长处理关联关系,一般采用以下4种方法处理关联:
- 对象类型
- 嵌套对象(Nested Object)
- 父子关联关系(Parent / Child)
- 应用端关联
Nested Object | Parent / Child | |
---|---|---|
优点 | 文档存储在一起,读取性能高 | 父子文档可以独立更新 |
缺点 | 更新嵌套的子文档时,需要更新整个文档 | 需要额外的内存维护关系,读取性能相对较差 |
适用场景 | 子文档偶尔更新,以查询为主 | 子文档更新频繁 |
Nested
1 | # 插入两条数据 |
上面的搜索初看没什么问题,但是实际上查出了我们不需要的数据。
- **Jack Mike来自hebei,Joe来自beijing,并不符合查询条件的“来自beijing的Jack”。
出现这个问题的原因是:
- ES存储时,Nested对象的边界没有被考虑在内,JSON格式被处理成了扁平式的键值对结构
用Nested Data Type可以解决这个问题:
1 | // 先指定user是nested域 |
像这么定义索引的话相同搜索条件就查不出来了,当然查询条件的city改成”hebei”的话就能重新查出来了。
- 其中,Nested数据结构,允许对象数组中的对象被独立索引
- 用nested和properties关键字将所有actors索引到了多个分隔的文档
- 在内部,Nexted文档会被存到两个Lucene对象中,
Parent / Child
Nested方式关联的局限性:
- 每次更新,需要重新索引整个对象(包括根对象和嵌套对象)
ES中Parent / Child的关系是类似关系数据库中的Join查询。
- 父文档和子文档是两个独立的文档;
- 更新父文档无需重新索引子文档,子文档被添加、更新或删除也不会影响到父文档和其他的子文档。
创建索引,设置mapping:
1 | PUT my_blogs |
索引父子文档并查询:
1 | #索引父文档 |
- 索引子文档时设置routing=父文档,保证父文档和子文档在一个分片上
- 同时在parent中设置其父文档的
根据需要查询:
1 | #根据父文档ID查看 |
查询子文档:
- has_parent
根据子文档查父文档:
- parent_id
- has_child
数据建模
过程:
- 概念模型
- 逻辑模型
实体属性
实体之间的关系
搜索相关的配置 - 数据模型
索引分片数
mapping字段配置、关系处理
字段建模
字段类型
text:
- 用于全文本字段,文本会被Analyzer分词
- 默认不支持聚合分析及排序,需要设置fielddata为true
keyword:
- 用于id、枚举及不需要分词的文本
- 适用于Filter(精确匹配)、sorting、aggregation
设置多字段类型:
- 默认会为文本类型设置成text,并且设置一个keyword的子字段
- 在处理人类语言时,通过增加“英文”、”拼音”、”标准”分词器,提高搜索体验
结构化数据:
- 精确数据类型,可以用byte的情况下就不要用long
- 枚举类型设置为keyword,即便是数字也应该设置成keyword,获取更好的性能
搜索和分词
- 如果不需要搜索、排序和聚合分析,则设置enable为false
不需要检索的话,将index设置为false - 对需要搜索的字段,设置存储粒度
index_options / norms
不需要归一化数据时,也可以关闭,节约磁盘存储
聚合及排序
- 如果不需要聚合或排序
设置Doc_values / fielddata为false - 对更新频繁、聚合查询频繁的keyword类型的字段
设置eager_global_ordinals为true,利用缓存提高聚合性能
额外存储
_source设置enabled为false可以节约磁盘空间
但是一般不会把_source关掉,而是优先考虑增加压缩比,因为关掉后无法再看到_source字段,且无法做Reindex和Update
数据建模最佳实践
关联关系
- 优先考虑Denormalization
- 当数据包含多数值对象,同时有查询需求时,使用Nested Object
- 关联文档更新非常频繁时,使用Parent / Child
避免过多字段
过多字段带来的问题:
- 不容易维护
- Mapping信息保存在Cluster State中,数据量过大的话会对集群性能造成影响(Cluster State信息需要和所有节点同步)
- 删除或修改数据需要reindex
默认最大字段数是1000,可以设置index.mapping.total_fields.limit来修改
避免正则查询
正则查询存在的问题:
- 正则、通配符查询、前缀查询属于Term查询,但是性能不够好
- 特别是将通配符放在开头的话,性能极差
避免空值引起的聚合不准
比如下面插入两条文档,一条文档的rating值为null:
1 | PUT ratings/doc/1 |
聚合分析结果中可以看到,total虽然是2,但是avg结果却是5:
1 | POST ratings/_search |
解决办法是给null取默认值:
1 | DELETE ratings |
为索引的Mapping加入Meta信息
1 | PUT softwares |
Mapping的设置是一个迭代的过程:
- 加入新的字段很容易(必要时需要update_by_query)
- 更新删除字段不允许(需要Reindex重建数据)
- 最好能对Mapping加入Meta信息,更好地进行版本管理
- 可以考虑将Mapping文件上传git进行管理
ES5_2创建Index原则
定义索引的最佳实践。