FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

简介

FoundationDB是一个开源的分布式KV存储,号称是第一批将NoSQL的灵活性、扩展性和ACID相结合的系统。FoundationDB的架构充分体现了无共享、解耦的思想,将整个系统分为三部分组件,分别为:

(1)内存事务管理组件

(2)分布式存储组件

(3)内置的分布式配置管理组件

每个组件都可以按照期望的扩展性、高可用、容错能力进行灵活配置。

另外FoundationDB设计了一套确定性仿真框架,类似这样的测试框架还是首次遇到,目的是为了提前发现bug,快速开发新功能,提升产品的稳定性。

从设计原则上来讲,FoundationDB追求的是最小功能集合,一个支持事务的分布式KV存储,可以在此基础上,根据需要扩展结构化、半结构化、图数据库等。

FoundationDB采用了松耦合的架构,分为控制面和数据面

控制面:管理集群元数据,为了实现高可用,采用了Active DIsk Paxos 协议。

数据面:

事务管理模块,负责处理更新;

分布式存储模块,处理读请求

每个模块都可以独立的扩展。通过OCC+MVCC实现支持严格串行化的事务。

除了上述信息,FoundationDB区别于其他分布式存储系统的一点是对于故障的处理方式,FoundationDB不依赖于quorum(多数派)机制来实现容错,而是采用故障->重启->恢复的方式,即快速失败,快速恢复。这样允许用更少的资源来满足系统容错性要求。一般用Raft、Paxos、zab协议的系统,为了容忍f个副本故障,需要将数据保存2f+1份,需要更多的存储资源。FoundationDB容忍f个副本故障,只需要f+1份副本即可。对于广域网部署,FoundationDB提供了一种新的策略,可避免跨域写入延迟,同时在不丢失数据的情况下提供区域间的自动故障切换。

设计一个生产系统能用的数据库需要考虑很多方面,包括数据持久化、数据分区、负载均衡、成员变更、故障检测、故障恢复、副本放置策略、数据同步、流控、弹性扩展、并发、任务调度、系统监控、告警、备份恢复、多语言客户端支持、系统升级、部署和配置管理。

FoundationDB的设计原则

(1)模块化分割,尽量细分且模块之间相互解耦

比如事务模块内,提交和读取路径是相互独立的,根据事务功能的不同,区分了很多角色,包括时间戳管理、接收提交请求、冲突检测和日志模块。此外还包括集群级的任务编排,比如流控、负载均衡和故障恢复等各种各样的角色。

(2)将故障作为高频事件

与一般的数据库不同,FoundationDB处理各种类型故障的方式都一致,就是发现故障后,重启整个事务模块,通过恢复机制来修复故障,因此必须做到快速检测和快速恢复。

(3)快速故障恢复

生产环境中从出现问题到发现问题,再到主动重启,最后恢复,一般在5秒以内。

(4)仿真测试

FoundationDB依赖确定性仿真测试框架来发现深层次的bug,并提高产品质量。

系统接口

FoundationDB提供的接口包括操作单个key的get()/set(),操作key范围的getRange()/SetRange(),删除指定范围或前缀的clear().

事务机制是典型的OCC+MVCC机制,事务涉及的修改在最终调用commit()之前,全部缓存在客户端本地。为了保证较好的性能,事务内key和value的大小限制在10KB~100KB之间,事务大小的上限为10MB

FoundationDB架构

架构图如下所示:

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

控制面

负责存储系统的关键元数据信息,包含五个组件:

Coordinators:保存事务系统的配置信息

ClusterController:多个Coordinators组成一个disk Paxos group,选出一个leader作为ClusterController,负责监控集群中的所有节点,并创建三个单例进程,分别为Sequencer,DataDistributor和RateKeeper,这三个单例进程的功能如下:

Sequencer:分配事务的读写版本号

DataDistributor:监控状态并负责数据在StorageServers之间分布的负载均衡

RateKeeper:流控,负责集群的过载保护

数据面

FDB本身针对的负载类型是OLTP + 小事务 + 读为主 + 高并发但低冲突。其内部的3层也都是松耦合的。

分布式事务管理模块(TS)负责in-memory内存处理,由Sequencer领头,创建Proxy / Resolver,整个TS层是无状态的,便于发生failure时,快速整体重启。 

 日志模块(LS)负责WAL的存储,按照key range做分片存储,且每个分片有多个副本。

分布式存储模块(SS)负责实际数据的存储,和LS的分片对应,每个分片有自己的WAL日志,底层目前使用的是SQLite,后续会考虑Rocksdb。

从上面的架构图可以看到,读写路径是分离的,TS层+LS层和write path相关,而SS层和read path相关。这是其设计的核心思想,将功能尽可能细分为不同role,由不同服务进程负责,不同role各自独立配置和扩展。例如如果想提高读吞吐,扩展storage server,如果想提高写吞吐,扩展Resolver/proxy/LS。

 启动

FoundationDB所有的用户数据和大部分的系统元数据(以0xFF开头的key)都存在StorageServers中,StorageServers的元数据保存在LogServers中,LogServers的配置信息保存在所有的Coordinators中。

启动时从Coordinators中选举出ClusterController,ClusterController创建Sequencer,Sequencer则启动另外3组进程,然后从Coordinator中获取老的LS的配置,并从老的LS中获取SS的配置,利用老的LS执行必要的recovery过程,完成后老的TS系统就可以退休了,新的LS的信息写入Coordinator中,系统完成启动,开始对外提供服务。

重新配置

当TS系统发生failure或者配置变化时,Sequencer检测到后会主动shutdown,Controller检测到后会重启新的Sequencer从而形成新的TS,新的Sequencer会阻止老的TS再提供服务,然后走和bootstrap类似的recovery流程即可。

为了标识不同的TS系统,引入了epoch的概念,任何时候新老TS交替,epoch就要+1。

事务管理

点到点的事务处理

客户端开始事务后,先连接Proxy获取读版本号,然后Proxy向Sequencer获取读版本号,并返回给客户端,之后客户端连接StorageServers获取指定读版本号的数据。客户端的写入先缓存在本地,等到提交时,客户端将事务修改涉及的读写集合发送给Proxy,等待Proxy回应提交还是重试事务。Proxy提交客户端的事务分为三步:

(1)Proxy连接Sequencer获取提交版本号,Sequencer以每秒一百万的速度分配提交版本号

(2)Proxy将事务信息发给Resolvers(为了支持并发检测,Resolvers也是按范围分区的),进行冲突检测,如果所有的Resolvers都返回无冲突,则事务可提交,否则Proxy将事务置成终止状态

(3)将待提交的事务发给LogServers进行持久化,当Proxy收到所有指定LogServers完成持久化的回应后,Proxy将提交版本号发给Sequencer(这是为了保证后面事务的读版本是大于提交版本 号的),然后回应客户端事务提交成功。同时StorageServers不停的从LogServers拉取WAL日志,并应用到本地。

除上述读写事务外,FDB还支持只读事务和快照读取。FDB中的只读事务既可序列化(发生在读取版本)又可执行(由于MVCC),客户端可以在本地提交这些事务,而无需联系数据库。这一点尤其重要,因为大多数事务都是只读的。FDB中的快照读取通过减少冲突(即并发写入不会与快照读取冲突)选择性地放松事务的隔离属性。

支持严格串行化的事务

FoundationDB通过OCC+MVCC实现了Serializable Snapshot Isolation(SSI)隔离级别的事务。事务Tx从Sequencer获取读写版本号,保证读版本号不小于事务Tx开始时刻的任何提交版本号,写版本号大于任何读写版本号,提交版本号定义了事务的历史记录,由Log Sequence Number(LSN)标识,因此事务Tx可以读到任何已提交事务的数据,所有FoundationDB实现了严格串行化事务。为了保证LSN是连续中间没有间隔的,Sequencer在返回提交版本号时,会返回前一个提交版本号(即前一个LSN)。Proxy会把当前LSN和前一个LSN一起发给Resolvers和LogServers,他们按照LSN的顺序串行处理事务。类似的,StorageServer从LogServers拉取WAL日志时,也按照LSN递增的顺序处理。

下图描述了Resolvers采用的无锁事务冲突检测算法

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

 lastCommit数据结构是一个SkipList

上图中的1·5行,循环遍历Tx事务内读取的每个Range,在lastCommit数据结构中找和Tx事务每个读取的Range有交集的Range,然后循环遍历这些有交集的Range,如果有任何一个Range的提交版本号大于事务Tx的读取版本号,则终止事务,这是为了避免幻读。

上图中的6-7行,如果没有任何冲突,则用写入Range的列表更新lastCommit数据结构,并返回提交版本号。

写快照隔离级别是在检测完读版本冲突后,分配提交版本号,而FoundationDB是在冲突检测之前分配提交版本号,这样可以高效的批量分配版本号和冲突检测。

这里有一个问题,由于是多个resovler并发检测冲突,可能一些resolver局部认为是无冲突的,因此更新了自己维护的LastCommit结构,导致后续不应该失败的事务发生冲突(false-positive)。FDB认为这不是大问题,首先它面向多租户应用,冲突较少,一般事务都会落入一个resovler。此外即使失败后重试,新的ts的read version增长后,超过这个伪提交事务的commit ts即可。

日志协议

所有Resovler返回成功时,Proxy认为事务可以提交,向LogServer发送log做多副本同步复制,所有副本的日志都落盘后,Proxy认为事务提交成功,返回给客户端。同时Storage Server异步的从LS获取log entry并apply,即使log entry还没有落盘也会apply,采用这种激进策略来保证data和log之间的低延迟。

可串行化的并发控制使得log entry之间形成了严格的顺序,大大简化了log管理的逻辑,可以用version来表示LSN,针对每个key,它所面对的实际是一个有序的log entry队列,依次apply就可以了。

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

Proxy本地有缓存一份key range -> SS的映射关系,这样就可以知道要写入哪些SS和对应的LS。例如上图中,LS1 + LS4是要写入的LS,因此把这个事务的log都写入(形成副本),此外由于是3副本,再额外写一个LS,其余的LS也要发送,但只传递Log Header,其中包含的最主要信息是当前的LSN和Proxy上的KCV,即本Proxy已知的最大已提交事务,LS收到后会更新自己本地的KCV,这个KCV在recovery时会使用。

LS上的WAL -> SS和apply redo并不在commit path上,是异步持续完成,因此可以说FDB也遵循了”log is database”的思想。这种方式client做read一般可以读到目标version的数据,如果不行就等待或者向其他副本请求,都不行的话,可以超时后重试。

由于是异步apply,可以做batching,将一批更新缓存在SS上,批量刷盘提高IO效率。但这里也有个问题,由于LS中在内存中(未提交)的entry也可能被apply,因此SS是有脏数据的,在recovery时要rollback。

 事务模块恢复

传统数据库系统通常采用ARIES恢复协议,该协议依赖于预写日志(WAL)和周期性粗粒度检查点。在恢复过程中,系统通过将上一个检查点的重做日志记录重新应用到相关数据页来处理这些记录。这将使数据库在发生故障时处于一致状态,并且可以通过执行撤消日志记录回滚崩溃期间的运行中事务。

FDB做恢复是最为与众不同的,由于其基于recovery来做failure处理,因此recovery是常规操作,需要快速恢复。

由于redo log apply是在后台持续进行的,因此本质上它将redo apply从recovery中解耦出来,等于持续在checkpointing,在recovery期间不需要做redo/undo apply,只是确认当前的log序列需要恢复到哪个位置即可!!后续基于log -> data的过程仍然是异步。这保证了recovery的速度。

具体恢复流程如下:

发现failure后,老Sequencer退出,新Sequencer启动,并从Coordinator获取老的TS的配置信息,包括老的LS的位置等,同时给Coordinator加个全局锁,避免并发recovery,然后关闭老的LS,禁止接收新的log写入,开始恢复,恢复完成后启动TS系统接收用户请求。

Proxy和Resolver都是stateless的,直接重启就可以,只有LogServer有log信息,恢复如下:

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

 

KCV:known committed version,表示各个LogServer已知的最大提交版本号

DV:Durable Version,各个LogServer已经完成持久化的版本号

RV:Recovery Version,各个LogServer已持久化版本号的最小值

PEV:Previous epoch's end Version,各个LogServer KCV的最大值

LSN:Log Sequence Number,每个事务对应一个递增的LSN

 

由于在日常提交写日志时,Proxy会把本地记录的KCV广播给所有LS(见持久化一节),LS中就记录了自己见过的最大的KCV。选取所有LS中KCV的最大值,在这个值之前的事务,其日志已经完全复制并落盘,且已告知Proxy,可以作为上一个epoch的终点,称为PEV(previous epoch’s end version)。

同时每个LogServer都记录了本地已持久化的version (DV),选取所有DV中的最小值,作为Recovery Version(RV),在PEV -> RV之间的日志,已持久化且不在内存中,但不确定是否已提交(因为proxy没有该信息,可能崩溃的那个没持久化),因此这部分需要进行恢复(redo),而 > RV的log entry,肯定没有多副本都持久化,因此不可能提交,这部分要undo。

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

因此整个的recovery流程,就是将老的LS中的[PEV+1 , RV]之间的部分,copy到新的LogServer中并完成log复制即可。这样这部分事务已成功排好队,后续在开始接受用户请求前,先启动一个事务将RV之后的log对应的数据rollback,然后就可以处理用户请求了(log已准备好继续append)。

复制

系统中的不同组件采用了不同的复制策略

(1)Metadata复制:控制面的系统元数据通过Active Disk Paxos协议保存在Coordinators中,只要Coordinators的多数派节点正常,就可以恢复。

(2)Log复制:存储在LogServer中,是全量同步复制,因此允许f个失败 (f+1个副本)。

(3)Storage复制:每个Shard都是异步复制到f+1个StorageServer,这f+1个StorageServer称为一个team。一个StorageServer通常保存多个Shard的数据,以便在不同StorageServer之间进行负载均衡,如果StorageServer发生故障,会触发数据重分布。

为了做fault tolerent,storage的各个副本是有一定策略来分布到各个fault domain的,防止多个副本同时失效的情况,这个和Spanner的调度策略类似。

其他优化

Transaction Batching

在Proxy上为了减少与Sequencer/LS的交互成本,可以把不冲突的并发事务合并,获取同一个commit version,并一起下发到LogServer。相当于group commit。

这个策略是可以自适应的,在系统负载不大时,为了减少延迟,可以减小patch大小,当系统重负载时,为了保证吞吐则可以加大patch。

Atomic operations

对于一些只写不读的操作,其相互之间可以不做冲突检测,直接获取提交时间戳就可以,这对于某些counter类型的操作会提高效率,因为避免了从storage的一次读,也避免了resolve confilct。

Geo-replication and failover

 

 

跨Region提供高可用通常是在性能和一致性之间进行折中。如果采用同步复制,可以保证强一致性,但是性能会受到影响。如果采用异步复制,性能可以得到提升,但是在某个Region故障时,可能会存在数据丢失的风险。针对这种情况,FoundationDB进行了特殊设计,可以实现如下特性:

(1)像异步复制那样,避免跨Region的写入延迟;

(2)像同步复制那样,只要一个Region内的多个可用Region没有同时发生故障,就可以提供完整的事务持久性;

(3)可以在Region之间进行完整、快速的故障切换;

(4)在不太可能同时发生所有Region故障的情况下,可以手动故障切换,并具有与异步复制相同的保证(提供A、C和I的ACID,但可能表现出持久性故障);

(5)只需要主Region和备Region的主可用Region中的数据库的完整副本,而不是每个Region中的多个副本。

FoundationDB:A Distributed Unbundled Transactional Key Value Store论文解读

上图描述了一个集群包含两个Region的场景,每个Region都有一个DC(Data Center)和一个或多个satellite sites,satellites部署在与DC属于同一个Region靠近DC的位置,但是是独立的,不会和DC一起故障。satellites的资源需求无关紧要,因为它们只需要存储日志副本(即重做日志的后缀),而GC中包含托管LS、SS和TS(主DC)。控制面跨三个或更多故障域部署(在某些使用附加区域的部署中),通常至少有9个副本。根据多数派原理,控制面可以容忍一个site(DC/satellites)故障和另一个副本故障。DC1作为主DC(包含TS/LS/SS),优先级高于DC2(包含LS/SS)。

可以从主数DC和从DC的存储副本提供读取服务(一致读取确实需要从主DC获取读取版本)。所有客户端写入都被转发到主Region并由DC1中的Proxy进行处理,然后同步保存到DC1中的LS和主Region中的一个或两个satellites(取决于配置),从而避免跨Region网络延迟。然后,这些更新将异步复制到DC2,并存储在多个LS上,最终分布到多个StorageServer。LogRouter实现了一种特殊类型的FoundationDB角色,有助于跨Region数据传输。创建它们是为了避免相同信息的冗余跨Region传输。相反,LogRouter只在网络上传输每个日志条目一次,然后将其传送到DC2中本地的所有相关LS。

如果主DC不可用,将自动故障切换到从DC。在某些情况下,satellite 故障也可能导致故障切换,但这一决定目前是手动的。发生故障切换时,DC2可能还没有最新日志,因为主从之间的日志复制是异步的,它将继续从主Region中的剩余LS中恢复。接下来,我们将讨论几种提供不同容错级别的替代satellite配置。

可以为每个Region指定satellite配置。每个satellite都有一个静态优先级,相对于同一Region内的其他satellite而言,该优先级被视为相对优先级。FoundationDB通常配置为在每个位置存储多个日志副本。支持三种主要替代方案:(1)以该Region最高优先级同步存储satellite上所有日志副本的更新。在这种情况下,如果satellite出现故障,则为任务招募另一个具有第二高优先级的satellite,(2)同步存储Region内具有最高优先级的两个satellite的所有副本上的更新。在这种情况下,如果一个satellite出现故障,同样可以用优先级较低的另一个satellite进行更换,或者,如果没有可用的satellite,则返回使用单个satellite的选项(1)。无论哪种情况,从Region都不会受到影响,因为它可以继续从主Region中的其余LS获取更新。最后,选项(3)与选项(2)类似,但FoundationDB仅等待两个satellite中的一个使完成持久化,然后才考虑提交成功。在所有情况下,如果没有可用的satellite,则只使用DC1中的LS。使用选项1和3,除了一个或多个LogServer故障外,还可以容忍单个站点(DC或satellite)故障(因为其余位置有多个日志副本)。使用选项2,除了一个或多个LogServer故障外,还可以容忍两个站点故障。然而,在选项1和2中,提交延迟对主DC及其satellite之间的尾部网络延迟敏感,这意味着选项3通常更快。选择最终取决于可用satellite位置的数量、它们与DC的连接以及所需的容错和可用性级别。

当主Region中的DC1突然变得不可用时,集群检测到故障并在DC2中启动新的TS。根据该Region的复制策略,在从Region的satellite招募新的LS。在恢复过程中,DC2中的LogRouter可能需要从主Region的satellite中获取最后几秒钟的数据,由于异步复制,在故障切换之前,这些数据可能无法到达DC2。恢复后,如果主Region中的故障得到修复,并且可以再次满足其复制策略的要求,则集群将自动故障恢复,以使DC1作为主DC,因为它具有更高的优先级。或者,可以招募不同的从Region。

总结

总的来说,FDB有几大特色:

  1. 非常松耦合的系统,read/write path是分开的,每个功能组件可以独立扩展来避免成为系统瓶颈。
  2. log is database,redo apply + undo和recovery解耦,减少commit path的负载。
  3. 通过OCC + MVCC实现串行化事务,简化log的处理。
  4. 快速恢复,通过fast failure + fast recovery,来统一对于failure的处理方式,这个recovery路径比较快且稳定。

但缺点也很明显:

  1. 有限的功能集。
  2. 串行化事务对事务大小的限制。
  3. 面对场景比较确定,就是可扩展的,支持小事务高并发的KV存储,且冲突不能太多,不然重试+回滚太多,影响系统吞吐。

以上内容有部分参考了https://developer.aliyun.com/article/789474中的描述。

上一篇:Linux下,文件权限UGO,ls -l命令的详细查看内容


下一篇:servlet知识点大全总结