HBase默认适用于写多读少的应用,正是依赖于它相当出色的写入性能:一个100台RS(主从)的集群可以轻松地支撑每天10T的写入量。当然,为了支持更高吞吐量的写入,HBase还在不断地进行优化和修正,这篇文章结合0.98版本的源码全面地分析HBase的写入流程,全文分为三个部分,第一部分介绍客户端的写入流程,第二部分介绍服务器端的写入流程,最后再重点分析WAL的工作原理

客户端流程解析

  • 用户提交 put 请求后,HBase客户端会将 put 请求添加到本地 buffer 中,符合一定条件就会通过 AsyncProcess 异步批量提交。HBase默认设置 autoflush=true,表示 put 请求直接会提交给服务器进行处理;用户可以设置autoflush=false,这样的话 put 请求会首先放到本地 buffer,等到本地 buffer 大小超过一定阈值(默认为2M,可以通过配置文件配置)之后才会提交。很显然,后者采用 group commit 机制提交请求,可以极大地提升写入性能,但是因为没有保护机制,如果客户端崩溃的话会导致提交的请求丢失

  • 在提交之前,HBase会在元数据表 .meta. 中根据 rowkey 找到它们归属的 region server(服务器端),这个定位的过程是通过HConnection 的 locateRegion 方法获得的。如果是批量请求的话还会把这些 rowkey 按照 HRegionLocation 分组,每个分组可以对应一次RPC请求

  • HBase 会为每个 HRegionLocation 构造一个远程 RPC 请求 MultiServerCallable,然后通过rpcCallerFactory. newCaller() 执行调用,忽略掉失败重新提交和错误处理,客户端的提交操作到此结束

    服务器端流程解析

    服务器端 RegionServer 接收到客户端的写入请求后,首先会反序列化为 Put 对象,然后执行各种检查操作,比如检查region 是否是只读、memstore大小是否超过 blockingMemstoreSize 等。检查完成之后,就会执行如下核心操作:
    数据写入流程解析 - 图1

  • 获取行锁、Region更新共享锁: HBase中使用行锁保证对同一行数据的更新都是互斥操作,用以保证更新的原子性,要么更新成功,要么失败

  • 开始写事务:获取write number,用于实现MVCC,实现数据的非锁定读,在保证读写一致性的前提下提高读取性能

    非锁定读(Consistent Nonlocking Reads)包含了两方面含义,一个是nonlocking,表明这种读操作不需要加S锁(共享锁),所以即便其他事务已经在该数据行加了X锁(排他锁),读操作仍然可以执行,不会阻塞。同样,一旦执行了读操作,其他事务仍然可以执行写操作,不会被阻塞。另一层含义是consistent,这个“一致性”取决于当前的隔离级别,在不同隔离级别下,读到的值是不同的。非锁定读可以保证即便其他事务修改了数据,但是当前读操作是不会被阻塞的,并且保证返回相应一致性要求下的快照数据。这个原理是由MVCC实现的

  • 写缓存memstore:HBase中每列族都会对应一个store,用来存储该列数据。每个store都会有个写缓存memstore,用于缓存写入数据。HBase并不会直接将数据落盘,而是先写入缓存,等缓存满足一定大小之后再一起落盘

  • Append HLog:HBase使用WAL(write ahead log)机制保证数据可靠性,即首先写日志再写缓存,即使发生宕机,也可以通过恢复HLog还原出原始数据。该步骤就是将数据构造为WALEdit对象,然后顺序写入HLog中,此时不需要执行sync操作。0.98版本采用了新的写线程模式实现HLog日志的写入,可以使得整个数据更新性能得到极大提升
  • 释放行锁以及共享锁
  • Sync HLog:HLog真正sync(同步)到HDFS,在释放行锁之后执行 sync 操作是为了尽量减少持锁时间,提升写性能。如果Sync失败,执行回滚操作将memstore中已经写入的数据移除
  • 结束写事务:此时该线程的更新操作才会对其他读请求可见,更新才实际生效。具体分析见文章数据库事务系列-HBase行级事务模型》
  • flush memstore:当写缓存满64M之后,会启动 flush 线程将数据刷新到硬盘。刷新操作涉及到HFile相关结构

    WAL机制解析

    WAL(Write-Ahead Logging)是一种高效的日志算法,几乎是所有非内存数据库提升性能的不二法门,基本原理是在数据写入之前首先顺序写入日志,然后再写入缓存,等到缓存写满之后统一落盘。之所以能够提升写性能,是因为WAL将一次随机写转化为了一次顺序写加一次内存写。提升写性能的同时,WAL可以保证数据的可靠性,即在任何情况下数据不丢失。假如一次写入完成之后发生了宕机,即使所有缓存中的数据丢失,也可以通过恢复日志还原出丢失的数据

    WAL持久化等级

    HBase中可以通过设置 WAL 的持久化等级决定是否开启 WAL 机制、以及 HLog 的落盘方式。WAL的持久化等级分为如下四个等级:

  • SKIP_WAL:只写缓存,不写HLog日志。这种方式因为只写内存,因此可以极大的提升写入性能,但是数据有丢失的风险。在实际应用过程中并不建议设置此等级,除非确认不要求数据的可靠性

  • ASYNC_WAL:异步将数据写入HLog日志中
  • SYNC_WAL:同步将数据写入日志文件中,需要注意的是数据只是被写入文件系统中,并没有真正落盘
  • FSYNC_WAL:同步将数据写入日志文件并强制落盘。最严格的日志写入等级,可以保证数据不会丢失,但是性能相对比较差
  • USER_DEFAULT:默认如果用户没有指定持久化等级,HBase使用 SYNC_WAL 等级持久化数据

用户可以通过客户端设置WAL持久化等级,代码:put.setDurability(Durability._SYNC_WAL_);

HLog数据结构

HBase中,WAL的实现类为 HLog,每个 Region Server 拥有一个 HLog 日志,所有 region 的写入都是写到同一个HLog。下图表示同一个 Region Server 中的 3 个 region 共享一个 HLog。当数据写入时,是将数据对 按照顺序追加到 HLog 中,以获取最好的写入性能
数据写入流程解析 - 图2
上图中 HLogKey 主要存储了 log sequence number,更新时间 write time,region name,表名 table name 以及cluster ids。其中 log sequncece number 作为 HFile 中一个重要的元数据,和 HLog 的生命周期息息相关,后续章节会详细介绍;region name 和 table name 分别表征该段日志属于哪个 region 以及哪张表;cluster ids 用于将日志复制到集群中其他机器上
WALEdit用来表示一个事务中的更新集合,在之前的版本,如果一个事务中对一行 row R 中三列 c1,c2,c3 分别做了修改,那么 hlog 中会有3个对应的日志片段如下所示:
:
:
:
然而,这种日志结构无法保证行级事务原子性,假如刚好更新到 c2 之后发生宕机,那么就会产生只有部分日志写入成功的现象。为此,hbase将所有对同一行的更新操作都表示为一个记录,如下:
:
其中WALEdit会被序列化为格式<-1, # of edits, , , >,比如<-1, 3, , , >,其中 -1 作为标示符表征这种新的日志结构

WAL写入模型

了解了 HLog 的结构之后,我们就开始研究 HLog 的写入模型。HLog 的写入可以分为三个阶段,首先将数据对写入本地缓存,然后再将本地缓存写入文件系统,最后执行 sync 操作同步到磁盘。在以前老的写入模型中,上述三步都由工作线程独自完成,如下图所示:
数据写入流程解析 - 图3
上图中,本地缓存写入文件系统那个步骤工作线程需要持有 updateLock 执行,不同工作线程之间必然会恶性竞争;不仅如此,在Sync HDFS这步中,工作线程之间需要抢占 flushLock,因为 Sync 操作是一个耗时操作,抢占这个锁会导致写入性能大幅降低
所幸的是,来自中国(准确的来说,是来自小米,鼓掌)的3位工程师意识到了这个问题,进而提出了一种新的写入模型并被官方采纳。根据官方测试,新写入模型的吞吐量比之前提升3倍多,单台RS写入吞吐量介于12150~31520,5台RS组成的集群写入吞吐量介于22000~70000(见HBASE-8755)。下图是小米官方给出来的对比测试结果:
数据写入流程解析 - 图4
在新写入模型中,本地缓存写入文件系统以及 Sync HDFS 都交给了新的独立线程完成,并引入一个 Notify 线程通知工作线程是否已经 Sync 成功,采用这种机制消除上述锁竞争,具体如下图所示:
数据写入流程解析 - 图5

  • 上文中提到工作线程在写入 WALEdit 之后并没有进行 Sync,而是等到释放行锁阻塞在 syncedTillHere 变量上,等待AsyncNotifier 线程唤醒
  • 工作线程将 WALEdit 写入本地 Buffer 之后,会生成一个自增变量 txid,携带此 txid 唤醒 AsyncWriter 线程
  • AsyncWriter 线程会取出本地 Buffer 中的所有 WALEdit,写入 HDFS。注意该线程会比较传入的 txid 和已经写入的最大txid(writtenTxid),如果传入的 txid 小于 writteTxid,表示该 txid 对应的WALEdit已经写入,直接跳过
  • AsyncWriter 线程将所有 WALEdit 写入 HDFS 之后携带 maxTxid 唤醒 AsyncFlusher 线程
  • AsyncFlusher 线程将所有写入文件系统的 WALEdit 统一 Sync 刷新到磁盘
  • 数据全部落盘之后调用 setFlushedTxid 方法唤醒 AyncNotifier 线程
  • AyncNotifier 线程会唤醒所有阻塞在变量 syncedTillHere 的工作线程,工作线程被唤醒之后表示 WAL 写入完成,后面再执行 MVCC 结束写事务,推进全局读取点,本次更新才会对用户可见

通过上述过程的梳理可以知道,新写入模型采取了多线程模式独立完成写文件系统、sync磁盘操作,避免了之前多工作线程恶性抢占锁的问题。同时,工作线程在将 WALEdit 写入本地 Buffer 之后并没有马上阻塞,而是释放行锁之后阻塞等待WALEdit 落盘,这样可以尽可能地避免行锁竞争,提高写入性能