FoundationDB 是苹果公司从2009年开始,开发了十多年的分布式k/v 存储系统。
拥有如下几个亮点:
因为其有好的架构设计 以及 强大的稳定性保障,所以fdb 被广泛应用到了 苹果内部的存储系统之中,而且 被 snowflake 当做了自己的元数据存储(云原生数仓稳定性要求极高)。
FoundationDB 的设计目标是 称为能够支持各种上层应用场景的分布式存储引擎。
原因如下两点:
综合以上两点,苹果公司在当时设计了足够通用的分布式存储引擎,整个FoundationDB的 设计 以及 代码实现是能够看到苹果公司是真的在认真做一款分布式存储底座,至少国内公司不会让一个团队几年时间制作一个simulator 来证明整个产品的架构设计和稳定性都是满足要求的。
不过因为 FoundationDB 社区建设的不是很好,像是一些技术文档更新有版本差或者不完善(RedWood存储引擎设计),而且代码注释比较少,研究起来会费劲很多,不过并不影响它本身是一个非常值得学习的分布式事务型的k/v存储底座。
本文通过 FDB 在 2021年 SIGMOD 发表的 FoundationDB: A Distributed Unbundled Transactional Key
Value Store 来简单介绍一下 fdb的设计架构,这篇论文是 sigmod 的 2021的best paper。
fdb 提供了一些基操作单k/v 以及 批量k/v的接口:
一个fdb 集群 逻辑上 主要由两部分组成:
基本形态如下:
每一个部分内部又有一些逻辑上的小组件组成,充当各自系统内部的角色。
这一部分内部主要是用来之久化系统关键路径的元数据,比如 transaction system中 coordinator的配置信息。
这里简单介绍一下 control plane 中 coordinators 的作用,它们是这一层的主要角色,监管整个集群的各个服务运行的状态,不同的 coordinator 之间是通过 disk paxos group 达成共识的。
每一个 coordinator 是一个 fbserver进程,包括后面提到的其他的角色,都是统一的fb进程,只是采用的是对应角色的配置。
这一些通过 disk paxos 运行的coordinators 会选出来一个主进程(leader) 叫做 ClusterController,这个 类似leader的角色功能主要是:
所有 OLTP 型的负载处理都会在这一层中。这里 foundationdb 也采用了完全解藕的架构设计,从上面的图中可以看到,主要分为了三个部分:
TS 事务系统 中有几个 角色比较重要:
LS 日志系统的总体角色像是提供 复制、分片、分布式持久化队列 的能力。每一个 “持久化队列” 保存的事一个 StorageServe 的 WAL 数据。
SS 由多个 StorageServe 组成,这个服务进程是整个fdb 集群中最多的,主要用于服务自客户端的读请求,每一个 StorageServe 会按照 range (有序的)存储分片数据。StorageServer 核心部分就是存储引擎了,每一个 SS 都会有一个引擎实例。
目前 FoundationDB 7.1 版本 支持的存储引擎如下:
默认的存储引擎用的是SQLite,不过FoundationDB 在其之上为适配多版本做了一些修改(SQLite 是 B-tree 存储引擎,不支持多版本,FDB 为其支持了多版本,同时增加了更快的RangeDelete 以及 异步API 接口);除了 SQLite 之外 还支持了 Memory , RocksDB(目前还在试验中,没有上生产环境),RedWood 存储引擎 。
可以简单说一下 RedWood 存储引擎是 FoundationDB 因为 SQLite的一些问题,而为自己设计的存储引擎。因为SQLite 不支持多版本(FDB 写入的k/v 都会带有自己的版本,比如 同一个user key “key1" ,会有 key1-10, key1-11, key1-13等多个版本),而且因为 B-tree 的COW 更新方式对内存和性能都有非常大的影响,并且不能友好的支持前缀压缩。对FDB来说还是在设计上很难大幅度优化的,所以他们开发了一个适合自己场景的存储引擎 RedWood,关于RedWood 存储引擎的介绍后续会专门写一篇 该引擎的设计背景以及基本实现原理。
因为 FoundationDB 灵活的架构解藕,让来自客户端的读写可以被不同的组件去调度,从而实现了可扩展的读写分离。
来自客户端的读请求可以直接被 分片到某一个 SS 进程,随着 SS 服务数量的扩张,客户端读请求的性能也是线性增加的。
客户端下发的写请求则会被一系列进程处理,像是 Proxies, Resolvers 做事务提交和冲突检测。这里MVCC 的数据会存放到 SS 中。
在整个系统中存在的三个单例进程 : Sequencer、DataDistributor、RateKeeper 因为只管理元数据,所以并不会成为系统的性能瓶颈。
在FDB中, 所有的用户数据以及大多数的系统元数据都会被存储到 SS中。 SS 中 每一个服务进程的元数据则会被持久化到 LS(Log Servers) 中,LS 的配置数据则会被放在 Coordinators 中。而 Coordinator 则使用的是 disk paxos来保持共识的,如果 Coordinator 中的 “Leader” ClusterController 异常/未选举,则 会通过 diskpaxos 选择一个新的 ClusterController,这个新的 “Leader” 首先会选择一个 Sequencer 单例角色,sequencer 从 旧的 LS 读取 LS原本的配置信息,并生成一个新的 TS 和 LS。接下来 就是 事务系统中的 Proxies 会从 旧的 LS 读取系统元数据 (包括 SS 的元数据) 进行恢复。 sequencer 会等到新的 TS 完成事务数据恢复 会 将新的 LS配置写入到 所有的 coordinators 。
整个Cluster 角色选举 从 ClusterController 开始到完成各个组件的恢复就都做完了,到此才能为客户端提供读写服务。
事务处理从读写两方面展开描述:
关于写事务 中 TS 的 Proxy 提交流程如下图,3.1, 3.2 ,3.3 共三步
如上流程中,Proxy 拿到 sequencer 分配的大于 read version 以及 最新的 commit version的一个version 之后会将当前事务要提交的事务交给 resolvers 做冲突检测,这里是事务处理性能的关键,也是整个事务系统最为容易出现性能瓶颈的地方。FDB 这里在 resolvers 上实现的冲突检测算法是无锁的,大体算法流程如下:
lastCommit 是每一个 resolver 维护的一个历史提交记录 ,通俗来说是一个 map(在fdb 的实现中是一个 支持多版本的skiplist,类似 rocksdb 的 WriteBatchWithIndex),保存的是这段时间内提交的key-ranges 和 它们的commit versions 之间的映射。
对于要提交的事务 Tx 在冲突检测中的输入由两部分组成: R w R_w Rw 表示要修改的key range集合, R r R_r Rr 表示要读的key的集合。
详细代码实现是在 SkipList.cpp
中,每一个冲突检测函数内部 逻辑还是比较多的,感兴趣的同学可以看一下。
论文中提到每一个resolver 的 TPS 上限能够很容易达到 280k,而且是一个fbserver 作为 resolver,实际的生产环境单机可以部署多个resolver(正常情况这种高CPU负载的肯定会部署在不同机器上,实际的key 空间经过分片之后肯定是分布在不同机器上的),这样的单机事务能力还是比较强的。
上面提到了 resolver 做冲突检测的基本流程,这里就到了 Proxy 日志提交的最后一步,也就是持久化事务日志到 LogServers 中。
基本过程如下图:
set a=b
发送一份到 LS3。在 FDB 中,Recovery 过程成本非常低,因为其解藕架构设计 没有 checkpoint,recovery 的时候不需要重放 redo 或者 undo log。只需要保证一个非常简单的原则,Recovery 的过程中对 redo log的处理流程还是和之前一样就好了,即异步拉取 LS 中的 redo即可,完全不会对整个 Recovery的性能产生影响。
上文的 Proxy 在 LS 系统中持久化 redo log的时候也说到 SS 会异步得拉取 redo log进行本地的 group commit操作。所以如果整个事务系统进行重启的时候,根本不需要等到所有的 redo 都被提交到 SS中,而是重新像 1.3.4 小节中描述的,只需要保证选择出新的 TS 和 LS即可,过程中只是一些配置信息的相互交互。新的 TS 和 LS 系统 ready之后就可以接受用户请求了, 至于 Recovery 之前 SS 没有完成恢复的 redo log 可以重新异步得从 TS 拉取恢复就可以,这个过程是一个后台操作,对Recovery的性能不会有太大的影响。
所以FDB 这样的解藕设计能够提供非常快速的 recovery能力,这样整个系统就可以将这个能力发挥出来,即出现异常之后不需要复杂的异常处理逻辑,而是快速进行Recovery,Recovery过程中恢复系统的正常执行的逻辑。
FDB 利用自己编写的一套 支持并发原语 actor model 模型 的异步编程框架 Flow 搭建了自己的模拟测试框架。包括 模拟磁盘I/O,网络,系统时间 以及 随机数生成器。
这个模拟测试框架并不是一个工业级落地产品,却是拥有功能完全一样的模拟测试系统,对于一个做分布式存储底座的公司来说想要花费以年为单位的时间先做一个测试框架来证明后续产品的架构优势以及稳定性是否达到预期 成本是非常高的。这一点,苹果公司可以说是业界标杆了,是真的在做一个工业级的通用强一致分布式存储底座。FDB 的强大稳定性成为 snowflake 这样公司首选。除了社区管理不够完善之外,真的可以有很多分布式设计的细节值得学习借鉴,只是需要去花时间深入研究代码。
在模拟测试中,FDB 能够利用 Flow 模型快速模拟多个 fbserver 在自己所属的角色中利用模拟的IO ,网络,时钟 进行交互,从而达到和生产环境类似的运行延时/形态。
正常体系的运行过程中 会通过 Fault Injector 不断得随机注入异常(磁盘/IO 异常,机器重启,网络分区等等) + 随机 workload 能够非常高效准确得完成在分布式场景下的各种极端运行场景的稳定性测试,发现的bug 都能被复现、进一步分析 从而修复。
而且 FDB 生产代码开发之前是先开发的 Simulator,因为Simulator 无法覆盖到 引入的 thirdparty(不是用Flow 实现的),所以他们就拒绝引入thirdparty,完全使用自己的 Flow 实现了 rpc 系统(fbrpc),共识系统(disk paxos) 以及 分布式配置管理系统(coordinators)。
这里主要展示一下关键性能。
1.机器数量的增加,吞吐的变化情况如下
无论是 纯读/纯写 还是读写混合,性能都是随着机器数量的增加而增加。因为读事务的链路比较短,clients 除了拿read version的时候和 TS 进行交互之外,带着IO的读请求都是 client 直接和 SS 进行交互,所以读性能平均比写性能好很多。
2.吞吐和延时情况如下
图a 展示了请求处理的带宽可以随着 ops 的增加而线性增加,即吞吐是线性的。
图b 展示了延时情况,随着ops 的增加 在10w 量级以下,读平均延时不到1ms, GetReadVersion(Client 读的时候会从 TS 的Proxy 上请求一个 read version)1ms 左右, GetReadVersion比Read 延时会高一些,因为它需要Proxy和 Sequencer 进行 rpc 交互,相比于读直接和SS进行交互来说链路稍短一些;commit 链路会更长一些,涉及到持久化数据到logserver,平均延时在10w 一下的时候大概到几个ms。
在 10w-100w以及 100w 以上的 ops情况下,这几个操作的平均延时都会增加。对于commit来说,ops越大,其处理链路的复杂度会更高,尤其是 Resolvers做冲突检测好事比较长。
3.Recovery 性能
这个性能是FDB 百TB生产集群的 几百次配置变更时间分布(主要是recovery时间),其中90% 以上的变更恢复时间都在 3.08s-5.28s 之间。因为Recovery 对于完全解藕的FDB集群来说仅仅是恢复一些 TS / LS 的元数据,SS的恢复并不参与到主恢复流程中,它只负责异步从LS中拉取REDO,而且这段期间并不会影响读(读请求是client 直接和SS进行交互),也就是这段时间的恢复主要是对读写事务的提交有影响,读写事务会阻塞 直到恢复之后由 client重发。