CockroachDB权威指南——CockroachDB 架构

软件系统的架构定义了实现该系统目标的高层设计决策。正如您在第一章中回顾的那样,CockroachDB的目标是提供一个可扩展、高可用、高性能、强一致性、地理分布式、基于SQL的关系数据库系统,能够在各种硬件平台上运行。CockroachDB的架构与这些目标相一致。

可以跳过本章直接阅读!

CockroachDB的架构非常复杂:它结合了数十年的数据库工程最佳实践设计,以及若干独特的创新。然而,使用CockroachDB并不要求你理解其内部实现。如果你急于开始使用CockroachDB,可以跳到下一章,稍后再回来阅读这一章。当然,在本书后续讨论高级主题时,我们假设你对本章的关键概念有基本了解。这些关键概念将在接下来的几页中进行总结,并在本章的其余部分进行详细阐述。

我们可以从多种角度来看待CockroachDB的架构。在集群层面,一个CockroachDB部署由一个或多个无共享、无领导节点组成,这些节点协作以展示一个统一的分布式数据库系统视图。在每个节点内部,我们可以观察到CockroachDB架构作为一系列层次结构,提供基本的数据库服务,包括SQL处理、事务处理、复制、分发和存储。

在本章中,我们将努力为您提供CockroachDB架构的全面概述。章节的目标是为您提供帮助您做出合理决策的基本概念,包括模式设计、性能优化、集群部署等方面的决策。

CockroachDB 集群架构

从整体上看,一个CockroachDB部署由一个或多个数据库服务器进程组成。每个服务器都有自己独立的存储------这就是熟悉的"无共享"数据库集群模式。CockroachDB集群中的节点是对称的------没有"特殊"或"主"节点。这些存储通常直接附加到运行CockroachDB服务器的机器上,尽管这些数据也可以物理存储在共享存储子系统中。数据基于键范围在集群中分布。每个范围会被复制到集群中的至少三个成员。

数据库客户端------应用程序、管理控制台、CockroachDB命令行工具等------连接到集群中的一个CockroachDB服务器。

数据库服务器与数据库客户端之间的通信通过PostgreSQL wire协议格式进行。这个协议描述了SQL请求和响应如何在PostgreSQL客户端和PostgreSQL服务器之间传输。由于CockroachDB使用PostgreSQL wire协议,因此可以使用任何PostgreSQL驱动程序与CockroachDB服务器进行通信。在更复杂的部署中,一个或多个负载均衡器进程将负责确保这些连接在节点之间均匀且合理地分布。负载均衡器将客户端连接到集群中的一个节点,该节点将成为该连接的网关服务器。

客户端请求可能涉及读取和写入单个节点的数据,也可能是集群中多个节点的数据。对于任何给定的键值范围(KV范围),一个租赁节点将负责控制对该范围的读写操作。租赁节点通常也是Raft领导者,负责确保数据的副本正确维护。

图2-1说明了这些概念的一部分。数据库客户端连接到负载均衡器(1),该负载均衡器充当CockroachDB集群的代理。负载均衡器将请求引导到一个可用的CockroachDB节点(2)。这个节点成为该连接的网关节点。请求需要范围4的数据,因此网关节点与该范围的租赁节点进行通信(3),租赁节点将数据返回给网关节点,网关节点再将所需的数据返回给数据库客户端(4)。

该架构将负载均匀分布在集群的各个节点上。负载均衡器将网关职责均匀分布到集群中的各个节点;租赁节点的职责也通过范围在所有节点之间进行分配。

如果查询需要从多个范围获取数据,或者需要更改数据(因此需要复制数据),则工作流程将涉及更多步骤。我们将在本章稍后探讨CockroachDB的分布和复制的细节,但现在有几个概念需要先理解。

在底层,CockroachDB表中的数据是以KV存储系统组织的。KV存储的键是表的主键。KV存储中的值是该行所有列值的二进制表示。

索引也存储在KV系统中。在非唯一索引的情况下,键是索引键与表主键的连接。在唯一索引的情况下,键是索引键,主键作为该键的对应值。

范围存储连续的KV跨度。范围类似于其他数据库中的分片或分片块。图2-2展示了一个"dogs"表如何被划分为多个范围。

如前所述,租赁被授予给一个节点,使其负责管理对某个范围的读写操作。持有租赁的节点被称为租赁节点。通常,持有租赁的节点也是Raft领导者,负责确保该节点的副本在多个节点之间正确维护。

CockroachDB 软件栈

每个CockroachDB节点运行CockroachDB软件的副本,该软件是一个单一的多线程进程。从操作系统的角度看,CockroachDB进程可能看起来像是一个封闭的盒子,但从内部来看,它被组织成多个逻辑层次,如图2-3所示。

我们将在本章中逐一讨论这些层次。

CockroachDB SQL 层

SQL 层是 CockroachDB 软件堆栈的一部分,负责处理 SQL 请求。由于 CockroachDB 是一个 SQL 数据库,你可能会认为 SQL 层几乎完成了所有工作。然而,SQL 层的核心职责实际上是将 SQL 请求转换为 KV 操作。其他层则负责事务、范围的分布和复制以及数据的物理存储到磁盘。

SQL 层通过 PostgreSQL wire 协议接收来自数据库客户端的请求。数据库客户端是任何使用数据库驱动程序与服务器通信的程序。它包括 CockroachDB 命令行 SQL 处理器、GUI 工具(如 DBeaver 或 Tableau),以及用 Java、Go、Node.js、Python 或其他任何具有兼容驱动程序的语言编写的应用程序。

PostgreSQL wire 协议描述了用于发送请求和接收来自数据库客户端与服务器结果的网络数据包格式。wire 协议基于如 TCP/IP 或 Unix 风格的套接字等传输介质。使用 PostgreSQL wire 协议使 CockroachDB 能够利用广泛的兼容语言驱动程序和支持 PostgreSQL 数据库的工具生态系统。

SQL 层解析 SQL 请求,检查其语法准确性,并确保连接具有执行请求任务的权限。

然后,CockroachDB 为 SQL 语句创建执行计划,并继续优化该计划。

SQL 是一种声明性语言:你定义你需要的数据,而不是如何获取它。尽管 SQL 的非过程性特征提高了程序员的生产力,但数据库服务器必须支持一系列复杂的算法来确定执行 SQL 的最佳方法。这些算法统称为优化器。

对于几乎所有的 SQL 语句,CockroachDB 都会有多种方法来检索所需的行。例如,对于包含 JOIN 和 WHERE 子句的 SQL 语句,可能有多种连接顺序和多种访问路径(表扫描、索引查找等)可供选择。优化器的目标是确定最佳的访问路径。CockroachDB 的 SQL 优化器具有一些与其分布式架构相关的独特特性,但从广义上讲,其基于成本的优化器与其他 SQL 数据库(如 Oracle 或 PostgreSQL)中的优化器类似。

优化器使用启发式规则和基于成本的算法来执行其工作。

SQL 优化过程的第一阶段是将 SQL 转换为适合进一步优化的标准化形式。这个转换去除了 SQL 语句中的冗余,并进行了基于规则的转换以提高性能。转换考虑了表数据的分布,添加了谓词来将查询的某些部分定向到特定的范围,或添加了允许使用索引检索路径的谓词。

SQL 语句的优化分为两个阶段:扩展和排序。SQL 语句首先被转换为初步计划。然后,优化器将该计划扩展为一组等价的候选计划,涉及替代执行路径,如连接顺序或索引。接着,优化器通过计算每个操作的相对成本来对计划进行排序,利用统计信息来提供每个表内数据的大小和分布。然后,选择成本最低的计划。

CockroachDB 还支持一个矢量化执行引擎,可以加速数据批次的处理。该引擎将数据从行导向格式(数据集包含来自同一行的数据)转换为列导向格式(每个数据集包含来自同一列的信息)。

我们将在第 8 章详细讨论 SQL 调优时重新回到优化器的内容。

从 SQL 到键值对

如前所述,CockroachDB 数据存储在一个分布式的 KV 存储系统中,数据分布在多个节点的范围内。我们将在本章的最后讨论这个存储系统的细节,但由于 SQL 层的输出实际上是 KV 操作,因此数据从表和索引到 KV 表示的映射是 SQL 层的一部分。SQL 层的输出是 KV 操作。

这种转换意味着只有 SQL 层需要关注 SQL 语法------所有后续的层对 SQL 语言完全不知情。

表在 KV 存储中的表示

KV 存储中的每个条目都有一个基于以下结构的键:

/<tableID>/<indexID>/<IndexKeyValues>/<ColumnFamily>

我们将在下一节讨论列族(column families)。默认情况下,所有列都包含在一个默认的列族中。

对于基础表,默认的 indexID 是"primary"。

图 2-4 显示了这个映射的简化版本,省略了列族标识符。

图 2-4 说明了表名和索引名("primary")作为文本的表示,但在 KV 存储中,这些是作为紧凑的表和索引标识符表示的。

列族

在前面的例子中,表的所有列都被聚合在单个 KV 条目的值部分。然而,可以通过使用列族指示 CockroachDB 将一组列存储在单独的 KV 条目中。表中的每个列族将被分配到其自己的 KV 条目中。图 2-5 展示了这一概念------如果一个表有两个列族,那么表中的每一行将由两个 KV 条目表示。

列族可以带来许多优势。如果不常访问的大列被分开存储,那么在行查找时就不会被检索,这可以提高 KV 存储缓存的效率。此外,分别存储在不同列族中的列的并发操作不会相互干扰。

KV 存储中的索引

索引通过类似的 KV 结构表示。例如,图 2-6 显示了一个非唯一索引的表示方式。

非唯一索引的键包括表名和索引名、KV 以及主键 KV。对于非唯一索引,默认没有"值"。

对于唯一索引,KV 的值默认是主键的值。因此,如果在前面的例子中,库存表中的 name 是唯一的,那么对 name 的唯一索引将如图 2-7 所示。

倒排索引

CockroachDB 列可以定义为数组或 JSON 文档。我们将在第 4 章中详细讨论这一点。

倒排索引允许对这些数组或 JSON 文档中包含的值进行索引搜索。在这种情况下,KV 包括 JSON 路径和值以及主键,如图 2-8 所示。

倒排索引也用于空间数据。

倒排索引可能比其他索引更大且维护成本更高,因为行中的单个 JSON 文档将为每个唯一属性生成一个索引条目。对于复杂的 JSON 文档,这可能导致每个文档生成数十个索引条目。我们将在第 8 章进一步讨论这一点,并考虑一些替代方案。

STORING 子句

CREATE INDEX 的 STORING 子句允许我们将额外的列添加到 KV 索引结构的值部分。这些额外的列可以简化包含投影(例如,包含仅这些列和索引键的 SELECT 列表)的查询。例如,在图 2-9 中,我们看到一个基于 namedateOfBirth 的非唯一索引,使用 STORING 子句将电话号码添加到 KV 值中。现在,查询通过 namedateOfBirth 查找电话号码时,只需通过索引即可解决,而不需要引用基础表。

表定义和架构变更

表的架构定义(及其关联的索引)存储在一个称为表描述符的特殊键空间中。出于性能考虑,表描述符会在每个节点上进行复制。表描述符用于解析和优化 SQL,并正确构建表的 KV 操作。

CockroachDB 支持使用 ALTER TABLE、CREATE INDEX 等命令进行在线架构变更。架构变更分为离散的阶段,允许在前一个版本仍在使用时推出新架构。架构变更作为后台任务运行。

发起架构变更的节点将获取相关表描述符的写入租约。对表执行数据操作语言(DML)的节点将持有相关表描述符的租约。当持有写入租约的节点修改定义时,该修改会广播到集群中的所有节点,这些节点将在可能时释放对旧架构的租约。

架构变更可能涉及表数据的更改(删除或添加列)和/或创建新的索引结构。当所有表实例根据新架构的要求存储完毕后,所有节点将切换到新架构,并允许使用新架构进行表的读写操作。

CockroachDB 事务层

事务层负责维护事务的原子性,确保事务中的所有操作要么全部提交,要么全部中止。

此外,事务层默认保持事务之间的可序列化隔离性;这意味着事务完全隔离于其他事务的影响。尽管多个事务可能同时进行,但每个事务的执行体验就像事务一个接一个地执行------这是可序列化隔离级别。

隔离级别

事务的"隔离级别"定义了事务在多大程度上被隔离于其他事务的影响之外。ANSI SQL 定义了四种隔离级别,从最弱到最强依次为:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ 和 SERIALIZABLE。此外,许多数据库还使用 SNAPSHOT 隔离级别,作为一种替代的"强"隔离级别。

大多数关系型数据库使用默认隔离级别 READ COMMITTED,以提高并发性,但以牺牲一致性为代价。CockroachDB 支持 SERIALIZABLE 和 READ COMMITTED 两种隔离级别,其中 SERIALIZABLE 是默认的隔离级别。

在使用默认的 SERIALIZABLE 隔离级别时,CockroachDB 事务必须对所有其他事务表现出完全的独立性。一组并发事务的结果必须与它们按顺序依次执行的结果相同。

对于设计为 READ COMMITTED 隔离级别的应用程序,CockroachDB 允许操作员将默认隔离级别降低到 READ COMMITTED,以便更顺利地迁移到 CockroachDB。

事务层处理 SQL 层生成的 KV 操作。一个事务由多个 KV 操作组成,其中一些操作可能是单个 SQL 语句的结果。除了更新表条目外,索引条目也必须进行更新。在所有情况下保持完美的一致性涉及多个复杂的算法,部分算法在本章中无法涵盖。如需更全面的信息,您可以参考 CockroachDB 2020 年 SIGMOD 论文,里面更详细地涵盖了许多这些原理。

MVCC 原则

像大多数事务性数据库系统一样,CockroachDB 实现了多版本并发控制(MVCC)模式。MVCC 允许读取者在信息被修改时仍能获取一致的视图。如果没有 MVCC,一致性的读取操作需要阻塞(通常使用"读取锁")对该数据项的同时写入,反之亦然。而使用 MVCC,读取者即使在信息被并发事务修改的情况下,也能获得一致的视图。

图 2-10 说明了 MVCC 的基本原理。在 t1 时刻,会话 s1 从行 r2 中读取并访问该行的版本 v1(1)。在 t2 时刻,另一个数据库会话 s2 更新了该行(2),创建了该行的版本 v2(3)。在 t3 时刻,会话 s1 再次读取该行,但由于 s2 还没有提交更改,因此它继续从版本 v1 中读取(4)。在 s2 提交之后(5),会话 s1 发出另一个 SELECT 查询,现在从新版本 v2 中读取该行(6)。

CockroachDB 的实现限制了事务读取先前版本的能力。例如,如果一个读取事务在写入事务开始后启动,它可能无法读取该行的原始版本,因为该版本可能与事务中已读取或将要读取的其他数据不一致。这可能导致读取事务"阻塞",直到写入事务提交或回滚。

我们稍后会看到存储引擎如何实现 MVCC,但目前要理解的关键概念是,系统会维护任何行的多个版本,且事务可以根据其时间戳和任何并发事务的时间戳来确定要读取哪一个版本。

事务工作流

分布式事务必须分多个阶段进行。简而言之,分布式系统中的每个节点都必须为事务做好准备,只有当所有节点报告事务可以执行时,事务才会最终确定。

图 2-11 说明了事务准备的高度简化流程。在这种情况下,两个语句的事务被发送到 CockroachDB 网关节点(1)。第一个语句涉及对范围 2 的更改,因此该请求被发送到该范围的租约持有者(2),租约持有者创建该行的新临时版本,并将更改传播到副本节点(3 和 4)。第二个语句影响范围 4,因此事务协调器将该请求发送到适当的租约持有者(5),并且该更改也会被传播(6 和 7)。当所有更改正确传播后,事务完成,客户端会收到成功通知(8)。

写入意图

在事务处理的初期阶段,当尚不确定事务是否会成功时,租约持有者会将修改过的值写入临时修改记录,这些记录称为写入意图。写入意图是特别构造的符合 MVCC 规范的记录版本,它们被标记为临时的。它们既作为事务的临时结果,又作为锁,防止任何并发尝试更新相同记录。

在事务将要修改的第一个键范围内,CockroachDB 会写入一个特殊的事务记录。这条记录记录了事务的最终状态。在图 2-11 中所示的示例中,这条事务记录将存储在范围 2 中,因为这是事务中第一个被修改的范围。

该事务记录将记录事务的状态,可能是以下之一:

  • PENDING
    表示写入意图的事务仍在进行中。
  • STAGING
    所有事务写入操作已完成,但事务尚未被保证提交。
  • COMMITTED
    事务已成功完成。
  • ABORTED
    表示事务已中止,其值应被丢弃。

并行提交

在分布式数据库中,网络往返次数通常是延迟的主要因素。一般来说,提交一个分布式事务至少需要两次往返(实际上,经典的算法之一叫做两阶段提交)。CockroachDB 使用一种创新的协议叫做并行提交(Parallel Commits),以使客户端感知的延迟中隐藏其中的一次往返。

并行提交背后的关键理念是,当事务不再可能中止时,网关可以立即向客户端返回成功,即使事务尚未完全提交。剩下的工作可以在返回后完成,只要其结果是确定的。这是通过在事务的最后一次写入操作并行地将事务过渡到 STAGING 状态来实现的。这些写入的所有键都记录在事务记录中。STAGING 状态的事务只有在所有写入成功时才会提交。

通常,网关会在写入完成后尽快得知这些写入的状态,并在开始背景中进行最终事务解决之前将控制权返回给客户端。如果网关失败,下一个遇到 STAGING 事务记录的节点将负责查询每个写入的状态,并确定事务是提交还是中止(但因为事务记录和每个写入意图已被持久化,结果无论是由原始网关还是其他节点解决,都会是相同的)。

请注意,事务持有的任何锁都不会在解决过程完成之前释放。因此,从等待其锁的另一个事务的角度来看,事务的持续时间仍然至少是两次往返(就像在两阶段提交中一样)。然而,从发出事务的会话的角度来看,经过的时间显著减少。

事务清理

如前一节所述,COMMIT 操作通过"切换开关"将事务记录标记为已提交,从而最小化事务提交时可能发生的延迟。在事务达到 COMMIT 阶段后,它将异步地通过将写入意图转换为正常的 MVCC 记录来解决写入意图,表示新的记录值。

然而,与任何异步操作一样,执行此清理可能会有延迟。此外,由于已提交的写入意图与挂起的写入意图看起来相同,当事务在读取键时遇到写入意图记录时,必须确定该写入意图是否已提交。

如果另一个事务遇到尚未被事务协调器清理的写入意图,它可以通过检查事务记录来执行写入意图的清理。写入意图包含指向事务记录的指针,事务记录可以揭示该事务是否已提交。

事务流程概述

图 2-12 说明了一个成功的两语句事务的流程。客户端发出一个 UPDATE 语句(1)。这会创建一个事务协调器,该协调器将事务记录保持在 PENDING 状态。写入意图命令被发送给相关范围的租约持有者(2)。租约持有者将意图标记写入其数据副本中,并在无需等待副本确认意图的情况下,向事务协调器返回成功。

事务中的后续修改以相同方式处理。

客户端发出 COMMIT 操作(3)。事务协调器将事务状态标记为 STAGING。当所有写入意图确认后,发起客户端将收到成功通知,然后事务状态被设置为 COMMITTED(4)。

在成功提交后,事务协调器解决受影响范围内的写入意图,这些写入意图变为正常的 MVCC 记录(5)。此时,事务已释放所有锁,其他对相同记录的事务可以继续进行。

图 2-12 是高度简化的,但仍然有些难以理解。该图的两个主要要点如下:

  1. 大多数操作分为两个阶段响应;在第一次响应后,我们可以继续执行下一步,所有内容只需在提交结束时解决。
  2. 客户端的延迟不包括所有清理操作。UPDATE 操作在所有写入意图传播之前就返回,而 COMMIT 操作在所有写入意图解决之前就返回。希望这样可以减少分布式数据库管理对应用响应时间的影响。

读写冲突

到目前为止,我们讨论了成功事务的处理。如果所有事务都成功,那将是非常理想的,但在几乎所有非最简单的场景中,并发事务会产生需要解决的冲突。

最明显的情况是,当两个事务尝试更新相同的记录时。对于相同的键不能有两个写入意图,因此其中一个事务将等待另一个事务完成,或者其中一个事务将被中止。如果这两个事务具有相同的优先级,则第二个事务(尚未创建写入意图)将等待。然而,如果第二个事务具有更高优先级,则原始事务将被中止,并且需要重试。

事务优先级可以通过 SET TRANSACTION 语句进行调整------请参阅第 6 章。

事务隔离级别可以按每个事务进行设置,使用 BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED 语句,或通过在数据库或角色级别设置默认值,如下所示:

ini 复制代码
-- 数据库级别。 ALTER DATABASE your_db SET default_transaction_isolation = 'read committed';

-- 角色级别。 ALTER ROLE your_role SET default_transaction_isolation = 'read committed';

TxnWaitQueue 对象跟踪正在等待的事务及其等待的事务。这个结构由与事务相关的范围的 Raft 领导者维护。当事务提交或中止时,TxnWaitQueue 会被更新,任何等待的事务都会收到通知。

如果两个事务都在等待对方事务创建的写入意图,就可能发生死锁。在这种情况下,其中一个事务会被随机中止。我们将在第 6 章中更详细地讨论这一点。

事务冲突还可能发生在读取者和写入者之间。如果读取者遇到一个未提交的写入意图,并且该意图的时间戳低于(即更早于)读取的一致性时间戳,则一致性读取无法完成。如果在读取事务开始和尝试读取相关键之间发生了修改,这种情况可能会发生。在这种情况下,读取操作需要等待直到写入事务提交或中止。

这些"阻塞读取"可以在以下情况下避免:

  • 如果读取事务具有较高的优先级,CockroachDB 可能会将较低优先级写入的时间戳推迟到更高的值,从而允许读取完成。若推迟使得事务的某些先前工作无效,推迟的事务可能需要重启。
  • 使用 AS OF SYSTEM TIME 的陈旧读取不会被阻塞(只要事务不超过指定的陈旧度)。我们稍后将在本章中讨论 AS OF SYSTEM TIME。
  • 在多区域配置中------我们将在第 11 章中详细描述------GLOBAL 表使用修改过的事务协议,在这种协议中,读取不会被写入阻塞。

许多事务冲突会自动管理,虽然这些冲突对性能有影响,但不会影响功能或代码设计。然而,有多种场景下应用程序可能需要处理中止的事务。我们将在第 6 章中查看这些场景,并讨论事务重试的最佳实践。

时钟同步与时钟偏差

您可能已经注意到,在前面的章节中,CockroachDB 必须频繁比较操作的时间戳,以确定事务是否发生冲突。简而言之,我们可能会认为系统中的每个节点可以就每个操作的时间达成一致,从而轻松进行这些比较。实际上,每个系统的系统时钟可能略有不同,而且这种差异通常会随着系统地理分布范围的扩大而增大。时钟时间的差异被称为时钟偏差。因此,在具有非常高事务率的广泛分布的系统中,让节点就事务的确切顺序达成一致是一个问题。正如您可能记得的,Spanner 通过使用专用硬件------原子钟和 GPS------来解决这个问题,从而减少系统时钟之间的不一致。因此,Spanner 可以将时钟偏差保持在 7 毫秒以内,并且仅为每个事务添加 7 毫秒的延时,以确保事务按顺序完成。

由于 CockroachDB 必须在通用硬件上可靠运行,因此它使用久经考验的互联网网络时间协议(NTP)来同步时间。NTP 提供准确的时间戳,但远不如 Spanner 的 GPS 和原子钟准确。

默认情况下,CockroachDB 可以容忍高达 500 毫秒的时钟偏差。像 Spanner 那样在每个事务上添加半秒钟的延时将是无法接受的,因此 CockroachDB 采用了不同的方法来处理出现在 500 毫秒不确定性区间内的事务。简而言之,虽然 Spanner 始终在写入后等待,CockroachDB 有时会重试读取操作。

如果读取者无法确定正在读取的值是否在读取事务开始之前已经提交,那么它会将自己的临时时间戳推高到不确定值的时间戳之上。不断从多个节点读取持续更新数据的事务可能被迫多次重试,尽管每次重试的时长不会超过不确定性区间,并且每个节点最多只能重试一次。

CockroachDB 的时间同步策略使得 CockroachDB 能够提供真正的可序列化一致性。然而,仍然可能会发生一些异常。两个操作在没有直接逻辑依赖关系的情况下操作无关的键值对,但它们在现实世界中可能有某种顺序依赖性,这可能导致它们被反向提交------这种现象被称为因果反转异常。这并不违反可序列化隔离,因为这些事务实际上并没有逻辑上的依赖。然而,在 CockroachDB 中,事务的时间戳可能不会反映它们在现实世界中的顺序。

CockroachDB 分布层

从逻辑上讲,CockroachDB 中的表表示为一个单一的 KV 结构,其中键是表的主键的串联,值是表中所有剩余列的串联。我们在图 2-2 中介绍了这个结构。

分布层将这个单一结构划分为大约 512 MB 的连续块。512 MB 的块大小是为了保持每个节点的范围数量可控。分布层确保数据在集群中均匀分布,同时向需要它的应用程序呈现统一且整合的数据视图。

元范围(Meta Ranges)

范围的分布存储在全局键空间 meta1 和 meta2 中。meta1 可以看作是一个"范围的范围"查找,它允许一个节点找到持有 meta2 记录的节点的位置,而该记录又指向持有"范围的范围"内每个范围副本的节点。图 2-13 说明了这种两级查找结构。

节点 1 需要获取键为 "HarrisonGuy" 的数据。它在自己的 meta1 副本中查找,得知节点 2 包含范围 G--M 的 meta2 信息。它从节点 2 获取相关的 meta2 数据,发现节点 4 是范围 G--I 的租约持有者,因此也是该范围的租约持有者。

Gossip

CockroachDB 使用 gossip 协议在节点之间共享短暂的信息。Gossip 是一种在分布式系统中广泛使用的协议,节点通过网络以病毒式的方式传播信息。

Gossip 在所有 CockroachDB 节点上维护一个最终一致的 KV 映射。它主要用于引导初始化:它包含一个 "meta0" 记录,告诉集群 meta1 范围的位置,以及将 meta 记录中存储的节点 ID 映射到网络地址。Gossip 还用于某些不需要强一致性的操作,例如维护每个节点的可用存储空间信息,以便用于负载均衡。

租约持有者

租约持有者是负责为特定键范围提供读取服务并协调写入操作的 CockroachDB 节点。我们在《CockroachDB 事务层》中讨论了租约持有者的一些职责。当事务协调器或网关节点想要对某个范围执行读取或写入时,它会找到该范围的租约持有者(使用前一节中讨论的 meta 范围结构),并将请求转发给租约持有者。

租约持有者是通过 Raft 协议分配的,我们将在《CockroachDB 复制层》中讨论该协议。

范围拆分

CockroachDB 会尝试将一个范围保持在 512 MB 以下。当范围超过该大小时,范围将被拆分成两个较小的连续范围。

如果一个范围超过负载阈值,也可以进行拆分。如果参数 kv.range_split.by_load.enabled 为 true,并且每秒查询次数超过 kv.range_split.load_qps_threshold 的值,则即使该范围未超过正常的拆分大小阈值,也可能会进行拆分。是否实际拆分,还取决于其他因素,包括拆分后是否能在两个新范围之间分担负载,以及拆分对查询的影响,查询可能需要跨越新的范围。

基于负载拆分时,两个新范围的大小可能不相等。默认情况下,范围将在负载大致相等的点进行拆分。图 2-14 显示了一个基本的范围拆分示例,当插入导致范围超过 512 MB 阈值时,产生了两个新的范围。

范围也可以通过使用 ALTER TABLE 和 ALTER INDEX 语句中的 SPLIT AT 子句手动拆分。

范围也可以合并。如果 DELETE 语句删除了范围中的数据,并且该范围的大小低于阈值,CockroachDB 可能会将该范围与邻近的范围合并。

多区域分布

地理分区允许将数据定位于特定的地理区域。从性能角度来看,这可能是理想的------减少来自某个区域的查询的延迟;或者从数据主权的角度来看------因法律或监管原因将数据保留在特定的地理区域内。CockroachDB 支持多区域配置,用于控制数据如何在不同区域之间分布。以下核心概念相关:

  • 集群区域:是用户在节点启动时指定的地理区域。
  • 区域可以有多个可用区
  • 超级区域:允许数据驻留,并包含一个或多个区域。
  • 集群中的数据库:会分配到一个或多个区域,其中一个区域是主区域。
  • 数据库中的表:可以有特定的本地性规则(全局、按表区域、按行区域),这些规则决定了数据如何在可用区之间分布。
  • 生存目标:决定数据库能承受多少次同时故障。

对于区域级生存目标,数据库在一个区域完全宕机的情况下仍能保持完全可用,进行读写操作。这当然意味着需要在其他区域维护数据副本,从而增加写入时间。

默认情况下,所有多区域数据库中的表都是区域表------即 CockroachDB 优化了来自单一区域(默认情况下是数据库的主区域)对表数据的访问。按行区域表为来自单一区域的一个或多个行提供低延迟的读写操作。表中的不同行可以为来自不同区域的访问优化。

全局表针对来自所有区域的低延迟读取进行了优化。

CockroachDB 复制层

高可用性要求在节点故障时不会丢失数据或使数据不可用。这当然需要保持数据的多个副本。

最常用的高可用性设计有两种:

  • 主动-被动
    一个单一节点是"主节点"或"活跃节点",其更改会传播到被动的"次节点"或"被动节点"。
  • 主动-主动
    所有节点运行相同的服务。通常,主动-主动的数据库系统是"最终一致性"类型的。由于没有"主节点",冲突的更新可以由不同的节点处理。这些冲突需要解决,可能通过丢弃其中一个冲突的更新来实现。

CockroachDB 实现了一种分布式共识机制,称为多活(multi-active)。像主动-主动一样,所有副本都可以处理流量,但为了接受更新,必须得到大多数投票副本的确认。

并非所有副本都必须参与投票。非投票副本在全球分布式系统中非常有用,因为它们允许在远程区域进行低延迟读取,而不需要该区域在写入时参与共识。这个概念将在第 11 章中详细讨论。

这种架构确保在节点故障时不会丢失数据,且系统仍然可用,只要至少多数节点保持活跃。

CockroachDB 在范围级别实现复制:每个范围都独立于其他范围进行复制。在任何给定时刻,单个节点负责对单个范围的更改,但集群中没有整体的"主节点"。

Raft

CockroachDB 使用广泛应用的 Raft 协议作为其分布式共识机制。在 CockroachDB 中,每个范围是一个独立的 Raft 组------每个范围的共识是独立于其他范围确定的。

在 Raft 和大多数分布式共识机制中,我们需要至少三个节点。这是因为多数节点(法定人数)必须始终就状态达成一致。如果发生网络分区,只有分区中拥有多数节点的一方才能继续工作。

在 Raft 组中,一个节点会通过该组中大多数节点的选举成为领导者。其他节点被称为跟随者。Raft 领导者控制 Raft 组的更改。

发送到 Raft 领导者的更改会被写入其 Raft 日志,并传播给跟随者。当大多数节点接受该更改时,领导者会提交该更改。请注意,在 CockroachDB 中,每个范围都有自己的 Raft 日志,因为每个范围是独立复制的。

领导者选举定期进行,或当某个节点未收到领导者的心跳消息时触发。在后者的情况下,无法与领导者通信的跟随者会声明自己为候选人并发起选举。Raft 包括一套安全规则,防止在选举过程中丢失数据。特别地,候选人不能赢得选举,除非其日志包含所有已提交的条目。

暂时与集群断开的节点可以被发送到 Raft 日志的相关部分以重新同步,或在必要时,通过 Raft 日志进行时点快照并跟进。

Raft 和租约持有者

CockroachDB 的租约持有者和 Raft 领导者职责相似。租约持有者控制对某个范围的访问,以确保事务的一致性和隔离性,而 Raft 领导者控制对该范围的访问,以确保复制和数据安全。

租约持有者是唯一可以向 Raft 领导者提议写入的节点。CockroachDB 会尝试选举出一个既是租约持有者又是 Raft 领导者的节点,以便简化这些通信。租约持有者负责所有写操作和大多数读操作,因此它能够维护必要的内存数据结构,用于调解事务层的读/写冲突。

闭合时间戳和跟随者读取

租约持有者会定期"关闭"一个最近的时间戳,确保不会接受时间戳较低的新的写入操作。

这个机制还允许跟随者读取。通常,读取操作必须由副本的租约持有者处理。这可能会很慢,因为租约持有者可能与发出查询的网关节点地理位置较远。跟随者读取是从最近的副本读取,而不管该副本的租约持有者状态。这在地理分布广泛的多区域部署中可以显著改善延迟。

如果查询使用了 AS OF SYSTEM TIME 子句,则网关会将请求转发到包含数据副本的最近节点------无论该副本是跟随者还是租约持有者。查询中提供的时间戳(即 AS OF SYSTEM TIME 值)必须小于或等于节点的闭合时间戳。这允许跟随者提供一致的读取操作,读取的是最近的过去(即几秒钟前的数据)。

多区域数据库中的全局表使用一种称为非阻塞事务的特殊事务协议,这种协议优化了读取操作(来自任何副本),以牺牲写入操作为代价。在这种模式下写入表的操作会分配未来的时间戳,未来的时间戳可能会被关闭。这使得跟随者能够提供当前时间的一致性读取。

CockroachDB 存储层

我们在本章早些时候讨论存储时触及了 KV 存储的逻辑结构。然而,我们还没有深入探讨 KV 存储引擎的物理实现。

自 CockroachDB v20.2 起,CockroachDB 使用 Pebble 存储引擎------一个开源的 KV 存储引擎,灵感来自 LevelDB 和 RocksDB 存储引擎。Pebble 主要由 CockroachDB 团队维护,并专门针对 CockroachDB 的用例进行了优化。旧版本的 CockroachDB 使用 RocksDB 存储引擎。

让我们来深入了解 Pebble 存储引擎的内部工作原理,从而充分理解 CockroachDB 如何在其基础层面存储和操作数据。

日志结构合并树(LSM)

Pebble 实现了日志结构合并(LSM)树架构。LSM 是一种广泛实现且经过多次实战检验的架构,旨在优化存储并支持极高的插入速率,同时仍能支持高效的随机读取访问。

最简单的 LSM 树由两个索引的"树"组成:

  1. 一个内存中的树,接收所有新的记录插入------MemTable。
  2. 多个磁盘上的树,代表已刷新到磁盘的内存树副本。这些被称为排序字符串表(SSTables)。

SSTables 存在于多个级别,从 L0 到 L6(L6 也被称为基础级别)。L0 包含一组无序的 SSTables,每个 SSTable 只是一个已刷新到磁盘的 MemTable 的副本。周期性地,SSTables 会被压缩成更大的合并存储,在更低级别的存储中。除了 L0 级别,其他级别的 SSTables 是有序且不重叠的,因此每个级别中的一个 SSTable 只能包含一个特定的键。

SSTables 在内部是排序和索引的,因此在 SSTable 中的查找是快速的。

基本的 LSM 架构确保了写入操作始终是快速的,因为它们主要在内存速度下进行,尽管通常还会有一个磁盘上的顺序写日志(WAL)。写入到磁盘上的 SSTables 也很快,因为它是通过快速的顺序写操作进行的附加批量处理。读取操作可以从内存树或磁盘树中进行;无论是哪种情况,读取都由索引支持,并且相对较快。

当然,如果节点在数据处于内存存储时发生故障,那么这些数据可能会丢失。为此,LSM 模式的数据库实现包括一个 WAL,它将事务持久化到磁盘。WAL 通过快速的顺序写操作进行写入。

图 2-15 展示了 LSM 写入操作。来自 CockroachDB 更高层次的写入首先应用到 WAL(1),然后应用到 MemTable(2)。一旦 MemTable 达到一定大小,它就会被刷新到磁盘,创建一个新的 SSTable(3)。刷新完成后,WAL 记录可能会被清除(4)。多个 SSTables 会定期合并(压缩)成更大的 SSTables(5)。

压缩过程会导致多个"级别"------Level 0(L0)包含未压缩的数据。每次压缩都会在更深的级别创建一个文件------通常有 7 个级别(L0--L6)。

SSTables 和布隆过滤器

每个 SSTable 都是被索引的。然而,磁盘上可能有许多 SSTables,这会使索引查找的开销增加,因为理论上我们可能需要检查每个 SSTable 中的每个索引,以找到我们想要的行。

为了减少多次索引查找的开销,使用了布隆过滤器来减少必须执行的查找次数。布隆过滤器是一种紧凑且易于维护的结构,可以快速告诉你一个给定的 SSTable "可能"包含一个值。CockroachDB 使用布隆过滤器快速确定哪些 SSTables 包含某个键的版本。布隆过滤器足够紧凑,可以放入内存并且导航速度很快。然而,为了实现这种压缩,布隆过滤器是"模糊的",可能会返回假阳性。如果布隆过滤器返回阳性结果,这仅意味着文件可能包含该值。然而,布隆过滤器永远不会错误地告诉你一个值不存在。所以,如果布隆过滤器告诉我们某个键不包含在特定的 SSTable 中,我们就可以安全地从查找中省略该 SSTable。

图 2-16 展示了 LSM 的读取模式。数据库请求首先从 MemTable 中读取(1)。如果没有找到所需的值,它将检查 L0 中所有 SSTable 的布隆过滤器(2)。如果布隆过滤器表明没有匹配的值,它将检查每个后续级别中覆盖给定键的 SSTable(3)。如果布隆过滤器表明 SSTable 中可能存在匹配的 KV,则过程将使用 SSTable 索引(4)在 SSTable 中查找该值(5)。一旦找到匹配的值,就不需要检查更旧的 SSTables 了。

删除和更新

SSTables 是不可变的------一旦 MemTable 被刷新到磁盘并成为 SSTable,就不能对该 SSTable 进行进一步修改。如果一个值在一段时间内被多次修改,这些修改将跨多个 SSTable 累积。在检索一个值时,系统将从最新到最旧地读取 SSTable,以找到某个键的最新值。因此,要更新一个值,我们只需要插入新值,因为当存在新版本时,旧的值不会被检查。

删除操作是通过在 MemTable 中写入墓碑标记来实现的,这些墓碑标记最终会传播到 SSTable。当遇到某行的墓碑标记时,系统停止检查较旧的条目,并向应用程序报告"未找到"。

随着 SSTables 的增多,读取性能和存储将会退化,因为布隆过滤器、索引和过时的值的数量会增加。在压缩过程中,跨多个 SSTable 的行会被合并,并删除已删除的行。墓碑标记会被保留,直到它们被压缩到基础级别 L6。

多版本并发控制(MVCC)

我们在《MVCC 原则》中介绍了 MVCC 作为事务层的逻辑元素。CockroachDB 将 MVCC 时间戳编码到每个键中,以便将多个 MVCC 版本的键作为 Pebble 中的不同键存储。然而,之前介绍的布隆过滤器不包括 MVCC 时间戳,因此查询不需要知道确切的时间戳就能查找记录。

CockroachDB 会删除超过配置变量 gc.ttlseconds 的记录,但不会删除任何受保护时间戳覆盖的记录。受保护的时间戳是由长时间运行的任务(如备份)创建的,这些任务需要能够获得数据的一致视图。

块缓存

Pebble 实现了一个块缓存,用于提供对频繁访问的数据项的快速访问。这个块缓存与内存中的索引、布隆过滤器和 MemTables 是分开的。块缓存基于最近最少使用(LRU)算法工作------当添加新数据项到缓存时,最近最少访问的数据项将从缓存中驱逐。

从块缓存读取跳过了扫描多个 SSTable 和相关布隆过滤器的过程。我们将在第 14 章中讨论缓存时,进一步讲解集群优化。

总结

在本章中,我们概述了 CockroachDB 的基本架构元素。虽然深入理解 CockroachDB 架构在进行高级系统优化或配置时很有帮助,但它并不是使用 CockroachDB 系统的前提条件。CockroachDB 包含许多复杂的设计元素,但其内部复杂性并不会反映在其用户界面中------您可以在不掌握本章架构概念的情况下,愉快地开发 CockroachDB 应用。

在集群级别,CockroachDB 部署由三个或更多对称节点组成,每个节点都携带完整的 CockroachDB 软件堆栈,并且每个节点都可以处理任何数据库客户端请求。CockroachDB 表中的数据被拆分为 512 MB 的范围,并分布在集群的节点上。每个范围至少复制三次。

CockroachDB 软件堆栈由五个主要层次组成:

  1. SQL 层:接受 PostgreSQL 网络协议的 SQL 请求,解析和优化 SQL 请求,并将请求转换为可以由下层处理的 KV 操作。
  2. 事务层:负责确保 ACID 事务和事务隔离性,确保事务看到一致的数据视图,且修改就像一次次执行的那样进行。
  3. 分布层:负责将数据分割成范围并分配这些范围到集群中,管理范围租约并分配租约持有者。
  4. 复制层:确保数据在集群中正确复制,以保证在节点故障时的高可用性,实现分布式共识机制,确保所有节点就任何数据项的当前状态达成一致。
  5. 存储层:负责将数据持久化到本地磁盘并处理对这些数据的低级查询和更新。

在下一章中,我们将愉快地抛开复杂的 CockroachDB 架构,专注于开始使用 CockroachDB 系统这一简单的任务。

相关推荐
uhakadotcom10 小时前
视频直播与视频点播:基础知识与应用场景
后端·面试·架构
沉登c13 小时前
第 3 章 事务处理
架构
数据智能老司机16 小时前
CockroachDB权威指南——CockroachDB SQL
数据库·分布式·架构
数据智能老司机16 小时前
CockroachDB权威指南——开始使用
数据库·分布式·架构
松果猿16 小时前
空间数据库学习(二)—— PostgreSQL数据库的备份转储和导入恢复
数据库
c无序16 小时前
【Docker-7】Docker是什么+Docker版本+Docker架构+Docker生态
docker·容器·架构
无名之逆17 小时前
Rust 开发提效神器:lombok-macros 宏库
服务器·开发语言·前端·数据库·后端·python·rust
s91236010117 小时前
rust 同时处理多个异步任务
java·数据库·rust
IT成长日记17 小时前
【Kafka基础】Kafka工作原理解析
分布式·kafka