searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享

FoundationDB:分布式KV存储

2023-07-31 03:38:26
175
0

FoundationDB是一个开源的事务性键值存储库,创建于 10 多年前。它是最早将 NoSQL 架构的灵活性和可扩展性与 ACID 事务相结合的系统之一。FoundationDB将解耦做到了极致,主要包含三个子系统:内存事务管理系统、分布式存储系统和内置分布式配置系统。每个子系统都可以独立提供可扩展性、高可用性和分区容错的能力。FoundationDB 包含一个模拟测试框架,用于在无数可能的故障下测试每一个新功能。这种严格的测试使 FoundationDB 极为稳定,允许开发人员快速引入和发布新功能。FoundationDB 提供了一个经过精心选择的最小功能集,这使得一系列不同的系统可以作为层级构建在其上。由于其在存储用户数据、系统元数据和配置以及其他关键信息方面的一致性、健壮性和可用性,FoundationDB 已成为苹果、Snowflake 和其他公司云基础设施的基础。

1. 介绍

许多云服务都依赖可扩展的分布式存储后端来持久化应用状态。此类存储系统必须具有容错性和高可用性,同时提供足够强大的语义和灵活的数据模型,以实现快速的应用开发。此类服务必须能够扩展到数十亿用户、PB 或 EB 级存储数据以及每秒数百万次请求。

十多年前,NoSQL 存储系统的出现为应用开发提供了便利,使存储系统的扩展和操作变得简单,并提供容错功能和支持多种数据模型(而不是传统的僵化关系模型)。为了扩大规模,这些系统牺牲了事务语义,转而提供最终一致性,迫使应用开发人员需要考虑并发的更新操作。

FoundationDB(FDB)创建于2009年,其名称来源于专注于提供构建上层分布式系统所需的一组基础(foundational)模块。它是一个有序的、支持事务性的键值存储,原生地支持在整个key空间范围,跨多个key的严格可序列化的事务。大多数的数据库都将存储引擎、数据模型和查询语言耦合在一起。而FDB则不同,它采用了模块化的方法:提供了一个高度可扩展的事务性存储引擎,具有最少但精心选择的一组功能。它不提供结构化语义查询语言、数据模型或schema管理二级索引或通常在事务数据库中找到的许多其他功能。提供这些功能将使一些应用程序受益,但其他不需要它们的应用程序则需要绕过它们。相反,NoSQL模型为应用程序开发人员提供了极大的灵活性。应用程序可以管理以简单键值对形式存储的数据,同时还能实现一些高级功能,如一致的二级索引和引用完整性检查。FDB默认为严格可序列化的事务,但允许为不需要它们的应用程序放宽这些语义,并对冲突进行灵活、细粒度的控制。

FoundationDB广受欢迎的原因之一是它专注于数据库的 "下半部分",将其余部分留给 "层"--在其上开发的无状态应用程序,以提供各种数据模型和其他功能。这样一来,传统上需要完全不同类型存储系统的应用程序都可以利用 FDB。事实上,近年来在 FDB 上构建的各种层就证明了这种不同寻常的设计实用性。例如:FoundationDB 记录层(FoundationDB Record Layer)增加了用户对关系数据库的许多期望,而图数据库JanusGraph则提供了一个FoundationDB Record层的实现。CouchDB最近也完成了一个基于FDB Record层的实现。

测试和调试分布式系统和构建分布式系统一样困难。意外的进程或网络故障、消息重排以及其他非确定性来源的错误,这些错误极难重现或调试。但他们对数据库系统造成的后果尤其严重。此外,数据库系统的状态性意味着,任何此类错误都可能导致数据损坏,而这些数据可能在几个月内都不会被发现。模型检查技术可以验证分布式协议的正确性,但往往无法检查实际的实现。一些深层的错误,只有在按照特定顺序多个崩溃或重新启动时才会发生。这些问题,对端到端测试基础设施来说也是一个挑战。FDB采取了一种激进的方法——在构建数据库之前,我们构建了一个确定性数据库模拟框架,该框架可以模拟一个交互进程网络以及各种磁盘、进程、网络和请求级别的故障和恢复,所有这些都在一个物理进程中。C++的语法扩展Flow就是专门为这个目的创建的。这种严格的模拟测试使FDB非常稳定,并允许其开发人员快速引入新功能和版本。

FDB采用包括控制面和数据面的模块化架构。控制面管理集群的元数据,并使用Active Disk Paxos实现高可用性。数据面由负责处理更新的事务管理系统和用于读取的分布式存储层组成;两者都可以独立地伸缩。FDB通过乐观并发控制(OCC)和多版本并发控制(MVCC)的组合实现了严格的可串行性。FDB与其他分布式数据库的区别之一是其处理故障的方法。与大多数类似的系统不同,FDB不依赖quorum来隐藏故障,而是试图通过Recovery来快速检测故障并从中恢复。这使我们能够用更少的资源实现相同级别的容错:FDB可以容忍只有f+1(而不是2f+1)个副本的f个故障。这种方法最适合在内网或跨网部署。对于跨网部署,FDB提供了一种新的策略,可以避免跨区域写入延迟,同时在不丢失数据的情况下提供区域之间的自动故障切换。

2. 设计

FDB的主要设计原则:

  1. 分而治之。FDB将事务管理系统(写入路径)与分布式存储(读取路径)解耦,并对其进行独立扩展。在事务管理系统中,为每个子系统设置不同的角色,以提供不同的事务管理能力。如:时间戳管理、处理事务提交、冲突检测、事务日志管理(REDO/UNDO)等。此外,还将分布式系统中需要的,如:过载控制和负载平衡,等功能也由其他角色去完成。
  2. 让异常成为常态。对于分布式系统来说,故障是一种常态,而不是一种例外。为了应对FDB事务管理系统中的故障,它通过recovery处理所有故障:事务系统在检测到故障时主动关闭。因此,所有的故障处理都被简化为一个单一的recovery 操作,这将成为一个常见且经过良好测试的代码路径。为了提高可用性,FDB努力将平均恢复时间(MTTR)降至最低。在FDB的生产集群中,总时间通常不到5秒。
  3. 模拟测试。FDB依赖于一个随机的、确定性的模拟框架来测试其分布式数据库的正确性。模拟测试不仅暴露了深层次的错误,而且提高了开发人员的生产力和FDB的代码质量。

2.1 架构

FDB 集群有一个控制面,用于管理关键的系统元数据和整个集群的协调工作。还有一个控制面,用于事务管理和数据存储。架构图如下:

f1.jpg

控制面

  • 负责在Coordinator上持久化关键系统元数据,即事务系统的配置。每一个Coordinator是一个 fbserver进程,包括后面提到的其他的角色,都是统一的fb进程,只是采用的是对应角色的配置。这些Coordinator组成一个Paxos组,会选择一个主进程叫ClusterController。ClusterController监视集群中的所有服务器,由它选择三个服务进程,Sequencer、DataDistributor和Ratekeeper,如果它们失败或崩溃,则会重新选择它们。 Sequencer 为data plane 事务系统 分配 read 和 commit 版本(时间戳)。DataDistributor负责监控故障并在StorageServers之间平衡数据。Ratekeeper为集群提供过载保护。

数据面

FDB认为OLTP系统的工作场景一般是读多写少,每次事务只读写一小部分键,冲突小,并要求可扩展性。FDB 选择了一种完全解耦的架构设计。从上面的图中可以看到,主要分为了三个部分:

    • 分布式事务管理系统(TS)由序列器、代理和冲突检查器组成,所有这些都是无状态进程。
    • 日志系统(LS)通过 WAL(write-ahead-log) 持久化 TS 中的事务数据
    • 单独的分布式存储系统(SS)用于存储数据和提供读取服务。

LS 包含一组日志服务器,SS 由若干个存储服务器组成。

TS 事务系统中有几个角色比较重要:

  • 一个 Sequencer:这个就是 ClusterController 选择出来的一个角色。用于一致性读以及事务提交时分配版本。
  • 多个 Proxies:为 client 提供mvcc读以及协调事务提交。
  • 多个 resolvers 用于事务之间做冲突检测。

LS 的总体角色是提供 复制、分片以及分布式持久化队列 的能力。每一个“持久化队列“保存的是一个 StorageServe 的 WAL 数据。

SS 由多个 StorageServe 组成,它主要用于服务来自客户端的读请求。每一个 StorageServe 会按照 range (有序的)存储分片数据。SS的服务进程是整个fdb 集群中最多的,我们可以把它看作是一个分布式的B树。StorageServer 核心部分就是存储引擎了,每一个 SS 都会有一个引擎实例。其默认的存储引擎用的是SQLite,不过FoundationDB 在其之上为适配多版本做了一些修改(SQLite是 B-tree 存储引擎,不支持多版本,FDB 为其支持了多版本,同时增加了更快的RangeDelete 以及 异步API 接口);除了 SQLite 之外 还支持了 Memory , RocksDB(目前还在试验中,没有上生产环境),RedWood存储引擎(FDB 7.1开始生产可用,2022年6月版本)。

RedWood 存储引擎是 FoundationDB 因为 SQLite的一些问题,而为自己设计的存储引擎。因为SQLite 不支持多版本(FDB 写入的k/v 都会带有自己的版本,比如 同一个user key “key1" ,会有 key1-10, key1-11, key1-13等多个版本),而且因为 B-tree 的COW 更新方式对内存和性能都有非常大的影响,并且不能友好的支持前缀压缩。对FDB来说还是在设计上很难大幅度优化的,所以他们开发了一个适合自己场景的存储引擎 RedWood。

读写分离

如上所述,进程被分配了不同的角色;FDB通过为每个角色添加新进程来进行扩展。来自客户端的读请求可以直接被 分片到某一个StorageServers,随着 SS 服务数量的扩张,客户端读请求的性能也是线性增加的。写入是通过添加更多的 Proxies, Resolvers 和Log system来扩展。在整个系统中存在的三个单例进程 : Sequencer、DataDistributor、RateKeeper 。因为只管理元数据,所以并不会成为系统的性能瓶颈。

自选举

FDB 不依赖外部协调服务。所有用户数据和大多数系统元数据(以 0xFF 前缀开头的键)都存储在 StorageServers 中。StorageServers中每个服务进程的元数据保存在 LogServers 中,LogServers的配置数据则会被放在Coordinators 中。Coordinators 是一个Paxos 组;如果 Coordinator 中的“Leader”ClusterController 异常/未选举,会通过 diskpaxos选择一个新的 ClusterController。ClusterController首先会选择一个 Sequencer 单例角色,sequencer 从旧的LS读取LS原本的配置信息,并生成一个新的TS 和 LS。接下来就是事务系统中的 Proxies 会从 旧的 LS 读取系统元数据 (包括 SS 的元数据) 进行恢复。sequencer 会等到新的 TS 完成事务数据恢复后,然后将新的 LS配置写入到 所有的 coordinators 。

整个Cluster 角色选举 从 ClusterController 开始到完成各个组件的恢复就都做完了,到此才能为客户端提供读写服务。

恢复

Sequencer进程监视Proxies , Resolvers , 和Log Servers。只要TS或LS出现故障,或者数据库配置发生更改,Sequencer就会终止。ClusterController检测Sequencer故障,然后重新选择并生成一个新的TS和LS。这样,事务处理被划分为多个epochs,每个epoch代表一个Sequencer。

2.2 事务管理

端到端的事务处理

参考前面的架构图,事务处理可以从读写两个方面来讲述:

  1. 对于读事务来说,客户端会先从 TS 的 Proxies 中的一个获取一个 read version(时间戳),Proxy 会和 Sequencer进行交互 并 获取到当前系统最新提交的版本,Proxy 将获取到的版本返回给客户端。客户端可能会调度多次读请求到 SS,并获取到他们想要的小于等于这个版本的value数据。
  2. 对于写事务来说,客户端下发的写请求会先缓存到客户端本地,并不会和FDB 集群的角色有交互。客户端下发提交请求的时候,提交 rpc 会被发送到 Proxies 中的一个并等待提交结果的返回。如果提交失败,客户端可能会重试。

关于写事务的提交可以参考架构图中的3.1、3.2、3.3

  • 3.1 proxy 向 sequencer 请求一个新的commit version,要比已经存在的 read version 和 commit version都大。sequencer 看起来像是一个中心授时器,能够提供百万级别的版本生成能力。
  • 3.2 proxy 拿到新的版本 之后会发送给 resolvers 对 commit keys sets 做冲突检测,使用的是OCC的方式(提交的时候才进行),主要检测的是读写冲突,即是否有其他的事务在读当前commit 的这些keys。如果所有的resolvers 都返回没有冲突,则返回给Porxy可以进行最后的提交阶段;如果有冲突,则返回Proxy当前事务终止。
  • 3.3 Porxy 将提交事务发送到 LogServers 中进行持久化。一个事务在 LS 中完成提交的前提是所有的 LogServers 都向 Proxy 返回成功,并将提交的 commited version 发送给sequencer 用于推进下一次的 commit version。 最后返回给Client 提交成功。与此同时,SS 会从 LogServer 异步拉取 transaction logs 进行 REDO(因为在 fdb 中 logserver 保存的是提交成功的事务日志,所以并不是 undo log),将事务操作数据 重新执行,持久化到 SS 的本地存储中。

严格一致性

FDB 通过结合 OCC 和 MVCC 实现了可序列化快照隔离(SSI)。回想一下,事务 Tx 从sequencer获取读取版本和提交版本,其中读取版本保证不小于 Tx 启动时的任何提交版本,而提交版本则大于任何现有的读取或提交版本。提交版本定义了事务的历史提交记录,并用作日志序列号(LSN)。由于 Tx 会观察所有先前提交的事务的结果,因此 FDB 实现了严格的序列化。为确保 LSN 之间没有间隙,sequencer会在每个提交版本中返回上一个提交版本(即上一个 LSN)。Proxy将 LSN 和上一个 LSN 发送给resolvers和LogServers,以便它们按照 LSN 的顺序串行处理事务。同样,StorageServers也会按照 LSN 递增的顺序从LogServers提取日志数据。

resolvers使用类似于写快照隔离的无锁冲突检测算法。与写快照隔离的不同之处在于,FDB的提交版本是在冲突检测之前选择的。这使得FDB可以高效地批处理版本分配和冲突检测。

大体算法流程如下:

lastCommit 是每一个 resolver 维护的一个历史提交记录 ,通俗来说是一个 map(在fdb 的实现中是一个 支持多版本的skiplist,类似 rocksdb 的 WriteBatchWithIndex),保存的是这段时间内提交的key-ranges 和 它们的commit versions 之间的映射。

对于要提交的事务 Tx 在冲突检测中的输入由两部分组成:Rw表示要修改的key range集合,Rr表示要读的key的集合。

  • 1-5 行代码用于Tx中的读 和 lastCommited中的写进行冲突检测。主要从Rr中取出事务Tx内部的读请求的key-ranges,拿着这些range所对应的read version去和lastCommit 中的历史key版本进行比较,如果历史版本更大,则终止,否则继续向下执行。
  • 如果没有读写冲突,则将当前的 Rw中修改的key range添加到 lastCommitted,用于后续事务的读写冲突检测。

整个key空间经过分片后被分配给不同的 Resolvers,从而可以并行地进行冲突检测。只有当所有 Resolvers 都承认该事务时,事务才能提交。否则,事务将被中止。可能会出现这样的情况,即中止的事务被一个部分 Resolvers 接受,而这些 Resolvers 已经更新了其潜在提交事务的历史记录,这可能会导致其他事务发生冲突(即误报)。在实践中,这对我们的生产工作负载来说并不是问题,因为事务的key range通常属于一个 Resolver。此外,由于修改后的key会在 MVCC 窗口后过期,因此这种误报只限于在较短的 MVCC 窗口时间(即 5 秒)内发生。

FDB 的 OCC 设计避免了获取和释放(逻辑)锁的复杂逻辑,从而大大简化了 TS 和 SS 之间的交互。其代价是事务中止造成的工作浪费。在我们的多租户生产工作负载中,事务冲突率非常低(不到 1%),OCC 运行良好。如果发生冲突,客户端只需重新启动事务即可。

日志协议

上面提到了 resolver 做冲突检测的基本流程,这里就到了 Proxy 日志提交的最后一步,也就是持久化事务日志到 LogServers 中。

Proxy决定提交事务后,会向所有LogServers发送一条消息:负责当前key range的LogServers会收到修改,而其他LogServers则会收到一条空消息体。日志消息头包括从Sequencer获得的当前和以前的LSN,以及该代理的最大已知提交版本(KCV)。一旦日志数据变得持久,LogServers就会回复Proxy,如果所有副本LogServers都已回复,并且此LSN大于当前KCV,则Proxy会将其KCV更新为LSN。

LogServers向StorageServers传输重做日志不是提交路径的一部分,而是在后台异步执行的。在 FDB 中,StorageServers会将来自 LogServers 的非持久化的重做日志应用到内存索引中。一般情况下,这个异步执行非常快,通常在事务返回客户端之前已经做完。因此,当客户端读取请求到达存储服务器时,所请求的版本(即最新提交的数据)通常已经可用。如果存储服务器副本中没有新数据可供读取,客户机要么等待数据可用,要么在另一个副本中重新发出请求 。如果两个读取都超时,则客户端可以简单地重新启动事务。

由于日志数据在LogServers上已经是持久的,StorageServers可以在内存中缓冲更新,并定期将一批批数据持久化到磁盘,从而提高I/O效率。不过这样做存在的问题也比较明显,如果StorageServer挂了,内存中缓存的一部分操作就会丢失或者是一个事务只提交了一半 ,所以还需要 Recovery以及 recovery过程中的rollback能力。

事务系统的恢复

传统数据库系统通常采用 ARIES 恢复协议在恢复过程中,系统会处理上次检查点的重做日志记录,将其重新应用到相关的数据页上,从而使数据库达到一致的状态;崩溃期间正在运行的事务可以通过执行撤销日志记录来回滚。

在 FDB 中,恢复的成本非常低,不需要应用撤销日志条目。之所以能做到这一点,是因为在设计上做了极大的简化:没有 checkpoint,recovery的时候不需要重放 redo 或者 undo log。只需要保证一个非常简单的原则,Recovery 的过程中对 redo log的处理流程还是和之前一样就好了,即异步拉取LogServers中的 redo即可,完全不会对整个 Recovery的性能产生影响。在 FDB 中,StorageServers从LogServers拉取日志并在后台应用。恢复过程开始时会检测到故障并重启事务系统。在旧LogServers中的所有数据处理完毕之前,新的事务系统可以接受事务。恢复则只需找出重做日志的末尾: 此时(与正常前向操作一样),StorageServers异步重放日志。

恢复旧LogServers的本质是找出重做日志的终点,即恢复版本 (RV)。回滚撤消日志实质上是丢弃旧LogServers和StorageServers中 RV 之后的任何数据。图 2 说明了sequencer如何确定 RV。回想一下,向LogServers发出的代理请求会捎带其 KCV、该代理已提交的最大 LSN 以及当前事务的 LSN。每个日志服务器都会保存收到的最大 KCV 和持久版本 (DV),后者是日志服务器持久保存的最大 LSN(DV 优先于 KCV,因为它对应的是正在进行中的事务)。在恢复过程中,序列器会尝试停止所有 m 个旧日志服务器,其中每个响应都包含该日志服务器上的 DV 和 KCV。假设 LogServers 的并行度为 k。一旦sequencer收到的回复超过 m - k,sequencer就会知道上一epoch提交的事务已达到所有 KCV 的最大值,这就是上一epoch的结束版本(PEV)。该版本之前的所有数据都已完全复制。对于当前版本,其起始版本为 PEV + 1,sequencer选择所有 DV 中的最小值作为 RV。在 [PEV + 1, RV] 范围内的日志会从上一个epoch的LogServers复制到当前的LogServers。复制这一范围的日志开销非常小,因为它只包含几秒钟的日志数据。

f2.jpg

当 Sequencer 接受新事务时,第一个事务是一个特殊的恢复事务,它会通知StorageServers RV,以便它们可以回滚任何大于 RV 的数据。当前的 FDB 存储引擎由不支持多版本的 SQLite B 树和自实现的内存多版本化的重做日志数据组成。只有离开 MVCC 窗口的修改(即已提交数据)才会写入 SQLite。回滚只是在 StorageServers 中丢弃内存中的多版本数据。然后,StorageServers从新的LogServers中提取任何大于版本 PEV 的数据。

副本

FDB对不同的数据使用各种复制策略的组合来容忍f个故障:

  • 元数据复制。控制面的系统元数据使用Paxos存储在Coordinator上。只要有法定数量(即大多数)的Coordinater处于活动状态,就可以恢复此元数据。
  • 日志复制。当Proxy将日志写入LogServers时,每个分片的日志记录都会在k=f+1个LogServers上同步复制。只有当所有k都以成功的持久性进行了回复时,代理才能向客户端发回提交响应。LogServer故障导致事务系统恢复。
  • 存储复制。每个shard,也就是一个key range,都被异步复制到k=f+1 StorageServers,称为一个team。StorageServer通常负责多个分片,以便其数据均匀地分布在多个团队中。StorageServer的失败会触发DataDistributor将数据从包含失败进程的Team移动到其他正常Team。

请注意,storage team的抽象比Copysets更复杂。为了减少由于同时发生故障而导致数据丢失的机会,FDB确保replica组中最多有一个进程位于fault domain,例如主机、机架或可用性区域。每个Team都保证至少有一个进程处于活动状态,如果各个fault domain中的任何一个仍然可用,则不会丢失数据。

3. 模拟测试

测试和调试分布式系统是一个具有挑战性且效率低下的过程。这个问题对FDB来说尤其严重——其强并发控制契约的任何失败都可能在上层系统中产生几乎任意的损坏。因此,从一开始就采用了一种雄心勃勃的端到端测试方法:在确定性离散事件模拟中运行真实的数据库软件,以及随机合成工作负载和故障注入。恶劣的模拟环境很快就会在数据库中引发漏洞,而确定性保证了每一个这样的漏洞都可以被重现和调查。

FDB 利用自己编写的一套支持并发原语 actor model 模型 的异步编程框架 Flow 搭建了自己的模拟测试框架。包括模拟磁盘I/O,网络,系统时间 以及随机数生成器。

f3.jpg

它将FDB服务器进程的各种操作抽象为由Flow运行库调度的多个Actor。模拟器进程能够生成多个FDB服务器,这些服务器通过模拟网络在单个离散事件模拟中相互通信。

模拟器运行多个工作负载(用 Flow 编写),这些负载通过模拟网络与模拟的 FDB 服务器通信。这些工作负载包括故障注入指令、模拟应用程序、数据库配置更改和内部数据库功能调用。工作负载具有可组合性,并可重复用于构建综合测试案例。

4. 性能

性能测试是在一个数据中心内由 27 台机器组成的测试集群上进行的。每台机器的配置是16 核 2.5 GHz 英特尔至强 CPU、256 GB 内存和 8 个固态硬盘,通过万兆以太网连接。每台机器在 7 个固态硬盘上运行 14 个StorageServer,并为LogServers保留剩余的固态硬盘。在实验中,我们使用相同数量的代理和LogServers。LogServers和StorageServer的replication degrees均设为 3。

我们使用合成工作负载来评估 FDB 的性能。具体来说,有四种类型的事务:(1) 盲写:写和更新可配置数量的key;(2) 范围读取:随机选择一个key,然后读取连续可配置数量的key;(3) 点查: 获取 10 个随机key;以及 (4) 点写:获取 5 个随机key并更新另外 5 个随机key。我们分别使用盲写和范围读来评估写入和读取性能。同时使用点读和点写来评估读写混合性能。例如,90% 的读取和 10% 的写入(90/10 读写)由 80% 的点读取和 20% 的点写入事务构成。每个键为 16 字节,值的大小在 8 到 100 字节(平均 54 字节)之间均匀分布。数据库预先填充了相同大小分布的数据。在实验中,我们确保数据集不能完全缓存在存储服务器的内存中。

扩展性测试

f4.jpg

上图是FDB从4台机器到24台机器的可扩展性测试,使用2到22个proxy或LogServers。从结果上看:

  • 写入吞吐量对于每个事务100次操作(T100)从67扩展到391 MBps(5.84倍)以及对于每个事务500次操作(T500)从73扩展到467 MBps(6.40 倍)。写的场景,每份数据都会写3个副本(当前的replication degrees是3)到LogServers和StorageServers 。因此LogServers的CPU最容易达到饱和。
  • T100 的读取吞吐量从 2946 MBps 增加到 10,096 MBps(3.43 倍),T500 的读取吞吐量从 5055 MBps 增加到 21,830 MBps(4.32 倍)。读的场景StorageServers的CPU最容易达到饱和。
  • 90/10读写流量的每秒操作数,从593k增加到2779k(4.69X)。在这种情况下,Resolver和Proxies的CPU饱和。

无论是 纯读/纯写 还是读写混合,性能都是随着机器数量的增加而增加。因为读事务的链路比较短,clients 除了拿read version的时候和 TS 进行交互之外,带着IO的读请求都是 client 直接和 SS 进行交互,所以读性能平均比写性能好很多。

吞吐和延迟测试

在一个 24 台机器的集群上,以 90/10 读写负载的不同操作速率测试客户端性能。此配置有 2 个resolvers、22 个LogServers、22 个proxy和 336 个StorageServers。

f5.jpg

图 a 显示,读取和写入的吞吐量随每秒操作次数(Ops)的增加而线性增长。在延迟方面,图 b 显示,当 Ops 低于 100k 时,平均延迟保持稳定:读取key约 0.35 毫秒,提交约 2 毫秒,获取读取版本 (GRV) 约 1 毫秒。读取是单次操作,因此比两次操作的GRV 请求快。提交请求涉及多次操作,需要持久化到三个日志服务器,因此比读取和 GRV 的延迟更高。当 Ops 超过 100 k 时,由于队列时间增加,所有这些延迟都会增加。当 Ops 达到 2m 时,Resolvers 和 Proxies 已达到饱和。批处理有助于维持吞吐量,但由于饱和,提交延迟骤增至 368 毫秒。

Recovery性能

我们从通常承载数百 TB 数据的生产集群中收集了 289 个重新配置(即事务系统恢复)跟踪。由于这些集群面向客户端,因此较短的重新配置时间对集群的高可用性至关重要。下图展示了重新配置时间的累积分布函数 (CDF)。中位数和 90 分位数分别为 3.08 秒和 5.28 秒。恢复时间之所以这么短,是因为它们不受数据或事务日志大小的限制,只与系统元数据大小有关。在恢复过程中,读写事务被暂时阻塞,并在超时后重试。但是,客户端读取不受影响。

f6.jpg

5 经验总结

架构设计

分而治之。首先,将事务系统与存储层分离,可以更灵活地独立放置和扩展计算资源和存储资源。增加LogServers的一大好处是它相当于是witness replicas。在多可用区场景的生产环境,LogServers显著减少了实现相同高可用性所需的StorageServers(完整副本)的数量。此外,运维可以自由地将 FDB 的不同角色放在不同类型的服务器实例上,从而优化性能和成本。其次,解耦设计使数据库功能的扩展成为可能,例如存储引擎可替换。

模拟测试

通过仿真进行严格的正确性测试使 FDB 极为可靠。当首次发现未知错误时,我们总是通过提升模拟环境的能力,直到能在里面重现,然后才开始调试的过程。由于对系统可测试性的信心增强,生产率的提高难以估量。FDB 团队曾多次对主要子系统进行雄心勃勃的基础重写。如果没有模拟测试,这些项目中的许多都会被认为风险太大或难度太高,甚至不会尝试。

仿真的成功促使我们不断突破仿真测试的边界,消除依赖关系,并在 Flow 中重新实现这些依赖关系。例如,FDB 的早期版本依赖 Apache Zookeeper 进行协调,在实际故障注入发现 Zookeeper 中存在两个独立的错误后(大约在 2010 年),Zookeeper 被删除,取而代之的是用 Flow 编写的全新 Paxos 实现。自此以后,再未报告过任何生产性错误。

快速恢复

快速恢复不仅有助于提高可用性,还能大大简化软件升级和配置更改,使其更加快捷。升级分布式系统的传统方法是执行滚动升级,以便在出现问题时进行回滚。滚动升级的持续时间从数小时到数天不等。相比之下,FoundationDB 的升级可以通过同时重启所有进程来完成,通常在几秒钟内就能完成。由于这种升级路径已经过广泛的模拟测试,苹果生产集群中的所有升级都是通过这种方式进行的。此外,这种升级路径简化了不同版本之间的协议兼容性,我们只需确保磁盘上的数据是兼容的。无需确保不同软件版本之间 RPC 协议的兼容性。

一个有趣的发现是,快速恢复有时可以自动修复潜伏的错误。例如,在我们将DataDistributor 角色从Sequencer中分离出来后,我们惊讶地发现DataDistributor中存在几个未知的错误。这是因为在改变之前,DataDistributor 与 Sequencer 一起重新启动,这有效地重新初始化并修复了 DataDistributor 的状态。分离后,我们让 DataDistributor 成为独立于事务系统恢复(包括 Sequencer 重启)的常驻进程。因此,DataDistributor 的错误状态永远不会被修复,从而导致测试失败。

5s MVCC窗口

FDB选择一个5秒的MVCC窗口来限制事务系统和存储服务器的内存使用,因为多版本数据存储在Resolver和StorageServers的内存中,这反过来又限制了事务大小。根据我们的经验,这个5s窗口对于大多数OLTP用例来说已经足够长了。如果事务超过了时间限制,通常情况下客户端应用程序正在执行一些低效的操作,例如,逐个发出读取而不是并行读取。因此,超过时间限制往往会暴露出应用程序的效率低下。

对于一些可能跨越5秒以上的事务,许多事务可以划分为较小的事务。例如,FDB的连续备份过程将扫描key空间并创建key范围的快照。由于5s的限制,扫描过程被划分为多个较小的范围,因此每个范围都可以在5s内执行。事实上,这是一种常见的模式:一个事务创建多个作业,每个作业可以在事务中进一步划分或执行。FDB在一个名为TaskBucket的抽象中实现了这样一种模式,备份系统在很大程度上依赖于它。

总结

FoundationDB 是专为OLTP云服务设计的键值存储。其主要理念是将事务处理与日志记录和存储解耦。这种模块化的架构实现了读写处理的分离和横向扩展。事务系统结合了OCC和MVCC,以确保严格的序列化。日志记录的解耦和事务顺序的确定性极大简化了恢复工作,因此异常快速恢复,提高可用性。最后,确定性和随机模拟确保了数据库实现的正确性。

0条评论
0 / 1000
花子
4文章数
0粉丝数
花子
4 文章 | 0 粉丝
花子
4文章数
0粉丝数
花子
4 文章 | 0 粉丝

FoundationDB:分布式KV存储

2023-07-31 03:38:26
175
0

FoundationDB是一个开源的事务性键值存储库,创建于 10 多年前。它是最早将 NoSQL 架构的灵活性和可扩展性与 ACID 事务相结合的系统之一。FoundationDB将解耦做到了极致,主要包含三个子系统:内存事务管理系统、分布式存储系统和内置分布式配置系统。每个子系统都可以独立提供可扩展性、高可用性和分区容错的能力。FoundationDB 包含一个模拟测试框架,用于在无数可能的故障下测试每一个新功能。这种严格的测试使 FoundationDB 极为稳定,允许开发人员快速引入和发布新功能。FoundationDB 提供了一个经过精心选择的最小功能集,这使得一系列不同的系统可以作为层级构建在其上。由于其在存储用户数据、系统元数据和配置以及其他关键信息方面的一致性、健壮性和可用性,FoundationDB 已成为苹果、Snowflake 和其他公司云基础设施的基础。

1. 介绍

许多云服务都依赖可扩展的分布式存储后端来持久化应用状态。此类存储系统必须具有容错性和高可用性,同时提供足够强大的语义和灵活的数据模型,以实现快速的应用开发。此类服务必须能够扩展到数十亿用户、PB 或 EB 级存储数据以及每秒数百万次请求。

十多年前,NoSQL 存储系统的出现为应用开发提供了便利,使存储系统的扩展和操作变得简单,并提供容错功能和支持多种数据模型(而不是传统的僵化关系模型)。为了扩大规模,这些系统牺牲了事务语义,转而提供最终一致性,迫使应用开发人员需要考虑并发的更新操作。

FoundationDB(FDB)创建于2009年,其名称来源于专注于提供构建上层分布式系统所需的一组基础(foundational)模块。它是一个有序的、支持事务性的键值存储,原生地支持在整个key空间范围,跨多个key的严格可序列化的事务。大多数的数据库都将存储引擎、数据模型和查询语言耦合在一起。而FDB则不同,它采用了模块化的方法:提供了一个高度可扩展的事务性存储引擎,具有最少但精心选择的一组功能。它不提供结构化语义查询语言、数据模型或schema管理二级索引或通常在事务数据库中找到的许多其他功能。提供这些功能将使一些应用程序受益,但其他不需要它们的应用程序则需要绕过它们。相反,NoSQL模型为应用程序开发人员提供了极大的灵活性。应用程序可以管理以简单键值对形式存储的数据,同时还能实现一些高级功能,如一致的二级索引和引用完整性检查。FDB默认为严格可序列化的事务,但允许为不需要它们的应用程序放宽这些语义,并对冲突进行灵活、细粒度的控制。

FoundationDB广受欢迎的原因之一是它专注于数据库的 "下半部分",将其余部分留给 "层"--在其上开发的无状态应用程序,以提供各种数据模型和其他功能。这样一来,传统上需要完全不同类型存储系统的应用程序都可以利用 FDB。事实上,近年来在 FDB 上构建的各种层就证明了这种不同寻常的设计实用性。例如:FoundationDB 记录层(FoundationDB Record Layer)增加了用户对关系数据库的许多期望,而图数据库JanusGraph则提供了一个FoundationDB Record层的实现。CouchDB最近也完成了一个基于FDB Record层的实现。

测试和调试分布式系统和构建分布式系统一样困难。意外的进程或网络故障、消息重排以及其他非确定性来源的错误,这些错误极难重现或调试。但他们对数据库系统造成的后果尤其严重。此外,数据库系统的状态性意味着,任何此类错误都可能导致数据损坏,而这些数据可能在几个月内都不会被发现。模型检查技术可以验证分布式协议的正确性,但往往无法检查实际的实现。一些深层的错误,只有在按照特定顺序多个崩溃或重新启动时才会发生。这些问题,对端到端测试基础设施来说也是一个挑战。FDB采取了一种激进的方法——在构建数据库之前,我们构建了一个确定性数据库模拟框架,该框架可以模拟一个交互进程网络以及各种磁盘、进程、网络和请求级别的故障和恢复,所有这些都在一个物理进程中。C++的语法扩展Flow就是专门为这个目的创建的。这种严格的模拟测试使FDB非常稳定,并允许其开发人员快速引入新功能和版本。

FDB采用包括控制面和数据面的模块化架构。控制面管理集群的元数据,并使用Active Disk Paxos实现高可用性。数据面由负责处理更新的事务管理系统和用于读取的分布式存储层组成;两者都可以独立地伸缩。FDB通过乐观并发控制(OCC)和多版本并发控制(MVCC)的组合实现了严格的可串行性。FDB与其他分布式数据库的区别之一是其处理故障的方法。与大多数类似的系统不同,FDB不依赖quorum来隐藏故障,而是试图通过Recovery来快速检测故障并从中恢复。这使我们能够用更少的资源实现相同级别的容错:FDB可以容忍只有f+1(而不是2f+1)个副本的f个故障。这种方法最适合在内网或跨网部署。对于跨网部署,FDB提供了一种新的策略,可以避免跨区域写入延迟,同时在不丢失数据的情况下提供区域之间的自动故障切换。

2. 设计

FDB的主要设计原则:

  1. 分而治之。FDB将事务管理系统(写入路径)与分布式存储(读取路径)解耦,并对其进行独立扩展。在事务管理系统中,为每个子系统设置不同的角色,以提供不同的事务管理能力。如:时间戳管理、处理事务提交、冲突检测、事务日志管理(REDO/UNDO)等。此外,还将分布式系统中需要的,如:过载控制和负载平衡,等功能也由其他角色去完成。
  2. 让异常成为常态。对于分布式系统来说,故障是一种常态,而不是一种例外。为了应对FDB事务管理系统中的故障,它通过recovery处理所有故障:事务系统在检测到故障时主动关闭。因此,所有的故障处理都被简化为一个单一的recovery 操作,这将成为一个常见且经过良好测试的代码路径。为了提高可用性,FDB努力将平均恢复时间(MTTR)降至最低。在FDB的生产集群中,总时间通常不到5秒。
  3. 模拟测试。FDB依赖于一个随机的、确定性的模拟框架来测试其分布式数据库的正确性。模拟测试不仅暴露了深层次的错误,而且提高了开发人员的生产力和FDB的代码质量。

2.1 架构

FDB 集群有一个控制面,用于管理关键的系统元数据和整个集群的协调工作。还有一个控制面,用于事务管理和数据存储。架构图如下:

f1.jpg

控制面

  • 负责在Coordinator上持久化关键系统元数据,即事务系统的配置。每一个Coordinator是一个 fbserver进程,包括后面提到的其他的角色,都是统一的fb进程,只是采用的是对应角色的配置。这些Coordinator组成一个Paxos组,会选择一个主进程叫ClusterController。ClusterController监视集群中的所有服务器,由它选择三个服务进程,Sequencer、DataDistributor和Ratekeeper,如果它们失败或崩溃,则会重新选择它们。 Sequencer 为data plane 事务系统 分配 read 和 commit 版本(时间戳)。DataDistributor负责监控故障并在StorageServers之间平衡数据。Ratekeeper为集群提供过载保护。

数据面

FDB认为OLTP系统的工作场景一般是读多写少,每次事务只读写一小部分键,冲突小,并要求可扩展性。FDB 选择了一种完全解耦的架构设计。从上面的图中可以看到,主要分为了三个部分:

    • 分布式事务管理系统(TS)由序列器、代理和冲突检查器组成,所有这些都是无状态进程。
    • 日志系统(LS)通过 WAL(write-ahead-log) 持久化 TS 中的事务数据
    • 单独的分布式存储系统(SS)用于存储数据和提供读取服务。

LS 包含一组日志服务器,SS 由若干个存储服务器组成。

TS 事务系统中有几个角色比较重要:

  • 一个 Sequencer:这个就是 ClusterController 选择出来的一个角色。用于一致性读以及事务提交时分配版本。
  • 多个 Proxies:为 client 提供mvcc读以及协调事务提交。
  • 多个 resolvers 用于事务之间做冲突检测。

LS 的总体角色是提供 复制、分片以及分布式持久化队列 的能力。每一个“持久化队列“保存的是一个 StorageServe 的 WAL 数据。

SS 由多个 StorageServe 组成,它主要用于服务来自客户端的读请求。每一个 StorageServe 会按照 range (有序的)存储分片数据。SS的服务进程是整个fdb 集群中最多的,我们可以把它看作是一个分布式的B树。StorageServer 核心部分就是存储引擎了,每一个 SS 都会有一个引擎实例。其默认的存储引擎用的是SQLite,不过FoundationDB 在其之上为适配多版本做了一些修改(SQLite是 B-tree 存储引擎,不支持多版本,FDB 为其支持了多版本,同时增加了更快的RangeDelete 以及 异步API 接口);除了 SQLite 之外 还支持了 Memory , RocksDB(目前还在试验中,没有上生产环境),RedWood存储引擎(FDB 7.1开始生产可用,2022年6月版本)。

RedWood 存储引擎是 FoundationDB 因为 SQLite的一些问题,而为自己设计的存储引擎。因为SQLite 不支持多版本(FDB 写入的k/v 都会带有自己的版本,比如 同一个user key “key1" ,会有 key1-10, key1-11, key1-13等多个版本),而且因为 B-tree 的COW 更新方式对内存和性能都有非常大的影响,并且不能友好的支持前缀压缩。对FDB来说还是在设计上很难大幅度优化的,所以他们开发了一个适合自己场景的存储引擎 RedWood。

读写分离

如上所述,进程被分配了不同的角色;FDB通过为每个角色添加新进程来进行扩展。来自客户端的读请求可以直接被 分片到某一个StorageServers,随着 SS 服务数量的扩张,客户端读请求的性能也是线性增加的。写入是通过添加更多的 Proxies, Resolvers 和Log system来扩展。在整个系统中存在的三个单例进程 : Sequencer、DataDistributor、RateKeeper 。因为只管理元数据,所以并不会成为系统的性能瓶颈。

自选举

FDB 不依赖外部协调服务。所有用户数据和大多数系统元数据(以 0xFF 前缀开头的键)都存储在 StorageServers 中。StorageServers中每个服务进程的元数据保存在 LogServers 中,LogServers的配置数据则会被放在Coordinators 中。Coordinators 是一个Paxos 组;如果 Coordinator 中的“Leader”ClusterController 异常/未选举,会通过 diskpaxos选择一个新的 ClusterController。ClusterController首先会选择一个 Sequencer 单例角色,sequencer 从旧的LS读取LS原本的配置信息,并生成一个新的TS 和 LS。接下来就是事务系统中的 Proxies 会从 旧的 LS 读取系统元数据 (包括 SS 的元数据) 进行恢复。sequencer 会等到新的 TS 完成事务数据恢复后,然后将新的 LS配置写入到 所有的 coordinators 。

整个Cluster 角色选举 从 ClusterController 开始到完成各个组件的恢复就都做完了,到此才能为客户端提供读写服务。

恢复

Sequencer进程监视Proxies , Resolvers , 和Log Servers。只要TS或LS出现故障,或者数据库配置发生更改,Sequencer就会终止。ClusterController检测Sequencer故障,然后重新选择并生成一个新的TS和LS。这样,事务处理被划分为多个epochs,每个epoch代表一个Sequencer。

2.2 事务管理

端到端的事务处理

参考前面的架构图,事务处理可以从读写两个方面来讲述:

  1. 对于读事务来说,客户端会先从 TS 的 Proxies 中的一个获取一个 read version(时间戳),Proxy 会和 Sequencer进行交互 并 获取到当前系统最新提交的版本,Proxy 将获取到的版本返回给客户端。客户端可能会调度多次读请求到 SS,并获取到他们想要的小于等于这个版本的value数据。
  2. 对于写事务来说,客户端下发的写请求会先缓存到客户端本地,并不会和FDB 集群的角色有交互。客户端下发提交请求的时候,提交 rpc 会被发送到 Proxies 中的一个并等待提交结果的返回。如果提交失败,客户端可能会重试。

关于写事务的提交可以参考架构图中的3.1、3.2、3.3

  • 3.1 proxy 向 sequencer 请求一个新的commit version,要比已经存在的 read version 和 commit version都大。sequencer 看起来像是一个中心授时器,能够提供百万级别的版本生成能力。
  • 3.2 proxy 拿到新的版本 之后会发送给 resolvers 对 commit keys sets 做冲突检测,使用的是OCC的方式(提交的时候才进行),主要检测的是读写冲突,即是否有其他的事务在读当前commit 的这些keys。如果所有的resolvers 都返回没有冲突,则返回给Porxy可以进行最后的提交阶段;如果有冲突,则返回Proxy当前事务终止。
  • 3.3 Porxy 将提交事务发送到 LogServers 中进行持久化。一个事务在 LS 中完成提交的前提是所有的 LogServers 都向 Proxy 返回成功,并将提交的 commited version 发送给sequencer 用于推进下一次的 commit version。 最后返回给Client 提交成功。与此同时,SS 会从 LogServer 异步拉取 transaction logs 进行 REDO(因为在 fdb 中 logserver 保存的是提交成功的事务日志,所以并不是 undo log),将事务操作数据 重新执行,持久化到 SS 的本地存储中。

严格一致性

FDB 通过结合 OCC 和 MVCC 实现了可序列化快照隔离(SSI)。回想一下,事务 Tx 从sequencer获取读取版本和提交版本,其中读取版本保证不小于 Tx 启动时的任何提交版本,而提交版本则大于任何现有的读取或提交版本。提交版本定义了事务的历史提交记录,并用作日志序列号(LSN)。由于 Tx 会观察所有先前提交的事务的结果,因此 FDB 实现了严格的序列化。为确保 LSN 之间没有间隙,sequencer会在每个提交版本中返回上一个提交版本(即上一个 LSN)。Proxy将 LSN 和上一个 LSN 发送给resolvers和LogServers,以便它们按照 LSN 的顺序串行处理事务。同样,StorageServers也会按照 LSN 递增的顺序从LogServers提取日志数据。

resolvers使用类似于写快照隔离的无锁冲突检测算法。与写快照隔离的不同之处在于,FDB的提交版本是在冲突检测之前选择的。这使得FDB可以高效地批处理版本分配和冲突检测。

大体算法流程如下:

lastCommit 是每一个 resolver 维护的一个历史提交记录 ,通俗来说是一个 map(在fdb 的实现中是一个 支持多版本的skiplist,类似 rocksdb 的 WriteBatchWithIndex),保存的是这段时间内提交的key-ranges 和 它们的commit versions 之间的映射。

对于要提交的事务 Tx 在冲突检测中的输入由两部分组成:Rw表示要修改的key range集合,Rr表示要读的key的集合。

  • 1-5 行代码用于Tx中的读 和 lastCommited中的写进行冲突检测。主要从Rr中取出事务Tx内部的读请求的key-ranges,拿着这些range所对应的read version去和lastCommit 中的历史key版本进行比较,如果历史版本更大,则终止,否则继续向下执行。
  • 如果没有读写冲突,则将当前的 Rw中修改的key range添加到 lastCommitted,用于后续事务的读写冲突检测。

整个key空间经过分片后被分配给不同的 Resolvers,从而可以并行地进行冲突检测。只有当所有 Resolvers 都承认该事务时,事务才能提交。否则,事务将被中止。可能会出现这样的情况,即中止的事务被一个部分 Resolvers 接受,而这些 Resolvers 已经更新了其潜在提交事务的历史记录,这可能会导致其他事务发生冲突(即误报)。在实践中,这对我们的生产工作负载来说并不是问题,因为事务的key range通常属于一个 Resolver。此外,由于修改后的key会在 MVCC 窗口后过期,因此这种误报只限于在较短的 MVCC 窗口时间(即 5 秒)内发生。

FDB 的 OCC 设计避免了获取和释放(逻辑)锁的复杂逻辑,从而大大简化了 TS 和 SS 之间的交互。其代价是事务中止造成的工作浪费。在我们的多租户生产工作负载中,事务冲突率非常低(不到 1%),OCC 运行良好。如果发生冲突,客户端只需重新启动事务即可。

日志协议

上面提到了 resolver 做冲突检测的基本流程,这里就到了 Proxy 日志提交的最后一步,也就是持久化事务日志到 LogServers 中。

Proxy决定提交事务后,会向所有LogServers发送一条消息:负责当前key range的LogServers会收到修改,而其他LogServers则会收到一条空消息体。日志消息头包括从Sequencer获得的当前和以前的LSN,以及该代理的最大已知提交版本(KCV)。一旦日志数据变得持久,LogServers就会回复Proxy,如果所有副本LogServers都已回复,并且此LSN大于当前KCV,则Proxy会将其KCV更新为LSN。

LogServers向StorageServers传输重做日志不是提交路径的一部分,而是在后台异步执行的。在 FDB 中,StorageServers会将来自 LogServers 的非持久化的重做日志应用到内存索引中。一般情况下,这个异步执行非常快,通常在事务返回客户端之前已经做完。因此,当客户端读取请求到达存储服务器时,所请求的版本(即最新提交的数据)通常已经可用。如果存储服务器副本中没有新数据可供读取,客户机要么等待数据可用,要么在另一个副本中重新发出请求 。如果两个读取都超时,则客户端可以简单地重新启动事务。

由于日志数据在LogServers上已经是持久的,StorageServers可以在内存中缓冲更新,并定期将一批批数据持久化到磁盘,从而提高I/O效率。不过这样做存在的问题也比较明显,如果StorageServer挂了,内存中缓存的一部分操作就会丢失或者是一个事务只提交了一半 ,所以还需要 Recovery以及 recovery过程中的rollback能力。

事务系统的恢复

传统数据库系统通常采用 ARIES 恢复协议在恢复过程中,系统会处理上次检查点的重做日志记录,将其重新应用到相关的数据页上,从而使数据库达到一致的状态;崩溃期间正在运行的事务可以通过执行撤销日志记录来回滚。

在 FDB 中,恢复的成本非常低,不需要应用撤销日志条目。之所以能做到这一点,是因为在设计上做了极大的简化:没有 checkpoint,recovery的时候不需要重放 redo 或者 undo log。只需要保证一个非常简单的原则,Recovery 的过程中对 redo log的处理流程还是和之前一样就好了,即异步拉取LogServers中的 redo即可,完全不会对整个 Recovery的性能产生影响。在 FDB 中,StorageServers从LogServers拉取日志并在后台应用。恢复过程开始时会检测到故障并重启事务系统。在旧LogServers中的所有数据处理完毕之前,新的事务系统可以接受事务。恢复则只需找出重做日志的末尾: 此时(与正常前向操作一样),StorageServers异步重放日志。

恢复旧LogServers的本质是找出重做日志的终点,即恢复版本 (RV)。回滚撤消日志实质上是丢弃旧LogServers和StorageServers中 RV 之后的任何数据。图 2 说明了sequencer如何确定 RV。回想一下,向LogServers发出的代理请求会捎带其 KCV、该代理已提交的最大 LSN 以及当前事务的 LSN。每个日志服务器都会保存收到的最大 KCV 和持久版本 (DV),后者是日志服务器持久保存的最大 LSN(DV 优先于 KCV,因为它对应的是正在进行中的事务)。在恢复过程中,序列器会尝试停止所有 m 个旧日志服务器,其中每个响应都包含该日志服务器上的 DV 和 KCV。假设 LogServers 的并行度为 k。一旦sequencer收到的回复超过 m - k,sequencer就会知道上一epoch提交的事务已达到所有 KCV 的最大值,这就是上一epoch的结束版本(PEV)。该版本之前的所有数据都已完全复制。对于当前版本,其起始版本为 PEV + 1,sequencer选择所有 DV 中的最小值作为 RV。在 [PEV + 1, RV] 范围内的日志会从上一个epoch的LogServers复制到当前的LogServers。复制这一范围的日志开销非常小,因为它只包含几秒钟的日志数据。

f2.jpg

当 Sequencer 接受新事务时,第一个事务是一个特殊的恢复事务,它会通知StorageServers RV,以便它们可以回滚任何大于 RV 的数据。当前的 FDB 存储引擎由不支持多版本的 SQLite B 树和自实现的内存多版本化的重做日志数据组成。只有离开 MVCC 窗口的修改(即已提交数据)才会写入 SQLite。回滚只是在 StorageServers 中丢弃内存中的多版本数据。然后,StorageServers从新的LogServers中提取任何大于版本 PEV 的数据。

副本

FDB对不同的数据使用各种复制策略的组合来容忍f个故障:

  • 元数据复制。控制面的系统元数据使用Paxos存储在Coordinator上。只要有法定数量(即大多数)的Coordinater处于活动状态,就可以恢复此元数据。
  • 日志复制。当Proxy将日志写入LogServers时,每个分片的日志记录都会在k=f+1个LogServers上同步复制。只有当所有k都以成功的持久性进行了回复时,代理才能向客户端发回提交响应。LogServer故障导致事务系统恢复。
  • 存储复制。每个shard,也就是一个key range,都被异步复制到k=f+1 StorageServers,称为一个team。StorageServer通常负责多个分片,以便其数据均匀地分布在多个团队中。StorageServer的失败会触发DataDistributor将数据从包含失败进程的Team移动到其他正常Team。

请注意,storage team的抽象比Copysets更复杂。为了减少由于同时发生故障而导致数据丢失的机会,FDB确保replica组中最多有一个进程位于fault domain,例如主机、机架或可用性区域。每个Team都保证至少有一个进程处于活动状态,如果各个fault domain中的任何一个仍然可用,则不会丢失数据。

3. 模拟测试

测试和调试分布式系统是一个具有挑战性且效率低下的过程。这个问题对FDB来说尤其严重——其强并发控制契约的任何失败都可能在上层系统中产生几乎任意的损坏。因此,从一开始就采用了一种雄心勃勃的端到端测试方法:在确定性离散事件模拟中运行真实的数据库软件,以及随机合成工作负载和故障注入。恶劣的模拟环境很快就会在数据库中引发漏洞,而确定性保证了每一个这样的漏洞都可以被重现和调查。

FDB 利用自己编写的一套支持并发原语 actor model 模型 的异步编程框架 Flow 搭建了自己的模拟测试框架。包括模拟磁盘I/O,网络,系统时间 以及随机数生成器。

f3.jpg

它将FDB服务器进程的各种操作抽象为由Flow运行库调度的多个Actor。模拟器进程能够生成多个FDB服务器,这些服务器通过模拟网络在单个离散事件模拟中相互通信。

模拟器运行多个工作负载(用 Flow 编写),这些负载通过模拟网络与模拟的 FDB 服务器通信。这些工作负载包括故障注入指令、模拟应用程序、数据库配置更改和内部数据库功能调用。工作负载具有可组合性,并可重复用于构建综合测试案例。

4. 性能

性能测试是在一个数据中心内由 27 台机器组成的测试集群上进行的。每台机器的配置是16 核 2.5 GHz 英特尔至强 CPU、256 GB 内存和 8 个固态硬盘,通过万兆以太网连接。每台机器在 7 个固态硬盘上运行 14 个StorageServer,并为LogServers保留剩余的固态硬盘。在实验中,我们使用相同数量的代理和LogServers。LogServers和StorageServer的replication degrees均设为 3。

我们使用合成工作负载来评估 FDB 的性能。具体来说,有四种类型的事务:(1) 盲写:写和更新可配置数量的key;(2) 范围读取:随机选择一个key,然后读取连续可配置数量的key;(3) 点查: 获取 10 个随机key;以及 (4) 点写:获取 5 个随机key并更新另外 5 个随机key。我们分别使用盲写和范围读来评估写入和读取性能。同时使用点读和点写来评估读写混合性能。例如,90% 的读取和 10% 的写入(90/10 读写)由 80% 的点读取和 20% 的点写入事务构成。每个键为 16 字节,值的大小在 8 到 100 字节(平均 54 字节)之间均匀分布。数据库预先填充了相同大小分布的数据。在实验中,我们确保数据集不能完全缓存在存储服务器的内存中。

扩展性测试

f4.jpg

上图是FDB从4台机器到24台机器的可扩展性测试,使用2到22个proxy或LogServers。从结果上看:

  • 写入吞吐量对于每个事务100次操作(T100)从67扩展到391 MBps(5.84倍)以及对于每个事务500次操作(T500)从73扩展到467 MBps(6.40 倍)。写的场景,每份数据都会写3个副本(当前的replication degrees是3)到LogServers和StorageServers 。因此LogServers的CPU最容易达到饱和。
  • T100 的读取吞吐量从 2946 MBps 增加到 10,096 MBps(3.43 倍),T500 的读取吞吐量从 5055 MBps 增加到 21,830 MBps(4.32 倍)。读的场景StorageServers的CPU最容易达到饱和。
  • 90/10读写流量的每秒操作数,从593k增加到2779k(4.69X)。在这种情况下,Resolver和Proxies的CPU饱和。

无论是 纯读/纯写 还是读写混合,性能都是随着机器数量的增加而增加。因为读事务的链路比较短,clients 除了拿read version的时候和 TS 进行交互之外,带着IO的读请求都是 client 直接和 SS 进行交互,所以读性能平均比写性能好很多。

吞吐和延迟测试

在一个 24 台机器的集群上,以 90/10 读写负载的不同操作速率测试客户端性能。此配置有 2 个resolvers、22 个LogServers、22 个proxy和 336 个StorageServers。

f5.jpg

图 a 显示,读取和写入的吞吐量随每秒操作次数(Ops)的增加而线性增长。在延迟方面,图 b 显示,当 Ops 低于 100k 时,平均延迟保持稳定:读取key约 0.35 毫秒,提交约 2 毫秒,获取读取版本 (GRV) 约 1 毫秒。读取是单次操作,因此比两次操作的GRV 请求快。提交请求涉及多次操作,需要持久化到三个日志服务器,因此比读取和 GRV 的延迟更高。当 Ops 超过 100 k 时,由于队列时间增加,所有这些延迟都会增加。当 Ops 达到 2m 时,Resolvers 和 Proxies 已达到饱和。批处理有助于维持吞吐量,但由于饱和,提交延迟骤增至 368 毫秒。

Recovery性能

我们从通常承载数百 TB 数据的生产集群中收集了 289 个重新配置(即事务系统恢复)跟踪。由于这些集群面向客户端,因此较短的重新配置时间对集群的高可用性至关重要。下图展示了重新配置时间的累积分布函数 (CDF)。中位数和 90 分位数分别为 3.08 秒和 5.28 秒。恢复时间之所以这么短,是因为它们不受数据或事务日志大小的限制,只与系统元数据大小有关。在恢复过程中,读写事务被暂时阻塞,并在超时后重试。但是,客户端读取不受影响。

f6.jpg

5 经验总结

架构设计

分而治之。首先,将事务系统与存储层分离,可以更灵活地独立放置和扩展计算资源和存储资源。增加LogServers的一大好处是它相当于是witness replicas。在多可用区场景的生产环境,LogServers显著减少了实现相同高可用性所需的StorageServers(完整副本)的数量。此外,运维可以自由地将 FDB 的不同角色放在不同类型的服务器实例上,从而优化性能和成本。其次,解耦设计使数据库功能的扩展成为可能,例如存储引擎可替换。

模拟测试

通过仿真进行严格的正确性测试使 FDB 极为可靠。当首次发现未知错误时,我们总是通过提升模拟环境的能力,直到能在里面重现,然后才开始调试的过程。由于对系统可测试性的信心增强,生产率的提高难以估量。FDB 团队曾多次对主要子系统进行雄心勃勃的基础重写。如果没有模拟测试,这些项目中的许多都会被认为风险太大或难度太高,甚至不会尝试。

仿真的成功促使我们不断突破仿真测试的边界,消除依赖关系,并在 Flow 中重新实现这些依赖关系。例如,FDB 的早期版本依赖 Apache Zookeeper 进行协调,在实际故障注入发现 Zookeeper 中存在两个独立的错误后(大约在 2010 年),Zookeeper 被删除,取而代之的是用 Flow 编写的全新 Paxos 实现。自此以后,再未报告过任何生产性错误。

快速恢复

快速恢复不仅有助于提高可用性,还能大大简化软件升级和配置更改,使其更加快捷。升级分布式系统的传统方法是执行滚动升级,以便在出现问题时进行回滚。滚动升级的持续时间从数小时到数天不等。相比之下,FoundationDB 的升级可以通过同时重启所有进程来完成,通常在几秒钟内就能完成。由于这种升级路径已经过广泛的模拟测试,苹果生产集群中的所有升级都是通过这种方式进行的。此外,这种升级路径简化了不同版本之间的协议兼容性,我们只需确保磁盘上的数据是兼容的。无需确保不同软件版本之间 RPC 协议的兼容性。

一个有趣的发现是,快速恢复有时可以自动修复潜伏的错误。例如,在我们将DataDistributor 角色从Sequencer中分离出来后,我们惊讶地发现DataDistributor中存在几个未知的错误。这是因为在改变之前,DataDistributor 与 Sequencer 一起重新启动,这有效地重新初始化并修复了 DataDistributor 的状态。分离后,我们让 DataDistributor 成为独立于事务系统恢复(包括 Sequencer 重启)的常驻进程。因此,DataDistributor 的错误状态永远不会被修复,从而导致测试失败。

5s MVCC窗口

FDB选择一个5秒的MVCC窗口来限制事务系统和存储服务器的内存使用,因为多版本数据存储在Resolver和StorageServers的内存中,这反过来又限制了事务大小。根据我们的经验,这个5s窗口对于大多数OLTP用例来说已经足够长了。如果事务超过了时间限制,通常情况下客户端应用程序正在执行一些低效的操作,例如,逐个发出读取而不是并行读取。因此,超过时间限制往往会暴露出应用程序的效率低下。

对于一些可能跨越5秒以上的事务,许多事务可以划分为较小的事务。例如,FDB的连续备份过程将扫描key空间并创建key范围的快照。由于5s的限制,扫描过程被划分为多个较小的范围,因此每个范围都可以在5s内执行。事实上,这是一种常见的模式:一个事务创建多个作业,每个作业可以在事务中进一步划分或执行。FDB在一个名为TaskBucket的抽象中实现了这样一种模式,备份系统在很大程度上依赖于它。

总结

FoundationDB 是专为OLTP云服务设计的键值存储。其主要理念是将事务处理与日志记录和存储解耦。这种模块化的架构实现了读写处理的分离和横向扩展。事务系统结合了OCC和MVCC,以确保严格的序列化。日志记录的解耦和事务顺序的确定性极大简化了恢复工作,因此异常快速恢复,提高可用性。最后,确定性和随机模拟确保了数据库实现的正确性。

文章来自个人专栏
开源
4 文章 | 1 订阅
0条评论
0 / 1000
请输入你的评论
0
0