CockroachDB|CockroachDB: The Resilient Geo-Distributed SQL Database 论文阅读笔记

前言 本文绝大部分内容源自 CockroachDB 论文。
介绍 CockroachDB 是一个可扩展(Scalable)的 SQL DBMS,它主要用于支撑 OLTP 工作流,并保持高可用性(High Availability)和强一致性(Strong Consistency)。论文主要呈现了 CockroachDB 创新的事务模型,它在商用硬件上支持一致(Consistent)的分布式事务。
论文在 Introduction 提出了一个情景:一个公司在欧洲和澳洲有一个大用户基数,并且美国的用户数也在迅速上升。为了赋能其全球平台、降低操作消耗,公司迁移到云上的 DBMS。为了遵守 EU 的法规,必须将用户数据保存在 EU。为了避免跨大陆导致的高延迟,数据必须保存在用户附近,并且随用户移动而移动。用户期望服务总是可用的,于是 DBMS 必须可以容错,甚至在一个地区的服务崩溃时仍然存活。最后,为了避免数据异象、简化应用部署,DBMS 必须支持可串行化(Serializable)的 SQL。
CRDB 具有以下特性:

  1. 容错性和高可用性;CRDB 为每份数据维护至少三个副本,这保证了高可用性和自动恢复。
  2. 地理分布分区和副本放置;
  3. 高性能事务。
系统概要 CRDB 架构
CRDB 是一个标准的 shared-nothing 架构——每个节点都用于数据存储和数据计算。
在一个具体的节点中,CRDB 采用一个分层架构:
  1. SQL Layer,包括 parser, optimizer, SQL 执行引擎(将 SQL 语句转化为低层次的键值对读写请求,并提供给底层的 KV store),SQL layer 不知道数据是怎么分区和分布的,因为下层提供了一个单体(monolithic) KV store 的抽象;
  2. Transactional KV Layer,确保横跨多个键值对的原子修改。它负责了大部分 CRDB 隔离性保证工作;
  3. Distribution Layer,给上层呈现了一个根据键排序的逻辑键空间抽象。无论是系统数据还是用户数据都是可以被寻址的。CRDB 根据键将数据划分成一个个 64MB 的 Range,这些 Range 分布在整个集群中。一个存在 system Range 中的两级索引维护 Range 之间的顺序。Distribution Layer 负责分辨哪些 Range 应该处理哪个请求子集,并将这些请求路由到它们那里。
  4. Replication Layer,默认每个 Range 都被复制三份;
  5. Storage Layer. 代表一个本地磁盘支撑的 KV store. 之前用的是 RocksDB,现在已经换成自研的 Pebble.
容错性和高可用
用 Raft 进行复制 CRDB 用 Raft 共识算法。一个 Range 的各 replica 组成一个 Raft group,一个 replica 要么是负责协调写操作的 leader,要么是一个 follower。CRDB 以 command 为复制最小单位,它代表着对存储层的一系列低层次操作。
CRDB 用 Range 层次的租约,Raft group 中一个持有租约的 replica 扮演者 leaseholder 的角色。只有 leaseholder 可以提供权威的、最新的读操作,或者向 Raft group leader 提出写操作。因为所有写操作经过 leaseholder,读操作可以绕过 Raft 共识所需的网络往返时间,而不牺牲一致性。每个 Range 的租约绑定到 leaseholder 所在节点的活性(liveness)。为了表明活性,一个 node 必须每 4.5 秒向一个 System Range 中的一个特别记录发送心跳。而 System Range 用基于过期机制的租约,它必须每 9 秒被更新一次。如果一个 replica 发现 leaseholder 不活跃,它将尝试获取租约。
为了保证每段时间只有一个 replica 持有租约,lease 获取信息由 Raft 捎带。尝试获取租约的 replica 必须提交一个特殊的获取租约日志项。租约获取请求中包含一个请求发起时认为是有效的租约的拷贝,以防止两个 replica 获取到时间重叠的 lease。
成员变更、负载均衡 Raft 确保了一个 leader 发生故障后能够选举出新的 leader,从而事务得以继续进行。从故障中恢复的 replica 也可以重新加入,并且其他节点将帮助其赶上丢失的更新。
对于时间较长的故障,CRDB 自动为缺少的 Range 创建新的 replica。做出这些决定所需的节点活性和集群指标是用一个点对点的 gossip protocol 实现的。
副本放置 CRDB 提供了自动、手动两种机制控制副本放置。
  1. 手动控制,用户给单一节点配置属性:节点能力、节点地理位置等。在数据库中创建表时,用户可以声明放置约束、偏好,作为表 schema 的一部分;
  2. 自动控制,CRDB 将副本横跨多个失败域(failure domain),CRDB 同样用多种启发式方法来平衡负载和磁盘利用率。
数据放置策略
  • 地理分割副本
  • 地理分隔 leaseholder
  • 复制索引
事务 概览 一个 SQL 事务从 gateway 节点开始,该节点负责接收请求并进行响应(对事务进行策划,最终提交/中止)。gateway 节点上有一个协调者(coordinator),它利用了两种优化:写流水线(Write Pipelining)和并行提交(Parallel Commit)。
  • 写流水线让操作命令复制完成前返回结果;
  • 并行提交让提交操作和写流水线并行复制。
上述两者结合让一个包含多个 SQL 语句的事务只需一回合的 replication即可完成。
为了完成上述优化,coordinator 追踪可能还未复制完成的操作。它也维护了事务的时间戳,它被初始化为当前时间,但是在事务执行过程中该时间戳可能向前移动。因为 CRDB 用 MVCC,时间戳被选定为事务执行读写操作的那一刻,以后,该时间戳能被其它事务看到。
写流水线 一个操作包含需要读取或者写入的 key,以及元数据,指示该事务是否应该在当前操作提交。当一个操作不打算提交,如果它不和该事务先前的操作重叠(即没有前后依赖关系),可以立刻执行它。这样对于不同 key 的多个操作可以被流水执行。如果一个操作依赖于在它之前的、仍在执行的(in-flight)操作,必须等这些 in-flight 操作完成后再执行当前操作。
之后 coordinator 将操作发送到 leaseholder 执行,并等待其响应。响应可能包含了一个更大的时间戳,这是因为另一个事务的读操作迫使 leaseholder 调整操作的时间戳。coordinator 随后尝试调整事务的时间戳。 coordinator 需要验证在新的时间戳下所有的读操作返回的值与老时间戳下返回的值相同。如果不同,事务失败,需要重试。
并行提交 笨方法是,当一个事务的所有操作都 replicate 完成后,再发送一个提交操作,让 Raft 对该操作进行 replicate。这样至少需要两轮共识。并行提交采用了一个称为 STAGING 的事务状态,让事务的真实状态取决于是否它的所有写操作都已 replicate。这避免了额外一轮共识:因为协调者可以同时开始对写操作和事务的 STAGING 状态进行 replicate。假设这些操作都成功,协调者可以立刻向 SQL Layer 确认事务提交。
事务协调者的代码逻辑:
CockroachDB|CockroachDB: The Resilient Geo-Distributed SQL Database 论文阅读笔记
文章图片

leaseholder 的执行逻辑 当一个 leaseholder 接收到一个操作,他首先检查其租约有效,然后获取 op 操作的 key 的 latch 以及其 op 依赖的操作的 latch,这确保了并发、重叠请求的互斥性。然后它验证 op 依赖的操作都已完成。如果它执行写操作,它必须保证写操作在冲突的读操作时间戳之后,并在必要时增大该写操作时间戳,确保不会使得其它事务的读操作不合法。
当上述检查完成后,leaseholder 估计(evaluate)此操作,以确定需要在存储引擎作出什么修改,但不真的做出这些修改。evaluation 结果是一系列详细描述修改操作的低层次命令,以及对给 client 的响应(写操作响应 success,读操作返回读出的值)。如果该操作不是提交事务,leaseholder 可以立刻回复 coordinator,而不用等待 replicate 完成。
leaseholder 可能在处理操作过程中遇到其它未提交事务的写操作,并且这个写操作与当前操作的时间戳太接近,导致无法确定事务的顺序。(在下面讨论这种情况)
leaseholder 的代码逻辑:
CockroachDB|CockroachDB: The Resilient Geo-Distributed SQL Database 论文阅读笔记
文章图片

原子性保证 通过在提交前把一个事务的所有写操作看作临时的实现原子性。CRDB 将这些临时的写操作称为 write intent,一个 intent 实际上就是一个常规的 MVCC 键值对,除了会在它开头的元数据中说明它是一个 intent。这个元数据指向一个 transaction record,它是一个特别的 key,存储了当前事务的状态。transaction record 用于原子地修改所有 intent 的可见性。transaction record 存储的位置与事务中涉及的第一个 key 所在的 Range 相同。coordinator 周期性发送心跳到 transaction record,以向竞争事务保证,它仍在取得进展。
在遇到一个 intent 时,一个读取 intent 指向的 transaction record,如果 transaction record 指出事务处于 PENDING 状态,读者阻塞并等待它完成。如果 coordinator 故障,竞争事务将发现其 transaction record 过期,将其标注为 ABORTED。(此处省略 COMMITTED 和 ABORTED 分析) 如果 transaction record 处于 STAGING 状态(这指出了事务要么已提交,要么中止,但读者不知道是哪种),读者尝试防止该事物的其中一个写操作 replicate,使其中止。如果所有写操作都已 replicate 完成,事务实际上已经提交。
并发控制 前面说到,CRDB 是一个多版本并发控制系统,每个事务都在其提交时间戳执行所有读、写操作。一下描述这样做可能出现的情况。每当一个事物的提交时间戳改变时,事务通常尝试证明它之前读取的值在新时间戳仍是有效的,这样它只需调整时间戳即可。
写-读冲突 一个读遇到一个时间戳更低的 intent 时,会等待该 intent 的事务结束。一个读遇到一个时间戳更高的 intent 时只需忽略它。
读-写冲突 一个写遇到一个时间戳更高的读时不能继续执行。CRDB 强制写时间戳高于读时间戳。
写-写冲突 一个写遇到一个时间戳更低的写时需要等待它对应的事务结束。如果它遇到一个时间戳更高的写操作,它提高自己的时间戳,使之高于另一个写的时间戳。写-写冲突可能导致死锁。CRDB 运用一种死锁监测算法中止一个事务,打破依赖图。
Read Refresh
CRDB 允许非 leaseholder replica 处理时间戳足够老的只读请求。一个非 leaseholder 节点在执行在时间戳 T 的读操作时,需要确定未来的写不能使得该读操作读出的数据不合法。它同样需要确保它有所有处理读请求必须的数据。
为了实现上述功能,leaseholder 追踪所有到达的请求的时间戳,周期性发出 closed timestamp,表明 leaseholder 不会接受低于该时间戳的写操作。closed timestamp 伴随在 Raft 日志中,随其 replicate 到每个 replica 中。
每个节点记录到其他节点的延迟。当一个节点收到一个足够老的读请求时(closed timestamp 通常落后当前时间约两秒),它将请求转发到具有该数据副本的最近的节点。
时钟同步 CRDB 不依赖特种硬件进行时钟同步。他可以用软件级别的时钟同步服务,例如 NTP 或 Amazon Time Sync Service。
CRDB 用 hybrid-logical clock cheme (以下简称 HLC)实现时间戳排序。
HLC
每个 CRDB 的节点维护一个 HLC,该时间戳是一个物理时间和逻辑时间的结合。物理时间基于节点的系统时钟,逻辑时间基于 Lamport 时钟
一个集群内的 HLCs 配置了一个本地物理时间和其他 HLC 的最大允许偏移。该偏移默认设置为 500ms。 HLC 提供了一些重要的特性:
  1. HLC 在每个节点间交换时通过其逻辑组件提供因果关系跟踪。节点将 HLC 时间戳附到每个发送的消息上,并用每个收到的消息的 HLC 时间戳更新其本地时钟。在多个节点间确定因果关系对于实施不变性非常关键。其中最重要的是:租约不相交性,即每个对于每个 Range,其租约区间应该是互不相交的。在合作租约移交时,通过 HLC 进行因果关系转移强制执行;在非合作租约获取时,通过延迟一个租约区间之间的最大时钟偏移强制执行。
  2. HLC 在一个节点重启时,重启前后仍提供严格单调性(strict monotonicity)。在重启中,通过在启动时等待一个最大时钟偏移,再开始处理请求,实现该单调性。
  3. HLC 在出现短暂的时钟偏移震荡时仍提供自稳性(self-stabilization)。当发生足够的集群内通讯时,HLC 被充分交换,HLC 趋于收敛。
不确定区间
【CockroachDB|CockroachDB: The Resilient Geo-Distributed SQL Database 论文阅读笔记】Serializability 本身并没有说明系统中的事务顺序与实时顺序的关系。
正常情况下,CRDB 满足单键的 linearizability,stale read 不可能发生。 前提条件是,各时钟直接误差在配置的最大时钟偏移内。CRDB 不支持严格的 Serializability,因为无法保证操作互不相干的 key 的事务的正确顺序。 这将不是个问题,除非外部的两个 client 操作完 CRDB 后立刻通过一个低延迟的通道进行通讯。
单键 linearizability 特性通过 CRDB 为每个事务追踪一个 uncertainty interval,在 uncertainty interval 内,两个相关的事务的因果关系是不能被确定的。事务在创建时,被赋予一个来自 coordinator 本地 HLC 的临时提交时间戳 commit_ts,以及一个不确定区间 [commit_ts, commit_ts + max_offset]。
当一个事务遇到一个键的值,且其时间戳低于 commit_ts,它在读取时查看值,并以时间戳更高的值覆写它。如果事务能够访问一个绝对同步时钟,这样做就足以实现单键 linearizability。
在没有绝对同步时钟时,uncertainty interval 是需要的,因为一个事务可能收到一个临时提交时间戳,且该时间戳相比 commit_ts 早 max_offset。当一个事务遇到一个临时提交时间戳更高,但是在其 uncertainty interval 以内的 value 时,它执行一个 uncertainty restart,将其临时提交时间戳移动到该 value 之上,但是保持其不确定区间的上限不变。
这相当于把一个事务 uncertainty interval 内的所有值当成过去的写。
时钟歪斜下的行为
考虑时钟偏移超出界线时的行为。
在一个 Range 中,通过 Raft 保证一致性。因为 Raft 不依赖于时钟,所以一个 Range 内的修改是 linearizable 的。如果所有的读操作和写操作都被写到 Raft log 里面,这就足以确保保证一致性。然而,Range lease 的存在让一个读操作可以不经过 Raft,当时钟歪斜足够大时,可能多个节点都认为自己持有一个 Range 的租约,当没有额外的保护措施,可能导致两个互相矛盾的操作被两个 leaseholder 接受。
CRDB 用了两个保护措施。
  1. Range 租约包含一个起始和结束时间戳。一个 leaseholder 不能处理 MVCC 时间戳高于其租约区间的读请求,以及 MVCC 时间戳在租约区间之外的写请求。又由上文说到的租约区间不相交性,一个请求只能落到其中一个租约区间,即只会有一个 leaseholder 接受该请求。
  2. 每次写入 Range 的 Raft 日志都包括其提议的 Range 租约的序列号。在 replicate 成功后,用序列号对比当前活跃的租约。如果不同,拒绝之。因为租约变更本身会被写到该 Range 的 Raft log 里,同一时间只有一个 leaseholder 能够对 Range 进行修改。即使多个节点同时认为它们有有效的 lease,这也成立。
这两条措施保证了两个同时活跃的 leaseholder 不能处理违反可串行化隔离性的请求。第一个措施确保一个即将上任的 leaseholder 不会处理一个写请求,使得即将离任的 leaseholder 处理的读请求无效。第二条措施确保一个即将离任的 leaseholder 不会处理一个读请求,使得即将上任的 leaseholder 处理的读/写请求无效。
这两条措施确保了即使有严重的时钟歪斜,违反了最大时钟偏移界限,CRDB 仍提供可串行化隔离性。
尽管时钟歪斜时仍能维护隔离性,超出配置的时钟偏移界限的时钟歪斜可能会导致在因果依赖的事务之间违反单键 linearizability。当事务由不同的 gateway 节点发起,它们的时钟歪斜超过了最大偏移界限。如果第二个事务的 gateway 节点被赋予一个 commit_ts_T2 < commit_ts_T1 - max_offset,有可能会让第一个事务写的值在 T2 的不确定区间之外,这让 T2 可以读取 T1 要覆写的那些值,从而产生 stale read.
各节点周期性测量相对其它节点的时钟偏移。如果一个节点超过配置最大偏移的 80%,它将自行停止。
SQL SQL 数据模型
每个 SQL 表和索引被存到一个或多个 Range 中。所有的用户数据被存到一个或多个排序索引,也被指定为主索引。主索引以主键为 key,其他所有列存在 value 里。辅助索引以索引键为键,并存储主键列以及索引 schema 指定的任意数量的附加列。CRDB 同样支持哈希索引。
Query Optimizer
SQL query planning 由瀑布式的 Query Optimizer 执行,以探索可能的查询执行空间。
Query Optimizer 用的转换规则由 DSL Optgen 编写。此 Query Optimizer 能感知数据分布。
Query Planning 和 Execution
CRDB 中 SQL 查询执行有两种模式:
  1. gateway-only mode,编排 Query Plan 的节点负责该查询的所有 SQL 处理;
  2. distribution mode,集群中其它节点能参与到 SQL 处理中。
在一个数据流中,CRDB 根据输入基数、计划复杂度采用两种执行引擎之一:Volcano 引擎、向量化引擎。
表模式变更
CRDB 采用了 F1 的解决方案:将每一个 schema 变更分解成一系列的增量变更。在这个协议中,添加一个副主索引需要两个过渡的 schema 版本,在集群中确保在它可用于读之前,索引在写时被更新。如果我们强制实施不变量:一个 schema 最多只能有两个连续版本在集群中使用,数据库就能够在整个 schema change 中保持一致性。

    推荐阅读