LogDevice---Facebook开源一种用于日志的分布式数据存储系统

日志是记下有序序列的不可变记录,并将记录可靠存储起来的最简单方法。如果你构建数据密集型分布式服务,很可能在某处需要一两个日志。我们Facebook构建许多庞大的分布式服务来存储和处理数据。想要连接数据处理管道的两个阶段,又不必担心数据流控制或数据丢失?让一个阶段写入日志,另一个阶段从日志读取。在维护大型分布式数据库上的索引?让索引服务读取更新日志,以适当的顺序应用所有更改。有一系列一周后要以特定顺序执行的工作项?将它们写入日志,让日志使用者(consumer)滞后一周。想要分布式事务?足以有能力为你的所有写入排序的日志使它们成为可能。有持久性方面的顾虑?那就使用预写(write-ahead)日志。

按照Facebook的规模,所有这一切做起来比说起来难得多。日志抽象附带大规模环境下难以兑现的两个重要承诺:高度可用、持久的记录存储,以及那些记录的可重复全排序(total order)。LogDevice试图在实际上无限制的规模下,兑现分布式系统设计师重视的这两个承诺。它是专为日志设计的分布式数据存储系统。

可以将日志视为一种面向记录、只可以追加、可修剪的文件。不妨更详细地看一下这意味着什么:

  • 面向记录意味着,数据以不可分割的记录,而不是以单个字节写入日志。更重要的是,记录是最小的寻址单元:读取器(reader)始终开始从特定记录(或从追加到日志的下一条记录)读取,每次一个或多个记录地接收数据。不过更重要的是,无法保证记录编号是连续的。编号序列可能有间断,写入器(writter)事先不知道一旦成功写入,记录会被赋予什么样的日志序号(LSN)。由于LogDevice并不受制于连续字节编号要求,因而出现故障时,它可以提供更好的写入可用性。

  • 日志天生只可以追加。支持修改现有记录的功能没有必要,也不提供。

  • 预计日志在被删除之前存在一段比较长的时间:几天、几月,或甚至几年。日志的主要空间回收机制是修剪(trimming),即根据基于时间或空间的保留策略,丢弃最旧的记录。

对于严格遵守POSIX语义的分布式文件系统而言,或者对于基于这种文件系统而建的日志存储系统而言,LogDevice宽松的数据模型让我们得以在可用性、持久性和性能等方面达到更合理的折衷点。

工作负载和性能要求

Facebook有各种日志工作负载,它们在性能、可用性和延迟等方面的要求大不一样。我们将LogDevice设计成可针对所有那些冲突的目标来调整,而不是设计成一应俱全的解决方案。

我们发现我们的大多数日志应用程序经常需要高写入可用性。日志记录器根本没有地方来存放数据,哪怕存放短短几分钟。LogDevice对它们来说得确保可用性。持久性要求也很普遍。与在任何文件系统中一样,没有人想要听到这个消息:在成功追加到日志予以确认后,数据却丢失了。硬件故障不是借口。最后,我们发现,虽然大多数时间日志记录只读取了几次,并在被追加到日志后不久读取,但我们的客户偶尔执行大规模的全量拷贝(backfill)。全量拷贝是一种颇具挑战性的访问模式,LogDevice的客户端针对几小时、甚至几天前的记录,开始为每个日志启动至少一个读取器。随后,那些读取器继续从那一刻读取每个日志中的所有内容。全量拷贝通常由下游系统中的故障触发,而下游系统使用含有状态更新或事件的日志记录。全量拷贝让下游系统得以重建丢失的状态。

能够处理单个日志的写入负载方面的峰值(spike)也很重要。LogDevice集群通常存放有数千个到数十万个日志。我们发现,在一些集群上,少数几个日志的写入速率会出现比稳定状态高出10倍或更高的峰值,而由该LogDevice集群处理的大多数日志的写入速率没有变化。LogDevice将记录排序与记录存储分开来,使用非确定性记录放置来提高写入可用性,并更好地容忍由这类峰值引起的临时性负载不均衡。

一致性保证

LogDevice日志提供的一致性保证是用户期望从文件获得的,尽管是面向记录的文件。多个写入器可以同时将记录追加到同一个日志。所有那些记录将以同样的顺序(即LSN的顺序)传送给该日志的所有读取器,具有可重复的读取一致性。如果记录被传送给一个读取器,它还会被传送给遇到该LSN的所有读取器,除非出现可能性极小的灾难性故障,导致记录的所有副本统统丢失。LogDevice提供了内置的数据丢失检测和报告机制。万一数据丢失,丢失的所有记录的LSN将被报告给试图读取受影响的日志和LSN范围的每个读取器。

不为不同日志的记录提供排序保证。来自不同日志的记录的LSN没有可比性。

设计和实施

非确定性记录放置

有选项是好事。记录副本有大量的放置选项,这提高了分布式存储集群中的写入可用性。与另外许多分布式存储系统相似,LogDevice实现持久性的方式是,将每个记录的几个相同副本(通常两三个副本)存储在不同的机器上。由于那些副本有许多放置选项,即便集群中的许多存储节点宕机或速度慢,你也可以完成写入,只要集群中正常运行的那些节点仍能处理负载。你还可以应对单个日志的写入速率出现的峰值,只需将写入分摊到所有可用节点上。反过来,如果某个特定的日志或记录仅限于几个特定的节点,单个日志的最大吞吐量将受到那些节点的容量的限制,而仅仅几个节点的故障可能会导致某些日志的所有写入失败。

许多成功的分布式文件系统采用了入站数据的放置选项最大化这个原则。比如在Apache HDFS中,数据块可能放置在集群中的任何存储节点上,受制于名为名字节点(name node)的集中式元数据存储库带来的跨机架和空间方面的约束。在Red Hat Ceph中,数据放置由多值哈希函数(又译散列函数)控制。哈希函数生成的值为入站数据项提供了多个放置选项。这就不需要名字节点,但是无法完全达到同样级别的放置灵活性。

LogDevice专注于日志存储,采用了一种不同的记录放置方法。它提供了与名字节点相当的那种放置灵活性,又不实际需要名字节点。这是如何实现的呢?首先,我们将排序日志记录与实际存储记录副本两块分开来。对于LogDevice集群中的每个日志,LogDevice运行序列器对象,序列器对象唯一的任务就是,记录追加到该日志后,发出单调增加的序号。序列器可以在方便的任何地方运行:在存储节点上,或者在专门用于排序和追加、不实际存储的节点上。

LogDevice中排序和存储分开来

一旦记录被标上了序号,该记录的副本有可能存储在集群中的任何存储节点上。只要读取器可以高效地查找和检索副本,记录副本的放置不会影响日志的可重复读取属性。

希望读取特定日志的客户端与允许存储该日志记录的所有存储节点联系。保存的该集(名为日志的节点集)通常小于集群中存储节点的总数。节点集是日志的复制策略的一部分。它可能随时更改,日志的元数据历史记录中有适当的注释,读取器可以查阅该注释,以便找到所要连接的存储节点。节点集让LogDevice集群得以独立于读取器的数据来进行扩展。客户端联系的节点通过以尽快的速度将记录副本推向TCP连接,将副本传送给客户端。每条记录的报头自然含有序号。LogDevice客户端库对记录执行重新排序的操作,偶尔执行重复数据删除的操作,这些操作是确保记录按LSN的顺序传送给读取应用程序所必需的。

虽然这种放置和传送方案很适合写入可用性和处理有峰值的写入工作负载,不过对于常常包含许多点读取(point read)的文件工作负载来说不是很高效。对于主要顺序型的日志读取工作负载而言,它很高效。读取器联系的所有存储节点可能会有一些记录要传送。根本不浪费IO和网络资源。我们确保,每个记录只有一个副本从磁盘读取,并通过在每个记录副本的报头中加入副本集,经由网络传送。一种基于副本集的简单的服务器端过滤方案以及密集副本集索引可保证:在稳定状态下,副本集中只有一个节点将读取记录副本,并传送给特定的读取器。

序号

如图1所示,LogDevice中记录的序号不是整数,而是整数对。整数对的第一个部分称为纪元号(epoch number),第二个部分是纪元偏移。通常的元组比较规则仍适用。LSN中使用纪元是另一种可用性优化机制。序列器节点崩溃或以其他方式变得不可用时,LogDevice必须为所有受影响的日志调出替换序列器对象。每个新的序列器开始发出的LSN必须严格大于已经为该日志写入的所有记录的LSN。纪元让LogDevice得以保证这一点,无需真正查看已存储的内容。新的序列器出现后,它从名为纪元存储区(epoch store)的元数据部分收到新的纪元号。纪元存储区充当持久计数器的存储库,每个日志一个,它们很少递增,保证永不退回。今天我们使用Apache Zookeeper作为LogDevice的纪元存储区。

多对多重建

驱动器失效,电源失效,机架交换机失效。由于这些故障经常发生,对一些或所有记录来说,可用副本数量随之减少。连续几次故障后可用副本数量降到零后,我们就会丢失数据,或至少丧失一些记录的读取可用性。两者都是糟糕结果,LogDevice力求尽量避免。重建(rebuilding)为一次或多次故障后变得复制不足(少于副本的目标数量)的记录生成了更多副本。

为了确保高效,重建一定要快。它要在下一次故障导致某个不走运的记录的最后一个副本丢失之前完成。与HDFS相似,LogDevice采用了多对多重建。所有存储节点同时充当了记录副本的供体(donor)和受体(recipient)。为重建调配整个集群的资源让LogDevice得以以每秒5GB至10GB的速度,全面恢复故障影响的所有记录的复制因子。

重建协调是完全分布式的,针对我们称为事件日志的内部元数据日志来执行。

本地日志存储区

排序和存储分开来有助于分配集群的总体CPU和存储资源,以便与变化的、有时出现峰值的工作负载匹配。然而,分布式数据存储区的节点效率很大程度上取决于本地存储层。最后,多个记录副本必须保存在非易失性设备上,比如硬盘驱动器或固态硬盘(SSD)。以每个节点超过100MBps 的速度存储数小时的记录时,全靠内存的存储不切实际。积压持续时间以天计算(这在Facebook不是一个罕见的要求)时,硬盘驱动器的性价比要比闪存高得多。这就是为什么我们设计了LogDevice的本地存储部分,不仅在拥有庞大IOPS容量的闪存上表现不俗,在硬驱上也表现不俗。商用硬盘可以获得相当高的顺序写入和读取速度(100-200MBps),不过随机IOPS最高也就100-140。

我们称LogDevice的本地日志存储区为LogsDB。它是一种针对写入优化的数据存储区,旨在确保磁盘寻道数量小、受控制,而存储设备上的写入和读取IO模式基本上是顺序型的。顾名思义,针对写入优化的数据存储区旨在写入数据时,提供出色的性能,即使数据属于多个文件或日志。虽然写入性能提高了,但缺点是一些访问模式的读取效率较糟糕。除了在硬盘上表现良好外,LogsDB对于日志拖尾(log tailing)工作负载来说特别高效,在这种常见的日志访问模式下,记录在被写入后很快传送给读取器。记录绝不会再次被读取,除非在罕见的紧急情况下:那些大规模的全量拷贝。然后,主要从内存来进行读取,因而让单个日志降低的读取效率显得无关紧要。

LogsDB是RocksDB上面的一层,RocksDB是基于LSM树的一种有序、持久的键值数据存储系统。LogsDB是按时间排序的RocksDB列族(column family)集合,这些RocksDB列族是功能完备的RocksDB实例,共享一个共同的预写日志。每个RocksDB实例名为LogsDB分区。所有日志的所有新写入,无论是1个日志还是100万个日志,都进入到最新的分区,它们按(日志id和LSN)来排序,并将一系列的大型排序不可变文件(名为SST文件)保存在磁盘上。这使得驱动器上的写入IO工作负载大部分是顺序型的,但由此一来读取记录时,需要合并来自多个文件的数据(文件数量最多是LogsDB分区中允许的最大文件数,通常约10个)。从多个文件读取可能会导致读取放大(read amplification),浪费一些读取IO。

LogsDB以一种特别适合日志数据模型的方式来控制读取放大,不可变LSN识别的不可变记录随时间而单调增加。一旦达到SST文件的最大数,LogsDB不理分区,新创建一个最新分区,而不是通过compacting(合并排序)成一个更庞大的有序段(sorted run)来控制有序文件的数量。由于分区是顺序读取的,同时读取的文件数量从不会超过单个分区中的最大文件数,即便所有分区中的SST文件总数达到数万个。通过删除(或在某些情况下偶尔合并排序)最旧的分区,可以高效地回收空间。

使用场合和未来工作

LogDevice已成为Facebook众多日志工作负载的一种用途广泛的解决方案。下面仅举几个例子。Scribe是就总吞吐量而言较大的LogDevice用户之一,峰值期间每秒获取的数据超过1TB,可靠地传送,还可能回放。Scribe提供了一套一劳永逸(fire-and-forget)的写入API,传送延迟预期在几秒左右。运行Scribe的LogDevice集群针对每个设备的效率进行了调整,而不是很低的端到端延迟或追加延迟。维护TAO数据的辅助索引是LogDevice的另一个重要的使用场合。这里,吞吐量不像Scribe那么大,但是每个日志的严格记录排序很重要,预期的端到端延迟是10ms左右。这需要进行大不一样的调整。另一个有意思的例子是机器学习管道,它使用LogDevice将相同的事件流传送到多个机器学习模型训练服务。

LogDevice正在积极开发中。它用C ++编写,没几个外部依赖项。我们目前在探究的新领域包括分解式集群,其中存储和CPU密集型任务由使用不同硬件配置的服务器来处理,支持非常庞大的卷日志,通过应用程序提供的密钥,在服务器端高效过滤记录。这些功能将共同提高LogDevice集群的硬件效率,将为高吞吐量数据流的使用者提供一种可扩展的负载分配机制。

发布了362 篇原创文章 · 获赞 144 · 访问量 110万+

猜你喜欢

转载自blog.csdn.net/FENGQIYUNRAN/article/details/103451309