Fast22 - Removing Double-Logging with Passive Data Persistence in LSM-tree based Relational Databases
基于 LSM-tree 的关系型数据库中,通过被动的数据持久化方式移除双重日志记录
1. 介绍
自 1970 年代以来,关系数据库 (RDB) 一直在企业系统的核心发挥着核心作用。存储引擎作为 RDB 的核心组件,通常采用基于 B 树的结构,针对在线事务处理和在线分析处理等传统数据库工作负载进行了大量调整和优化。
随着互联网服务和应用的出现,经典的基于 B 树的存储引擎在主导数据库系统数十年后,面临着几个关键的挑战。与传统的数据库工作负载不同,这些新应用程序及其支持系统通常会产生写入密集型工作负载 [1]。它们中的许多使用相对简单和固定的数据模式 [2]。一些系统采用昂贵的闪存[3-9],因此它们对存储空间的使用非常敏感,需要高效的数据压缩以节省成本。相应地,存储引擎设计必须满足一组新的要求——可扩展性、空间效率、可压缩性、I/O 顺序性等。
为了应对这些新的挑战和需求,最近的技术趋势是在 RDB 中部署基于日志结构合并树 (LSM-tree) 的存储引擎 [10-18]。一个典型的例子是 Facebook 的 MyRocks [7]。与 MySQL 的传统结构不同,MyRocks 将原来基于 B 树的存储引擎 InnoDB [19] 替换为基于 LSM 树的存储引擎 RocksDB [12]。虽然这种基于 LSMtree 的存储引擎在性能和存储空间使用方面明显优于基于 B 树的引擎,但它带来了一个关键的新问题,它可能会产生沉重且不必要的性能开销。
如图 1 所示,基于 LSM 树存储引擎的 RDB 本质上创建了一个两层结构:(1) 在顶层 RDB 层,RDB 逻辑处理数据库相关的复杂性,例如缓冲池管理、查询优化、SQL查询处理、事务支持、数据恢复等; (2) 在底层的存储引擎层,存储引擎处理来自 RDB 层的请求,负责将数据可靠、高效地进行持久化存储。 这样的设计具有很大的灵活性、效率和可移植性,允许两层独立优化而不影响彼此。然而,基于 LSM-tree 的存储引擎,如 RocksDB [12] 作为一个完整的数据存储本身,具有许多类似于其上的 RDB 的功能。其中一些功能是多余的和不必要的,这会导致严重的资源浪费和负面的性能影响。其中一个关键组件是日志,这是本文的重点。
双重日志记录问题。 在 RDB 系统中,一个 binlog 记录了所有处理过的 SQL 语句。一旦系统崩溃,将重放 binlog 中的 SQL 语句进行数据恢复。使用基于 LSM 树的存储引擎,每个 SQL 语句都被转换为一系列键值项 (KV),这些键值项存储在底层 LSM 树中以进行持久存储。在基于 LSM-tree 的存储引擎中,会维护一个 Write-ahead Log (WAL) 来记录所有的 KV 更新操作。每个 KV 都必须先写入 WAL,然后再插入到树结构中,这也是为了系统崩溃时的数据恢复。在整个堆栈中,对数据库所做的任何更改都受到两次 “保护” —— 一个冗余副本保存在数据库的 binlog 中,另一个保存在存储引擎的 WAL 中。 这种冗余显然会导致不必要的空间使用,但更糟糕的是,这些与日志相关的 I/O 操作是同步的,并且驻留在系统的关键路径中,导致大量不必要的 I/O 开销,严重影响系统性能。
我们将上述问题称为双重日志记录问题,这是一种通过多次保留数据库更改来过度保护数据的系统情况。 据我们所知,这是第一次在基于 LSM-tree 存储引擎的 RDB 中透露此问题。
在基于 LSM-tree 存储引擎的 RDB 中。 值得注意的是,双重日志记录问题不是“log-on-log”问题 [20],它通常出现在日志结构闪存 FTL 上运行日志结构文件系统时。 在 log-on-log 问题中,上层日志存储在另一个下层日志结构上。 相比之下,在双日志记录问题中两个日志是独立的,分别存储(binlog 不存储在 WAL 或依赖 WAL)。 因此,双重日志问题并不涉及 log-on-log 问题中已知的问题,例如数据重新映射、未对齐的段和未协调的垃圾收集等。相反,它更多地关注 I/O 操作中不必要的冗余和存储空间使用情况。
我们的解决方案。 为了解决双重日志记录问题,我们的核心思想是从基于 LSM-tree 的存储引擎中完全移除 WAL,完全依赖 binlog 进行数据恢复。 一个天真的解决方案是直接禁用 WAL(例如,RocksDB 提供了一个可配置的选项)。 然而,由于 SQL 和 KV 操作不协调,系统完整性无法得到保证,可能导致恢复不完全或错误。即使我们通过回放 binlog 来进行数据恢复,我们也得把整个 binlog 中的所有记录都回放,一个接一个的顺序。 这会导致非常耗时的数据恢复过程,
在本文中,我们提出了一种称为 被动数据持久性方案 (PASV) 的新颖解决方案来应对这些挑战。它包括三个主要组件:(1)为了弥合 RDB 层和存储引擎层之间的语义鸿沟,我们创建了一个特殊的数据结构,称为 Flush Flag,以传递关键的 RDB 层语义,包括关键数据持久点,每个 KV 项的逻辑序列号等。 (2) 为了避免对当前模块化系统设计的侵入性更改,我们提出了一种被动内存缓冲区刷新策略,为每个 LSM-tree 传递一个刷新标志,随着在存储引擎的定期下刷操作。无需显式刷新内存缓冲区,我们可以避免刷新造成的不良性能影响,并最大限度地减少对两层的结构更改。(3) 我们还制定了基于Epoch的持久化 (EBP) 策略来确定全局数据持久化点,保证我们只需要进行部分数据恢复来恢复必要的数据,并消除系统之前已经持久化的冗余 KV 操作碰撞。
我们已经实现了一个基于 Facebook 的 MyRocks [8] 的功能齐全的开源原型。我们的原型涉及微小的变化(只有大约 500 行 C/C++ 代码)并且是公开可用的 [21]。 基于 LinkBench [1] 和 TPC-C [22] 的评估结果表明,我们的解决方案可以有效地将吞吐量提高高达 49.9%,将延迟降低高达 89.3%,并且还节省了磁盘 I/O 和恢复时间高达 分别为 42.9% 和 4.8%。
本文的其余部分安排如下。第 2 节介绍了背景。第 3 节和第 4 节解释了问题和挑战。第 5 节描述了设计细节。第 6 节给出了评估结果。第 7 节讨论相关工作。最后一节总结了本文。
2 背景
2.1 Log-structured Merge Tree
LSM-tree 结构。 LSM-tree 采用独特的 append-only 结构 [10],专为处理密集型小型 KV 而量身定制。在 LSM-tree 中,传入的小而随机的 KV 首先在称为 MemTable 的内存缓冲区中进行缓冲和排序。一旦 MemTable 满了,它就会变成一个不可变的缓冲区,并作为 SSTable 刷新到存储中。 SSTable 是 LSM-tree 中的基本存储单元。每个 SSTable 按键的顺序存储其 KV。
存储上的 SSTable 以多级结构组织,除第一级外,每一级都维护一系列具有非重叠键范围的 SSTable。两个不同的级别可能具有重叠的键范围。下层通常比相邻的上层维护多几倍(更宽)的 SSTable,形成树状结构。如果某一层级的 SSTables 数量超过了 size limit,则将选中的 SSTables 通过归并排序合并到较低层级,称为 Compaction。在查询时,执行二进制搜索,从上到下逐级搜索,直到找到该项目或返回 “Not Found”。图 1(a) 说明了典型的 LSM-tree 的结构。
为了防止系统故障时内存中的数据丢失,对内存缓冲区(又名 MemTable)所做的所有更新必须首先写入磁盘上的日志结构,称为预写日志(WAL)[23 , 24]。当系统在崩溃后重新启动时,WAL 中的记录将被重放以重建内存缓冲区中的原始数据。
基于多 LSM 树的结构。 现代 KV 存储引擎通常维护多个 LSM-tree 来为高速存储设备(如 SSD)创建 I/O 并行性,以实现更好的性能。让我们以 RocksDB [12] 为例。在 RocksDB 中,它维护着几个所谓的列族 (CF)。每个列族对应一个LSM-tree。所有 LSM-trees 只维护一个 WAL,称为 Group Logging [25] 或 Group Commit [26]。虽然为所有 LSM-tree 保留一个 WAL 可以带来事务处理的性能优势,但它会导致一个问题,即记录在 WAL 中的批量 KV 请求可能以不同的速度插入 LSM-tree,导致故障时恢复过程效率低下.我们将在本文后面更详细地解释这个问题。
2.2 基于LSM-tree的存储引擎
最近的技术趋势是在基于 LSM-tree 的存储引擎部署 RDB。一个典型的例子是 Facebook 的 MyRocks [8],它在 MySQL 数据库中采用 RocksDB 作为存储引擎。 接下来,我们将首先解释使用 LSM-tree 作为 RDBs 存储引擎的好处,然后讨论它的结构和内在问题。
LSM-tree 作为存储引擎的好处。有两个主要优点。首先,LSM-tree 结构以其在写入密集型工作负载下的高性能而著称。在需要处理不断传入数据的巨大流量的新系统环境中,例如 Internet 服务,LSM-tree 的性能优势尤为吸引人。第二个优势是空间效率。传统的存储引擎通常使用基于 B 树的结构。由于针对查询性能进行了大量优化,此类存储引擎通常需要更多的存储空间来进行复杂的索引和元数据维护。随着压缩效率低下,磁盘空间使用成为一个令人担忧的问题 [4、6、8]。相比之下,LSM-tree 是一种日志结构设计,它以 append-only 的方式持久化数据,并以排序的方式存储数据。这允许在存储中以更精简的格式组织数据。假设每一层比上层大10倍,理论上 LSM-tree 结构的空间放大可以限制在低层(大约是原始数据大小的 1.111...倍)。
在表 1 中,我们显示了初步测试的结果,以说明 MyRocks 与使用 InnoDB 作为存储引擎的 MySQL(5.6 版)相比的性能和空间效率。 MyRocks 和 MySQL 都使用默认配置。我们使用 LinkBench [1] 使用一个加载器生成的相同 100GB UDB 式工作负载 [2] 来衡量它们的性能。有关系统设置的更多详细信息,请参见第 6 节。我们可以看到,与 MySQL 相比,MyRocks 节省了 34.9% 的磁盘空间,并且在总执行时间 (19.8%) 和吞吐量 (24.7%) 方面也大大优于 MySQL。这一结果很好地证明了在 RDB 中采用基于 LSM 树的存储引擎的性能和存储优势。
LSM-tree 上的 RDB。 最近,许多 RDB 开始采用基于 LSM 树的存储引擎。例如,Spanner [5] 是一个基于 LSM-tree 的数据库,它是一个功能齐全的 SQL 系统,用于在全球范围内分发数据并支持分布式事务 [27]。 Facebook 的 MyRocks [7] 是一个基于 MySQL 的实现,用于服务于 UDB 场景 [6]。 MyRocks 用 RocksDB 取代了原来基于 B 树的存储引擎。其他一些数据库也基于 LSM-tree 结构构建它们的存储引擎 [28-30]。下面我们以MyRocks为例,来说明一个基于LSM-tree的RDB的基本结构。
图 1(b) 显示了 MyRocks 的架构,它提供了一个 SQL 接口,但采用了基于 LSM-tree 的存储引擎。它是一个两层结构,包含三个主要组件:(1) 通用 MySQL 服务器层,(2) 可插入的 SQL-to-KV 转换器,以及 (3) 基于 LSM-tree 的存储引擎层。 MySQL服务器层将用户请求组织成SQL事务,将SQL语句记录在binlog中,并将事务下发给SQL-to-KV翻译器。翻译器将每个事务的 SQL 语句转换为一个 KV 批处理 [31],它由一组 KV 项组成。然后将 KV 批次发送到基于 LSM-tree 的存储引擎,该存储引擎将 KV 项发布到相应的列族。在 RocksDB 中,在将 KV 项插入 LSM-tree 的内存缓冲区(MemTable)之前,它首先被写入 WAL。一旦属于一个事务的所有 KV 项都被插入到 MemTables 中,这个事务就可以被安全地视为“持久化”,并返回这个事务的提交标志。
对于数据恢复,执行两阶段恢复过程。存储引擎首先在 WAL 中检索批处理的 KV 项,并将它们重写到相应的 LSM-tree 的 MemTables 中。然后,MySQL服务器层重放安全点(commit flag)之后 binlog 中的所有事务。第一阶段保证存储引擎本身恢复到崩溃前的状态;第二阶段保证 MySQL 服务器层 SQL 逻辑的一致性。
3 双重日志记录问题
基于 LSM-tree 的 RDB 本质上形成了一个两层结构。如图1(b)所示,RDB层和存储引擎层各自维护一套完整的日志记录机制,分别用于数据的持久化和恢复。两套机制既相互独立又共存于系统之中。图 2(a) 说明了此结构中数据持久化的冗余功能。我们可以看到,尽管 binlog 和 WAL 中持久化的数据存在差异,但这两个日志的目的是一样的。
有趣的是,根据端到端理论[32],这种 “双重保护” 并不会为数据安全带来额外的保障,只会带来沉重且不必要的性能损失。首先,所有日志记录 I/O 都必须执行两次。在RDB上层,一个事务需要先以 SQL 语句的形式写入 binlog;在存储引擎下层,需要先将事务翻译出来的 KV 项以 KV 操作的形式写入 WAL 中。这显然是对存储 I/O 资源的严重浪费。其次,更重要的是,由于安全提交日志记录的要求,所涉及的 I/O 必须同步并以串行方式执行以确保正确性 [33-35]。结果,不幸的是,这些冗余 I/O 操作处于关键路径,这进一步放大了对系统性能的负面影响。我们称之为双重记录问题。据我们所知,本文是第一份揭示基于 LSM 树的 RDB 中这个隐藏的关键问题的工作。
为了说明开销,我们对 MyRocks 进行了初步测试。 我们在 RocksDB 存储引擎中关闭 WAL,其他配置保持默认。 我们使用 LinkBench 根据 Facebook 的 UDB 分布生成具有大约 4.37 亿个 SQL 请求的工作负载。 图 2(b) 显示了使用 10 个加载器插入链接的吞吐量。 通过简单地禁用 WAL,我们可以将整体吞吐量 (KOPS) 提高 44.6%。 这一结果显示了通过解决基于 LSM 树的 RDB 中的双重日志记录问题来提高性能的巨大潜力。
4 大挑战
我们的主要想法是删除 WAL,同时在发生故障时仍保留数据可靠性。然而,实现这一目标并非易事。我们必须应对三个关键挑战。
- 无保证的数据持久性。 在基于 LSM-tree 的 RDB 中,上层 RDB 将每个事务翻译成多个 KV 项并提交给下层存储引擎层,后者接收 KV 项并使它们持久化。假设一旦 RDB 接收到完成,KV 项就是持久化的。但是,如果 WAL 被取消,这样的假设就不再成立了。换句话说,binlog 中的事务提交标志已经不能被可靠地视为数据持久化的安全点,因为上层 RDB 层无法确定在该点之前的事务是否已经真正持久化。
- 部分持久性。 在具有多个 LSM 树的存储引擎中,一个 SQL 事务被翻译成一批 KV 项,这些项通常分布到多个 LSM-tree,也就是 RocksDB 中的列族(CF)。一旦 CF 的内存缓冲区(MemTable)被填满,它就会以 SSTable 的形式刷新到存储中。由于容纳在不同 LSM-tree 中的 KV 项的大小和到达率可能不同,因此此类内存缓冲区刷新可能以不同的频率发生,并且在 LSM-tree 之间完全不协调。这可能会导致这样一种情况,即在发生系统故障的某个时间点,SQL 事务可能会部分持久化。换句话说,交易的一些 KV 项已经被刷新到存储,但其他一些还没有(仍在易失性内存缓冲区中)。
- 失去对 LSN 的追踪。 基于 LSM-tree 的 RDB 使用日志序列号 (LSN) [36] 进行并发控制并满足 ACID 要求 [24]。 每个 KV 都分配有一个 LSN,它本质上是一个全球唯一的序列号。 与 SQL 事务相对应的 KV 批处理的项目保证接收到一系列连续的 LSN。 通过 WAL,我们可以保证每个恢复的 KV 项仍然携带着原来分配的 LSN。但是,如果 WAL 被删除,我们将无法跟踪这些 LSN。 即使重放 binlog 也无法重新生成丢失的 LSN,如果我们这样做,持久化在存储中的 KV 项的 LSN 将变得不完整和乱序。
在下一节中,我们将展示我们的设计来处理这些具有挑战性的问题。 我们开发了一套有效的方案来安全地移除 WAL,同时仍然完全保留数据持久性的保证。
5 设计
在本文中,我们提出了一种高效的解决方案来解决基于 LSM-tree 的 RDB 中的双重日志记录问题。我们希望在我们的设计中实现三个重要目标。
- 目标#1:有效性和效率。我们的解决方案应该有效且高效地解决重复记录问题。我们不仅需要在正常运行期间实现低性能开销,而且在发生故障时实现快速数据恢复。
- 目标#2:数据持久性和正确性。删除冗余日志不应以削弱对数据持久性和正确性的承诺为代价。数据可靠性应与现有系统保持一致。
- 目标#3:最小和非侵入性的改变。当前基于 LSM-tree 的 RDB 设计的一个优点是它的模块化。 RDB 层和存储引擎层是相对独立的。我们应该避免引入复杂的、侵入性的变化,并保留当前系统的模块化结构。
通过遵循上述三个设计目标,我们提出了一种被动数据持久化方案(PASV)来解决基于 LSM-tree 的 RDB 中的双重日志记录问题。我们基于 Facebook 的 MyRocks 实现了一个功能齐全的开源原型 [21]。值得注意的是,本文提出的设计原理也可以应用于其他具有类似双重日志记录问题的基于 LSM-tree 的 RDB。尽管具体实施可能有所不同,但我们的原型提供了消除冗余日志以优化性能的指导,同时在系统出现故障时仍能实现快速可靠的数据恢复。下面我们先介绍整体设计,再对各个组成部分进行一一描述。
5.1 概述
图 3(a) 说明了基于 LSM-tree 的 RDB 的 PASV 架构。为了尽量减少对现有系统设计的改动,我们最大限度地保留了原有的两层结构,只去掉了下层存储引擎层的 Write-Ahead Log(WAL),数据完全依赖于 RDB 层的 binlog 恢复。
我们引入了三个新组件,目的是消除冗余日志记录,但仍能在发生故障时实现可靠、高效的数据恢复。具体来说,(1) Passive Logging Manager (PASV-Mgr) 协调两层之间的数据记录和恢复操作,并使用特殊的 Flush Flag 传递两层之间与数据恢复相关的事务信息,(2) Epoch-based Persistence (EBP) 模块确定最近的全局安全点,以便在发生故障时可靠且完整地恢复丢失的数据,以及 (3) Partial Recovery 过程确定每个列族要恢复的 KV 项的最小数量,从而实现快速和高效的数据恢复。我们在下面详细介绍每个组件。
5.2 被动数据持久化
为了解决双日志问题,我们的核心思路是完全去掉下层存储引擎层的WAL,依赖上层 RDB 层的 binlog 进行数据恢复。这是出于两个原因。
首先,binlog 包含了一组完整的提交到数据库的原始 SQL 事务,这使得我们可以恢复所有数据库数据的原始格式。其次,即使底层存储引擎无法保证数据持久化,下层接收到的所有 KV 项都可以从原始交易中重构。然而,按照原始顺序安全地恢复所有交易数据并非易事。
挑战。 主要困难源于底层 LSM-tree 的内存缓冲区刷新不协调。如前所述,现代基于 LSM 树的存储引擎(例如 RocksDB)维护多个 LSM 树结构以并行化 I/O 并最大化可实现的性能。每个 LSM-tree ,又名列族 (CF),维护一个单独且独立的内存缓冲区 (MemTable) 以临时保存传入的 KV 项。当内存缓冲区达到大小限制时,它会被刷新到磁盘或 SSD 以进行持久存储。如果没有 WAL,一旦系统发生故障,易失性内存缓冲区中的 KV 项将丢失且无法恢复。问题在于不同列族的内存缓冲区的刷新操作是完全独立且不协调的,这意味着内存缓冲区刷新可能会以不同的频率发生,具体取决于传入的 KV 项的大小和到达率,这通常在列族之间有很大差异并随时间动态变化。因此,从一个 SQL 事务翻译而来的 KV 项可能会在不同的时间点持久化在存储中。当发生故障时,我们可能会有一个部分持久化的事务,并且事务可能不会按照它们原始的序列顺序完全持久化,作为它们在 binlog 中的提交标志。
图 3(b) 说明了这样一个示例,其中我们有两个列族 CF1 和 CF2,以及三个事务 T1、T2 和 T3。如果 CF2 在 CF1 之前刷新,事务 T3 将完全持久存储,而 T1 和 T2 仍然有部分数据,(K12,V12) 和 (K22,V22),在 CF1 的易失性内存缓冲区中。如果发生故障,两个事务(T1 和 T2)在存储上将变得不完整,我们可以发现事务在 binlog 中并没有按照它们原来的提交顺序完全持久化。为了确保完整的数据恢复,我们将不得不从头开始重放整个 binlog,因为我们无法确定哪些事务已安全且完整地持久化在存储中。一种积极的方法。解决上述问题的一个简单方法是在每个或多个事务后直接插入一个 Active Flush Point 来显式调用底层存储引擎层同时刷新所有 LSM-tree 的内存缓冲区,任意创建一个同步点。尽管这种 “主动” 方法保证了主动刷新点之前的所有事务都被安全地持久化,但它有几个局限性。 (1) 事务本质上是序列化的,这阻碍了创建并行性的努力。 (2) 频繁刷新实际上会使内存缓冲区无效,导致许多小的同步 I/O 存储。 (3) 最重要的是,这种方法削弱了当前模块化设计的努力。它强制 RDB 层直接控制较低存储引擎层的内存缓冲区操作,这是我们希望避免的。一种被动的方法。为了避免对现有的两层结构进行侵入性更改,我们开发了一种 “被动” 方法,以更优雅的方式处理不协调的刷新。下面是它的工作原理。 当存储引擎刷新内存缓冲区时,一个特殊的 KV 项,称为 Flush Flag,被插入内存缓冲区并与其他 KV 一起刷新到存储。目的是在持久存储中放置一个“标记”,以指示最新的刷新操作的进度。 flush flag 是一种特殊用途的 KV 项目。它的密钥是一个随机选择的 128 位幻数,表示这个 KV 项包含一个刷新标志而不是用户数据。 每个列族都有一个用于刷新标志的唯一键。该值包含四个指标的向量 (CF, TSN, LSNfirst, LSNlast)。CF 是正在刷新其内存缓冲区的列族(LSM-tree);TSN 为列族内存缓冲区中插入 KV 项的最后一笔交易的 Transaction Sequence Number;LSNfirst 和 LSNlast 分别是事务的第一个 KV 和最后一个持久化的 KV 的 LSN。 要检索最新的刷新标志,我们只需使用相应的键查询 LSM-tree,这就像检索任何常规 KV 项一样。这样我们就可以通过 flush flag 来跟踪 flush 操作时持久化在存储中的最新事务及其 KV 项,从中可以推导出恢复时数据持久化的安全点。
由于基于 LSM-tree 的 RDB 中事务处理的串行属性,这种方法是安全的。在基于LSM-tree 的 RDB 中,KV 项的串行处理是可以保证的: (1) 在事务提交过程中,所有事务记录串行持久化到 binlog; (2) SQL 事务在 RDB 层解析,在存储引擎层串行翻译成 KV 批; (3) 从事务中翻译出来的 KV 项目被串行插入到 LSM-trees 的内存缓冲区中。因此,我们可以确保所有逻辑上早于最后一次事务内最后一个 KV 项的 KV 项,在列族中永远不会比它更晚地保存到存储中。值得注意的是,这种序列性并不是 MyRocks 独有的。其他数据库也采用串行设计。例如,Amazon Aurora [34],一种新颖的面向 OLTP 的 RDB,以 “将数据库建模为重做日志流” 和 “利用日志作为有序的更改序列前进这一事实” 而闻名。
基于这个序列性,我们可以得出结论, 对于 LSM-tree 存储引擎中的列族 CFi,如果检索到的 flush 标志包含事务 TXNp,则 CFi 中事务 TXNp−1 及其之前事务的 KV 项一定已经持久下来了。因此,事务 TXNp−1 可以被视为列族 CFi 的数据安全点。 相比之下,事务 TXNp 可能会被部分持久化(同一批次的某些 KV 项可能还没有到达内存缓冲区)。因此,我们将事务 TXNp 称为列族 CFi 的数据持久化点,指示持久化数据的当前位置。
在图3(b)的例子中,如果 CF1 和 CF2 的内存缓冲区被刷新,它们的数据安全点分别是事务 T1 和 T2,而它们的数据持久化点分别是 T2 和 T3。在这个例子中我们还看到,由于涉及的 KV 项目的大小和到达率不同,列族在事务的持久化数据方面可能会做出不相等的 “进展”(本例中的 T2 与 T3)。我们将在下一节讨论如何解决这个问题。
这种被动方法带来了几个重要的优势。首先,我们可以最大限度地减少对现有模块化设计的侵入性更改。 RDB 层不需要显式调用存储引擎层的内存缓冲区刷新操作。相反,当内存缓冲区被刷新时,flush flag 会自然地与其他 KV item 一起保存在存储中。其次,内存缓冲区刷新仍然遵循原来的逻辑。我们不需要过早地刷新尚未满的内存缓冲区,这最大限度地保留了并行性和内存缓冲的好处。第三,内存缓冲区管理也几乎保持不变,不会产生额外的性能开销。刷新标志非常小,意味着空间开销也很小。
5.3 基于 Epoch 的持久化
存储引擎有多个列族 (CF),每个列族都是一个带有易失性内存缓冲区的独立 LSM-tree。如上所述,不同的 CF 在持久化事务的 KV 项方面可能进展不均。因此,我们需要确定其 KV 项已在所有 CF 中完全持久化存储的最新事务。
受 Epoch-based Reclamation [37-40] 的启发,我们提出了一种基于 Epoch 的持久性 (EBP) 来确定数据持久性的全局安全点。基本思路是用 Local Epoh 分别管理每个 CF 的数据安全点,用 Global Epoch 来标识全局数据安全点,决定了我们应该从 binlog 的什么地方开始恢复。
Local Epoch。Flush flag 维护最后一个事务和最后一个被刷新到持久存储的 KV 项,这是上一节中描述的 Local Data Persistence Point。被动持久性管理器 PASV-Mgr 通过为每个列族维护一个元组 <CF,TXN> 来跟踪每个列族所做的持久化数据的进度。对于一个列族 CFi ,我们记录对应的本地数据持久化点 TXNp ,也就是它的 flush flag 中记录的事务。它表明列族 CFi 中 TXNp 之前的交易的所有 KV 项目必须已经安全地持久化在存储中。请注意,从 “全局” 的角度来看,“本地安全” 的事务可能并不安全,因为事务的某些 KV 项可能尚未持久化到另一个列族中。一个 Local Epoch 是 binlog 中两个连续持久化点之间的事务。
Global Epoch。基于 Local Epoch,系统知道每个列族(CF)的数据持久化状态。也就是说,系统知道所有 CF 的本地数据持久化点。每次创建一个新的 Local Epoch 时,我们可以通过比较本地数据持久点来导出全局数据持久点。最小的 TXN,或者最早的本地数据持久点,是事务序列中的全局持久点。对于给定的全局持久化点,在它之前在二进制日志中提交的所有事务必须已经安全且完整地持久化在存储中。如果发生系统崩溃,只需要检查和重放从这个全局数据持久点(包括它自己)开始的事务。
图 4 说明了一个示例,其中我们可以看到 Local Epoch 表明列族 CF1 通过在 TXNt 之前刷新所有事务的 KV 项取得了最显着的进展,而 CF2 是最慢的,它只刷新直到事务 TXNn。从 Global Epoch 来看,显然当前全局数据持久化点在事务 TXNn,也就是说 TXNn 之前的所有事务肯定已经完全持久化了。在数据恢复时,我们只需要重放从事务 TXNn 开始及之后的事务。
5.4 部分恢复
基于 epoch 的持久化策略使我们能够快速确定在存储中完全持久化的最新事务。在系统出现故障时,我们需要通过重放之后的事务来恢复数据。
一种简单的方法是重放所有列族中的所有 KV 操作。然而,如图 4 所示,由于列族取得不同的进展,这种保守的方法将是非常低效和浪费的。比如列族 CF1 中不需要重新插入事务 TXNn 的 KV 项,因为这个事务的 KV 项已经 flush 入库了。因此,我们针对本地数据持久化点与全局数据持久化点不同的列族开发了 Partial Recovery。也就是说,我们选择性地跳过已经持久化的事务和 KV 项。
部分恢复包括两个步骤。 (1) 我们首先从每个 CF 中获取最新的 flush flag 来确定全局数据持久化点和本地数据持久化点,利用它们我们可以确定我们需要为每个 CF 重放的事务范围。(2) 然后我们通过扫描 binlog 并将每个 SQL 事务转换为 KV 项并提交给相应的 CF 来执行重放操作。由于部分回收策略,KV 项仅被派发给需要重放操作的列族。例如,对于 CF1,可以跳过 TXNt 之前的事务。
这样,我们就可以避免从头开始重放整个 binlog,只需要根据需要对每个列族进行部分恢复即可。在每个列族中,跳过本地数据持久化点之前已经持久化的 KV 项。这样既保证了我们可以安全的恢复所有数据,又避免了不必要的重放操作,减少了涉及的 I/O 开销,加速了数据恢复。
5.5 重建 LSN
数据恢复的另一个挑战是如何为每个涉及的 KV 项重建原始逻辑序列号 (LSN)。如第 4 节所述,每个 KV 项都附有一个用于版本控制的唯一 LSN。不幸的是,删除 WAL 后,LSN 信息丢失了。
如果未正确复制 LSN,则无法使用正确的版本信息恢复数据,并且可能会返回过时的数据以供查询。 LSN 表示一个KV batch 中 KV 项的内部顺序(对应 RDB 层的一个事务)。LSN 的丢失可能会导致错误的数据更新。例如,假设一个 KV 批处理包含对同一个键的两个更新操作。在原始系统中,两个 KV 项附加了两个唯一的 LSN。因为 LSN 是一个单调递增的数,所以 LSN 大的 KV 项一定是最新的数据。但是,如果我们无法正确恢复 LSN,则可能会返回过时的数据,这是不可接受的。
在当前基于 LSM-tree 的 RDB 设计中,存储引擎层维护了一个全局 LSN 计数器,当它附加到插入的 KV 项时,每次递增 1。因此,KV 批次的 KV 项必须具有一系列连续的 LSN。只要我们知道批次中第一个 KV 项的原始 LSN,由于序列属性,我们可以恢复所有生成的 KV 项的整个 LSN 序列(参见第 5.2 节)。 flush flag 包含最后一个事务和事务的第一个 KV 和最后一个持久化的 KV 的 LSN。重放事务时,我们只需重新翻译事务并逐一分配 LSN。由于我们知道最初分配的 LSN 范围,我们可以为需要恢复的 KV 项推导出所有相关的 LSN。
5.6 把它们放在一起
在图 4 中,我们展示了一个重放过程的说明性示例。如图所示,在进行 recovery 的时候,我们先获取所有 column family 的 flush flags。然后我们确定我们开始重放事务的全局数据持久点以及我们执行部分恢复的本地数据持久点。
在这个例子中,CF2 的 flush flag 包含本地数据持久化点 TXNn,batch 的第一个LSN 是 p+1,最后一个持久化的 LSN 是 p+2。因此,我们将系统当前用于数据恢复的 TSN 更新为 TXNn,这标志着我们应该开始重放的事务,并将起始 LSN 设置为 p + 1。由于最后一个持久化的 LSN 是 p + 2,因此下一个将被回放的 KV 项的 LSN 应该是 p + 3。然后我们从 binlog 中检索事务并调用翻译过程重新生成相应的 KV 批。注意第一个 KV 项的 LSN 是 p+1,但是第一个需要恢复的 KV 项是 p+3,所以我们可以跳过前两项。在 KV 重放过程中,每次为 KV 项分配一个 LSN 时,我们都会将 LSN 加 1,依此类推。
6 评价
我们基于 Facebook 的 MyRocks [8] 实现了 PASV 的全功能原型,这是一种流行的基于 LSMtree 的 RDB。 PASV 对现有系统改动不大(仅500行左右C/C++代码),主要在 binlog 管理、RocksDB 数据持久化、事务转 KV 等组件。
在本节中,我们将老的 MyRocks 表示为 “MyRocks”,将我们的原型表示为 “PASV”。 MyRocks 和 PASV 都在 binlog 中使用 ROW 格式来记录 MySQL server 层的 SQL 语句。 MyRocks 的 WAL 设置为默认大小。 MyRocks 和 PASV 的其他参数使用标准 MyRocks 的默认设置进行配置。我们的实验是在配备 Intel i7-8700 3.2GHz 处理器、32GB 内存和 500GB SSD 的工作站上进行的。我们使用带有 Linux Kernel 4.15 和 Ext4 文件系统的 Ubuntu 18.04 LTS。
我们的工作负载模拟了支持 Facebook 社交网络引擎的典型应用场景,即用户数据库 (UDB) [2]。在 Facebook 中,社交图数据包括许多对象类型,例如图节点和链接等。此工作负载创建一个模型来模拟 UDB 的关键模式,其中包含节点、关系和元数据的三个主要表 LINKTABLE、NODETABLE 和 COUNTTABLE ,例如节点的链接类型计数等。表 2 总结了这三个表的架构。我们使用 LinkBench [1],这是一个模拟 UDB 类请求(例如 SQL INSERT、UPDATE 、SELECT 等),以生成用于评估 MyRocks 和 PASV 的工作负载。
由于数据映射到列族可能会影响性能,我们扩展了 LinkBench 以将翻译后的 KV 映射到不同的列族。如表 2 所示,在 MyRocks 和 PASV 的存储引擎层中,我们维护了五个列族 (CF_ID=1–5)。我们手动将LINKTABLE的主键和副键,COUNTTABLE的主键,NODETABLE的主键分别分配给CF2-CF5。系统列族 cf_system (CF-1) 被初始化以存储系统元数据,例如表模式。
个人总结:去掉每个 LSM-tree WAL(宕机恢复日志),转为记录一个持久化点。RDB 层确定每个 LSM-tree 的持久化点,通过 binlog 进行恢复。通过少量逻辑 & 空间占用,去掉了底层 WAL 的写入开销和空间占用。