前言

region服务器会将数据保存到内存中,直到积攒足够多的数据再将其刷写到硬盘上,这样可以避免创建很多小文件。存储在内存中的数据是不稳定的,例如,在服务器断电的情况下数据就可能会丢失。这是一个典型的问题。

WAL

我们知道存储在内存中的数据是不稳定的,一旦我们的机器断电就会导致我们内存中的数据丢失
一个比较常见的解决这个问题的方法是预写日志(WAL)°:每次更新(也叫做“编辑”)都会写入日志,只有写入成功才会通知客户端操作成功,然后服务器可以按需自由地批量处理或聚合内存中的数据。
当灾难发生的时候,WAL就是所需的生命线。类似于MySQL的binary log,WAL存储了对数据的所有更改。这在主存储器田现意外的情况下非常重要。如果服务器崩溃,它可以有效地回放日志,使得服务器恢复到服务器崩溃以前。这也就意味着如果将记录写入到WAL失败时,整个操作也会被认为是失败的。
如图展示了编辑流是怎样在memstore和WAL之间分流的。

所有的修改都先保存到WAL,再传递给memstore,处理过程如下:首先客户端启动一个操作来修改数据。例如,可以对put()、delete()和increment()进行调用。每一个修改都封装到一个KeyValue对象实例中,并通过RPC调用发送出去。这些调用(理想情况下)成批地发送给含有匹配 region的HRegionServer。
一旦KeyValue实例到达,它们会被发送到管理相应行的HRegion实例。数据被写入到WAL,然后被放入到实际拥有记录的存储文件的MemStore中。实质上,这就是HBase大体的写路径。
最后,当memstore达到一定的大小或是经历一个特定的时间之后,数据就会异步地连续写入到文件系统中。在写入的过程中,数据以一种不稳定的状态存放在内存中,即使在服务器完全崩溃的情况下,WAL也能够保证数据不会丢失,因为实际的日志存储在HDFS上。其他服务器可以打开日志文件然后回放这些修改——恢复操作并不在这些崩溃的物理服务器上进行。

HLog类

实现了WAL的类叫做HLog。当HRegion被实例化时,HLog实例会被当做一个参数传入到HRegion的构造器中。当一个region接收到一个更新操作时,它可以直接将数据保存到一个共享的WAL实例中去。
HLog类的核心功能是append()方法。注意,为了提高性能,在Put、Delete和Increment中可以使用一个额外的参数集合:setWriterowAL(false)。如果用户在设置时调用这个方法,例如,用户在设置一个Put实例时调用该方***导致向WAL写入数据的过程停止。这也是为什么图8-8中使用虚线创建的向下的箭头来表示可选步骤。默认情况下,用户最好使用WAL。但是,如果用户在运行一个离线的大批量导入数据的MapReduce作业时,其可以获得额外的性能,但是需要特别注意是否有数据在导入的时候丢失。
HLog的另一个特性是追踪修改,这个特性可以通过使用序列号来实现。它在内部使用一个进程安全的AtomicLong,且从0开始或从保存在文件系统中的最后一个所知的数字开始:当region 打开它的存储文件时,它读取存储在每一个HFile中meta域中最大的序列号,并且如果这个序列号大于之前记录的序列号,它就会把HLog的序列号设定为这个值。所以在打开所有存储文件的结尾后,HLog就会被初始化以反映存储在哪里结束以及从哪里继续存储。
如图展示了3个不同的region,它们存储在同一个region服务器上,并且每一个都包含不同的行键范围。每一个region 都共享同一个HLog实例。这意味着数据按照到达的顺序写入到WAL中,当日志需要回放(查看8.3.7节)时会产生额外工作。但是由于这很少发生,所以最优的做法就是按顺序存储,这样能够提供最好的IO性能。

图片说明
WAL按照修改的时间顺序存储,包括在同一个服务器上所有region

HLogKey类

WAL当前使用的是Hadoop的SequenceFile,这种文件格式按照键/值集合的方式存储记录。对WAL来说,值仅仅是客户端发送的修改请求。Key被HLogKey实例代表:
由于KeyValue仅仅代表行键、列族、列限定词、时间戳、类型以及值,所以要有一个地方来存储KeyValue的归属,即region和表名,这个信息存储在HLogKey中。HLogKey还存储了上面所提到的序列号。每一条记录的数字是递增的,以保持一个连续的编辑序列。
它还记录了写入时间,这是一个表示修改是什么时候被写入到日志的时间戳。最后,这个类存储了多个集群之间进行复制所需要的集群ID(clusterID)。

WALEdit类

客户端发送的每一个修改都会被封装到一个WALEdit实例。它通过日志级别来管理原子性。假设更新了一行中的10列,每一列或每一个单元格都是一个单独的KeyValue实例。
如果服务器将它们中的5个写入到WAL后就失败了,用户就会得到一半修改内容被持久化了的行。
这可以通过将包含多个单元格的,且所有被认为是原子的更新都写入到一个WALEdit实例中来解决。这一组的修改都会在一次操作中被写入,以保证日志的一致性。

LogSyncer类

表的描述符允许用户设置一个叫做延迟日志刷写(deferred log flush)的标志,这个值默认为talse,这意味着每一次编辑被发送到服务器时,它都会调用写日志的sync()方法。这个调用强迫写入日志的更新都会被文件系统确认,所以用户获得了持久性保证。
不幸的是,调用这个方***涉及一对N的服务器管道写(其中N是WAL文件的复制因子)。由于这是一个高代价的操作,所以可以选择稍微延迟这个调用,并让它在后台执行。记住,如果不调用sync()方法,那么在服务器出现故障的时候,将有一定几率造成数据丢失。请小心使用这个设置。
用户将defermed log flush 标志位设置为true会导致修改被缓存在region服务器中,然后在服务器上LogSyncer类会作为一个线程运行,负责在非常短的时间间隔内调用sync()方法。默认的时间间隔为1秒,可以通过hbase.regionserver.optionallogflushinterual属性来设置。
注意这只作用于用户表:所有的目录表会一直保持同步。

LogRoller类

日志的写入是有大小限制的。LogRoller类会作为一个后台线程运行,并且在特定的时间间隔内滚动日志。这可以通过hbase.regionserver.1ogro11.period属性来控制,默认值是1小时。
每60分钟旧的日志文件被关闭,然后开始使用新的日志文件。经过一段时间,系统会积攒一系列数量不断递增的日志文件,这些文件也需要维护。LogRoller会调用HLog.rol1Writer()方法来做上面所说的滚动当前日志文件的工作,接着HLog.ro11Writer()会调用HLog.cleanoldLogs()。
HLog.cleanoldlogs()会检查写入到存储文件中的最大序列号是多少,这是因为到这个序列号为止(小于或等于这个序列号)的所有修改都已经被保存了。然后它会检查是不是有日志文件的序列号都小于这个数字。如果是的话,它就会将这些文件移动到.oldlogs文件夹中,留下其余的日志。
其他控制日志滚动的参数有hbase.regionserver.hlog.blocksize(设置为文件系统默认的块大小或fs.1ocal.block.size,默认值是32MB)和hbase.region server.logrol1.multiplier
(设为0.95),这个参数表示当日志达到块大小的95%时就会滚动日志。所以,不管日志文件被认为已经满了,还是经过一定的时间文件达到了预设的大小,日志文件就会被滚动。

回放

master和region 服务器需要配合起来精确地处理日志文件,特别是需要从服务器失效中恢复的时候。WAL用来保持数据更新的安全,而回放则是一个使得系统恢复到一致性状态的更加复杂的过程。

单日志

因为所有的数据更新都会被写入到region服务器中的一个基于HLog的日志文件中去,为什么不分开将每个region的所有数据更改都写入到一个单独的日志文件中去呢?
下面是引自BigTable论文的相关内容:
如果我们将不同表的日志提交到不同日志文件中去的话,就需要向GFS并发地写入大量文件。以上操作依赖于每个GFS服务器文件系统的底层实现,这些写入会导致大量的硬盘寻道来向不同的物理日志文件中写入数据。
由于相同的原因,HBase也遵循这个原则:同时写入太多的文件,且需要保留滚动的日志会影响系统的扩展性。这种设计最终是由底层文件系统决定的。虽然在HBase中可以替换底层文件系统,但是通常情况下安装还是会选用HDFS。
通常情况下,以上设计不会造成什么问题,但当系统遇到一些错误时,以上设计可能将带来一些麻烦。如果用户的数据被及时地、安全地持久化了,那么所有事情就非常正常。但是只要遇到服务器崩溃,系统就需要拆分日志,即把日志分成合适的片,这些在下一节有相应的描述。现在的问题是,所有的数据更改的日志都混在一个日志文件中,并且没有任何索引。正是由于这个原因,master不可能立即把一个崩溃的服务器上的region部署到其他服务器上,它需要等待对应region的日志被拆分出来。如果服务器崩溃之前已经来不及将数据更新刷写到文件系统,对应的需要拆分的WAL数量也将非常庞大。

日志拆分

有两种日志文件需要被回放的情况:集群启动时或服务失效时。当master启动的时候——这也包括备用的master 接管系统的时候——它会检查文件系统中HBase根目录.logs文件夹下是不是有日志文件,以及这些日志有没有分配的region服务器。日志的名字不仅包含服务器的名字,还包含服务器的启动码(start code)。这个数字会在每次region服务器重启的时候重置。master可以通过这个数字来检查日志是否被遗弃了。
master还需要负责监控服务器如何使用ZooKeeper。当master检测到一个服务器失效时,它就会在重新分配region到新的服务器前立即启动一个进程来恢复日志文件。这项工作是由ServershutdownHandler类完成的。
在日志中的数据改动被回放之前,日志需要被单独放在每个region对应的单独的日志文件中。这个过程叫做日志拆分(log splitling):读取混在一起的日志,并且所有的条目都按照它所归属的region来分组。这些分组的修改记录被存放在一个紧挨着目标region的文件中以供接下来的数据恢复过程使用。
日志拆分的实质操作过程几乎在每一个HBase版本中都不太一样:早期版本会直接在master 上通过一个线程来读取文件。这个后来又提升为至少不同 region对应的修改是通过多线程的方式来重新执行。在0.92.0版本中,终于引入了分布式日志拆分(distributed log splitting)的概念,切分日志的实际工作从master转移到了region服务器上。
现在我们考虑一个更大的region服务器集群,该集群有很多的region服务器和很大的日志文件,过去master只能分别串行地恢复每个日志文件,这样它才不会在I/O和内存使用方面过载。这就意味着,每一个拥有被挂起的数据更改的 region都会被阻塞,直到日志拆分以及恢复完成之后才能被打开。
最新的分布式模式使用ZooKeeper 来将每一个被丢弃的日志文件分发给一个region 服务器。它们通过监测ZooKeeper来发现需要执行的工作,一旦master指出某个日志是可以被处理的,那么它们会竞争这个任务。获胜的region服务器就会在一个线程(为了不使已经负载很重的region服务器过载)中读取并且拆分这个日志文件。
拆分过程会首先将数据改动写入到HBase根目录下的splitlog暂存文件夹。它们已经被放置在与所需的目标 region相同的路径下。例如:

0/hbase/.corrupt
0/hbase/splitlog/foo.internal,60020,1309851880898_hdfs$3A82F%2F\
1ocalhost82Fhbase$2F.1ogs$2Efoo.interna1%2C60020%2C130985097120882F\
foo.interna1$252C60020%252C1309850971208.1309851641956/testtable/\
d9ffc3a5cd016ae58e23d7a6cb937949/recovered.edits/0000000000000002352

为了与其他日志区分开能够执行并发操作,路径中包含了日志文件名。路径中还包含了表名、region名(散列值)和recovered.edits文件夹。最后,拆分文件的名称是对应region的日志中第一次数据更改所对应的序列ID。
.corrupt文件夹包含所有不能被解析的日志文件。这种情况可以通过hbase.hlog.
split.skip.errors属性来改变,通常将其默认设置为true。这表示任何不可以从文件中读出来的数据更改都会使整个日志文件被移动到.corrupt文件夹中。如果将这一标志设置为false,则会抛出IOExecption异常,并且整个日志拆分过程都会被停止。
一旦日志文件被成功拆分,则每个region对应的文件就会被移动到实际的region目录。
然后region就可以使用对应的日志来恢复数据了。这也就是为什么拆分需要阻塞打开region,因为它需要首先提供挂起的修改来回放。

数据恢复

当集群启动,或region从一个region服务器移动到另一个region服务器时,region都会被打开,且此时region会首先检查recovered.edits目录是否存在。如果该目录存在,它就会打开该目录中的文件,并开始读取文件所包含的数据更改记录。由于文件是按照含序列ID的文件名排序的,region便可以按照序列ID的顺序来恢复数据。
任何序列ID小于或者等于保存在硬盘中存储文件序列ID的更改记录都会被忽略,因为这些记录之前的数据已经被刷写了。其他数据更新都会被添加到对应region的memstore中来恢复之前的数据状态。最后,一次强制的memstore刷写会将当前数据写入到硬盘中。
一旦recovered.edits文件夹中的文件都被处理完,且其中的数据更改也都被写入到硬盘后,该文件夹就会被删除。当出现文件不可读的情况时,hbase.skip.errors属性决定了接下来系统的行为:该属性为默认值false时,整个region 恢复过程失败;该属性为true时,对应文件就会被重命名为原始文件名加上.<currenttimemillis>。不管哪种情况,用户都需要小心地检查日志文件来判断为什么恢复会遇到问题,然后修复问题来让恢复过程继续执行。</currenttimemillis>

持久性

用户都想依靠系统来保存自己写入的所有数据,不论系统在内部使用了什么新奇的算法。在HBase系统中,用户可以尽量降低日志的刷写次数,也可以在每次数据更改时都同步日志。不管怎样做,用户都需要依赖上文提到的文件系统来做最后的持久化工作。用来存储数据的输出流已经被刷写了,但是对应的数据是否已经写入到硬盘中了呢?我们在谈论的是fsync相关的问题。对HBase来说,大多数情况下还是会选用Hadoop的HDFS作为存储数据的文件系统。
到现在为止,大家应当已经清楚日志是用来保证数据安全的。正是由于这个原因,日志有可能保持打开状态一小时(或者更多,如果用户配置过的话)。随着数据被不断写入,新的键/值对被写入到SequenceFile中,同时也间断地刷写到硬盘中。
但是,这种使用文件的方式并不是Hadoop设计的使用模式通常情况下,Hadoop会提供一套API并通过它向文件中写入数据(最好是大量的数据),此后立即关闭这个文件,并使之成为一个不可更改的文件,随后大家都可以多次读取这个文件。
同时文件只有在被关闭之后才对其他人可见和可读。如果在写入数据的过程中进程崩溃了,那么这个文件很大可能性会被认为丢失了。但对日志文件来说,能够读到上一次服务器崩溃时写操作的位置。这种属性被添加到了新版本的HDFS中,通常称为追加(append)属性。
HBase现在会检测当前的Hadoop库是否支持syncFs()或hflush()。如果在写入日志的时候触发sync(),则HBase会在内部调用以上两种方法之一。但是,如果HBase 运行在一个不需要持久的设置下,它什么也不会调用。sync()使用的是管道写来保证日志文件中数据更改记录的持久性,在服务器崩溃的情况下,系统可以安全地从废弃的日志文件中读取到最新的数据更改。