基于Google Spanner架构的分布式PostgreSQL——存储层
Karthik Ranganathan 【hudson 译】
2019年3月18号
在本文中,我们将深入探讨YugabyteDB分布式存储层架构,其灵感来自谷歌 Spanner设计。我们随后的文章涵盖了查询层,其中存储层将PostgreSQL作为SQL API。最后,这里是一篇后续文章揭示了我们在设计像YugabyteDB这样的分布式SQL数据库时所面临的关键技术挑战。
逻辑架构
YugabyteDB由两个逻辑层组成,其中每一个都是在多个节点上运行的分布式服务(在Kubernetes的情况下是pod)。
Yugabyte 查询层(YQL)形成了YugabyteDB的上层,应用程序使用客户端驱动程序与之交互。YQL处理特定于API的方面(如查询编译、数据类型表示、内置函数等)。它的构建考虑到了可扩展性,并支持两个API,即Yugabyte SQL或YSQL(PostgreSQL兼容)和Yugabyte Cloud QL或YCQL(具有 Cassandra QL 血统)。
DocDB,一个受Google Spanner启发的高性能分布式文档存储,作为底层。它提供了强大的一致性语义,包括单行线性化能力和多行ACID事务(当前具有快照隔离,不久的将来具有可串行化隔离)。它对各种类型的故障具有很强的恢复能力,最近成功地进行了Jepsen测试(请稍后关注官方公告)。
为什么谷歌 Spanner是DocDB的灵感来源?
为了支持地理分布、随机访问的OLTP工作负载(比如Gmail、日历、AdWords、Google Play等),Google构建了Spanner,可以说是世界上第一个全球一致的数据库。尽管它最初是在2012年作为设计论文问世,有关的工作早在2007年就开始了。最初,Spanner仅提供了一个键值API,并支持分布式事务、外部一致性和跨数据中心的透明故障切换。从那时起,它已经发展成为一个全球分布式、基于SQL的RDBMS。如今,Spanner几乎支撑着谷歌的每一项关键任务服务。Spanner系统的一个子集于2017年在谷歌云平台上公开,也就是名为Google Cloud Spanner的专有托管服务。
DocDB的分区、复制和事务的设计灵感来自Spanner论文中概述的设计。这种选择最重要的好处是,数据库的任何单个组件都不会成为地理分布、基于随机访问SQL的OLTP工作负载的性能或可用性瓶颈。 其他论文中提出的事务性数据库设计,如耶鲁大学的卡尔文,亚马逊的Aurora,以及谷歌的Percolator不能保证这一好处。卡尔文设计中用于事务处理的单一全局共识领导者本身可能成为瓶颈。此外,关键的SQL构造,如长时间运行的会话事务和高性能二级索引在卡尔文设计中不可能。亚马逊的Aurora在分布式存储层之上使用了一个单一的SQL层,因此缺乏水平写可伸缩性。最后,谷歌构建Spanner的主要理由是为了解决Percolator中缺乏低延迟分布式事务的问题。
DocDB 如何工作?
DocDB中的数据存储在表中。每个表由行组成,每行包含一个键和一个文档。DocDB围绕4个主要设计原则构建。如前一节所强调的,前三个原则是受Spanner体系结构的启发,而最后一个原则是确保高性能和灵活存储系统的关键。
- 透明的表分区
- 分区数据的复制
- 跨分区事务(也称为分布式事务)
- 基于文档的行持久性
透明的表分区
DocDB将用户表隐式管理为多个分区。这些分区被称为 tablets。表中每一行的主键唯一地确定该行所在的tablet。将数据划分为tablet有不同的策略,下面概述了一些策略。
基于散列的数据分区方案计算每行的分区id。这通过计算表的主键列(或其子集)的散列值来实现。该策略导致数据和查询在节点集群中均匀分布。它非常适合于需要低延迟、高吞吐量和处理大型数据集的应用程序。然而,对于范围查询,该方案并不适用,因为应用程序希望列出下限和上限之间的所有键或值。
您可以在下图中看到这个分区方案的示例。每行所属的分区由唯一的颜色表示。因此,我们将下面的7行表划分为3个tablet,每一行都属于一个随机分区。
基于散列的数据分区
基于范围的数据分区方案按主键的自然顺序对表进行分区。该策略允许应用程序执行有效的范围查询,其中应用程序希望查找下限和上限之间的所有值。然而,该方案可能导致数据的不均匀分割,这需要频繁地动态重新划分tablet。此外,根据访问模式,该方案可能无法均衡所有节点上的查询。
基于范围的分区
DocDB目前支持哈希分区和范围分区。请参阅YugabyteDB文档中的散列和范围分区更多细节。
分区复制–单行线性化能力
DocDB跨多个节点复制每个tablet中的数据,使tablet具有容错性。tablet的每个副本称为tablet成员。容错(或FT)是tablet在继续保持数据正确性的情况下能够存活的最大故障节点数。复制因子(或RF)是YugabyteDB universe中数据的拷贝数。FT和RF高度相关–为了在k个故障(节点)中存活,RF应配置为2k+1。
作为一个强一致的数据库核心,DocDB的复制模型使得单键更新即使在出现故障时也可以线性化。线性化能力是最强的单键一致性模型之一,它意味着每一个操作似乎都以原子的方式发生,并且以与这些操作的真实顺序一致的总线性顺序发生。
Tablet数据通过使用分布式共识算法在各成员之间互相复制。共识算法允许一组机器就一系列值达成一致,即使其中一些机器可能出现故障。Raft和Paxos是众所周知的分布式共识算法,并已被正式证明是安全的。Spanner使用Paxos。然而,我们选择了Raft,因为比Paxos更容易理解,而且提供了动态更改成员资格等关键特性。
领导者租约
实现容错分布式存储系统的最直接方法是以上面讨论的共识算法之一为基础将其实现为一个复制状态机。状态机上的每一个操作都将经过共识模块,以确保状态机的所有副本在操作和应用顺序上达成一致。
然而大多数实际的系统(例如:Paxos made live和Paxos made Simple),可以做得稍微好一点。这是因为只要求写操作通过共识模块,同时允许读操作由Raft组的领导者提供服务,从而保证具有所有最新的值。由于Raft组中的不同成员可能在不同任期中操作(具有不同的领导者),因此多个成员可能同时认为自己是领导者(对于不同任期)。为确保系统仅允许读取与最新任期相对应的领导者的值,Raft 论文建议领导者在响应读取操作之前,应与大多数成员进行沟通,以确认最新任期的领导者。
这种方法的主要缺点是读取性能可能会受到影响,尤其是在地理分布的集群中。YugabyteDB通过使用领导者租约克服了这一性能限制,这是确保读取操作只能由最新的领导者提供的一个新的选项 。这确保您可以从领导者读取数据,而无需往返到任何其他节点。领导者租约机制保证在任何时间点,Tablet Raft 组中最多有一台服务器认为自己是最新的领导者,可以提供一致的读取或接受写入请求。换句话说,如果没有领导者租约,可能会发生过时读取,因为分区分裂,旧领导者认为它仍然是领导者而可能会服务于一致的读取请求。
由于领导者租约,新当选的领导者在获得租约之前不能进行读取(或接受写入)。在领导者选举期间,投票人必须将该其所知道的旧领导者的最长任期传播给正在投票的新候选人。在获得多数票后,新领导者必须等待旧领导者的租约到期,然后才认为自己拥有租约。旧领导者在其领导者租约到期后会卸任,不再担任领导者。作为Raft复制的一部分,新领导者会不断延长其领导者租约。
请注意,领导者租约不依赖于任何类型的时钟同步或原子时钟,因为只有时间间隔通过网络发送。每个服务器根据其本地单调时钟进行操作。时钟实现的唯一两个要求是:
- 不同服务器之间的有界单调时钟漂移率。例如,如果我们使用标准Linux假设小于每秒500µs的漂移率,我们可以通过将上述所有延迟乘以1.001来解释。
- 单调的时钟不会冻结。例如,如果我们在暂时冻结的虚拟机上运行,当虚拟机再次开始运行时,虚拟机管理程序需要从硬件时钟刷新虚拟机的时钟。
跨分区事务 –多行分布式事务
分布式ACID事务修改多个分区中的多行。DocDB支持分布式事务,支持强一致性二级索引和多表/行ACID操作等功能。
让我们举一个例子,并通过该例中考察架构。下面是一个简单的分布式事务,它更新两个键k1和k2。
BEGIN TRANSACTION
UPDATE k1
UPDATE k2
COMMIT
从前面的章节中我们已经知道(在一般情况下),这些键可能属于两个不同的tablet——每个tablet都有自己的Raft组,可以位于一组单独的节点上。因此,问题现在归结为原子地更新两个独立的 raft 组。以原子方式更新两个Raft组需要两个Raft组的领导者(接受这些键的写入和读取)同意以下内容:
- 使最终用户可以在同一物理时间读取键
- 一旦确认写入操作成功,键应可读
注意,我们还没有讨论处理冲突,也就是来自另一个事务重叠的部分更新。
在同一物理时间跨两个节点进行更新需要一个可以跨节点同步时间的时钟,这不是一项容易的任务。谷歌Spanner使用的TrueTime是一个高度可用、全局同步的时钟示例,具有严格的误差界限。然而,在许多部署中,此类时钟并不可用。而定期可用的物理时钟无法在节点间完全同步,因此无法提供节点间的排序。
使用Raft和混合逻辑时钟(HLC)消除对TrueTime的需求
DocDB使用Raft操作的ID和混合逻辑时钟(HLC) 的组合,而不是TrueTime。HLC将粗略同步的物理时钟(使用NTP)与跟踪因果关系的兰波特时钟相结合。集群中的每个节点首先计算其HLC。HLC表示为 (物理时间组件,逻辑组件)元组,如下图所示。通过一个单调递增的计数器, 在粗粒度同步的物理时间间隔内实现了细粒度事件顺序 。
一旦每个节点上的HLC可用,DocDB充分利用了Raft操作ID和HLC时间戳(二者均由tablet领导者发布)是严格单调序列的事实。因此,YugabyteDB首先通过将两个值写入Raft日志中,在Raft操作id(包括一个项和一个索引)和此时tablet Raft领导者上的相应HLC(包括物理时间组件和逻辑组件)之间建立一对一的关联。
因此,HLC可以作为Raft操作id的代理。集群中的HLC充当分布式全局时钟,在各自独立的Raft组之间粗略同步。 仅使用Raft操作ID是不可能的。因此,我们现在有了独立节点可以就物理时间达成一致的概念。
问题的第二部分是节点何时应该提供读取。这是通过使用HLC跟踪读取点来实现的。读取点确定哪些更新对最终用户可见。在单行更新的情况下,领导者将发布HLC值更新。根据Raft协议,当更新被安全地复制到大多数节点上时,对用户而言即可确认成功,到该HLC的读取服务也是安全的。这形成了DocDB中多版本并发控制(MVCC)的基础。
对于分布式事务,节点将挂起状态和临时记录写入事务记录。这将在另一篇关于YugabyteDB中分布式事务如何工作的文章中详细介绍。事务记录更新为提交状态时,事务将被分配一个提交HLC。当HLC值变得可以安全地服务于客户端时,事务将自动地对最终用户可见。请注意,确定可读取的安全时间 的精确算法更为复杂,是一篇全新文章的主题。
基于文档的行持久化
在上一节中,我们考察了如何在容错的同时以强一致性复制每个tablet数据。下一步是以高效读写方式将其持久化到每个tablet成员的本地文档存储中。DocDB本地存储使用了高度定制的RocksDB版本,一个基于日志结构合并树(LSM)的键值存储。为实现可伸缩性和性能,我们极大增强了RocksDB。
文档如何在DocDB中持久化?让我们举例说明如下。
DocumentKey1 = {
SubKey1 = {
SubKey2 = Value1
SubKey3 = Value2
},
SubKey4 = Value3
}
在本例中,名为DocKey的文档键的值为“DocumentKey1”。对于每个文档键,都有一些可变数量的子键。在上面的示例中,文档的子键是“SubKey1、SubKey2.SubKey3”和“subkey 4”。这些子键中的每一个都是根据下面显示的序列化格式进行编码的。
串行化一个或多个文档产生的字节缓冲区是前缀压缩,以便有效地存储在内存(块缓存)或磁盘(文件)中。DocDB支持以下高效操作:
- 替换整个文档
- 更新文档中的一组子属性
- 查询整个文档或仅查询属性子集
- 在文档中进行比较并设置操作
- 在文档内或跨文档扫描
SQL表中的每一行对应于DocDB中的一个文档。由于DocDB允许对单个文档属性进行细粒度更新和查找,因此可以非常高效地更新和查询SQL表中行的列。本系列的后续部分将详细介绍这一工作原理。
总结
至少可以说,在保持高性能特性的同时,将Google Spanner架构带入开源、云原生基础设施的世界中是一个激动人心的工程之旅。此外,还有机会重用和扩展成熟的SQL层,如PostgreSQL。谚语“糖果店里的小孩”的感觉对我们来说非常真实🙂
在本系列的下一篇文章,我们深入探讨如何重用PostgreSQL的查询层并使其在DocDB上运行的细节。在后续文章中,我们揭示了在过去3年多的时间里,在构建这样一个 “Spanner遇到PostgreSQL” 数据库的过程中,我们学到的教训 。