本文绝大部分内容源自 CockroachDB 论文。
CockroachDB 是一个可扩展(Scalable)的 SQL DBMS,它主要用于支撑 OLTP 工作流,并保持高可用性(High Availability)和强一致性(Strong Consistency)。论文主要呈现了 CockroachDB 创新的事务模型,它在商用硬件上支持一致(Consistent)的分布式事务。
论文在 Introduction 提出了一个情景:一个公司在欧洲和澳洲有一个大用户基数,并且美国的用户数也在迅速上升。为了赋能其全球平台、降低操作消耗,公司迁移到云上的 DBMS。为了遵守 EU 的法规,必须将用户数据保存在 EU。为了避免跨大陆导致的高延迟,数据必须保存在用户附近,并且随用户移动而移动。用户期望服务总是可用的,于是 DBMS 必须可以容错,甚至在一个地区的服务崩溃时仍然存活。最后,为了避免数据异象、简化应用部署,DBMS 必须支持可串行化(Serializable)的 SQL。
CRDB 具有以下特性:
CRDB 是一个标准的 shared-nothing 架构——每个节点都用于数据存储和数据计算。
在一个具体的节点中,CRDB 采用一个分层架构:
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 提供了自动、手动两种机制控制副本放置。
一个 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 确认事务提交。
当一个 leaseholder 接收到一个操作,他首先检查其租约有效,然后获取 op 操作的 key 的 latch 以及其 op 依赖的操作的 latch,这确保了并发、重叠请求的互斥性。然后它验证 op 依赖的操作都已完成。如果它执行写操作,它必须保证写操作在冲突的读操作时间戳之后,并在必要时增大该写操作时间戳,确保不会使得其它事务的读操作不合法。
当上述检查完成后,leaseholder 估计(evaluate)此操作,以确定需要在存储引擎作出什么修改,但不真的做出这些修改。evaluation 结果是一系列详细描述修改操作的低层次命令,以及对给 client 的响应(写操作响应 success,读操作返回读出的值)。如果该操作不是提交事务,leaseholder 可以立刻回复 coordinator,而不用等待 replicate 完成。
leaseholder 可能在处理操作过程中遇到其它未提交事务的写操作,并且这个写操作与当前操作的时间戳太接近,导致无法确定事务的顺序。(在下面讨论这种情况)
通过在提交前把一个事务的所有写操作看作临时的实现原子性。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 运用一种死锁监测算法中止一个事务,打破依赖图。
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)实现时间戳排序。
每个 CRDB 的节点维护一个 HLC,该时间戳是一个物理时间和逻辑时间的结合。物理时间基于节点的系统时钟,逻辑时间基于 Lamport 时钟。
一个集群内的 HLCs 配置了一个本地物理时间和其他 HLC 的最大允许偏移。该偏移默认设置为 500ms。 HLC 提供了一些重要的特性:
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 用了两个保护措施。
这两条措施保证了两个同时活跃的 leaseholder 不能处理违反可串行化隔离性的请求。第一个措施确保一个即将上任的 leaseholder 不会处理一个写请求,使得即将离任的 leaseholder 处理的读请求无效。第二条措施确保一个即将离任的 leaseholder 不会处理一个读请求,使得即将上任的 leaseholder 处理的读/写请求无效。
这两条措施确保了即使有严重的时钟歪斜,违反了最大时钟偏移界限,CRDB 仍提供可串行化隔离性。
尽管时钟歪斜时仍能维护隔离性,超出配置的时钟偏移界限的时钟歪斜可能会导致在因果依赖的事务之间违反单键 linearizability。当事务由不同的 gateway 节点发起,它们的时钟歪斜超过了最大偏移界限。如果第二个事务的 gateway 节点被赋予一个 commit_ts_T2 < commit_ts_T1 - max_offset,有可能会让第一个事务写的值在 T2 的不确定区间之外,这让 T2 可以读取 T1 要覆写的那些值,从而产生 stale read.
各节点周期性测量相对其它节点的时钟偏移。如果一个节点超过配置最大偏移的 80%,它将自行停止。
每个 SQL 表和索引被存到一个或多个 Range 中。所有的用户数据被存到一个或多个排序索引,也被指定为主索引。主索引以主键为 key,其他所有列存在 value 里。辅助索引以索引键为键,并存储主键列以及索引 schema 指定的任意数量的附加列。CRDB 同样支持哈希索引。
SQL query planning 由瀑布式的 Query Optimizer 执行,以探索可能的查询执行空间。
Query Optimizer 用的转换规则由 DSL Optgen 编写。此 Query Optimizer 能感知数据分布。
CRDB 中 SQL 查询执行有两种模式:
在一个数据流中,CRDB 根据输入基数、计划复杂度采用两种执行引擎之一:Volcano 引擎、向量化引擎。
CRDB 采用了 F1 的解决方案:将每一个 schema 变更分解成一系列的增量变更。在这个协议中,添加一个副主索引需要两个过渡的 schema 版本,在集群中确保在它可用于读之前,索引在写时被更新。如果我们强制实施不变量:一个 schema 最多只能有两个连续版本在集群中使用,数据库就能够在整个 schema change 中保持一致性。