前言
在计算机数据存储领域,一直是关系数据库(RDBMS)的天下,以至于在传统企业的应用领域,许多应用系统设计都是面向数据库设计,也就是先设计数据库然后设计程序,从而导致关系模型绑架对象模型,并由此引申出旷日持久的业务对象贫血模型与充血模型之争。业界为了解决关系数据库的不足,提出了诸多方案,比较有名的是对象数据库,但是这些数据库的出现似乎只是进一步证明关系数据库的优越而已。从 Google 的 BigTable 开始,一系列的可以进行海量数据存储与访问的数据库被设计出来,更进一步说,NoSQL 这一概念被提了出来。HBase 之所以能够具有海量数据处理能力,其根本在于和传统关系型数据库设计的不同思路。传统关系型数据库对存储在其上的数据有很多约束,学习关系数据库都要学习数据库设计范式,事实上,是在数据存储中包含了一部分业务逻辑。而 NoSQL 数据库则简单暴力地认为,数据库就是存储数据的,业务逻辑应该由应用程序去处理,有时候不得不说,简单暴力也是一种美。
整体设计
HBase 可伸缩架构
HBase 的伸缩性主要依赖其可分裂的 HRegion 及可伸缩的分布式文件系统 HDFS 实现。
HRegion 是 HBase 负责数据存储的主要进程,应用程序对数据的读写操作都是通过和 HRegion 通信完成。数据以 HRegion 为单位进行管理,也就是说应用程序如果想要访问一个数据,必须先找到 HRegion,然后将数据读写操作提交给 HRegion,由 HRegion 完成存储层面的数据操作。HRegionServer 是物理服务器,每个 HRegionServer 上可以启动多个 HRegion 实例。当一个 HRegion 中写入的数据太多,达到配置的阈值时,一个 HRegion 会分裂成两个 HRegion,并将 HRegion 在整个集群中进行迁移,以使 HRegionServer 的负载均衡。
HBase 的做法是按 Key 的区域进行分片,这个分片也就是 HRegion,和 Memcached 这类分布式缓存的路由算法不同。每个 HRegion 中存储一段 Key 值区间[key1, key2) 的数据,所有 HRegion 的信息,包括存储的 Key 值区间、所在 HRegionServer 地址、访问端口号等,都记录在 HMaster 服务器上。为了保证 HMaster 的高可用,HBase 会启动多个 HMaster,并通过 ZooKeeper 选举出一个主服务器。
调用时序图:应用程序通过 ZooKeeper 获得主 HMaster 的地址,输入 Key 值通过HMaster查找分片(不能像Memcached 通过路由算法直接算),获得这个 Key 所在的 HRegionServer 地址,然后请求 HRegionServer 上的 HRegion,获得所需要的数据。数据写入过程也是一样,需要先得到 HRegion 才能继续操作。
HRegion 会把数据存储在若干个 HFile 格式的文件中,这些文件使用 HDFS 分布式文件系统存储,在整个集群内分布并高可用。当一个 HRegion 中数据量太多时,这个 HRegion 连同 HFile 会分裂成两个 HRegion,并根据集群中服务器负载进行迁移。如果集群中有新加入的服务器,也就是说有了新的 HRegionServer,由于其负载较低,也会把 HRegion 迁移过去并记录到 HMaster,从而实现 HBase 的线性伸缩。
HBase 可扩展数据模型
传统的关系数据库为了保证关系运算(通过 SQL 语句)的正确性,在设计数据库表结构的时候,需要指定表的 schema 也就是字段名称、数据类型等,并要遵循特定的设计范式。这些规范带来了一个问题,就是僵硬的数据结构难以面对需求变更带来的挑战,有些应用系统设计者通过预先设计一些冗余字段来应对,但显然这种设计也很糟糕。那有没有办法能够做到可扩展的数据结构设计呢?不用修改表结构就可以新增字段呢?当然有的,许多 NoSQL 数据库使用的列族(ColumnFamily)设计就是其中一个解决方案。这是一种面向列族的稀疏矩阵存储格式,在创建表的时候,只需要指定列族的名字,无需指定字段(Column)。那什么时候指定字段呢?可以在数据写入时再指定。,实际上是把字段的名称和字段的值,以 Key-Value 的方式一起存储在 HBase 中。实际写入的时候,可以随意指定字段名称,即使有几百万个字段也能轻松应对。
使用列族的缺点:
- 在需要读取整条记录的时候,需要访问多个列族组合数据,效率会降低,可以通过字段冗余来解决一些问题。
- 只能提供Key值和全表扫描两种访问方式,很多情况下需要自己建二级索引。
Hbase通过列族划分数据的存储:HBase底层存储依赖于HDFS,HBase中table在行的方向上分割为多个region,它是HBase负载均衡的最小单元,可以分布在不同的RegionServer上,但是一个region不能拆分到多个RegionServer上。但是region不是HBase物理存储的最小单元,它由一个或者多个store组成,每个store保存一个column family即列族。每个store由一个memstore和多个storefile组成(这里的memstore其实是Sorted Memory Buffer,在WAL机制开启的情况下,不考虑块缓存,数据日志会先写入HLog,然后进入Memstore,最后持久化到HFile中),storefile由hfile组成是对hfile的轻量级封装,存储在hdfs上。每个列族在文件层面上是以单独的文件存储的。所以,每个column family可以看作是HBase中一个集中的存储单元。在生产中,我们设计列族时会将具有相似属性的比如IO特性或者将经常一起查询的列放到一个列族中,可以减少文件的IO、寻址时间,从而提高性能。
HBase 的高性能存储
- 为了提高数据写入速度,HBase 使用了一种叫作 LSM 树的数据结构进行数据存储。
- 每行中的每一列在存储文件中都会以Key-value的形式存在于文件中。其中Key的结构为:行主键 + 列名,Value为列的值。存储数据按row-key排序。数据信息 和 结构信息(提高读写效率)混在一起,因为磁盘的缘故, 顺序写即可提高读效率。而查询/读效率 的提高花活儿就比较多了,并且通常 会降低写效率。 所谓 数据结构 或许精髓便是如此吧。
LSM tree
LSM 树的全名是 Log Structed Merge Tree,翻译过来就是 Log 结构合并树。数据写入的时候以 Log 方式连续写入,然后异步对磁盘上的多个 LSM 树进行合并。LSM 树可以看作是一个 N 阶合并树。数据写操作(包括插入、修改、删除)都在内存中进行,并且都会创建一个新记录(修改会记录新的数据值,而删除会记录一个删除标志)。这些数据在内存中仍然还是一棵排序树,当数据量超过设定的内存阈值后,会将这棵排序树和磁盘上最新的排序树合并。当这棵排序树的数据量也超过设定阈值后,会和磁盘上下一级的排序树合并。合并过程中,会用最新更新的数据覆盖旧的数据(或者记录为不同版本)。在需要进行读操作时,总是从内存中的排序树开始搜索,如果没有找到,就从磁盘 上的排序树顺序查找。在 LSM 树上进行一次数据更新不需要磁盘访问,在内存即可完成。当数据访问以写操作为主,而读操作则集中在最近写入的数据上时,使用 LSM 树可以极大程度地减少磁盘的访问次数,加快访问速度。
还有一个写操作日志记录数据,所以数据不会丢,但是宕机恢复需要时间,就是根据日志恢复数据,这段时间部分数据更新访问不到。
Log Structured Merge Trees(LSM) 原理
- 磁盘随机操作慢,顺序读写快
- 我们要避免随机读写,最好设计成顺序读写
- 顺序写的话,读取就很难受了 ==> 需要数据 结构(哈希、B+树等)提供 更多信息来 提高读效率 ==> 数据结构进而影响 写效率
- 比如mysql 的B+树,新增/更新数据,都要更新B+树的特定节点,学名叫:update-in-place。即 更新/新增一个数据,先找到数据所在的位置,然后进行操作。如果读写 数据key 值的随机性比较大的话,也就是key 分散在不同 树节点 中,则会引起 多次 树节点数据 载入到内存。
- 如果想不 update-in-place,一种方式是Copy-On-Write Tree。更新之前是一个B+树,更新之后,是一个新的B+树。但是因为每个写操作都要重写树结构,放大了写操作(干的活儿多了),降低了写性能。
- 另一种方式 将所有操作(主要是update 和 add) 顺序化。比如以前是将add1、update2、add3 这个操作序列 更新到B+树中,现在,原有的B+树还在,将add1、update2、add3 根据 key 组成一个新的B+树(此时在内存中操作B+树,所以不用担心效率),B+树到一定规模就刷新到磁盘上成一个文件。
- 以前对于一个数据表,只有一个B+树,现在有多个B+树文件(hbase)。读取时,就会逆序的一个一个检查B+树文件,直到key 被找到。 B+树文件越多,读取效率越低,因此会周期性的合并B+树文件
- 因为B+树文件 有一段在内存的空档期,为了防止数据丢失,自然就有一个WAL机制。
该策略 有一个问题是,大量的B+树文件被创建,在它们被合并之前,读效率很低。那么 出现了 Levelled Compaction(比如 LevelDB,其文件的存储结构可以参考redis 的skip list) ,通过优化 合并过程来提高 性能。