LogDevice:一种用于日志的分布式数据存储系统


说到日志,它就是一个将有序序列的不可变记录记下来,并将此记录可靠地保存下来的最简单的方法。如果想要构建一套数据密集型分布式服务,你可能需要一两套日志。在Facebook,我们构建了许多用来存储和处理数据的大型分布式服务。在Facebook,我们如何做到想要即连接数据处理管道的两个阶段,又无需担心数据流管控或数据丢失的呢?就是让一个阶段写入日志,另一个阶段从这个日志读取。那么如何去维护一个大型分布式数据库的索引呢?就是先让索引服务以适当的顺序应用索引更改,然后再来读取更新的日志。那要是有一个系列需要一周后再以特定顺序执行的工作呢?答案就是先将它们写入日志,让日志使用者滞后一周再来执行。一个拥有足够能力进行写入排序的日志系统,可以将你希望拥有分布式事务的梦想成为现实。既然如此,要是有持久性方面的顾虑?那就去使用预写日志吧。

以Facebook的规模,上面那些都是说起来容易做起来难。在此规模下,想要保证高可用和持久化存储以及将这些可重复的全排序记录下来,真的是日志抽象下的两个难以实现的承诺。LogDevice是一种专为日志设计的分布式数据存储系统。它试图在本质上无限制的规模下,让分布式系统设计师得以兑现这两个承诺。

我们可以将日志视为一种面向记录(record-oriented)、只可追加(append-only)、可修剪的(trimmable)文件。不妨更详细地看一下这意味着什么:
  • 面向记录(record-oriented)意味着,数据不是以单个字节的形式记录,而是以不可分割的形式进行记录的。重要的是,一条记录是最小的寻址单元:读取器始终从特定的记录(或从追加到日志的下一条记录)开始读取,每次以一个或多个记录地接收数据。不过需要注意的是,记录的编号不一定连续性的。编号序列可能有间断,写入器事先也不知道一旦成功写入的话,记录会被赋予什么样的日志序号(Log Sequence Number)。由于LogDevice并不受限于连续字节编号的要求,因此当出现故障时,它能提供更好的写入可用性。
  • 日志是原生的只可追加的(append-only)。它不支持修改现有记录的功能,因为没必要,也不提供。
  • 日志在被删除之前是可以保存比较长的一段时间的:几天、几月或甚至是几年。日志的主空间回收机制是修剪(trimming),或者是基于时间或空间的保留策略去丢弃(dropping)最旧的记录。


工作负载和性能要求

Facebook有各种日志工作负载,其性能,可用性和延迟等方面要求都各不相同。我们设计的LogDevice,是以可调整这些冲突参数为目标,而不是为了设计成一套一应俱全(one-size-fits-all)的解决方案为目标。

我们发现大多数日志应用程序的共同点是要求高写入可用性。即使是几分钟,记录器也没有任何地方可以存放这些数据。LogDevice必须提供高可用性。耐久性要求也很普遍。与任何文件系统一样,没人希望听到他们的数据在收到成功追加日志的确认后便丢失的消息。硬件故障可不是一个借口。最终,我们发现,我们的客户在大多数的情况下仅仅是读取几次日志,并且是在被追加到日志后不久便读取,然而他们会偶尔执行大规模的全量拷贝(backfill)。全量拷贝是一种颇具挑战的访问模式,LogDevice的客户端每个日志启动至少一个读取器,用于记录几小时甚至几天的记录。然后那些读取器从那一点开始阅读每个日志中的所有内容。全量拷贝通常由下游系统中的故障触发,而下游系统使用含有状态更新或事件的日志记录。全量拷贝允许下游系统以当时丢失的状态为时间的来重建它。

能够应对单个日志的写入负载中的峰值(spike)也很重要。LogDevice集群通常存放着数千到数万个日志。我们发现,在某些集群里,一些日志的写入速度会出现比稳定状态高10倍或者更高的峰值,然而由LogDevice集群处理的多数日志写入速率却没有什么变化。LogDevice将记录的排序从记录的存储中分开来,并使用记录的非确定性放置来提高写入的可用性,并更好地容忍由此类峰值引起的临时负载不平衡。

一致性保证

LogDevice日志提供的一致性保证指的是用户对文件的期望,尽管它是一个面向记录的文件。多个写入器可以同时将记录追加到同一个日志里。所有这些记录将以相同的顺序(Log Sequence Number)传递给日志的所有读取者,具有可重复的读取一致性。如果将记录传送给一个读取者,它同时也会被传送给遇到该LSN的所有读取器,除非发生导致所有记录副本丢失的灾难性故障。LogDevice提供内置的数据丢失检测和报告功能。如果发生数据丢失,所有丢失记录的LSN将报告给尝试读取受影响的日志和LSN范围的每个读取器。

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

设计和实施

非确定性的记录放置

有各种选项就是好事。存放记录副本有着大量的可选项,这有效的提高了分布式存储集群的写入可用性。同许多其他分布式存储系统类似,LogDevice把每个记录(通常为两个或三个)以相同的副本形式,存储在不同的机器上,从而来实现持久性。正是因为这些副本有多种放置选项,即使群集中的大量存储节点停机或运行缓慢,只要启动的群集部分仍可以处理负载,就可以完成写入任务。你还可以应对单个日志的写入速率出现的峰值,只需将此写入分摊到所有可用节点上。反过来,如果需要将某个特定的日志或记录仅限于几个特定的节点,单个日志的最大吞吐量将受到那些节点运力的限制,而且仅仅几个节点的故障可能会导致某些日志的所有写入失败。

许多成功的分布式文件系统都采用了最大化入站数据的放置选项这个原则。以Apache HDFS为例,数据块可以放置在集群中的任何存储节点上,但需要受制于跨机架和空间的限制,这是由被称为名称节点的集中式元数据存储库强制执行的。在Red Hat Ceph中,数据放置由多值哈希函数控制。哈希函数生成的值为传入数据项提供多个放置选项。这消除了对名称节点的需要,但无法达到相同级别的放置灵活性。

LogDevice专注于日志存储,采用了不同的方法来记录放置。它提供了与名称节点相同的放置灵活性级别,且实际上并不需要名称节点。这又是如何实现的呢?首先,我们把日志记录的顺序与记录副本的实际存储分开。对于LogDevice集群中的每个日志,LogDevice都会运行一个序列器对象,其唯一的工作是在记录附加到该日志时发出单调递增的序列号。序列器可以运行在任何方便的地方:在存储节点上,或在专门用于排序和追加以及非实际存储的节点上。
logdevice.jpg

图例1.LogDevice中的排序和存储分离

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

希望读取特定日志的客户端可以连接到所有存储着这些日志的存储节点。这个名为日志的节点集(node set of the log)通常小于集群中存储节点的总数。节点集属于日志复制策略的一部分。它可能随时更改,日志的元数据历史记录中有适当的注释,读取器可以查阅该注释,以便找到所要连接的存储节点。节点集允许LogDevice集群独立于读取器的数据来进行扩展。客户端节点间的通讯是依靠快速生成的记录副本通过TCP连接实现的。因此,每条记录的报头自然都会含有序号。LogDevice的客户端库的一些操作,如对记录执行重新排序作,以及偶尔执行重复数据删除,这些操作是确保记录按LSN的顺序传送给读取应用程序所必需的。

然而,这种放置和传递的机制虽然很适合写入性和处理有峰值的写入负载,但对于经常包含很多点读取(point read)的文件负载来说效率不是很高。对于多数顺序性的日志读取工作负载来说,它是很高效的。通过读取器联系的全部存储节点可能会有一些记录需要传送。这不会浪费任何IO和网络资源。我们会确保,每个记录只有一个副本会从磁盘读取,并通过在每个记录副本的报头中加入副本集,再经由网络传输。一种基于副本集的被称为简单服务器过滤机制(simple server-side filtering scheme)同密集型索引耦合,这可以保证在稳定状态下,副本集只有一个节点将读取记录副本,并将其传送给特定读取器。

序号:

如图1所示,LogDevice中记录的序列号不是整数,而是整数对。该对的第一个组件称为纪元数(epoch number),第二个组件是纪元内偏移。通常的元组比较规则适用。在LSN中另一种可用性优化机制就是使用纪元。当序列器节点崩溃或以其原因变为不可用时,每个新序列器开始生成的LSN必须严格大于所有已为该日志写入记录的LSN。不需要实际查看具体存了什么,纪元可以直接让LogDevice保证这一点。当新的序列器出现后,它从纪元存储区的元数据收到新的纪元数。纪元存储作为一个持久计数器的存储区,每个日志一个,很少递增且保证永不退化。现在我们使用Apache的Zookeeper作为LogDevice的纪元存储。

多对多重建

驱动器错误,电源故障,机架开关失灵,当这些故障发生时,某些或所有记录的可用副本数量可能会减少。当数次连续失败后,该数字降至零,就会丢失数据或至少会丢失一些记录的读取可用性。这都是LogDevice尽力去避免的情形。重建会在一次或多次失败后,为复制不足的记录(具有少于目标份数R的记录)创建更多副本。

为了确保高效,重建必须要快速。它必须在下一次失败之前完成一些已然失败记录的最后一个副本。与HDFS类似,LogDevice实现的重建是多对多的。所有存储节点都充当记录副本的提供者(donor)和接收者(recipient)。整个需要重建的集群会调配资源,让LogDevice可以以每秒5-10GB的速率完全恢复受故障影响的所有记录的复制因子。

重建协调是完全分布式的,而且并通过事件日志的内部元数据执行。

本地日志存储

排序和存储的分离解耦有助于分配集群的总体CPU和存储资源,以匹配不断变化的,有时的峰值负载。但是,分布式数据存储的每节点效率很大程度上取决于其本地存储层。最后,必须将多个记录副本保存在非易失性设备上,例如硬盘驱动器或固态硬盘(SSD)。当每个节点以100MBps+的速度存储数小时的记录时,仅靠内存(RAM)存储是不切实际的。当积压持续时间以天为单位(在Facebook这是一个常见的要求)时,硬盘驱动器的性价比明显高于闪存。所以我们在LogDevice设计了本地存储组件,不仅在具有巨大IOPS容量的闪存上,而且在硬盘上也能很好地运行。企业级硬盘驱动器可以推动相当数量的顺序写入和读取(100-200MBps),不过随机IOPS最高也就100-140MBps。

在LogDevice,它的本地日志存储被称为LogsDB,是一个写优化数据存储,旨在保持磁盘搜索的数量小和受控,并且存储设备上的写和读IO模式基本上是顺序的。正如它强调的写优化数据存储,它的目标就是在写入数据时,甚至数据是属于多个文件或日志,都能提供出色的性能。高写入性能的同时,会在某些系统里带来糟糕的读取效率。除了在硬盘上表现良好外,Logs DB在日志跟踪的负载方面,它的效率特别好。在这种正常的日志访问模式下,记录在被写入后会马上传递给读取器。这些记录不会再被读取,出发在非常罕见的紧急情况下:那些大规模的全量拷贝。这些读取器会从内存读取,这样可以使因为读取单个日志导致降低效率的问题变得无关紧要。

LogsDB是RocksDB之上的一个层,是基于LSM树的一种有序持久键值数据存储。LogsDB属于RocksDB列族按时间排序的集合,是完全成熟的RocksDB实例,共享一个共同的预写日志。每个RocksDB实例被称为LogsDB分区。所有日志的每个新写入,无论是一个还是一百万个日志,都会进入最新的分区,按照(日志id,LSN)对它们进行排序,并以一系列的大型已排序不可变文件(称为SST文件)中保存在磁盘上。这使得硬盘上写入的IO工作负载基本上是按顺序的,但这导致了在读取记录时,需要从多个文件来合并数据(文件的数量最多是Logs DB分区中允许的最大文件数,通常情况下是10个左右)。从多个文件读取会导致读取放大,或者浪费一些读取IO。

LogsDB的控制读取放大,是以一种特别适合日志数据模型的方式:不可变的LSN识别的不可变记录并随时间而单调递增。在控制文件数量方面,当SST文件的数量达到最大时,LogsDB不考虑分区,而是新创建一个最新分区,而不是通过合并排序(merge-sorting)成一个更大的有序LogsDB。由于分区是按顺序读取的,即便所有分区中的SST文件总数达到数万个,同时读取的文件数量也不可能超过单个分区中的最大文件数。通过删除(或在某些情况下偶尔合并排序)最旧的分区,可以有效地回收空间。

应用场景和未来展望

在Facebook,LogDevice已经成为众多日志工作负载的一种通用的解决方案。下面举几个例子。比如Scribe就是LogDevice众多海量级用户的一个使用者,峰值期间每秒获取的数据超过1TB,不仅可以可靠地传输并且可以回放。Scribe提供了一套即发即弃(fire-and-forget)的写入API,传送延迟预期在几秒左右。运行Scribe的LogDevice集群会针对每个设备的效率进行调整,而不是很低的端到端延迟或者追加延迟。TAO数据的二级索引也用的LogDevice,这是另一个应用场景。它的吞吐量虽没有Scribe大,但每个日志的严格记录排序很重要,期望的端到端延迟大概为10毫秒。这需要有非常不同的协调。另一个有趣的例子是机器学习管道,它使用LogDevice将相同的事件流提供给多个ML模型训练服务。

LogDevice还有更多的功能正在积极开发中。它用C++编写的,几乎没什么外部依赖。目前正在探索的新领域包括集群分解,其中存储和CPU密集型任务由具有不同硬件配置文件的服务器处理,支持非常高容量的日志,以及通过应用程序提供的密钥对记录进行高效的服务器端过滤。这些功能将不仅能提高LogDevice集群的硬件效率,而且为高吞吐量数据流的用户提供可扩展的负载分配机制。

最新更新:LogDevice已经开源,并可以通过GitHub找到它。

原文链接:LogDevice: a distributed data store for logs(翻译:伊海峰)

0 个评论

要回复文章请先登录注册