分布式系统

HBase Architecture(译):中

2011年10月23日 阅读(263)

原文: http://ofps.oreilly.com/titles/9781449396107/architecture.html

译者:phylips@bmy 2011-10-1 

出处:http://duanple.blog.163.com/blog/static/709717672011923111743139/

1.   Write-Ahead Log

Region servers在未收集到足够数据flush到磁盘之前,会一直把它保存在内存中,这主要是为了避免产生太多的小文件。当数据驻留在内存中的时候,它就是不稳定的,比如可能会在服务器发生供电问题时而丢失。这是一个很典型的问题,参见the section called “Seek vs. Transfer”

 

解决这个问题的通常采用write-ahead logging策略:每次更新(也称为”edit”)之前都先写到一个log里,只有当写入成功后才通知客户端该操作成功了。之后,服务端就可以根据需要在内存中对数据进行随意地进行批处理或者是聚合。


1.1.  概览

WAL是灾难发生时的救生索。与MySQL中的binary log类似,它会记录下针对数据的所有变更。在主存产生问题的时候这是非常重要的。如果服务器crash了,它就可以通过重放日志让一切恢复到服务器crash之前的那个状态。同时这也意味着如果在记录写入到WAL过程中失败了,那么整个操作也必须认为是失败的。

 

本节主要解释下WAL如何融入到整个HBase的架构中。实际上同一个region server持有的所有regions会共享同一个WAL,这样对于所有的修改操作来说它提供了一个集中性的logging支持。图8.8展示了这些修改操作流在memstores和WAL之间的交互。

Figure 8.8. All modifications are first saved to the WAL, then passed on the memstores

HBase Architecture(译):中 - 星星 - 银河里的星星

 

过程如下:首先客户端发起数据修改动作,比如产生一个put(),delete()及increment()调用(后面可能简写为incr())。每个修改操作都会被包装为一个KeyValue对象实例,然后通过RPC调用发送出去。该调用(理想情况下是批量进行地)到达具有对应regions的那个HRegionServer。

 

一旦KeyValue实例到达,它们就会被发送到给定的行所对应的HRegion。数据就会被写入WAL,然后被存入相应的MemStore中。这就是大体上的HBase的一个write path。

 

最终,当memstore的达到一定大小后,或者过了特定时间段后,数据就会异步地持久化到文件系统中。在此期间数据都是保存在内存中的。WAL可以保证数据不会丢失,即使是在服务端完全失败的情况下。需要提醒一下,log实际上是存储在HDFS上的,任何其他的服务端都可以打开该日志然后replay其中的修改操作—这一切都不需要失败的那台物理服务器的任何参与。


1.2.  HLog

HLog实现了WAL。在一个HRegion实例化时,一个唯一的HLog实例会被传递给它作为构造函数参数。当一个region接收到更新操作时,它就可以将数据直接交给共享的WAL实例。

 

HLog类的核心功能部分就是append()函数,它内部又调用了doWrite()。需要注意的是,为了性能方面的考虑,可以针对Put,Delete和Increment操作设置一个选项,通过:setWriteToWAL(false)。在执行Put时,如果调用了该方法,那么将不会进行WAL的写入。这也是上面图中指向WAL的朝下箭头采用了虚线形式的原因。毫无疑问,默认情况下你肯定是需要写入WAL的。但是比如说,你可能正在运行一个大批量数据导入的MapReduce job,同时也可以在任何时刻重新执行它。为了性能考虑你可以关闭WAL写入,但是需要额外保证导入过程中不会丢失数据。

警告:强烈建议你不要轻易地关闭WAL的写入。如果你这样做了,那么丢数据只是早晚的事情。而且,HBase也没法恢复那些此前未写入到日志的丢失数据。

HLog的另一个重要功能是对变更的追踪。这是通过使用一个序列号做到的。它采用一个线程安全的内部AtomicLong变量,该变量要么从0开始,要么从已经持久化到文件系统中的最后的那个序列号开始:当region打开它对应的存储文件时,它会读取存在HFile的meta域里的最大序列号字段,如果它大于此前记录下的序列号,就会将它设置为HLog的序列号。这样,当打开所有的存储文件后,HLog的序列号就代表了当前序列化到的位置。

Figure 8.9. The WAL saves edits in the order they arrive, spanning all regions of the same server

HBase Architecture(译):中 - 星星 - 银河里的星星

 

上图代表了同一个region server上的三个不同的regions,每个region都包含了不同的row key range。这些regions共享同一个HLog实例。同时数据是按到达顺序写入WAL的。这就意味着在log需要进行replay时,需要做一些额外的工作(见the section called “Replay”)。这样做的原因是replay通常很少发生,同时也是为了实现顺序化存储优化,以得到最好的IO性能。


1.3.  HLogKey

当前的WAL实现采用了Hadoop SequenceFile,它会将记录存储为一系列的key/values。对于WAL来说,value通常是客户端发送来的修改。Key是通过一个HLogKey实例表示的:因为KeyValue仅表示了row key,column family,column qualifier,timestamp,type和value;这样就需要有地方存放KeyValue的归属信息,比如region和table名称。这些信息会被存储在HLogKey中。同时上面的序列号也会被存进去,随着记录的加入,该数字会不断递增,以保存修改操作的一个顺序。

 

它也会记录写入时间,一个用来代表修改操作何时写入log的时间戳。最后,它还存储了cluster ID,以满足用户多集群间的复制需求。


1.4.  WALEdit

客户端发送的每个修改操作都是通过一个WALEdit实例进行包装。它主要用来保证log级的原子性。假设你正在更新某一行的十个列,每列或者说是每个cell,都有自己的一个KeyValue实例。如果服务器只将它们中的五个成功写入到WAL然后失败了,这样最后你只得到了该变更操作的一半结果。

 

通过将针对多个cells的更新操作包装到一个单个WALEdit实例中,将所有的更新看做是一个原子性的操作。这样这些更新操作就是通过单个操作进行写入的了,就保证了log的一致性。

注:在0.90.x之前,HBase确实是将这些KeyValue实例分别保存的。


1.5.  LogSyncer

Table descriptor允许用户设置一个称为log flush延迟的flag,参见the section called “Table Properties”。该值默认是false,意味着每次当一个修改操作发送给服务器的时候,它都会调用log writer的sync()方法。该调用会强制将日志更新对于文件系统可见,这样用户就得到了持久性保证。

 

不幸的是,该方法的调用涉及到对N个服务器的(N代表了write-ahead log的副本数)流水线写操作。因为这是一个开销相当大的操作,因此提供给用户可以轻微地延迟该调用的机会,让它通过一个后台进程执行。需要注意的是,如果不进行sync()调用,在服务器失败时就有可能丢数据。因此,使用该选项时一定要小心。

流水线 vs. n-路写入

当前的sync()实现是一个流水线式的写入,这意味着当修改操作被写入时,它会首先被第一个data node进行持久化。成功之后,它会被该data node发送给另一个data node,如此循环下去。只有当所有的三个确认了该写操作后,客户端才能继续执行。

将修改操作进行持久化的另一种方式是使用n-路写,该写入请求同时发送给三个机器。当所有机器确认后,客户端再继续。

不同之处在于,流水线写需要更多的时间来完成,因此具有更高的延迟。但是它可以更好地利用网络带宽。n-路写具有低延迟,因为客户端只需要等待最慢的那个data node的确认。但是所有的写入者需要共享发送端的网络带宽,对于一个高负载系统来说这会是一个瓶颈。

目前HDFS已经在开展某些工作以同时支持这两种方式。这样用户就可以根据自己应用的特点选择性能最好的那种方式。

将log flush延迟 flag设为true,会导致修改操作缓存在region server,同时LogSyncer会作为一个服务端线程负责每隔很短时间段调用sync()方法,该时间段默认是1秒钟,可以通过hbase.regionserver.optionallogflushinterval配置。

 

需要注意的是:只对用户表应用它,所有的元数据表必须都是立即sync的。


1.6.  LogRoller

写入的log有一个大小限制。LogRoller类作为一个后台线程运行,负责在特定的区间内rolling log文件。该区间由hbase.regionserver.logroll.period控制,默认设为1小时。

 

每60分钟当前log会被关闭,启动一个新的。随着时间的推移,系统积累的log文件数也在不断增长,这也是需要进行维护的。HLog.rollWriter()方法会被LogRoller调用已完成上面的当前log文件的切换。后面的会通过调用HLog.cleanOldLogs()完成。

 

它会检查写入到存储文件中的最大序列号,因为在此序列号之前的都被持久化了。然后在检查是否有些log文件,它们的修改操作的序列号都小于这个序列号。如果有这样的log文件,它就把它们移入到.oldlogs目录,只留下那些还需要的log文件。

注意,你可能在log中看到如下晦涩的消息

2011-06-15 01:45:48,427 INFO org.apache.hadoop.hbase.regionserver.HLog: \  Too many hlogs: logs=130, maxlogs=96; forcing flush of 8 region(s):  testtable,row-500,1309872211320.d5a127167c6e2dc5106f066cc84506f8., …

上面信息是因为当前未被持久化的log文件数已经超过了配置的可持有的最大log文件数。发生这种情况,可能是因为用户文件系统太忙了,导致数据增加的速度超过了数据持久化的速度。此外,memstore的flush也与之相关。

当该信息出现时,系统将会进入一种特殊工作模式,以尽力将修改操作持久化以降低需要保存的log文件数。

控制log rolling的其他参数还有:

hbase.regionserver.hlog.blocksize(设定为文件系统默认的block大小,或者是fs.local.block.size,默认是32MB)和hbase.regionserver.logroll.multiplier(设为0.95),当日志到达block size的95%时会开始rotate。这样,当日志在填满或者达到固定时间间隔后会进行切换。


1.7.  Replay

Master和region servers需要小心地进行log文件的处理,尤其是在进行服务器错误恢复时。WAL负责安全地保存好各种操作日志,将它进行replay以恢复到一致性状态是一个更复杂的过程。


1.7.1.             Single Log

因为每个region server上的所有修改操作都是写入到同一个基于HLog的log文件内的,你可能会问:为什么这样做呢?为什么不是为每个region单独创建一个它自己的log文件呢。下面是引用自Bigtable论文中的内容:

如果为每个tablet保存一个单独的commit log,那么在GFS上将会有大量的文件被并发地写入。由于依赖于每个GFS server上的底层的文件系统,这些写入会因为需要写入到不同的物理日志文件产生大量的磁盘seek操作。

HBase因为相同的原因而采取了类似策略:同时写入太多文件,再加上log的切换,这会降低可扩展性。所以说这个设计决定源自于底层文件系统。尽管可以替换HBase所依赖的文件系统,但是最常见的配置都是采用的HDFS。

 

目前为止,看起来好像没什么问题。当出错的时候,问题就来了。只要所有的修改操作都及时地完成,数据安全地被持久化了,一切都很顺利。但是在服务器crash时,你就不得不将log切分成合适的片段。但是因为所有的修改操作掺杂在一块也根本没有什么索引。这样master必须等待该crash掉的服务器的所有日志都分离完毕后才能重新部署它上面的region。而日志的数量可能会非常大,中间需要等待的时间就可能非常长。


1.7.2.             Log Splitting

通常有两种情况日志文件需要进行replay:当集群启动时,或者当服务器出错时。当master启动—(备份master转正也包括在内)—它会检查HBase在文件系统上的根目录下的.logs文件是否还有一些文件,目前没有安排相应的region server。日志文件名称不仅包含了服务器名称,而且还包含了该服务器对应的启动码。该数字在region server每次重启后都会被重置,这样master就能用它来验证某个日志是否已经被抛弃。

 

Log被抛弃的原因可能是服务器出错了,也可能是一个正常的集群重启。因为所有的region servers在重启过程中,它们的log文件内容都有可能未被持久化。除非用户使用了graceful stop(参见the section called “Node Decommission”)过程,此时服务器才有机会在停止运行之前,将所有pending的修改操作flush出去。正常的停止脚本,只是简单的令服务器失败,然后在集群重启时再进行log的replay。如果不这样的话,关闭一个集群就可能需要非常长的时间,同时可能会因为memstore的并行flush引起一个非常大的IO高峰。

 

Master也会使用ZooKeeper来监控服务器的状况,当它检测到一个服务器失败时,在将它上面的regions重新分配之前,它会立即启动一个所属它log文件的恢复过程,这发生在ServerShutdowHandler类中。

 

在log中的修改操作可以被replay之前,需要把它们按照region分离出来。这个过程就是log splitting:读取日志然后按照每条记录所属的region分组。这些分好组的修改操作将会保存在目标region附近的一个文件中,用于后续的恢复。

 

Logs splitting的实现在几乎每个HBase版本中都有些不同:早期版本通过master上的单个进程读取文件。后来对它进行了优化改成了多线程的。0.92.0版本中,最终引入了分布式log splitting的概念,将实际的工作从master转移到了所有的region servers中。

 

考虑一个具有很多region servers和log文件的大集群,在以前master不得不自个串行地去恢复每个日志文件—不仅IO超载而且内存使用也会超载。这也意味着那些具有pending的修改操作的regions必须等到log split和恢复完成之后才能被打开。

 

新的分布式模式使用ZooKeeper来将每个被抛弃的log文件分配给一个region server。同时通过ZooKeeper来进行工作分配,如果master指出某个log可以被处理了,这些region servers为接受该任务就会进行竞争性选举。最终一个region server会成功,然后开始通过单个线程(避免导致region server过载)读取和split该log文件。

注:可用通过设置hbase.master.distributed.log.splitting来关闭这种分布式log splitting方式。将它设为false,就是关闭,此时会退回到老的那种直接由master执行的方式。在非分布式模式下,writers是多线程的,线程数由hbase.regionserver.hlog.splitlog.writer.threads控制,默认设为3。如果要增加线程数,需要经过仔细的权衡考虑,因为性能很可能受限于单个log reader的性能限制。

Split过程会首先将修改操作写入到HBase根文件夹下的splitlog目录下。如下:

    0 /hbase/.corrupt      0 /hbase/splitlog/foo.internal,60020,1309851880898_hdfs%3A%2F%2F \localhost%2Fhbase%2F.logs%2Ffoo.internal%2C60020%2C1309850971208%2F \foo.internal%252C60020%252C1309850971208.1309851641956/testtable/ \d9ffc3a5cd016ae58e23d7a6cb937949/recovered.edits/0000000000000002352

为了与其他日志文件的split输出进行区分,该路径已经包含了日志文件名,因该过程可能是并发执行的。同时路径也包含了table名称,region名称(hash值),以及recovered.edits目录。最后,split文件的名称就是针对相应的region的第一个修改操作的序列号。

 

.corrupt目录包含那些无法被解析的日志文件。它会受hbase.hlog.split.skip.errors属性影响,如果设为true,意味着当无法从日志文件中读出任何修改操作时,会将该文件移入.corrupt目录。如果设为false,那么此时会抛出一个IOExpectation,同时会停止整个的log splitting过程。

 

一旦log被成功的splitting后,那么每个regions对应的文件就会被移入实际的region目录下。对于该region来说它的恢复工作现在才就绪。这也是为什么splitting必须要拦截那些受影响的regions的打开操作的原因,因为它必须要将那些pending的修改操作进行replay。


1.7.3.             Edits Recovery

当一个region被打开,要么是因为集群启动,要么是因为它从一个region server移到了另一个。它会首先检查recovered.edits目录是否存在,如果该目录存在,那么它会打开目录下的文件,开始读取文件内的修改操作。文件会根据它们的名称(名称中含有序列号)排序,这样region就可以按顺序恢复这些修改操作。

 

那些序列号小于等于已经序列化到磁盘存储中的修改操作将会被忽略,因为该修改操作已经被apply了。其他的修改操作将会被apply到该region对应的memstore中以恢复之前的状态。最后,会将memstore的内容强制flush到磁盘。

 

一旦recovered.edits中的文件被读取并持久化到磁盘后,它们就会被删除。如果某个文件无法读取,那么会根据hbase.skip.errors来确定如何处理:默认值是false,会导致整个region恢复过程失败。如果设为true,那么该文件会被重命名为原始名称+” .<currentTimeMillis>”。不管是哪种情况,你都需要仔细检查你的log文件确认问题产生的原因及如何fix。


1.8.  持久性

无论底层采用了什么稀奇古怪的算法,用户都希望可以依赖系统来存储他们所有的数据。目前HBase允许用户根据需要调低log flush的时间或者是每次修改操作都进行sync。但是当存储数据的stream被flush后,数据是否真的写入到磁盘了呢?我们会讨论下一些类似于fsync类型的问题。当前的HBase主要依赖于底层的HDFS进行持久化。

 

比较明确的一点是系统通过log来保证数据安全。一个log文件最好能在长时间内(比如1小时)一直处于打开状态。当数据到达时,一个新的key/value对会被写入到SequenceFile,同时间或地被flush到磁盘。但是Hadoop并不是这样工作的,它之前提供的API,通常都是打开一个文件,写入大量数据,立即关闭,然后产生出一个可供其它所有人读取的不可变文件。只有当文件关闭之后,对其他人来说它才是可见的可读的。如果在写入数据到文件的过程中进程死掉通常都会有数据丢失。为了能够让日志的读取可以读到服务器crash时刻最后写入的那个位置,或者是尽可能接近该位置,这就需要一个feature:append支持。

插曲:HDFS append,hflush,hsync,sync…

HADOOP-1700就已经提出,在Hadoop 0.19.0中,用来解决该问题的代码就已提交。但是实际情况是这样的:Hadoop 0.19.0里的append实现比较糟糕,以至于hadoop fsck会对HBase打开的那些日志文件向HDFS报告一个数据损坏错误。

所以在该问题又在HADOOP-4379即HDFS-200被重新提出,之后实现了一个syncFs()函数让对于一个文件的变更更可靠。有段时间我们通过客户端代码来检查Hadoop版本是否包含了该API。后来就是HDFS-265,又重新回顾了append的实现思路。同时也引入了hsync()和hflush()两个syncable接口。

需要注意的是SequenceFile.Writer.sync()跟我们这里所说的sync方法不是一个概念:SequenceFile中sync是用来写入一个同步标记,用于帮助后面的读取操作或者数据恢复。

HBase目前会检测底层的Hadoop库是否支持syncFs()或者hflush()。如果在log writer中一个sync()调用被触发,它就会调用syncFs()或者hflush()中的一个方法—或者是不调用任何方法,如果HBase工作在一个non-durable setup上的话。Sync()将会使用流水式的write过程来保证日志文件中的修改操作的持久性。当服务器crash的时候,系统就能安全地读取被抛弃的日志文件更新到最后的修改操作。

 

大体上,在Hadoop 0.21.0版本之前,经常会碰到数据丢失。具体细节参见the section called “Hadoop”

You Might Also Like