HBase row lock解析
行锁的使用场景
row lock,行锁,顾名思义,就是给行级的锁。那什么场景下会需要加行锁?行锁的开销如何?对性能的影响如何?如何实现的?下面我们逐个分析。
锁的产生一般都是为了做互斥或者同步,对HBase来说,各个请求之间的互斥/同步关系如下:
- 读读:不涉及
- 读写:通过mvcc保证读写的一致性
- 写写:094版本是互斥的,1.x系列不再互斥
- 写和read-and-modify:互斥
由于读写之间是通过mvcc进行协调的,所以,读本身是不需要加行锁的。MVCC机制确保了在并发读写同一行时,不会读到写了一半的数据。
HBase的数据更新操作可以分为3种
- 写
- 删除:本质仍然是写(写一个delete marker)
- read-and-modify(如increment/checkAndPut/checkAndDelete/append)
在094及更早的版本中,行锁是互斥的(与Mutex的效果一样,但不是用的Mutex),并发写同一行实际上是串行的。在经常需要并发写同一行的场景下(比如一些监控数据存储场景,HBase自己的replication机制是乱序的,也可能会并发写同一行),这种互斥对性能的损失无疑是巨大的。所以,后来将写的行锁从独占的锁修改为了读写锁,一般的写和删除操作都只加行锁的读锁,可以进行并发。只有read-and-modify操作会加写锁,因为它要确保读和写的事务性。在现实场景中,read-and-modify是非常少见的,同一行的并发读写和并发写也基本不存在。所以,行锁的冲突是非常少见的。从这个意义上讲,行锁本身不会给读写的时延带来很大影响。但在加锁过程中,确实new了一些对象,在较高的写吞吐时,会给YGC带来一定的压力。但这个跟memstore和mvcc比起来,基本可以忽略不计。
对于写写并发,会不会产生数据错乱呢?可能会,也可能不会。因为hbase使用timestamp来确定数据的覆盖关系(timestamp相同时,会比较复杂,有一些corner case,后面单独发文讨论)。在时间戳不同时,即使是并发的写,最终也能得到一个确定的结果。
下面,我们来看一下094和1.x版本中的行锁的实现。
实现:独占锁(094)
在讨论具体的实现之前,我们先看看行锁从设计层面涉及哪些实体,如何从上层来理解行锁。
- 粒度:行。每行都可以有一个锁,为了控制内存(不可能预先为每一行都创建好锁对象),一定是有人加行锁,其行锁才会存在,如果没有人持有锁,则行锁从内存中删除。
- lock ID:int类型,个人理解引入这个概念应该是为了方便客户端显式的加解锁,如果客户端不支持操作行锁,其实没有必要有这个概念。
- rowkey:要加锁的行的唯一标识
所以,这里可以看到两个很明显的需求:
- 根据rowkey来查找行锁:客户端和服务端来加锁,都只能通过rowkey来操作
- 根据lock ID来查找行锁:解锁时使用
因此,094的行锁实现中,引入了两个Map来分别支持上述两种操作。
(1)ConcurrentHashMap<HashedBytes, CountDownLatch> lockedRows
HashedBytes是rowkey byte[]的封装。因为Java中byte[]是不缓存hash code的。为了让byte[]能够更高效的作为HashMap的key,必须使其支持hashcode缓存,就像String一样。
这个Map提供了按rowkey查找行锁的方式。具体的锁对象是CountDownLatch。
(2)ConcurrentHashMap<Integer, HashedBytes> lockIds
这个Map提供了按lock id查找行锁的方式,key是锁id,value是被锁住的rowkey。
上面两个map都是HRegion内部的,即每个region都有上面两个map,所以,这里使用int来管理锁id。但这个锁id并非客户端拿到的锁id,对于客户端,HRegionServer层面对锁ID又进行了一次映射:
(3)ConcurrentHashMap<String, Integer> rowlocks
将region层面的锁id映射为一个long类型,将这个值的字符串形式作为map的key。这也是客户端显式加行锁时拿到的锁ID。
之所以这样做,是因为解锁的接口参数只有两个:region name和lock id。而rowkey居然没有在解锁接口里。因为底层的lockid是region层面的,同一张不同region的lockid可能会重复,所以,在region server层面必须做一次映射,来确保用户拿到的lockid在本RS是全局唯一的。想法是好的,但事实上根本没有保证,因为它用的是Random#nextLong(),可能会产生重复的值,而且,系统也没有做任何的检查。可能是用的人太少了,1.x版本已经移除了客户端显式操作行锁的相关接口。
加解锁流程
这里我们简要的过一下加锁和解锁的流程。
(1)加锁(HRegion#internalObtainRowLock()函数)
- 加region的close锁(startRegionOperation())
- 创建HashedBytes对象,封装rowkey
- 创建CountDownLatch(1)
- lockedRows.putIfAbsent(步骤3中创建的latch):如果putIfAbsent没有返回已经存在的latch对象,就说明自己拿到锁了(因为当前的latch是自己刚创建的了)。如果返回了一个latch对象,则说明当前有人持有锁,需不停的循环,直到自己拿到锁为止。所以,我们可以认为行锁的抢锁冲突就发生在ConcurrentHashMap的putIfAbsent函数里。
- 自己拿到锁了,需要给这个锁分配一个lockid,放到lockIds map中:生成一个随机的int,检查冲突,如果冲突,就循环再做一次,如果不冲突,就用它了。
- 在finally块中解close锁
虽然加锁的流程中有两个while(true)循环,但考虑到并发更新同一行的场景不多,所以,lockedRows的冲突不会很严重。另外,单region的写吞吐不会特别高,通常几千tps就已经不错了,上万的话,一般就有写热点的嫌疑,所以,分配lockid时也基本不太会遇到冲突。
这里比较低效的是每次都创建新的CountDownLatch对象和lockId的Integer。行锁是写链路的关键路径,我们应尽量少的创建新对象。这样,不仅仅是降低RT,同时也能减轻YGC的压力。
(2)解锁(HRegion#releaseRowLock(lockid)函数)
解锁流程的入参只有lockid,所以,我们必须先查找lockids表,得到对应的rowkey,然后在用这个rowkey查找lockedRows表,进行解锁。
我个人是非常不喜欢这种2层map的行锁设计的。跟HBase的很多流程一样,有过度设计的倾向,不够直白、高效。后续的HBase版本中,虽然移除了lockedIds这个map,但是锁的设计反而更复杂了。
实现:读写锁(1.x)
todo
后面有空再补吧~
是否可以不加行锁?
行锁是有开销的,那我们是否可以不加行锁呢?或者说,什么时候可以不加行锁呢?从上面的分析可知,行锁本身的用途其实只有一个,就是将的put/delete与read-and-modify操作进行互斥。如果我们能够确保read-and-modify的操作不存在,其实是可以完全不加行锁的。不加行锁可能确实会有收益,但收益有多大,我没有测试过。不过从实现层面看,行锁的开销,与region的close锁,updates锁比起来,非常的小。所以,去掉行锁的收益可能是非常有限的。
另外,“确保read-and-modify操作不存在”也是需要额外工作的。我们需要精确定义表的访问行为和约束,最好能够在表属性层面进行限制。比如phoenix的immutable_rows属性,表示表只插入数据,不修改数据(HBase无法高效的区分update和insert请求,所以,这个约束只能是弱约束)。但对于read-and-modify来说,是可以做到强约束的,即如果表属性不符合要求,checkAndPut接口可以直接抛错。这需要我们修改hbase,增加表属性,给接口添加属性检查。