Abstract:本文提出了Sherman,一种在存算分离架构下的写优化的分布式B+树索引。Sherman结合了RDMA硬件特性和RDMA友好的软件技术,从三个角度提高了索引写性能。
- 为了减少往返,Sherman通过利用RDMA的按顺序交付特性来合并依赖的RDMA命令。
- 为了加速并发访问,Sherman引入了利用rdma网卡的HOCL锁。
- 为了减轻写放大,Sherman对B+Tree的数据结构布局进行了两级版本机制的裁剪。
实验结果表明,与现有技术相比,在典型的写密集型工作负载上,Sherman的吞吐量和99%的延迟都要快一个数量级。
Contributions:
- 对存算分离架构上现有树索引的分析,说明了单边方法中写入操作的低效率源于过多的往返、缓慢的同步原语和写入放大。
- Sherman的设计和实现,一种在存算分离架构上进行写优化的B+树索引,通过结合RDMA硬件功能和RDMA友好的软件技术来提高写性能。
- 实验评估显示Sherman在不同工作负载下的高性能
1 Introduction
Memory Disaggregation
传统的数据中心将CPU和Memory集成在一起,这样导致了内存的利用率非常低。为了解决这一问题,研究者们探索出了存算分离架构。如下图所示,这种架构将CPU和Memory在物理上分离成了两个独立的组件:compute severs(CSs) 和 memory servers(MSs)。CSs拥有大量的CPU内核(10s - 100s),同时为了减少从CSs到MSs的远程访问,CSs总是配备一小块内存作为本地缓存(1 - 10gb)。而MSs拥有大容量内存(100s - 1000s GB),但是拥有一小组很小的CPU核(1 - 2),以支持轻量级的管理任务,但是计算能力几乎为零。
RDMA
CSs中的CPU可以通过高速RDMA网络直接访问MSs中的分解内存。RDMA是一种高性能网络通信技术,允许计算机系统在网络上直接访问另一台计算机系统的内存,而无需涉及操作系统的干预。
RDMA提供了两种verbs:
- two-sided verbs(双边):RDMA_SEND和RDMA_RECV 用于发送和接收消息。
- one-sided verbs(单边):RDMA_WRITE、RDMA_READ和RDMA_ATOMIC(RDMA_FAA和RDMA_CAS) 直接在远程存储器上操作,而不涉及接收器的CPU。
Motivation
在这种存算分离架构中,
-
Excessive Round Trips:由于单侧动词的有限语义,修改索引节点(例如,B+树中的树节点)总是需要多次往返
-
Slow Synchronization Primitives:解决写-写冲突锁速度较慢,甚至在高争用场景下会可能出现性能崩溃。有以下三种原因:
- NIC采用昂贵的并发控制来确保RDMA原子命令之间的原子性,其中每个命令需要两个PCIe事务,延长了冲突命令的队列时间
- 不必要的重试:获取锁失败时,客户端线程重试,此类重试需要远程网络访问,浪费有限资源
- 缺乏公平性:现有锁没有考虑冲突锁之间获取的公平性,使得客户端请求饥饿
-
Write Amplification:写放大问题。RDMA更偏向于更小的I/O。
- B+树的树节点需要保证一定的顺序,但是每一次的节点移动都会造成写放大
- 粗粒度一致性检查:由于粗粒度的一致性检查机制,任何对部分节点区域的修改都需要回写整个节点,从而导致严重的写放大
Overview
下图为Sherman的总体架构。
-
B+Tree Structure:Sherman是一个B+树,其中的值存储在叶节点中。同时为每个叶节点和内部节点记录一个同级指针,在节点拆分/合并的情况下,客户端线程总是可以通过遵循这些同级指针来到达目标节点,从而有效地支持并发操作。Sherman中的每个指针(即子/兄弟指针)都是64位的,包括两部分:16位的MS唯一标识符和对应MS中的48位内存地址。
-
Concurrency Control
- 写-写冲突:Sherman使用节点粒度的独占锁来解决写冲突,在修改树节点之前,客户端线程必须获取关联的独占锁。
- 读-写冲突:Sherman支持无锁搜索,通过一致性检查机制,来检测由于写入引起的不一致数据。
-
Cache Mechanism:为了减少树遍历中的远程访问,Sherman采用了缓存机制。cache只存储两种类型的数据节点copies:
- 叶节点的上一层节点(level 1 in Figure 5)
- 包括根节点在内的最高两层节点
客户端线程首先查找类型2的节点,当缓存命中时,直接从MS获取目标叶节点;否则,他将搜索类型1的节点,通过RPC遍历所有节点。类型1所有节点都在缓存中。类型2的节点被构造成无锁skiplist,通过power-of-two-choices进行驱逐。随机选择两个缓存节点,驱逐最近使用最少的节点。
-
Memory Management.:
- 申请内存时,客户端线程使用两阶段内存分配方案从MS获取内存。客户端线程首先以轮询方式选择MS,并向MS请求空闲块。然后,它为块中的本地树节点分配内存空间。这样的两阶段方案有两个优点。首先,它避免了大多数分配操作的网络通信。其次,它显著降低了内存线程的处理开销
- 释放内存时,客户端线程只需要在释放树节点之前清除该节点中的一个空闲位;稍后在获取垃圾节点的请求时将意识到该节点已被释放。
Hierarchical On-Chip Lock
On-chip lock table
Sherman将锁从树节点中分离出来,并将锁存储到MS侧的on-chip中;每个树节点与保护它的锁在同一个MS中,每个MS中的这些锁被构造为一个数组,即全局锁表(GLT)。获取树节点的锁时,客户端线程首先将树节点的地址散列为相应GLT中的索引号,然后向锁发出RDMA_CAS命令。对于锁释放,客户端线程通过RDMA_WRITE命令清除锁。
Hierarchical structure
Sherman在每个CS中设计了一个本地锁表(LLT),以协调同一CS中的冲突锁请求。LLT为所有GLT中的每个锁存储一个本地锁。当线程需要锁定树节点时,它首先在LLT中获取关联的本地锁,然后在GLT中获取相关的锁;因此,来自相同CS的冲突锁定请求在CS的LLT上排队,避免了不必要的远程重试。对于锁释放,线程首先释放GLT中的锁,然后释放LLT中的本地锁。
Handover mechanism
HOCL的层次结构实现了一种切换机制。释放锁时,如果线程发现了本地锁的等待队列不是空的,它将把锁移交给等待队列的第一个线程。为了避免其他CS的线程饥饿,Sherman将连续切换的最大数量限制为4。移交锁的线程不再需要远程访问来获取锁,从而至少节省了一次往返。
HOCL伪代码如下所示:
MAX_LOCK_PRE_MS = 131072 # the number of locks in each MS
MAX_DEPTH = 4 # maximum number of consecutive handovers
def HOCL_Lock(addr):
idx = calculate_hash(addr) % MAX_LOCK_PRE_MS
l = LLT[addr.ms_id][idx]
if lock(l) == False: # get locks in LLT first
handover = False
# wait queue ensures first-come-first-served fairness
l.wait_queue.push(&handover)
while &handover != l.wait_queue.head():
continue
lock(l) # get locks in LLT first
l.wait_queue.pop()
if handover == True: # be handed over a lock
return
# get locks in GLT finally: RDMA_CAS(addr, compare, swap)
while RDMA_CAS(>L[addr.ms_id][idx], 0, cs_id) == False:
continue
def HOCL_Unlock(addr, combine_list):
idx = calculate_hash(addr) % MAX_LOCK_PRE_MS
l = LLT[addr.ms_id][idx]
handover = l.wait_queue.head()
if handover != None && (++l.handover_depth) <= MAX_DEPTH:
*handover = true # hand over lock to another thread
else:
l.handover_depth = 0
# collect an RDMA_WRITE command for lock (in GLT) release
# command's details: write 0 into 16-bit GLT[addr.ms_id][idx]
combine_list.push{(0, &GLT[addr.ms_id][idx], 16bit)}
unlock(l) # release locks in LLT
RDMA_WRITE combine_list # posting combined commands in list
Two-Level Version
为了能够以无锁的方式读取树节点,就需要在读取数据之前检测当前节点是否正在进行写入。有两种主要的一致性检查机制。
- 第一种机制是图中(a),每个节点都包括一个覆盖整个节点区域的校验和(校验和本身除外);校验和在修改相应节点时被重新计算,并且在读取节点时被验证
- 另一种机制是图中(b),在每个节点的开始和结束处存储一个version;当通过RDMA_WRITE修改节点时,相应的两个version递增;只有当两个version相同时,通过RDMA_READ获得的内容才是一致的。
由于上述两种机制的粒度是树节点,因此对部分节点区域的任何修改都需要写回整个节点(包括元数据,例如checksum和version),从而导致严重的写放大。因此为了解决写放大问题,herman采用了Two-Level Version机制。
-
首先,Sherman使B+树节点内部保持无序,这样就可以避免在插入/删除时的位移操作。但是可能会造成写操作的复杂度增加。
- 在查找键时,客户端线程需要遍历整个目标叶节点
- 在拆分叶节点之前,客户端线程必须对其中的条目进行排序
-
其次,Sherman引入了entry-level versions,实现细粒度一致性检查。在叶节点中,每个entry存储一对4位entry-level version(即FEV和REV)。此外,在每个叶节点的开始和结束处存储一对4位node-level version(即FNV和RNV),以保护节点粒度的一致性。当只涉及entry 级别的修改,相关联的entry-levels versions递增,并只需要将已经修改的entry通RDMA_WRITE写回,可以有效的避免写放大。当分割叶节点时,客户端线程递增相关联的FNV和RNV,并通过RDMA_WRITE写回整个节点。
Command Combination
为了减少往返,Sherman引入了一种命令组合技术。RDMA在硬件级别提供了一个强的顺序属性,在一个可靠的连接(RC)队列对中,RDMA_WRITE命令是按照它们被发布的顺序发送的,并且接收端NIC也按照这个顺序来执行这些命令。该技术让客户端可以将依赖的命令组合在一起同时发出(例如,写回和锁定释放)。
命令的组合有以下两种情况:
- 将树节点的回写和锁释放结合起来,这样可以节省一个往返行程,缩短关键路径
- 当一个节点A拆分时,我们检查新分配的兄弟节点是否与该节点A属于相同的MS;如果是,三个RDMA_WRITE命令可以组合在一起:➀写回同级节点,➁写回A,➂释放A的锁
Sherman的增删改查操作
插入操作伪代码
NODE_SIZE # size of a tree node
ENTRY_SIZE # size of an entry
def Sherman_insert(key, value):
leaf_addr = index_cache.find(key) # search index cache
HOCL_Lock(leaf_addr) # lock the targeted leaf node
node = RDMA_READ(leaf_addr, NODE_SIZE) # RDMA_READ(remote_addr, size);
combine_list = {} # RDMA_WRITE task list: (buffer, addr, size)
if ∃i,node.entry[i].k == key || node.entry[i].k == None: #update/insert
# entry-level node modification
node.entry[i].{k, v} = {key, value}
node.entry[i].front_entry_ver++
node.entry[i].rear_entry_ver++
# 个人感觉应该这样写
# node.entry[i].front_entry_ver++
# node.entry[i].{k, v} = {key, value}
# node.entry[i].rear_entry_ver++
# collect a combined command for write-back of the entry in leaf node
combine_list.push({node, leaf_addr+offset(entry[i]), ENTRY_SIZE})
else: # split
sibling = malloc(NODE_SIZE) # local buffer
sibling_addr = remote_alloc(NODE_SIZE) # allocate memory at MSs
node.sort().move_half_entries_to(sibling) # sort and move
sibling.{level, free} = {0, 0}
link(node, sibling) # link sibling and list: ... node → sibling ...
update_fence_keys(node, sibling)
# node-level node modification
node.add(key, value)
node.front_node_ver++
node.rear_node_ver++
if leaf_addr.ms_id == sibling_addr.ms_id:
# collect a combined command for write-back of the sibling node
combine_list.push({sibling, sibling_addr, NODE_SIZE})
else:
RDMA_WRITE(sibling, sibling_addr, NODE_SIZE)
# collect a combined command for write-back of the leaf node
combine_list.push({node, leaf_addr, NODE_SIZE})
HOCL_Unlock(leaf_addr, combine_list) # unlock the targeted leaf
if sibling != None: # update internal nodes
insert_internal(sibling.entry[0].k, sibling_addr)
查找操作伪代码
def Sherman_lookup(key):
leaf_addr = index_cache.find(key)
retry:
node = RDMA_READ(leaf_addr, sizeof(node))
# node-level check
if node.front_node_ver != node.rear_node_ver:
goto retry
if ∃e, e in node && e.k == key: # find targeted entry
# entry-level check
if e.front_entry_ver != e.rear_entry_ver:
goto retry
return e.v
return None
Sherman的github链接:https://github.com/thustorage/Sherman