FoundationDB in Practice
mac上运行FoundationDB
fdb_java的java包没有打包fdb_c的动态链接库,所以需要通过-DFDB_LIBRARY_PATH_FDB_C=/usr/local/lib/libfdb_c.dylib指定对应的动态链接库位置。
你的当前服务器是 FoundationDB 7.3.63,最大支持 API version 是 730。因此,Java 客户端的 jar 包版本必须也在 7.3.x 版本范围内,否则可能不兼容(尤其高版本如 7.4 会尝试使用 API version 740)。
<!-- Maven 配置示例 -->
<dependency>
<groupId>com.apple.foundationdb</groupId>
<artifactId>foundationdb</artifactId>
<version>7.3.63</version>
</dependency>❯ fdbcli --version
FoundationDB CLI 7.3 (v7.3.63)
source version 5140696da2df47c143ae74c0f4207b65d0d94876
protocol fdb00b073000000事务
事务是一系列的数据库读写被作为一个整体处理,具有一些关键的性质。首先所有在事务中的读都看到数据库的相同快照,不会看到并发的其他事务的修改,其次,在一个事务中的写入要不全部成功,要不全部失败(比如由于连接中断导致的失败),最后当一个事务成功提交时,写入被持久化存储。事务的这些性质(隔离isolation,原子atomicity,持久化durability)构成了ACID模型的基本保障。FoundationDB团队认为ACID事务的支持不只是一项额外的特性,而是使用简单有效方式构建健壮应用的核心因素。
需要支持并发访问的每个应用都应该基于具有ACID特性的事务构建,事务是处理并发最简单和最强有力的编程模型,随着NoSQL技术的不断成熟,事务将扮演越来越重要的角色。一个通常的误解是事务仅对电子商务或者银行业务有用,然而事务的真正价值在于它们在应用开发中的工程意义,而非某个特定应用的实现细节。得益于ACID特性,事务可以被组合以构建新的抽象层,支持更高效的数据结构,并实施完整约束。由此可见,与其他方案相比,基于事务构建应用程序更为简便,可靠且具备很强的可扩展性。从系统设计的角度看,你可能会觉得,系统的设计权衡破事你放弃事务带有的优势,以换取速度、可扩展性和容错能力,然而,这样构建的系统通过脆弱、难以管理,并且几乎无法适应不断变化的业务需求,放弃事务所带来的代价往往得不偿失,尤其是在存在在大规模下提供事务完整性的替代方案时。
当多个客户端、用户或者应用程序的不同部分同时对同一数据进行读写时,就会产生并发。事务让开发者更容易管理并发,实现这种简化的关键事务特性是隔离性,即ACID中的I,当系统保证事务完全隔离时(称为可串行化),开发者可以将每个事务视为顺序执行,尽管它们实际上可能是并发执行的,这样,开发者无需再费心推理不同事务操作之间可能的相互影响。有些系统仅在一小部分预定义操作上提供ACID事务支持,通常受限于数据模型的结构,比如,一个文档数据库可能只允许在事务中更新单个文档。然而事务的真正强大之处在于应用开发者可以在任意一组数据上自由定义事务,对于使用键值存储的开发者来说,应用能够定义可读取和可写入任意数量键值对的事务,只有当开发者可以不受人为限制地自由定义事务时,事务才能成为构建应用程序的基本构件。
由应用定义的事务是可组合的,隔离性和原子性结合确保一个事务的执行不会影响另一个事务的可见行为,这一保障使事务之间具有可组合性,从而可以将它们组合起来构建新的抽象层,这些抽象可以封装在系统的不同层中。例如,一个常见的抽象是,为了快速查找满足某个条件的数据项,需要在维护主数据的同时维护一个索引,在任何键值存储中,这个功能可以通过使用索引字段作为键,再存储一份数据副本来简单实现。然而,并发更新可能使这个设计变得复杂,没有事务的情况下,难以保证在数据变更是,数据有索引都能被一致的更新。而通过事务,索引层可以在同一个事务中同时更新数据和索引,从而保证二者的一致性,构建出一个强大的抽象。
事务使各层可以简单高效的构建抽象,从而提供了高度可扩展的能力,以支持多种数据模型,针对层次化文档、列式数据或关系型数据优化的数据模型,都可以构建在有序键值存储上,并通过抽象层实现。在这些场景中,高层模型中的一个数据对象通常会对应到底层的多个键值对,事务通过将多个键值对的更新封装在一个原子操作中,使这些映射的可靠实现变得十分直接。
事务带来的好处不仅仅是让高层数据模型的选择更加灵活,它们还能提升在给定数据模型下的数据表示效率。以文档型数据库中常用的“嵌入式数据”设计模式为例(关系型数据库背景的开发者可能更熟悉反规范化这一术语),在这种模式中,数据被嵌套在单个文档的层级结构中(通常会出现数据重复),而不是通过引用共享其他文档。这种嵌入的做法往往是因为缺乏对全局事务的支持,由于大多数文档数据库只支持对单个文档的原子操作,开发者不得不将相关数据压缩进一个文档中,以实现原子更新,但这样做的结果是,文档更大、更复杂、访问和更新的效率通过也更低。而有了事务,就无需再依赖嵌入来实现原子性,开发者可以以访问效率为导向来建模,适当地将数据拆分成多个文档,同时仍可通过单个事务保证这些数据的一致性,更进一步,这些数据还会被多个客户端共享并并发更新,而事务机制提供了安全管理共享状态所需的并发控制能力。
大多数承担关键业务功能的应用程序,都会随着时间经历需求的变化(而且,变化往往是增加需求而不是减少),你可能认为事务完整性是一个不错但非必需的特性。确实,许多应用可以通过为特定的、简单的访问模式精心设计数据结构,从而避免对全局事务的依赖。然而,当这些应用需要演进时,能够灵活修改数据模型,以及拥有全局事务的能力,往往决定了你是轻松改进还是不得不推倒重构。假设你运营一个web应用,用户既可以发送帖子,也可以接收来自其他用户的帖子,所有帖子都保存在后端数据库中,某天管理员希望你开发一个分析看板,用来进行一些基础的数据分析,尽管你的数据存储支持读写操作,但这个看板最初只是用于只读查询,而且数据只是用来分析用途,即使结果偶尔有点延迟,也不会有人在界面上注意到,因此你选择不引入事务,并开发了几个非常出色的数据可视化功能,让公司能以全新方式分析和查看帖子。好消息是,看板非常受欢迎,坏消息是,你开始不断收到新功能的需求,用户希望能在看板中直接编辑或审核被分析出来的帖子,广告部门也希望通过api访问看板中的数据,用于驱动计费系统,此时,看板不再是一个简单的只读工具,而你的新API则需要为其返回的数据提供强一致性保障。这种需求演进的情况是非常自然且常见的模式,事务的存在,决定了你是否能轻松应对这些变化,顺利添加新的功能,或者只能推翻大量已有代码,从头开头重构。
你可能会因为对事务存在的严重的tradeoff而不愿使用它们,尤其是在NoSQL数据库所面向的高性能应用场景更是如此,然而,如果你更深入地分析这些tradeoff就会发现,对用户而言,事务的代价其实非常低。(真正的代价在于数据库工程师-构建分布式事务系统确实非常困难)。
我们尚未发现支持事务的系统在可扩展性或性能方面存在任何实际限制,当NoSQL数据库运动兴起时,早期的一些系统(如Google BigTable)采用了极简的设计,专注于可扩展性和性能,这些系统主动舍弃了关系型数据库中常见的许多特性,并假设这些被舍弃的功能对于可扩展性或性能目标来说是多余甚至有害的。但这些假设是错误的,现在越来越清晰的表明,支持事务本质上是一个工程实现的问题,而不是架构设计上的根本性权衡,用于维护事务完整性的算法同时可以像其他分布式算法一样进行扩展。事务确实会带来一定的CPU成本,但根据我们的经验,这部分开销通常不到系统总CPU使用量的10%,为了获得事务完整性,这点代价是非常小的,并且完全可以通过其他方式轻松补偿回来。
事务保证写入操作的持久性,这种保证会带来一定的写入延迟增加,持久性意味着一旦写入提交,即使之后发生硬件故障,数据也不会丢失,因此,持久性是容错能力的关键组成部分。不支持持久性的NoSQL系统在容错性方面必然较弱,由于真正的容错能力至关重要,为实现持久事务所带来的写入延迟通常是值得付出的代价,对于那些对写入延迟有严格要求的应用,也可以选择关闭持久性,同时仍保留ACI三项事务特性。
随着NoSQL数据库 被越来越广泛地应用于各种场景,构建其上的应用程序也越来越多地涉及多个客户端的非简单并发操作。如果没有充分的并发控制,传统并发中的各种问题将重新出现,给应用开发者带来沉重的负担。ACID事务通过提供严格可串行化的操作,帮助开发者简化并发处理,并可组合使用以构建健壮的应用系统。如果你正在构建一个需要可扩展性的应用,而系统又不支持事务,最终你将为此付出代价。幸运的是,NoSQL数据库在具备事务支持的情况下,依然可以实现良好对的可扩展性、容错性和性能。因此,是否使用事务,并不是架构设计上的根本性权衡,而是一个工程理性选择,随着技术的不断成熟,事务将成为未来NoSQL数据库的基础能力之一。
CAP理论
数据库可以在发生网络分区时同时提供强一致性和系统可用性,人们普遍认为这种组合不可能实现,但这种看法是基于对CAP定理的误解。
2000年,Eric Brewer提出了一个猜想,一个分布式系统不能同时满足一致性、可用性和分区容忍性这三个性质:
- Consistency(一致性):每次读取都能看到之前完成的写操作
- Availability (可用性):每次读写请求总能成功返回响应
- Partition tolerance(分区容忍性):即使网络故障导致部分节点之间无法通信,系统仍能维持其承诺的性质
在2002年,Gilbert和Lynch在异步和部分同步网络模型下正式证明了这一点,这一结论后来被称为CAP定理。Brewer最初认为开发者必须在三者中选出两项(CP、AP或CA),但进一步的思考发现CA(不支持分区容忍)并不是一个可行的选项,因为从定义出发,在实际发生网络分区时,系统就必须放弃一致性或可用性。因此,现代对CAP的理解是,在发生网络分区的情况下,分布式系统必须在一致性和可用性之间做出选择。
让我们考虑一个AP型的数据库,在这种数据库中,即使节点之间的网络连接不可用,读写操作也总是能够成功,从表面上看,这似乎是非常理想的特性,然而缺点很明显,想象一个简单的分布式数据库系统,它由两个节点组成,而网络分区导致他们无法通信,为了满足可用性,这两个节点必须各自继续接受客户端的写入操作。当然,由于网络分区使通信变得不可能,一个节点上的写入操作无法被另一个节点看到,这样的系统实际上只能算是名义上的一个数据库,只要分区继续存在,该系统就完全等同于两个独立的数据库,它们的内容甚至不必相关,更不用说一致了。
很多人误解CAP定理,通常是因为搞错了可用性在CAP中的含义,在CAP的语义中,可用性指的是即便出现网络分区,系统中的所有节点都必须仍能处理读写请求,如果系统在分区时,只有部分节点可以正常响应读写请求,那它在CAP的意义上就不算可用,即使用户表面上看起来还能用,也就是说,即便系统对客户端来说表面高可用(比如响应速度快,服务没挂),甚至符合其SLA服务等级协议,但只要不是所有的节点都保持读写能力,在CAP理论中就不能称为可用。
像所有遵循ACID的数据库一样,在网络分区发生时,FoundationDB会优先保证一致性而不是可用性,但这并意味着数据库会对客户端完全不可用,当承载FoundationDB的多个机器或者数据中心之间发生网络分区,导致它们无法通信时,其中一些机器可能无法执行写操作,不过,在很多真实世界的情况下,整个数据库系统以及使用它的应用程序仍然可以保持正常运行,某些机器发生网络分区的影响,并不会比这些机器直接故障更严重,而FoundationDB由于其容错设计,能很好地应对这类故障。
FoundationDB的设计目标是即使某些机器宕机或网络不可靠,数据库整体和客户端应用依然可以继续运行,这看起来是高可用,但不是CAP理论中的A,因为在网络分区时,被孤立的机器上的数据库不可用。FoundationDB呈现的是一个统一的逻辑数据库,即使物理上是分布式的,网络分区时,挑战是要决定哪些机器还能继续处理读写请求,为了解决这个问题,FoundationDB配置了一组协调服务器(coordination servers)来做出决定,FoundationDB会选择占据多数的分区继续对外提供服务,如果不存在这样的分区,数据库就真的停机了。协调服务器之间通过Paxos算法保持一个小规模的共享状态,这个状态具有一致性和分区容忍性,类似于整个数据库,共享状态在CAP意义上也不是可用的但在占据多数的分区中能够提供读写能力。FoundationDB利用这个共享状态来维护和更新拓扑结构(replication topology),当有节点失败时,协调服务器会协调这个拓扑的变更,但注意协调服务器并不参与具体事务的提交操作。
为了说明协调服务器是如何支持容错的,我们来看一个支持数据副本的最小FoundationDB集群,当然,当集群规模更大时,协调机制提供的容错性和可用性也会更强。想象一家小型的网络创业公司希望其应用程序在一个数据中心内部运行,并且即使有一台机器故障也能保持可用,它部署了一个由三台机器组成的集群, A、B、C,每台机器上都运行一个数据库服务器和一个协调服务器。根据多数派规则,在这个集群中,任何能彼此通信的两台机器都能保持系统的可用性,该公司将FoundationDB配置为双副本冗余模式,即double,也就是说,每份数据都会被复制两次,分别保存在不同的机器上。现在设想一种情况,某个机架顶部的交换机发生故障,导致A与网络隔离,由于FoundationDB要求必须从B或者C获取确认,所以A无法提供新的事物,运行在A上的数据库服务器只能与A上的协调服务器通信,因此无法获取多数票来建立新的副本拓扑结构(replicatioin topology),对于哪些只能链接到A的客户端来说,数据库处于不可用状态。然而对于所有其他客户端来说,数据库服务器仍然可以访问大多数协调服务器(B和C),副本配置也确保了即使没有A,系统中仍然保留了完整的数据副本,对于这些客户端来说,数据库仍然可以读写,web服务器也可以继续提供服务。
当网络分区结束后,节点A将再次能够与大多数协调服务器进行通信,并重新加入数据库,根据通信中断持续的时间长短,A会通过以下两种方式之一重新同步,如果中断时间较短,它会接受在它离线期间所发生的事务,如果中断时间较长,最糟糕的情况是,它将需要重新传输整个数据库的内容,一旦A成功重新加入数据库,所有数据库将再次能够以容错方式处理事务,与上文提到的最小化集群相比,实际生产环境通常会配置为三重冗余模式,即triple,并部署在五台或者更多机器上,从而实现相应更高的可用性。
一致性
在分布式数据库中语境中,一致性这个概念经常被体积,然而,在ACID属性和CAP定理中,一致性指的是不同的含义,这两个意思经常被混淆。ACID中的一致性指的是数据始终符合应用程序的完整性约束,例如,某条数据与其索引之间保持一致的约束,这指的是事务执行后数据库的状态必须是合法的,比如主外键、唯一性约束、余额不能为负等。CAP中的一致性指的是一致性模型,描述的是一个客户端的写操作在什么条件下对其他客户端可见,一个例子是最终一致性模型,在这种模型中,写入的数据在足够长的时间后会在所有副本中保持一致,CAP的一致性更接近于副本一致性或者可见性保证。完整性约束属于应用层面的范畴,而一致性模型属于数据库内部实现的范畴,两者都很重要,因为如何不支持其中任意一种,就会导致数据损坏。
应用程序通常根据其所属领域定义完整性约束,这些约束可以表现为对某个数据值的类型约束,限制某些字段只能存储特定类型的数据(即domain integrity),多个数据值之间的关系(reference integrity),或者来自应用领域的业务规则。在关系型数据库管理系统中,完整性约束通常在设计关系模式时使用SQL进行定义,在这种方法中,完整性约束的定义和强制执行与关系模型紧密绑定,相比之下,大多数NoSQL数据库根本不支持完整性约束,而是将维护数据完整性的责任完全转移给应用程序开发者。FoundationDB采用第三种法师,由于完整性约束是由应用领域定义的,FoundationDB的核心并不会直接强制执行这些约束,然而,FoundationDB所提供的事务具备原子性和隔离性两个保证,使得应用程序开发者能够直接根据领域需求维护完整性约束。简而言之,只要每个事务在执行时都能保持所需的约束,FoundationDB就能保证多个客户端同时执行事务时也能保持这些约束。这种方法允许构建多种数据模型,包括面向文档或关系型模型,它们都可以作为上层系统,自行维护各自的完整性约束。
一致性模型用于定义数据库在并发写入的情况下,写入对读取者何时可见的保证,根据保证强度,不同的一致性模型分布在一个从弱到强的谱系中,一般来说,一致性越强,开发人员就越容易理解和推理数据库行为,从而加快开发进度。例如,因果一致性(casual consistency)保证读取者能看到所有因果上已提交的写入(也就是与当前读取操作有依赖关系的写操作,读取时是可见的),最终一致性(eventual consistency),仅保证在足够的时间,读取者最终能看到写入的数据,但不能保证何时可见,许多早期的NoSQL系统采用的就是最终一致性模型。FoundationDB提供严格串行化(strict serializability),这是最强的一致性模型,以提供最大程度的开发便利性,这意味着它在并发场景下表现得像所有事务是按某个顺序一个接着一个执行,没有任何交错,从而大大简化了应用程序的逻辑。
可扩展性
可扩展性被广泛认为是成功应用程序必备的属性之一,实际上,可扩展性是与系统性能密切相关的三个属性之一:
- 高性能(High Performance):在特定配置下实现最高性能的能力
- 可扩展性(scalability):在不同规模下高效提供服务的能力
- 弹性(elasticity):能够快速适应规模的增减的能力
特别是高性能和可扩展性之间的相互作用,常常被人们误解,例如蚂蚁群搬土很有扩展性,但性能不高,而推土机搬土性能很高,但不具备可扩展性。这三个属性对你的业务都至关重要:
- 高性能意味着在业务流量翻倍是你无需重构架构
- 可扩展性意味着你的支持可以从小起步,并随着业务增长而增长
- 弹性意味着你可以持续根据需求优雅地扩展扩展或者收缩系统规模
FoundationDB的构建目标是优化一系列关键的性能指标,这种方法是它在众多分布式数据库中的一个重要差异点,需要其他系统更关注简化自身产品的开发工作,而不是提升产品的性能,而我们在系统的每一个层面都会评估潜在设计在真实环境中的效率。我们自行构建了针对CPU、内存控制器、磁盘、网络和SDD的基准测试工具,并进行建模和mointor,甚至为了最大化性能而牺牲开发过程的简洁性。在我们追求高性能是,关注的不只是各种算法在理论上的可扩展性,我们追求也确实实现了现实世界中的高性能表现-比如每秒处理数百万个操作。
FoundatioinDB提供了出色的可扩展性,从在单台机器上仅占用一个核心的一部分资源,到在一个集群中充分利用数十台高性能多核机器的全部算力,都能够灵活适应。
FoundationDB支持根据不断变化的需求动态添加或移除硬件资源,而不会中断服务或降低服务质量。每当数据写入数据库时,系统会自动将每一条数据复制到多台独立的计算机上,这种副本机制不仅能立即进行负载均衡,还能在更长时间内自动在计算机之间迁移数据以持续平衡负载,根据请求负载和数据规模,FoundationDB会无缝地在分布式服务器之间重新分布数据,它具有完全的弹性,面对热点数据能欧在毫秒级作为响应,面对重大使用变化能在数分钟内完成调整。
FoundationDB的架构
FoundationDB让你的架构更具有灵活性,且易于运维,你的应用程序可以将数据直接发送到FoundationDB,或发送到一个层Layer中,这是用户编写的模块,可提供新的数据模型、与现有系统的兼容性,甚至作为完整的框架使用,无论那种方式,所有数据都会通过一个有序地、事务性的键值API存储到同一个地方。FoundationDB的架构采用了解耦式设计,其中每个进程被分配不同的异构角色(例如Coordinator、Storage Server、Master等),集群在运行时会尝试将不同的角色分别招募为独立的进程,然而为了满足集群的招募目标,也有可能将多个无状态角色合并部署在同一个进程上,通过为不同角色水平扩展进程数量,可以实现数据库的扩展。
Coordinator
所有客户端和服务器通过一个cluster file连接到FoundationDB集群,该文件中包含Coordinator的IP:PORT信息,客户端和服务器都会使用Coordinator与ClusterController建立连接,如果当前没有集群控制器,服务器会尝试竞选称为控制器,并在选举完成后向其注册,客户端则通过集群控制器获取最新的GRV代理(GRV proxies)和提交代理(commit proxies)列表。
Cluster Controller
集群控制器是一个由多数协调者选举产生的单例角色,它是整个集群中所有进程的入口,负责以下任务:判断某个进程是否故障、告知各个进程应承担的角色,在所有进程之间传递系统信息
Master
Master负责协调写子系统(write sub-system)从一个代(generation)过渡到下一个代的过程,写子系统包括Master、GRV代理、提交代理、解析器以及事务日志,这三个角色(指Master、GRV proxy、commit Proxy)被视为一个整体,如果其中任何一个失败,将会同时重新招募这三个角色的替代进程。Master还负责为一批mutation(变更操作)提供提交版本号,将其分发给commit proxies,在早期的版本中,Ratekeeper和Data Distributor是与Master运行在同一个进程中,但从6.2版本开始,它们都成为了集群中的单例角色,其生命周期不再依赖于Master
GRV Proxies
GRV代理负责提供读取版本(read version),并与Ratekeeper通信以控制读取版本的发放速率,为了提供一个读取版本,GRV代理会向所有Master查询当前为止最大的已提交版本(commited version),同时检查事务日志(transaction log)是否仍在正常运行(未停止),Ratekeeper会人为地减慢GRV代理提供读取版本的速率,以控制系统负载。
Commit Proxies
提交代理负责提交事务、向Master汇报已提交的版本,并追踪每个键范围所对应的存储服务器,提交事务的过程包含以下步骤:
- 向Master获取一个提交版本(commit version)
- 使用解析器(resolvers)判断当前事务是否与之前已提交的事务冲突
- 将事务写入事务日志,以保证其持久性
以\xff字节开头的键空间是保留的系统元数据区域,所有写入该区域的mutation(变更操作)都会通过解析器分发到所有提交代理,这个系统元数据包含一个键范围到存储服务器的映射关系,即每个键区间是由哪些存储服务器负责存储的。提交代理会按需将这些映射信息提供给客户端,客户端会缓存这份映射表,如果客户端向某个存储服务器请求一个该服务器没有的数据键,会清楚缓存并从提交代理获取一份最新的服务器列表。