SOSP 2019关注的几篇文章
1,File Systems Unfit as Distributed Storage Backends: Lessons from 10 Years of Ceph Evolution
作者首先总结了在分布式文件系统Ceph多年开发过程中的经验教训,然后介绍了团队开发的新型用户态存储后端BlueStore——直接访问存储设备上的数据,并将元数据储存在Key-Value Store中。目前,Ceph已经使用BlueStore作为默认的存储后端,并且相较于使用传统的POSIX文件系统有着显著的性能提升。
具体来说,使用传统的POSIX文件系统作为分布式文件系统中的存储后端主要会遇到三个方面的挑战:提供高效的事务支持、实现快速的元数据操作以及兼容新型硬件接口。以下会逐一介绍。
事务 事务接口可以将多个应用的动作整合为一个原子的操作,从而简化应用程序的开发过程。一种方案是使用文件系统内部的事务机制,然而其有限的语义只能保证文件系统内部状态的一致,而非向用户提供应用层面的一致性保障(例如缺少回滚操作),因此这种方案实用性不高。另一种解决方案是在用户态实现Write-Ahead-Log(WAL)来支持事务,但是这会导致频繁调用fsync以持久化WAL和数据、同时也要处理非幂等操作等问题,使用Key-Value Store存储元数据可以一定程度解决这些问题,但保证Key-Value Store与文件系统中元数据的一致性又引入了新的开销。
元数据相关操作 在Ceph中常常需要对特定目录下的文件进行遍历(readdir),而这一操作的性能会随着目录文件数量的增长而下降,同时只返回无序的结果。利用传统文件系统中解决这一问题,需要文件尽量均匀地分布在各个目录中,即当文件数量超过阈值时对目录进行分裂操作。然而这样又会引入新的问题:inode数量增多不仅降低dentry cache的效率,也会增加小型I/O操作的次数,甚至使得数据分布更加零散,降低了空间局部性。
存储硬件接口 作者提到近年来硬件厂商提出了诸如Shingled Magnetic Recording(SMR)和Zoned Namespace(ZNS)等新技术,这类技术可以增加存储容量并且提升访问性能,但需要在Zoned Interface下才可以更好地发挥效果。而传统文件系统仍然使用的Block Interface并不能与其兼容,同时二者的更新方式(Overwrite和Copy-On-Write)也存在冲突。
以上的原因使作者团队决定开发一个新型的存储后端代替传统文件系统,即BlueStore:一个在用户态实现的直接与底层存储设备交互的存储后端。
如上图所示,BlueStore将数据直接保存在存储设备中,而元数据则先保存在RocksDB中,再通过给RocksDB搭配的轻量级定制文件系统BlueFS以将数据持久化至存储设备中。首先,这样的设计使得元数据只存在于RocksDB中,因此无需再试图保证Key-Value Store与文件系统中元数据的一致,可以高效地支持事务。其次BlueStore中通过元数据的键值前缀将其组织成不同的Namespace,例如将元数据键值前K位相同的文件定义成属于同一个文件夹,则通过改变K的值可以快速实现文件夹分裂操作。最后,由于BlueStore拥有对I/O栈的完全控制,其可以自由地决定使用何种硬件接口,同时由于使用Copy-On-Write的更新方式,BlueStore可以很好地兼容Zoned Interface。此外BlueStore还提供了一系列诸如高效Checksum、透明压缩的特性。
从以上的测试结果可以看出,BlueStore在不同的I/O Size下的吞吐量都要显著高于(50%-100%)原来的FileStore,并且性能更加稳定。
2,Aegean: Replication beyond the client-server model
当服务模型由传统的Client-Server模式向如今的Microservice式转变时,诸如Primary-Backup、Paxos和PBFT等容错机制是否仍然适用?如何在保证容错正确性的同时提升应用性能?密西根大学团队带来的Aegean回答了这两个问题。
不同于传统的Client-Server模式,如今的Server端大都采用多个服务互相交互的方式来响应客户请求。考虑一个简单的模型:来自于Client的请求首先到达Middle Service,然后Middle Service需要向Backend Service发起一个Nested请求,并根据该请求的结果来响应用户。
然而,当Middle Service由多个备份构成时,传统的容错机制并不能正确地处理其中的Failure。作者以Primary-Backup协议举了一个例子:若Primary在向Backend发出Nested请求之后崩溃,由于无法获取之前Nested请求的返回值甚至无法得知之前向Backend发出了何种请求,Backup成为新的Primary之后也不能恢复至之前Primary的状态。作者指出,这种情形的根本原因在于,与只有Client这一个外部观察者的Client-Server模式不同,Backend也对于Middle Service来说也是一个观察者。因此,每一层Service在向Client和其他Service发出请求之前,都必须在内部的备份之间达到一致的状态。
作者提出了以下三种机制来达到这一要求:
- Service Shim 为了将Middle和Backend Service解耦开来,Aegean在每个备份中加入了一层Service Shim抽象:每当一个备份收到请求时,Service Shim会检查其他备份中是否有大多数也收到了这一请求,只有检查成功后,才会将请求交给备份执行。向Client(包括上层服务)返回结果时,Shim会向其所有备份发送结果,同时会将最近的返回值保存在Cache中。
- Response Durability 在传统的容错机制中,会在处理请求之前将输入进行持久化以便之后恢复。然而在多服务交互的场景中,输入不仅包括来自Client的请求,也包括来自Backend Service的返回值,因此这类返回值也应当在使用前被持久化。每当Middle Service的备份接收Backend的返回结果时,会向其他备份发出一份ACK,而只有当一个备份收到大多数的来自其他备份的ACK之后,才会使用其返回值。
- Taming Speculation 在Client-Server模式中往往采用推测执行的方式来提升性能:各个备份不需要事先就请求的执行顺序达成共识,而是各自推测一个顺序然后执行,最后在向Client返回之前,如果备份的状态不一致再通过Rollback-Replay统一各个备份的状态。然而在多任务交互的场景下,推测执行的过程中Middle Service往往会向Backend发出Nested请求,这样一来最后仅仅统一Middle Service的各个备份便不足以保证一致性。因此,本文提出在推测执行的过程中,每当备份向外界发起任何请求之前,都需要插入Barrier来确保各个备份的状态已经达到了一致。
除此之外,Aegean还实现了Request Pipeling机制以隐藏等待Backend返回结果的延时,同时不再保证Linearizability,而是保证会产生与没有备份的服务产生一样的结果,从而提升性能。
3,Taiji: Managing Global User Traffic for Large-Scale Internet Services at the Edge
背景、动机及系统概要: 在大型网络服务中,数据中心直接与边缘结点连接。数据中心负责绝大多数计算和存储,而边缘结点较数据中心更小,更靠近端用户。其主要职责是引导用户连接到最近的ISP,以及缓存和分发静态的数据。但是当需要动态的内容时,用户必须经过边缘结点连接到数据中心。本工作提出的Taiji系统核心目标是在的大规模网络服务中,1)平衡数据中心的利用率,2) 降低用户请求的延迟。
Taiji的主要思路是通过将关联程度高的用户路由到同一个数据中心,来增强用户数据的本地性以增加数据中心的缓存命中率,减少shard的迁移率,最终提升服务器的性能。
Taiji系统主要分为两个部分:一个是Runtime,其负责根据数据中心的实时情况以及边缘结点与数据中心的连接情况,生成某一段用户数据应该发到哪个数据中心;一个是Traffic pipeline,其负责在边缘结点上根据关联度(connection-aware)生成具体的路由条目。Taiji已经在facebook中使用了超过四年。
Taiji系统设计: 传统边缘结点到数据中心使用的是静态的映射,但是当数据规模扩大,静态的映射很难维护。另外,在实际场景中,不同的边结点在一天中的流量变化很大,且峰值流量与正常流量差距很大。因此静态的映射不再使用。Taiji的提出就是为了解决上述问题。
Taiji的系统架构如上图所示,其主要分为两个部分:Runtime和Trafic Pipeline。
-
Runtime
Runtime是用来决定某一段流量应该根据预定的策略,要发送到哪个数据中心中。实质上是一个分配问题,其其最终会生成一个路由表。影响其决定的两方面因素:1)数据中的状况,如容量,使用量等,2)动态数据包括实际边结点到数据中心的时延以及边的流量。同时其会根据实际情况持续更新路由表。
-
Traffic pipeline
Pipeline是根据路由表,根据具体应用的关联度来生成每个Edge LB的路由配置。其会利用social hashing将高度相关用户会分到同一个bucket里面,而这个bucket里面的用户会被路由到相同的数据中心内。Edge LB的作用就是按照用户的请求的不同,将用户放进合适的bucket中,然后将用户的请求转发到路由表中指定的数据中心内。其包含两个部分:离线的的user to bucket的分配 (用的social hashing 分成不同的bucket),以及在线的bucket to datacenter的分配。
除此之外,Taiji的动态路由还有以下优势:
- 适应不同产品不同的服务类型,如有的交互式的需要与数据中心的连接更稳定。
- 适应硬件异构,新的硬件可能动态地更新。
- 容错,可以动态的调整路由。
Taiji性能测试: 图(a)当DC1出现failure的情况下,使用Taiji依然能保持稳定且平均的数据中心利用率。图(b)使用了不同资源的DC,而Taiji可以根据不同DC的实际资源分配合适的流量。图(c)与静态的路由配置(static circa 2015)相比,使用Taiji拥有更低的RTT。Lower Bound是假设邻近的数据中心有无限的容量而直接路由到最近的数据中心得到的数据。
4,KVell: the Design and Implementation of a Fast Persistent Key-Value Store
这是本session的第一篇文章。文章提出了一个听起来反直觉的想法:持久键值存储系统,不应该继续追求设备的顺序访问。之所以说反直觉,因为在传统的存储设备(如磁盘)上,顺序访问的性能要远远高于随机访问。如何保证存储I/O是顺序的,一直是存储研究的一个重要问题。
然而这篇论文指出,随着技术的不断发展,现代的NVMe SSD除了拥有了更高的带宽之外,其随机访问的速度已经和顺序访问相近。这种硬件性能的改变,也使得现有的存储设计无法完全发挥出现有NVMe SSD存储的性能。
论文主要关注在持久键值存储系统(Persistent key-value stores)上。为了符合传统存储的顺序访问特性,现有的持久键值存储系统使用 LSM(Log-structured Merge)或者B树保存键值对。这两种数据结构的设计尽量避免随机访问,保证磁盘上数据是有序的。而大量的计算被耗费在了维护这种有序和进行数据同步上,使得CPU成为了整个系统的瓶颈。在下图中可以看出,无论是在LSM还是在B树上,键值存储的I/O带宽还远未达到设备瓶颈,然而CPU却已经基本占满。
除了CPU问题之外,另外一个问题是LSM和B树都会产生严重的性能波动。从下图中可以看出,在波动时,系统的性能最低不足1万操作/秒,而系统的最高性能要超过12万操作/秒。不管是LSM还是B树,造成性能抖动的根本原因在于数据结构中的维护操作,如LSM中的compaction操作。
因此,论文提出了一种新的设计,KVell。与此前的设计不同,KVell的设计不再一味强调顺序访问,而是从整个系统的瓶颈角度试图降低CPU的计算负担。KVell提出的设计遵循以下四个原则:
- shared-nothing,通过将数据结构在多个CPU上进行划分,避免由于数据共享造成的同步开销。换句话说,每个工作线程负责一个键的子集,且每个工作线程维护自己的一份数据结构来管理自己所负责的键。
- 磁盘上数据无需保证有序,数据直接无序的持久化在其最终的存储位置上,避免昂贵的排序操作。同时,通过使用内存中的有序索引来提供高效的scan操作。
- 不再强行保证顺序访问,但是依然将I/O进行批处理(batch),减少系统调用带来的开销。
- 不适用提交日志(commit log),每次更新将数据直接持久化在它们最终存储的位置,避免不必要的I/O操作。
通过遵守这些原则,可以减轻CPU的计算工作量,提升键值存储系统的性能。同时,这些原则可以降低性能抖动,保证操作在吞吐量和延迟上的可预测性。但这些原则也并非只有好处,比如shared-nothing的设计,会带来负载不均衡的问题。又比如磁盘上无序的数据存储,当读取一个键值对时,需要把整个数据块都读取到内存,会影响到scan的性能。
在测试部分, 这篇论文首先使用YCSB以及其中的标准负载进行测试(如上图)。其中可以看出随着CPU的计算瓶颈被解决,Kvell的性能提升对于其他系统是十分明显的。在YCSB-E这个负载中,有大量的scan操作,因而kvell的性能会有些影响。在uniform的分布下,KVell的性能略逊于RocksDB。而在Zipf的分布下,由于热点数据大多被缓存,KVell在磁盘上的scan操作较少,KVell在其他设计方面的优势便发挥了出来。于是其性能依旧领先于其他系统。
下图给出了KVell在存储I/O带宽和CPU占用率方面的情况。从图中可以明显地看出设备的最大I/O带宽被占满,且CPU的占用率被减低到了不足40%。
下图则给出了KVell在性能波动方面的情况。从图中可以看出,KVell的性能波动和其他系统相比非常之小。这说明KVell可以持续的提供稳定的吞吐和延迟保证。
5,Recipe: Converting Concurrent DRAM Indexes to Persistent-Memory Indexes
非易失性内存(NVM)是一种新型存储介质。它同时结合了存储和内存的性质。具有接近DRAM内存一样的性能,可以像DRAM一样被以CPU以字节粒度进行访问,同时可以像磁盘、固态硬盘等存储设备一样具有比较大的容量、能保证写入其中的数据在失去电力之后是持久存在的。
NVM的出现使得存储系统的设计可以变得非常不同,因而有很多不同的索引结构为了NVM进行了重新设计,其中包括哈希表、B+树、Trie、Radix树等。这些索引结构在NVM出现之前,都曾经有为DRAM而设计的实现,那么有没有一种方法可以直接将这些为DRAM设计的数据结构转化成为NVM上的数据结构呢?这篇文章便是回答了这个问题。
由于NVM上的数据可以持久保存,为NVM设计的数据结构需要考虑崩溃一致性。这其中最大的问题来自于CPU缓存。在使用传统存储设备时,我们往往会使用DRAM作为传统设备的缓存。然而,NVM直接被放入内存插槽中,CPU可以像访问DRAM一样访问NVM。CPU缓存便成为了CPU和NVM之间的缓存。然而由于CPU缓存并非是非易失的。这意味着,当电力中断的时候,虽然NVM中的数据是持久保存的,但CPU缓存中的数据会丢失。同时由于CPU缓存的换出机制(eviction)由硬件来管理,CPU发出的两个顺序写操作(先写A,再写B),在被CPU缓存之后,到达NVM的顺序可能是相反的(B比A先到达NVM)。若在两次操作中间产生了断电,则会造成数据的不一致。
这篇文章的思路,来自于发现某些特定的并行DRAM索引结构中隔离性(isolation)可以通过很小的修改转变为相同结构的崩溃一致性(crash consistency)。如为了保证DRAM索引结构的高性能,索引结构的读者(readers)经常被设计成非阻塞的。为了保证读者不被同时发生的写者(writer)所影响,读者往往可以检测和容忍一些临时的非一致性(如读者可以自动忽略其看到的重复的键值对)。此外,写者往往也可以检测到这些非一致性,并进行相应的修正。
因此,本文提出Recipe,通过分析DRAM索引结构中的隔离性设计,针对三种情况提出了三条方法,可以帮助开发人员将DRAM上的索引结构转变为NVM上具有崩溃一致性保证的数据结构。
方法1:若更新操作是通过单个的原子写进行,可直接在每个写的后面加上flush(缓存刷除)操作,并通过fence将原子写操作与其他操作隔离开,保证这些写操作完成的顺序(如下图)。通过这种转化,便可在索引结构从DRAM转化到NVM时,保证崩溃一致性。
方法2:这个方法需要的条件有些复杂:1) 读者和写者均是非阻塞的,这意味着它们均使用原子指令,而非被锁保护。2)索引结构中的写操作,需要由一系列有顺序的原子写组成。3) 当读者可以观察到不一致状态的时候(比如一系列的原子写中,只有前几个原子写被完成了),可以检测到并容忍这些不一致。4) 当(另外一个)写者检测到不一致状态的时候,可以通过帮助机制(helping mechanism)来修复不一致状态(比如需检测到这一系列原子写被完成到了哪一步,并可以从这一步开始,将之后的一系列原子写操作继续做完)。
在方法2中,当DRAM索引满足了上述条件,便可以简单的在每个store之后加入clush和fence操作,来将DRAM索引转变为NVM索引(如下图)。之所以可以简单的加入flush和fence操作,主要因为在这种条件下,DRAM数据结构中的读者和写者均已经具备了一定的检测和容忍、恢复不一致状态的性能。
方法3:方法3和方法2的条件类似,不同之处在于,写者由锁进行保护。在被锁保护的情况下,DRAM索引中的写者是没有机会看到不一致的状态的。因此,这种条件下的写者在DRAM实现中并没有修复不一致性的能力。
在方法3中,除了需要在每个store操作之后加入flush和fence之外,还需要开发人员手动添加一些机制,来检测到不一致性,并实现一些帮助机制,来对不一致性进行修复。
方法3所涉及到的修改量是最大的。
除了转换方法外,论文还提供了一种新的方法用于检测NVM上的索引在crash之后是否正确地进行恢复,以消除不一致性。有兴趣的读者可以关注一下论文的这一部分,此处便不再赘述。
通过上述的方法,论文对一些DRAM上的索引进行了修改,并进行了评测,下图展示了论文中所设计到的DRAM中的索引,以及每个索引所应用到的转换方法。
除了性能之外,本工作的另外一个重要关注点在于进行转换的代价,下图展示了转换不同索引结构所涉及到的代码修改量。我们可以看出,通过Recipe方法进行转换,最复杂的修改是针对masstree,修改量在200行左右,占原代码总量的9%。
在性能方面,通过Recipe进行转换过的代码,其性能甚至优于专门为NVM设计的结构(如下图)。
6,SplitFS: Reducing Software Overhead in File Systems for Persistent Memory
这篇文章同样是在文件系统上的工作。但是却没有上面我们的工作那么激进。同样是用户态NVM文件系统的设计,在这篇文章中。其将文件的数据操作和元数据操作进行分离。将数据操作部分由用户态文件系统库来完成。而元数据部分依然由内核中的文件系统完成。这种职责的分离,也是SplitFS名字的由来。
下图是SplitFS的高层设计图。从图中可以比较明显的看出,所有的文件读写操作由用户态直接访问文件,而元数据操作则通过内核中的Ext4-DAX文件系统来进行。在实际操作中,所有的文件均是保存在Ext4-DAX文件系统中。U-Split通过mmap的方式,将Ext4-DAX中的文件映射到用户态空间,此后任何的文件读写操作均可以在U-Split中完成。元数据操作则需要交给Ext4-DAX进行处理。
看起来这样就结束啦?
事实上并没有。通过上面讲的方法,有一些情况是比较难处理的。比如:假如文件通过append变大了怎么办?要知道mmap操作是只能映射文件现有大小的,对于一个文件的append操作,是不能通过mmap来进行的。
为了处理append操作(以及后面会提到的原子写操作),文章提出了staging file的概念。此处staging表明这文件里面的写是已经被发出,但是还未被提交的。staging file是预先被分配,并被预先mmap到用户态的一些文件。
当应用程序发出对一个文件的append操作之后,U-Split会将append的数据写入到staging file对应的mmap区域中,并在U-Split中进行记录,以保证后续的读能够读到这些append的数据。然而数据并不能一直存放在staging file中。当调用fsync时,staging file中的数据需要被写入到其对应的原文件中。在此过程中,为了避免数据搬移,SplitFS提出了一个relink的操作。
relink的设计的初衷,是为了快速的将staging file中的data block移交到原文件中。relink的接口是这样的:relink(file1, offset1, file2, offset2, size),意味着其作用是将file1中的offset1开始的size个字节,挪到file2中的offset2开始的位置。注意,此操作涉及到Ext4-DAX中文件的存储,因此只能在kernel中进行。为了避免对Ext4-DAX的侵入式修改,SplitFS通过ioctl的方法给Ext4-DAX增加了relink的功能。
上图中展示了staging和relink的用法过程。最初,U-Split中有一些空闲的staging file,当目标文件进行append的时候,会找到一个空闲staging file并将其分配给此目标文件。后续的append均写入到此staging file已经mmap好的区域中。对这些append的读,同样会被重定向到staging file。在fsync的时候,通过relink将数据从staging file中移动到目标文件上。但是mmap的区域依然保留,因此在U-Split中可以直接将mmap区域的逻辑对应关系进行修改即可。
论文中还对文件系统的语义进行了讨论。U-Split的实现可以满足三种不同的语义,如下图,包括POSIX、sync和strict。
POSIX模式是最弱的语义,与Ext4-DAX相同,数据和元数据的更新均是异步的,意味着数据和元数据在fsync之后才能保证持久化,文件系统在崩溃后根据元数据将文件系统恢复到一个一致的状态。
在sync模式下,所有的修改都是同步的,意味着一旦数据操作返回到了应用程序,这个元数据的修改已经被持久化了。这种情况下,应用程序无需再调用fsync操作。但是此时的数据更新依然无法保证原子性。这意味着,当向文件中写入5K的数据,在写入操作返回之前发生崩溃,则无法保证这5K中有多少数据被持久化。
在strict模式下,则保证了数据写入的原子性。通过使用staging file和relink操作,SplitFS在strict模式下可以保证每个文件写操作都是原子地被持久化的。
论文中有大量的测试来展示SplitFS的性能提升。在下图中给出了SplitFS中各个关键技术所带来的性能收益。可以看出用户态的U-Split、staging file和relink均带来了不同程度的性能收益。更多的性能评测,可以参看论文中的测试部分。
本文内容转载自: