问题
分布式内存键值(KV)存储正在采用分离式内存(DM)体系结构以提高资源利用率。然而,现有的DM上的KV存储采用半分离式设计,在DM上存储KV对,但在单个元数据服务器上管理元数据,因此仍然在元数据服务器上遭受低资源效率的问题。
如图1a,Clover[60]采用半分离式设计,在计算节点(CN)上部署客户端,在内存节点(MN)上存储KV对,采用额外的单个元数据服务器来管理元数据,包括内存管理信息(MMI)和哈希索引。对于搜索请求,客户端从元数据服务器查找KV对的地址,然后使用RDMA_READ操作获取MN上的数据。对于插入和更新请求,客户端使用RPC从元数据服务器分配内存块,使用RDMA_write操作将KV对写入MN,并通过RPC更新元数据服务器上的哈希索引。为了防止客户端的频繁请求淹没元数据服务器,客户端一次分配一批内存块,并在本地缓存哈希索引。
挑战
构建完全内存分解的KV存储存在三个挑战:索引复制、远程内存分配和元数据损坏。
-
以客户端为中心的索引复制。为了容忍内存节点故障,客户端需要在内存池中的内存节点上复制索引,并保证索引副本的一致性,保证线性化。传统方法以服务器为中心,即数据副本是由管理数据的CPU独占访问和修改的,通过状态机复制(SMR)[33,44,46,49,50,59,62]和共享寄存器协议[7,43]的方法。但不适用于以客户端为中心,所有客户端都可以使用单侧RDMA动词直接访问和修改哈希索引,缺少统一的中心化管理节点。如果客户端简单地使用共识协议[36、46、50]或远程锁[60]实现序列化,受限于MN计算能力弱,KV存储的可扩展性较差[4,11,64,70]。
-
远程内存分配。有两种DM管理方法:以计算为中心和以内存为中心。以计算为中心将内存管理元数据存储在MN上,允许客户端直接修改MN上的元数据来分配内存空间,因此必须同步客户端的访问。导致受到DM上远程同步所导致的高内存分配延迟的影响[38]。以内存为中心在计算能力较弱的MN上维护所有内存管理元数据,但来自客户端的频繁细粒度KV分配请求会淹没内存侧计算能力[25,60]。
-
客户端故障导致元数据损坏。客户端故障可能会使其他客户端访问部分修改的元数据,从而损害整个KV存储的正确性。崩溃的客户端可能会使索引处于部分修改的状态,导致其他客户端无法访问数据。崩溃的客户端可能会分配内存空间,导致严重的内存泄漏。
本文方法
本文提出了FUSEE,一种完全内存分离的KV存储,将分离引入到元数据管理中。FUSEE在内存节点上复制元数据,即索引和内存管理信息,在客户端直接管理它们,并在DM体系结构下处理复杂的故障。
-
为了在客户端上可扩展地复制索引,FUSEE提出了一种客户端为中心的复制协议SNAPSHOT,允许客户端同时访问和修改复制的索引。在不涉及昂贵的请求序列化的情况下解决写冲突。SNAPSHOT在数据副本中更新,客户端能够协作决定写入冲突下的最后一个写入程序,最后的写者获胜。
-
为了高效管理分离的内存,采用了两级内存管理方案,将以服务器为中心的内存管理过程分为轻计算和重计算任务。计算量较小的粗粒度内存块由计算能力较弱的内存节点管理,计算量较大的细粒度对象由客户端处理。
-
为了处理客户端故障下的元数据损坏,采用嵌入式操作日志恢复客户端部分执行的操作。嵌入式操作日志重用内存分配顺序,并将日志条目嵌入KV对中,以减少DM上的日志维护开销。
我们使用微型和YCSB混合基准对FUSEE进行评估。实验结果表明,FUSEE在资源消耗更少的情况下,比DM上的最先进的KV存储性能提高了多达4.5倍。
FUSEE 设计
如图4所示,FUSEE由客户端、MN和主机组成。客户端为应用程序提供SEARCH、INSERT、DELETE和UPDATE接口。MN存储内存管理信息(MMI)、哈希索引和KV对,通过复制哈希索引和KV对以容忍MN故障。采用RACE哈希来索引KV对;并提出SNAPSHOT复制协议来保证复制哈希索引的强一致性;采用两级内存管理方案,有效地分配和复制可变大小的KV对;使用日志处理客户端故障下损坏的元数据,并采用嵌入式操作日志方案来减少日志维护开销。
RACE 哈希
一种单侧RDMA友好的哈希索引,如图5所示,包含多个8字节的槽,每个槽存储一个指向KV对地址的指针、KV对的长度(Len)、8位指纹(Fp)[73]。对于SEARCH请求,客户端根据目标密钥的哈希值读取哈希索引的槽,然后根据槽中的指针读取MN上的KV对。对于UPDATE、INSERT和DELETE请求,首先向MN写入一个KV对,然后用RDMA_CAS原子地将哈希索引中的相应槽修改为KV对的地址。
SNAPSHOT 复制协议
以客户端为中心的复制协议,在不进行请求序列化的情况下实现线性化。在内存分解下实现线性化有两个主要挑战:
-
如何在读写冲突期间保护读者避免读不完整数据:将复制的哈希索引拆分为单个主副本和多个备份副本,使用备份副本来解决写入冲突。因此,写入冲突期间的不完整状态仅出现在备份副本上,主副本始终包含正确和完整的值,读者可以简单地读主副本中的内容。
-
如何在不昂贵地序列化所有冲突请求的情况下解决写写冲突:采用类似于共享寄存器协议的最后写入者获胜冲突解决方案。利用了RACE哈希的错位修改特性,即冲突的写者总是将不同的值写入同一插槽,因为这些值是指向不同位置的KV对的指针。因此,根据备份副本中冲突写入器写入的值定义了三个冲突解决规则,使客户端能够协同决定写入冲突下的最后一个写入器。
对于READ操作,客户端使用RDMA_READ直接读取主插槽中的值。
对于WRITE操作,让冲突的写入者使用三个冲突解决规则协同决定最后一个写入者来解决写入冲突,随后让最后一个编写者修改主插槽。图6显示了两个客户端同时写入同一插槽的过程。(1)客户端读取主槽中的值为vold,(2)客户端通过广播RDMA_CAS操作修改所有备份槽,vold为期望值,vnew为交换值。在接收到RDMA_CAS时,MN仅在vold与当前值匹配的情况下原子地修改目标中的值。由于所有写入程序都使用相同的vold和不同的vnew启动RDMA_CAS操作,并且所有备份插槽最初都为vold,因此RDMA_CAS的原子性确保每个备份插槽只能由单个写入程序修改一次。在所有备份槽中都从一个写入器接收到RDMA_CAS之后,所有备份槽的值将是固定的。(3)由于RDMA_CAS在修改之前返回槽中的值,因此所有客户端可以通过RDMA_ CAS操作的返回值来感知槽中的新值,得到v_list。
SNAPSHOT定义了三条规则,让冲突的客户端协同决定最后一个写入器:
-
规则1:成功修改所有备份插槽的客户端是最后一个写入器。
-
规则2:成功修改了大多数备份插槽的客户端是最后一个写入器。
-
规则3:如果前两条规则无法确定,则写入最小目标值(vnew)的客户端是最后一个写入器。
三条规则按顺序进行评估。当没有冲突时,规则1提供了一条快速路径。规则2保留了最成功的CAS操作,以在冲突时最大限度地减少在RNIC上执行原子操作的开销[29]。规则3确保协议始终可以决定最后一个写入器。
为了确保最后一次写入的唯一性,客户端在评估规则3之前,会发出另一个RDMA_READ来检查主插槽是否已被修改。如果主插槽尚未修改,则客户端的RDMA_CAS_backup必须在最后一个写入程序修改主插槽之前发生。因此,评估规则3是安全的,因为v_list必须包含最后一个写入器的值,否则,规则3将不会被评估,因为主插槽的修改意味着决定了最后一个写入器。
性能:保证客户端写入哈希索引时的最坏情况延迟。在触发规则1或规则2或规则3的情况下,分别需要3或4或5个RTT完成WERTE。
两级内存管理
内存管理负责为MN上的KV对分配、复制和释放内存空间。FUSEE提出两级内存管理方案,关键思想是将以服务器为中心的内存管理任务分为在MN和客户端上运行的轻计算粗粒度管理和重计算细粒度管理。
首先在多个MN上复制和分区48位内存空间,将内存空间分片为2GB内存区域,并使用一致性哈希将每个区域映射到rMN[32],其中r是复制因子。具体来说,一致性哈希将一个区域映射到哈希环中的一个位置。然后,副本被连续存储在该位置之后的r个MN中,主区域被放置在r个MN的第一个上。
图7显示了两级内存分配。第一级是轻计算的粗粒度MN侧内存块分配。每个MN将其本地存储区域划分为16MB的粗粒度存储块,并在每个区域前维护一个块分配表,块分配表记录了分配它的客户端ID(CID)。客户端通过向MN发送ALLOC请求来分配内存块,在接收到ALLOC请求时,MN从其主存储区域之一分配存储块,将客户端ID记录在主存储区域和备份区域的块分配表中,并向客户端回复存储块的地址。因此,粗粒度的内存分配信息在r个MN上复制,确保在MN故障时保留。第二级是细粒度的客户端对象分配,分配小对象来保存KV对。客户端仅使用板分配器管理从MN分配的块,客户端板分配器将内存块拆分为不同大小类别的对象,从适合它的最小尺寸类别中分配一个KV对。
任何客户端都可以释放分配的对象。为了在客户端有效地回收释放的内存对象,在每个内存块之前存储一个空闲位图,其中每个位表示内存块中一个对象的分配状态。通过读取空闲位图,客户端可以知道其内存块中的空闲对象,并在本地回收它们。FUSEE以批处理的方式使用后台线程定期释放和回收内存对象,以避免在KV访问的关键路径上进行额外的RDMA操作。
嵌入式操作日志
为了减少DM上的日志维护开销,采用了嵌入式操作日志方案,将日志条目嵌入到KV对中。嵌入式日志条目与其对应的KV对一起通过RDMA_WRITE操作写入,消除了持久化日志条目的额外RTT。然而,在KV对中嵌入日志条目,无法维持KV请求的执行顺序,因为日志条目不连续。
因此,维护了按大小分类的链表,以按照KV请求的执行顺序组织客户端的日志条目。如图8b所示,每个链表是一个双向链表,按照分配的顺序链接大小分类的所有分配对象。对象分配顺序反映了KV请求的执行顺序,因为所有修改哈希索引的KV请求,例如INSERT和UPDATE,都需要为新的KV对分配对象。对于DELETE,FUSEE分配一个记录日志条目和目标键的临时对象,并在完成DELETE请求后回收该对象。FUSEE在客户端初始化期间将列表头存储在MN上,这些列表头将在客户端恢复过程中访问。
FUSEE将链表维护过程与内存分配过程共同设计,对于每个大小的类,客户端在本地将远程空闲对象的地址组织为空闲列表。由于对象总是从空闲列表的头部分配的,因此每个大小的类的分配顺序都是预先确定的。基于预先确定的顺序,每次分配时,客户端将下一个指针预先定位在本地空闲列表的头部,指向空闲对象,并将上一个指针预定位在大小类的最后一个分配对象。因此,在每次分配之前,下一个指针和上一个指针都是已知的,整个日志条目可以在单个RDMA_WRITE中用KV对写入MN。
自适应索引缓存
对于一个键,索引缓存在本地缓存复制的索引槽的远程地址和KV对的地址。使用缓存的KV对地址,UPDATE、DELETE和SEARCH请求可以在搜索哈希索引的同时读取KV对,从而减少缓存命中的RTT。为了保证缓存一致性,每个KV对都存储了一个无效位,客户端使用该无效位来检查KV对是否有效。然而,通过访问索引缓存,无效的KV对可能会被提取到客户端,导致读放大。
为此,FUSEE通过区分读密集型和写密集型密钥来绕过索引缓存。对于每个缓存的密钥,都维护一个访问计数器和一个无效计数器,每次访问或发现密钥无效时,对应计数器都加1。随后为每个缓存密钥计算无效比率I=无效计数器/访问计数器。当I>阈值时,判断为写密集型,会绕过索引缓存。在大多数情况下,自适应方案不会影响搜索延迟,因为只有写密集型密钥会绕过缓存。
RDMA相关优化
KV请求需要多次远程内存访问。FUSEE采用门铃批处理和选择性信号[29]来减少RDMA开销。每个请求由多个阶段和多个网络操作组成。对于每个阶段,FUSEE采用门铃批处理[29]来减少从用户空间向RNIC传输网络操作的开销,并采用选择性信号来减少轮询RDMA完成队列的开销。因此,每个阶段只产生1个网络RTT。对于INSERT、DELETE和UPDATE请求,通常需要四个RTT。对于SEARCH请求,最多需要两个RTT,由于索引缓存的原因,在最好的情况下只需要一个RTT。
故障处理
FUSEE依赖基于租约的成员服务[24]来处理故障。主服务器为客户端和MN维护会员租约,客户端通过定期延长租约来始终知道存活的MN。当客户端和MN不再延长租约时,主服务器可以检测到它们的故障。
故障模型
对于部分同步的系统,其中进程(即客户端和MN)配备了松散同步的时钟[20,24,33]。FUSEE假设崩溃故障,其中进程(即客户端和MN)可能会因崩溃而故障。在这种故障模型下,FUSEE保证了可线性化的操作,即每个KV操作都是在其调用和完成之间的时间内原子性地提交的[26]。FUSEE的所有对象都是持久的,在任意数量的客户端崩溃和最多r−1 MN的崩溃下都是可用的,其中r是复制因子。
内存节点崩溃
MN崩溃导致对KV对和哈希槽的访问失败。对于KV对的访问,客户端可以根据一致性哈希方案访问备份副本。复杂性来自不可用的主插槽和备份插槽,这些插槽会影响索引读取和写入操作的正常执行。FUSEE依赖于容错主机在MN故障下代表客户端执行操作。
在MN崩溃下执行索引WRITE时,FUSEE允许SNAPSHOT复制协议决定的最后一个写入器继续将所有可用插槽修改为相同的值。其他写入程序向主机发送RPC请求,并等待主机在复制的插槽中回复正确的值。在无法确定最后一个写入器的情况下,主服务器决定最后一个编写器,并代表客户端修改所有索引槽。对于READ操作,在以下两种情况下执行不受影响。首先,如果主插槽仍然有效,客户端可以正常读取主插槽。其次,如果主插槽崩溃,客户端将读取所有可用的备份插槽。如果所有备份插槽都包含相同的值,则读取此值是安全的,因为没有写入冲突。否则,客户端将使用RPC并依赖主服务器为崩溃的插槽返回正确的值。由于READ操作仅在写冲突下受影响,因此大多数READ可以在现实世界中占主导地位的读密集型工作负载下继续[9,71]。
在检测到MN崩溃时,主服务器首先阻止客户端在租约到期时进一步修改崩溃的插槽。然后,master充当代表性的最后写入器,将所有活动插槽修改为相同的值。具体来说,主设备在活动备份插槽中选择一个值v,并将所有活动插槽修改为v。由于SNAPSHOT协议在主插槽之前修改了备份插槽,因此备份插槽中的值总是比主插槽新。因此,主设备从备份插槽中选择值是正确的,因为它会进行冲突的写入操作。在所有备份插槽崩溃的情况下,主插槽会选择主插槽中的值。从主服务器接收旧值的客户端会重试其写入操作,以确保写入新值。然后,主机将旧值写入操作日志头,以防止客户端在从崩溃的客户端恢复时重新执行操作。最后,主服务器重新配置新的主插槽和备份插槽,并将所选值返回给等待回复的所有客户端。重新配置主插槽和备份插槽之后,所有KV请求都可以正常执行,无需主插槽。在整个过程中,只有对崩溃插槽的访问受到影响,并且由于微秒级的会员服务,阻塞时间可以很短[24]。
客户端崩溃
客户端崩溃可能会导致两个问题:它们分配的内存块仍然处于非托管状态,导致内存泄漏;如果崩溃的客户端是最后一个写入器,其他客户端可能无法修改复制的索引槽。主机使用嵌入式操作日志来解决这两个问题。
恢复过程在计算池中执行,包括两个步骤,即内存重新管理和索引修复。内存重新管理恢复客户端分配的粗粒度内存块和客户端的细粒度对象使用信息。恢复过程首先让MN搜索其本地块分配表来获取崩溃客户端管理的所有内存块。然后,遍历按大小分类的链表,以查找所有已使用的对象和日志条目。
索引修复过程会修复部分修改的哈希索引。将每个大小类的链表末尾的所有请求视为潜在的崩溃请求。对于不完整的日志条目,即日志条目末尾的已用位未设置,由于对象的写入尚未完成,因此可直接回收对象。对于根据CRC字段具有不完整旧值的日志条目,根据操作字段和KV对重新执行请求。在这种情况下,请求要么属于提交日志之前崩溃的最后一个写入程序(图9中的c1),要么属于其他非最后一个编写程序。在第一种情况下,备份插槽中的值可能不一致,主插槽尚未修改为新值。重新执行请求可以使备份和主插槽保持一致。在第二种情况下,由于崩溃的非最后一个写入程序的请求尚未返回给客户端,因此重新执行请求并不违反线性化。对于具有完整旧值的请求,该请求必须属于最后一个写入者。然而,请求可能会在主时隙被修改之前完成(c3)或崩溃(c2)。恢复过程检查主时隙(vp)中的值和旧值(vold)中的数值,以区分c2和c3。如果vp=vold,则请求在修改主请求之前崩溃,因为vold记录了索引修改之前的值。由于所有备份插槽都是一致的,因此恢复过程会将主插槽修改为新值并完成恢复。否则,请求完成,不需要进一步操作。恢复请求后,主服务器异步检查崩溃客户端日志条目的卷中的内容,以恢复其批处理的空闲操作。
混合崩溃
客户端和MN一起崩溃的情况下,FUSEE会分别恢复故障。首先让主服务器恢复所有MN崩溃,然后启动故障客户端的恢复过程。KV请求可以继续进行,因为主服务器充当所有被阻止的KV请求的最后一个写入器。由于主服务器代表客户端提交操作日志,因此不会提交两次请求。
实验
实验环境:在CloudLab[19]的APT集群上的22台物理机器(5台MN和17台CN)上运行实验。每台机器都配备了一个8核Intel Xeon E5-2450处理器、16GB DRAM和一个56Gbps Mellanox ConnectX3 IB RNIC。这些机器通过56Gbps Mellanox SX6036G交换机互连。
数据集:microbenchmark,YCSB
实验对比:延迟、吞吐量、可扩展性、故障恢复
实验参数:不同操作、客户端数量、内存节点数量
总结
针对分离式内存系统中,KV存储的元数据可扩展性差的问题。作者提出将元数据分离到每个内存节点上,并在内存节点间复制元数据信息。提出四个优化点:(1)客户端为中心的复制协议SNAPSHOT,在不序列化的同时解决写冲突,在多个副本上更新最后判断顺序解决冲突;(2)两级内存管理,将内存管理分为轻计算粗粒度管理和重计算细粒度管理,分别由内存节点和客户端管理;(3)嵌入式操作日志,将日志嵌入到KV对中,减少日志记录开销,增加双向链表维护日志顺序。(4)索引缓存,在本地缓存KV对地址加快查询,根据访问成功率判断是否越过缓存。