微软FASTER KV存储论文
简介
这篇论文介绍了一个新的键值存储系统,名为Faster,它支持点读、更新和读-改-写操作。Faster将一个高度缓存优化的并发哈希索引与一个混合日志结合起来:一个跨越主内存和存储的并发日志结构化记录存储,同时支持对内存中热数据集的快速原地更新。
创新点
- Faster提出了一个新颖的“混合记录日志”设计,它将日志结构化记录存储与原地更新结合起来,以适应不同的工作负载和数据分布。
- Faster实现了一个高效的并发哈希索引,它利用缓存行对齐和原子操作来提高性能和可扩展性。
- Faster支持读-改-写(RMW)操作,并提供了一种基于冲突解决器的机制来处理并发更新。
- Faster提供了一种灵活的检查点和恢复机制,可以在不影响在线服务的情况下将数据持久化到存储中。
3 Hash Index
Faster的一个关键构建块是哈希索引,这是一个b。如第2节所述,索引与返回逻辑或物理内存指针的记录分配器一起工作。为了便于说明,我们假设一台64位机器具有64字节缓存线。在更大的架构上,我们期望有更大的原子操作[19],从而允许我们的设计规模化。在第4节、第5节和第6节中,我们将将此索引与不同的分配器配对,以创建功能不断增强的键值存储。
Faster索引是一个由\(2^k\)个哈希桶组成的缓存对齐数组,其中每个桶具有缓存行的大小和对齐方式(图2)。因此,一个64字节的桶由七个8字节的哈希桶条目和一个8字节条目组成,用作溢出桶指针。每个溢出桶也具有缓存行的大小和对齐方式,并使用内存分配器按需分配。
选择8字节的Entry非常关键,因为它允许我们使用64位原子比较和交换操作对条目进行无锁存操作。在64位机器上,物理地址占用的空间通常少于64位;例如Intel机器使用48位指针。因此,我们可以为索引操作窃取额外的位(Faster至少需要一位)。我们在本文的其余部分使用48位指针
每个哈希桶Entry(图2)由三部分组成:Tag(15位)、地址(48位)。地址值为0的条目表示entry为空。在具有\(2^k\)个哈希桶的索引中,tag用于将索引的有效哈希分辨率从k位提高到k+15位,这通过减少哈希冲突提高了性能。
哈希值为h的key的哈希桶,首先使用h的前k位(称为h的偏移量)来识别。h的后15位称为h标记。标记仅用于提高哈希分辨率,可以更小,也可以完全删除,具体取决于地址的大小。暂定tentative是插入所必需的,稍后将介绍。
Index Operations
Faster索引基于一个不变量(偏移量+标记)都有一个唯一的索引entry,该entry指向其键哈希到相同偏移量和标记的记录集。在支持索引项的并发无锁存读取、插入和删除是一项挑战.
查找
查找与key相对应的Entry很简单:我们使用k个哈希位来识别哈希桶,扫描桶以找到与tag匹配的条目。
删除
从索引中删除条目也很简单:我们使用CAS将匹配的条目(如果有)替换为零。
插入
插入条目。考虑这样一种情况,即bucket中不存在标记,并且必须插入一个新条目。一种简单的方法是寻找一个空条目并使用CAS插入标记。然而,两个线程可以在桶中的两个不同的空槽中同时插入相同的标记,这打破了我们的不变。考虑一种解决方案,其中每个线程从左到右扫描存储桶,并确定地选择第一个空条目作为目标。他们将使用CAS来竞争插入,只有一个会成功。即使这种方法也违反了删除时的不变量,如图3a所示。
线程T1从左到右扫描桶,并选择用于插入标签g5的槽5。另一个线程T2从同一桶中的槽3中删除标签g3,然后尝试插入具有相同标签g5的密钥。从左到右扫描将导致线程T2为此标记选择第一个空条目3。可以看出,任何独立选择插槽并直接插入的算法都存在此问题:在线程T1进行CAS之前,它可能会被交换掉,数据库状态可能会任意更改,包括具有相同标记的另一个插槽。
虽然锁定桶是一种可能(但很重)的解决方案,但Faster使用了一种无锁的两阶段插入算法,该算法利用了tentative位条目。一个线程找到一个空槽并插入带有tentative位集的记录。设置了tentative位的条目被视为对并发读取和更新不可见。然后我们重新扫描bucket(注意它已经存在于缓存中)以检查如果同一tag有另一个tentative entry;如果是,我们后退并重试。否则,我们reset tentative位以完成插入。由于每个线程都遵循这种两阶段方法,因此我们保证保持索引不变。图3b显示了两个线程的操作顺序.
4 In Mem KV Store
我们现在使用Sec. 3中的Faster哈希索引和简单的jemalloc[1]内存分配器来构建一个完整的内存键值存储。具有相同(偏移,tag)值的记录以反向单链表的形式组织。哈希桶entry指向列表中的尾部(最新的记录),尾部指向上一个记录,以此类推(见图1)。
每个record可以是固定大小或可变大小,并且由64位header,key和value组成。header如图2所示。
除了先前的指针之外,我们使用一些位(invalid and tombstone)来跟踪对于日志结构分配器(cf. Sec. 5和6)必要的信息。这些位作为地址字的一部分存储,但如果必要,也可以单独存储。
Operations with In-Memory Allocator
在Faster中,用户线程在epoch保护的安全范围内读取和修改记录值,record级并发由用户的读取或更新逻辑处理。例如,可以对计数器使用fetch-and-add,获取record锁定,或利用应用程序级别的分区知识进行无锁更新。接下来描述了存储上的操作。
- 读取。我们按照Sec. 3.2中的描述,从索引中找到匹配的标记entry,然后遍历该条目的链表以找到具有匹配键的记录。
- 更新和插入。更新(upserts)和RMW更新都从找到键的哈希桶entry开始。如果该entry不存在,则我们使用Sec. 3.2中描述的两阶段算法将tag与新记录的地址插入到哈希桶的空槽中。如果该条目存在,则扫描链表以找到具有匹配key的记录。如果存在这样的记录,则在原地执行操作。只要线程不刷新其epoch,它就可以保证访问记录的内存位置。这个特性允许线程在原地更新值而不必担心内存安全。如果不存在这样的记录,则使用CAS将新记录拼接到列表的尾部。在我们的计数存储示例中,我们对现有键的计数器值进行递增,使用的是:使用fetch-and-increment或正常的原地递增(如果键被分区)来进行递增。对于新键的插入,初始值设置为0。
- 我们通过在记录头或哈希桶条目(对于第一条记录)上使用CAS原子操作将记录从链接列表中删除。在从单个链接列表中删除记录时,该entry设置为0,以使其可用于将来的插入。由于在同一位置进行并发更新,因此无法立即将已删除的记录返回给内存分配器。我们使用我们的epoch保护框架来解决此问题:每个线程维护一个线程本地(以避免并发瓶颈)的自由列表,其中包含(epoch,地址)对。当epoch变得安全时,我们可以安全地将它们返回给分配器。
5 HANDLING LARGER DATA IN FASTER
为了处理更大的数据,我们将Sec. 4中的内存Faster转换为一个完整的键值存储系统,可以通过构建基于log结构的record分配器来处理大于内存的数据。log结构是一个经过充分研究的领域,我们的方法是现有技术的直接适应,加上了epoch保护以降低同步开销。由于是只append,这样的系统性能并不好;我们将在Sec. 7.4.1中展示它的吞吐量不超过2000万ops/s,并且不随线程数扩展。然而,这个设计对于解释我们在Sec. 6中的主要贡献是有用的,我们将使用一种新的混合log分配器来重新实现可扩展性。
Logical Address Space
我们开始定义一个跨越主内存和辅助存储的全局逻辑地址空间。记录分配器分配并返回48位逻辑地址,对应于该地址空间中的位置。与我们的纯内存版本不同,Faster哈希索引现在存储记录的逻辑地址,而不是物理地址。逻辑地址空间使用尾偏移量进行维护,该偏移量指向日志末尾的下一个空闲地址。另一个偏移量,称为头偏移量,跟踪可用于内存的最低逻辑地址。头偏移量与尾偏移量保持大约恒定的间隔,等于log的可用内存。为了最小化开销,我们仅在尾偏移量跨越页面边界时更新头偏移量。
头部偏移量与尾部偏移量之间的连续地址空间(即日志的尾部部分)以有界的内存循环缓冲区的形式存在,如图4所示。循环缓冲区是固定大小的页面帧的线性数组,每个页面帧的大小为2F字节,每个页面帧都与基础存储设备对齐。
为了允许无缓冲读写而无需额外的内存拷贝,每个大小为2F字节的固定大小页帧都与底层存储设备对齐,并按照扇区对齐进行分配。若逻辑地址L大于头地址,则位于主内存中的偏移量等于L的最后F位,在循环数组中位置等于L ≫ F的页帧中。
新记录分配总是在尾部进行。我们将尾部偏移量维护为一个字中的两个值 - 一个页面编号和一个偏移量。为了提高效率,线程使用原子加法(fetch-and-add)在偏移量上分配内存;如果偏移量对应的分配无法适应当前页,则它增加页号并重置偏移量。看到一个大于页面大小的新偏移量的其他线程会等待偏移量变得有效,并重试。
Circular Buffer Maintenance
为了以无锁的方式管理日志记录的卸载到辅助存储器中,我们需要在epoch边界之间实现线程执行不受限制的内存访问。为了实现这一点,我们维护了两个状态数组:一个刷新状态数组跟踪当前页面是否已被刷新到辅助存储器中,而一个关闭状态数组则确定是否可以回收页面框以便重用。由于我们总是向日志中添加内容,因此一旦创建了记录,就不可变了。当尾部进入新的页面 p + 1 时,我们通过刷新触发操作来增加时期,并发出异步 I/O 请求以将页面 p 刷新到辅助存储器中。只有在epoch变得安全时才会调用此操作——因为线程在操作边界处刷新时期,我们保证所有线程都已完成写入页面 p 中的地址,因此刷新是安全的。当异步刷新操作完成时,页面的刷新状态将被设置为已刷新。
随着尾部不断增长,可能需要从内存中清除已存在的页面,但我们需要确保没有线程正在访问该页面。传统的数据库在每次访问之前使用ping针来将页面固定在缓冲池中,以防止在使用时被清除。然而,为了实现高性能,我们利用epoch来管理清除。头偏移量确定记录是否在内存中可用。为了从内存中清除页面,我们会增加头偏移量,并使用触发器操作来提高当前epoch,并设置旧页面帧的closed-status数组条目。当该epoch是安全的时,我们知道所有的线程都会看到更新的头偏移值,因此不会访问那些内存地址。需要注意的是,在更新头偏移量之前,必须确保要清除的页面已经完全刷新,以便需要这些记录的线程可以从存储中检索它。
6 IN-PLACE UPDATES IN FASTER
日志分配器的设计,除了处理大于内存的数据外,还能够通过其只追加数据的特性实现无需锁的更新访问路径。但这也带来了一个代价:每次更新都涉及原子递增尾部偏移量以创建新记录,从之前的位置复制数据并原子替换哈希索引中的逻辑地址。此外,只追加日志会快速增长,特别是在更新密集的工作负载下,很快会使磁盘 I/O 成为瓶颈。相比之下,就地更新在这样的工作负载中具有几个优点:(1)经常访问的记录很可能在较高级别的缓存中可用;(2)不同哈希桶的键的访问路径不会相互冲突;(3)更新较大值的一部分是高效的,因为它避免了复制整个记录或维护需要压缩的昂贵的增量链;(4)大多数更新不需要修改 Faster 哈希索引
Introducing HybridLog
HybridLog的设计是一个新颖的数据结构,它将原地更新(in-place updates)和日志结构组织(log-structured organization)相结合,同时提供无需pin锁的并发访问记录(concurrent access to records)。HybridLog覆盖了内存和二级存储,其中内存部分作为热数据的缓存并适应于不断变化的热数据集合。在HybridLog中,逻辑地址空间被划分为三个连续的区域:(1)稳定区域(stable region)、(2)只读区域(read-only region)和(3)可变区域(mutable region),如图5所示。稳定区域位于二级存储中。内存部分由只读区域和可变区域组成。
可变区域中的记录可以就地进行修改,而只读区域中的记录则不可以。为了更新当前位于只读区域中的记录,我们采用了Read-Copy-Update (RCU) 策略:在可变区域中创建一个新副本,然后对其进行更新。这样的记录进行进一步更新时,只要它仍在可变区域中,就可以直接在原地进行更新。我们使用附加标记(称为只读偏移量)在Sec. 5的日志分配器上实现了HybridLog,该标记对应于位于内存循环缓冲区中的逻辑地址。头偏移量和只读偏移量之间的区域是只读区域,只读偏移量之后的区域是可变区域。如果记录的逻辑地址大于只读偏移量,则对其进行就地更新。如果地址位于只读偏移量和头偏移量之间,则在尾部创建一个更新后的副本,并更新哈希索引以指向新位置。如果地址小于头偏移量,则在必要时发出异步I/O请求以从二级存储中检索记录。一旦获取到记录,就会在尾部创建一个新的更新副本,然后更新哈希索引。只读偏移量与尾偏移量保持恒定的差距,并且仅在页面边界处进行更新,类似于头偏移量。由于没有任何逻辑地址小于只读偏移量的页面正在同时进行更新,因此可以安全地将它们刷新到二级存储中。随着尾偏移量的增长,只读偏移量也会随之移动,使页面准备好被刷新。一旦它们安全地刷到磁盘上,就可以使用头偏移量和关闭状态数组(类似于Sec. 5中的做法)将它们从循环缓冲区中去除(在必要时)。