[翻译]HBase 的 MVCC 和内建的原子操作

时间:2021-04-04 06:10:20

翻译一篇:HBase MVCC and built-in Atomic Operations

作者:Lars Hofhansl

HBase 有一些特殊的原子操作:

  • checkAndPut, checkAndDelete - 检查列值作为执行 put 和 delete 的前置条件,检查成功则执行。
  • Increment, Append - 对一个列 value 的原子操作,将一个整数值增加,或者在值的尾部附加(译注:我理解是非整数值,比如 String

checkAndPut 和 checkAndDelete 操作是幂等的,可以执行多次。但是在重试的过程中,如果有其他修改发生,结果可能就不一样了。

Increment 和 Append 不是幂等的。他们是 HBase 中唯一的不可重复操作。他们也是唯一的 HBase MVCC 模型提供的快照隔离机制不能完全管用的操作。

但是后来发现 checkAndPut 和 checkAndDelete 并没有像预期那样是原子的(HBASE-7051)。

通过代码很容易发现:

HBASE-4528 对 Put 做了优化,允许修改在同步到 WAL 之前释放行锁(rowlock)。这同时需要在 MVCC 修改提交之前释放锁,保证修改确认持久化之前对其他事务不可见。

其他的操作(比如 checkAndXXX)请求行锁做原子修改,尽管持有了行锁,但事实上可能并没有看到当前的快照,因为仍然有未完成的 MVCC 修改。这个快照只有在行锁被释放、重新获取之后才变成可见。所以后来的可能操作脏数据。在 HBASE-4528之后,持有行锁不再够用。

Increment 和 Append 有同样的问题。

修复 checkAndXXX 这个问题相对简单:实现一个"MVCC barrier"。比起先前在更新阶段结束的时候执行一个单独的 MVCC 事务(等待之前所有的事务结束),现在只需要在开始原子操作的检查获取(check and get)阶段之前就开始等待 ( 译注:checkAndXXX 分两个阶段,读检查阶段和修改阶段,之前是在修改阶段用一个 MVCC 事务,它会等待先前的事务结束,现在在读检查阶段就要等待先前的事务结束,避免脏读。MVCC 对于读操作是宽容的,而这个在读的时候就要等待了,所以降低了并发效率)。这样只是稍微降低了并发效率:在当前事务结束之前,需要无条件等待所有先前的事务。HBASE-7051完全是按照这样的方式实现了 checkAndXXX 操作。

前面提到,Increment 和 Append 有另一个问题,它们需要可串行化的事务。快照隔离不能满足需求。
比如:从0开始,一个操作 Increment 1,另一个操作 Increment 2,期望结果应该是3。但如果两个操作基于同一个快照0开始执行,结果要么是1,要么是2,取决于哪个先结束(显然这是不对的)。

Increment 和 Append 当前用一种很丑的办法解决了问题:当这些操作向 memstore 写入修改的时候,把所有新 KeyValue 的 memstoreTS 设置为0,效果是这些修改马上对其他事务可见,破坏了 HBase 的 MVCC 机制。查看ACID in HBase了解 memstoreTS 的含义(译注:不是一般提到的时间戳,写操作开始之前取回的先前最大的事务号,应该叫 WriteNumber,历史原因称为 memstore timestamp)。

这种做法保证了并发的 Increment 和 Append 操作的正确结果,但是对于并发 scanner 的可见度和预期是不一致的。在 Increment 和 Append 执行修改的过程中,并发的 scanner 本来应该看到先前版本的数据快照,但是现在却可以看到部分行修改的结果(译注:Increment 和 Append 修改应该是一个事务,但是前面的这种取巧的做法,使得部分修改结果提前曝光

Increment 和 Append 出于高吞吐量的设计考虑,他们会把HBase memstore 中刚刚修改后的列的老版本移除。这样一来,以丢失修改的版本历史为代价避免了 memstore 被 Increment 和 Append 的历史版本挤爆。这就是 HBase 中的 "插上(upsert)"(译注:upsert,与 insert 对比,in 表示插入,不覆盖,up 表示插上,历史版本没有了,相当于覆盖)。Upsert 的优点在于避免了 memstore 中充满"无人问津"的旧数据,缺点在于它们是 memstore 上的一类特殊操作(没有了版本历史),与 MVCCC 的理念不搭调(w.r.t. with respect to),同时它们不能与 mslab 兼容(memstore-local allocation buffers,见 Avoiding Full GCs in Apache HBase with MemStore-Local Allocation Buffers: Part 3)。

如果不关注数据可见性的话,丢掉 memstore 中老的值并不算个问题,如果坚持 MVCC 原理的话,需要先证明这样删除一个 KV 是安全的(即对并发访问没有影响)。

本人一年前试图通过 HBASE-4583来解决这个问题,但是经过与同事讨论后放弃了这个方案。

前段重新打开了 HBASE-4583,提供了一个彻底的补丁,除去了所有的 upsert 类逻辑(将 memstoreTS 设为0),统一在开始 Increment/Append 操作之前等待先前的事务。基于HBASE-4241进行了修改,在对 memstore 进行 flush 的时候,仅仅将有必要 flush 的列的版本 flush 出去。测试发现比先前慢大约10-15%,因为这种机制会频繁 flush memstore(其实很多是空文件)。但是这种方案避免了 upsert 那样逻辑特殊的代码,使得 Increment 和 Append 操作符合 HBase 的惯例(译注:符合了 MVCC 的并发控制机制),所以是值得的。

另一个不彻底的解决方案:让 upsert 操作兼顾 MVCC 。

实现起来不像说的那么简单。从 memstore 中移除一个列(一个 KeyValue 对象)的版本时候,需要证明该对象:既不被当前的并发 scanner 看到,又不会被将来的scanner看到。为了验证这个条件,需要找到所有 scanner 最早的 readpoint,并且保证这个 KV 对象至少有一个版本比那个最早的 readpoint 还要老;这样我们就可以安全地删除 memstore 中所有更老的版本了,因为所有的 scanner 都看到了更新的版本。

译注:设所有 scanner 最早的 readpoint 为t1,要删除的kv 版本的版本号为 t2,只要保证未移除的 KV 中存在一个数据版本 t3,有 t3<t1, 即所有 scanner 看到的都是 t3之后的快照,且 t2<t3 ,则说明当前要移除的 t2 是安全的,并且 t3 之前的所有版本,都可以删除

"不彻底"的补丁就是按照上面的逻辑实现的。

最终用 HBASE-4583 提交的版本是这样做的:

如果列族中有列经过 Increment 或 Append 操作后,VERSIONS 被设置为 1,执行一个兼顾 MVCC 的 upsert 操作;如果 VERSIONS>1,使用通常的逻辑(不用 upsert)将一个 KeyValue 添加到 memstore。这样在所有的情况下执行结果都符合预期:如果有请求访问多个版本,这多个版本都会被保留,即使在有 Increment 和 Append 情况下,做时间范围查找(time range)也能正常返回(译注:time range 查询查的是时间戳,会返回时间范围内所有版本的 KeyValue); 同时在 VERSIONS 为1的情况下保持了 upsert 操作的高性能。
译注:这里以 VERSIONS 作为判断的基准,是因为可以在建表的时候,设置保存 VERSIONS 的值,如果设为1,也就是默许了都是用 upsert,在折中里面选择了性能,如果设为>1,就是作者说的两种情况分别处理