FoundationDB:Apple 开源的分布式 KV 存储

2,847 阅读8分钟

FoundationDB(简称 FDB),是 Apple 公司开源的一个支持分布式事务的 Key-Value 存储,可以认为类似 PingCAP 开源的 TiKV。它最近发表了一篇论文 FoundationDB: A Distributed Unbundled Transactional KeyValue Store,介绍了其内部实现原理。本文是这篇论文及其官方文档的学习笔记。

整体架构

如上图所示,FDB 的架构中规中矩,大的模块可以分成三部分:

  • 客户端接口 Client
  • 控制平面 Control Plane
  • 数据平面 Data Plane

论文重点介绍的是 Control Plane 和 Data Plane。

Control Plane

Control Plane 负责管理集群的元数据,使用 Active Disk Paxos 来保证高可用。Control Plane 分成以下几个部分:

  • Coordinator

几个 coordinator 进程组成一个 paxos group,其中有一个 leader,称为 cluster controller。Cluster controller 负责故障检测,管理各种进程的角色,汇集、传递整个集群的各种信息。同时,cluster controller 是整个集群访问的入口点。Client 通过一个保存有 coordinator 的 IP:Port 的配置文件访问集群,并从 cluster controller 获取最新的 proxy 列表。

  • DataDistributor

DataDistributor 负责监控 StorageServer 的故障情况和数据的平衡调度。

  • Ratekeeper

Ratekeeper 通过控制单调递增时间戳的分配速度来进行过载保护。

Data Plane

Data Plane 大体上可以划分成三大部分:

  • Transaction System 负责实现 serializable snapshot isolation 级别的分布式事务。
  • Log System 负责日志的复制,保证系统的高可用。
  • Storage System 保存实际数据,或者说状态机,会从 Log System 异步拉取日志进行 apply。目前单机存储引擎是使用一个修改过的 SQLite。

Transaction System 较为复杂,大体上可以分层三个模块:

  • Proxy 作为事务系统面向 client 的代理接口,事务请求都需要通过 proxy 获取 read version,获取 key ranges 对应的 storage server 的信息,提交事务。
  • Sequencer 负责分配递增的 read version 和 commit version。
  • Resolver 负责 SSI 级别的事务冲突检测。

日志复制

日志复制是每个分布式数据库实现高可用所必须的。 ​

FDB 有两种数据需要复制:Control Plane 的元数据和 Data Plane 的用户数据。 ​

Control Plane 的元数据日志复制使用了 Active Disk Paxos(Paxos 的一个变种)。基于 Paxos 复制日志实现系统的高可用是常规的业界做法,也是不依赖外部仲裁实现高可用、强一致的标准方法。

Data Plane 采用了同步复制的方式——每次复制给 f + 1 个节点,只有 f +1 个节点都成功才返回成功。这种方式只需要 f + 1 个副本就可以容忍 f 个副本丢失,而采用 Paxos/Raft 进行复制的话,需要 2 * f + 1 个副本。

看起来,同步日志复制的成本比采用 Paxos/Raft 的方式要低许多,但是这里 Log System 的选主和故障恢复、故障转移应该是要严重依赖 cluster controller 的仲裁。并且任意一个副本故障都会导致写失败,如果采用 Paxos/Raft 的方式,只有 leader 故障才会导致写失败。另外,这里的 membership change 是怎么做的?这个估计要看看代码才清楚。这其中还有什么坑呢?估计要深度使用过后才清楚。

事务

Data Plane 的分布式事务是为典型的 OLTP 场景设计的——水平扩展、读多写少、小事务(5 秒)。FDB 通过 OCC +MVCC 来实现 SSI 的事务隔离级别。 ​

一个事务的基本流程大概如下:

  1. Client -> proxy -> sequencer 获取 read version。
  2. Client with read version -> storage 根据 read version 读取数据快照。
  3. 写请求在提交前会先缓存在本地。
  4. 事务提交:
    1. Client 发送读集合和写集合给 proxy。
    2. Proxy 从 sequencer 获取 commit version。
    3. Proxy 将读集合和写集合发送给 resolver 进行事务冲突检测。
    4. 如果事务冲突,则 abort 掉。否则 proxy 发送写集合给 log server 进行持久化,完成事务的提交。

事务的执行流程,这里有几个关键的问题。

  1. 如何决定 read version?
  2. 如何进行事务冲突检测,以满足 SSI?
  3. 如何从故障中恢复?

如何决定 read version?

按照 SSI 事务的要求,通过 read version,必须能读到所有 commit version 小于等于 read version 的事务。对于已经提交的事务,这个问题不大。主要问题在于并发事务。

举个例子:

事务 A 和事务 B 是并发事务,A 获取 read vesion,准备读取数据;B 获取 commit version,准备提交事务。

如果 read version < commit version,事务 A 无论如何无法读到事务 B 的数据,所以没问题。

如果 read version >= commit version,事务 A 需要能够读取到事务 B 的数据,但是此时事务 B 可能还没提交…

如何解决这个问题呢?比较传统的做法是,commit 之前先对数据上锁,表示有 pending 的事务。读取的时候,需要检查数据有没有上锁,如果数据已经上锁,说明有事务正在写,则等待事务执行、或者推进事务 commit/abort、或者 abort 当前事务。

FDB 的做法是:从所有 proxy 收集最新最大的 commit version 作为 read version。而由于 commit version 的分配是全局单调递增的,所以可以保证,并发事务的 commit version 肯定大于 read version。

When a client requests a read version from a proxy, the proxy asks all other proxies for their last commit versions, and checks a set of transaction logs satisfying replication policy are live. Then the proxy returns the maximum commit version as the read version to the client.

虽然从 proxy 收集 commit version 在实现上很容易做 batch 优化来提升性能,但是每次需要访问所有的 proxy,我对其扩展性和可用性保持怀疑。

如何进行事务冲突检测,以满足 SSI?

FDB 的事务冲突检测算法看起来很简单,核心点就是检查读写冲突(Read-Write Conflict)

至于为什么只检查读写冲突就能满足 SSI,本论文并没有说明。 之前看过一篇论文 A Critique of Snapshot Isolation,比较系统地介绍了 Snapshot Isolation 和 Serializable Snapshot Isolation。论文里面介绍的实现 SSI 的事务冲突检测算法和 FDB 基本一样,有兴趣可以读一下。

FDB 的事务实现其实和 Omid 很像,有兴趣可以参考相关论文:

  • Omid: Lock-free Transactional Support forDistributed Data Stores
  • Omid, Reloaded: Scalable and Highly-AvailableTransaction Processing
  • A Critique of Snapshot Isolation 其实也是 Omid 的一篇相关论文。

如何从故障中恢复?

FDB 的故障恢复设计我也觉得比较奇怪,看不太懂。 FDB 事务系统的核心进程是:Sequencer、Proxy、Resolver、LogServer,其中 Sequencer 是事务系统的“主控”:

  • 如果 Sequencer 挂掉了,会有一个新的 Seqencer 被重新拉起来。
  • 如果其它进程挂掉了,Sequencer 会自杀,然后会有一个新的 Seqencer 被重新拉起来。

新的 Sequencer 从 Coordinators 获取旧的事务系统的信息,包括所有的 Proxy、Resolver、LogServer,阻止它们处理新的事务。然后,重新组建新的事务系统(Proxy、Resolver、LogServer)。

事务系统恢复的时候,主要是要确定 LogServer 已经提交的最新的 log 的位置,论文中叫 Recovery Version,这个位置之前(包括这个位置)的日志已经提交,这个位置之后的日志可以直接忽略掉。

FDB 这种同步复制的方式,故障恢复其实很简单: 收集至少 m-k+1 个 LogServer 的最大持久化 LSN,然后取其中的最小即可。

  • m 是 LogServer 的总数
  • k 是日志同步复制的副本数。

FDB 的实现比上面这个方式稍微复杂一点,不过原理上是类似的。

分层设计

FDB 很强调设计分层、模块解耦、分而治之的思想。比如上面介绍的 Control Plane 和 Data Plane,设计上就被划分成多个功能模块。

FDB 只提供 get()、set()、getRange()、clear()、commit() 这几个简单的接口。FDB 认为自己实现了一个数据库的底层("lower half")—— 一个支持事务的分布式 KV。其他任何数据模型,比如关系模型、文档模型,都可以在这之上通过一层无状态的服务来实现。

FoundationDB's focus on the "lower half" of a database, leaving the rest to its "layers"—stateless applications developed on top to provide various data models and other capabilities.

这里可以参考 FDB 之前的一篇论文:FoundationDB Record Layer: A Multi-Tenant Structured DatastoreFDB Record Layer 是在 FDB 之上设计的一个类似关系数据库的模型,也是开源的,不过好像还不支持 SQL。

基于一个单纯的分布式 KV 实现所有数据模型,这是一个很理想的架构,但现实可能没想象的好(主要是性能问题)。对这个话题感兴趣的,可以看看这篇文章:FOUNDATIONDB'S LESSON: A FAST KEY-VALUE STORE IS NOT ENOUGH

仿真测试

分布式系统的测试是一件非常麻烦但非常重要的事情。FDB 从设计之初就一直很强调“测试”。完善的测试,是软件快速迭代的基础。

FDB 给 C++ 做了扩展,增加了一些关键字用于“原生”支持异步编程,这套东西叫做 Flow

除了更方便地支持异步编程,Flow 还对所有外部依赖,比如文件系统、网络进行仿真模拟,方便各种错误注入和场景模拟。

小结

看完这篇论文,我发现,我什么都没了解到。真想知道细节,还是去看看代码吧…