CockroachDB 的思路源自 Google 的全球性分布式数据库 Spanner。其理念是将数据分布在多数据中心的多台服务器上,实现一个可扩展,多版本,全球分布式并支持同步复制的数据库。具体关于Spanner可查看上一篇博客全球级分布式数据库Google Spanner
一、概述
CockroachDB是一个分布式关系型数据库,主要设计目标是可扩展,强一致和高可靠 。CockroachDB旨在无人为干预情况下,以极短的中断时间容忍磁盘、主机、机架甚至整个数据中心的故障 。 CockroachDB采用完全去中心化架构,集群中各个节点的地位完全对等,同时所有功能封装在一个二进制文件中,可以做到尽量不依赖配置文件直接部署。
CockroachDB对外提供标准SQL接口,集群中任意节点都可以作为接入节点处理用户的SQL请求。接入节点把SQL请求转换为KV操作,并且在必要时将该操作发送至其它节点进行处理,完成后将结果返回给客户端。CockroachDB底层将数据组织成有序的Key-Value对形成一个KV map,其中Key和Value均为字节串。
KV map逻辑上按照范围被切分成大量的Key空间,每个Key空间称为Range。每个Range数据由本地KV存储引擎(RocksDB,LevelDB的变体)存储。每个Range被复制多份分布到多个CockroachDB节点上,Range副本数量可配置。每个Range默认大小为64M,合理的Range大小有利于加速节点故障恢复和扩容,及均衡读写负载。Range大小应该根据系统压力进行设置,以便管理更多Range。
CockroachDB支持水平扩展:
- 添加更多节点可以提升整个集群的存储容量,理论上最大可以支撑4EB的数据存储;
- 客户端的查询请求可以发送到集群任意节点,且每个查询可独立并发执行(无论有无冲突),意味着集群的吞吐能力可以随着节点数的增加线性提升。
- 查询以分布式任务的方式在各个数据节点并发执行,可以通过增加节点数来提升单个查询的性能。
CockroachDB支持强一致性:
- Range的多个副本之间使用一致性协议——Raft同步数据,所有一致性状态都存储在RocksDB中。
- 对同一个Range内数据的单一或批量修改,由Raft保证Range操作的ACID语义。
- 涉及多个Range的操作,CockroachDB使用高效的无锁分布式事务保障ACID语义。
CockroachDB支持高可用:
- 将Range副本分布在一个数据中心,可以确保低延迟复制,同时能容忍磁盘或机器故障。如果将副本分布在不同机架,即使某些网络交换机故障,CockroachDB仍可提供服务。
- Range副本可以跨数据中心和跨地域分布,以应对来自数据中心电源中断或网络中断,以及区域电力故障等问题。
(多数据中心跨地域的例子:{ US-East-1a, US-East-1b, US-East-1c }, {US-East, US-West, Japan }, { Ireland, US-East, US-West}, { Ireland, US-East,US-West, Japan, Australia })
CockroachDB 提供快照隔离 (SI) 和 串行快照隔离 (SSI) 两种隔离级别,基于历史快照时间和当前时间,提供外部一致的无锁读写。SI提供无锁读写,但是存在写偏序问题(write skew); SSI消除了写偏序,但在竞争激烈的系统中会存在性能问题。SSI是默认的隔离级别,用户须根据实际性能情况,选择合适的隔离级别。CockroachDB只提供有限的linearizability(严格的顺序一致性)。
类似于Spanner的Directory,CockroachDB允许根据复制策略、存储设备类型或者数据中心位置把集群划分成任意个数据区域(Zone),以提升集群性能和可用性。与Spanner不同的是,区域是一个整体,不存在子区域。
二、架构
CockroachDB采用分层架构,最顶层是SQL层。CockroachDB在SQL层沿用了传统关系型数据库的概念,如schema, table, column和 index。同时,SQL层构建于分布式KV存储之上,后者负责Range路由寻址,提供统一的key-value存储。分布式KV存储可以由任意数量的CockroachDB物理节点组成,每个节点包含一个或多个Store(通常一个Store独占一块磁盘)。
每个Store包含多个Range,Range为KV层数据管理的最小单元,每个Range的多个副本之间使用Raft协议进行同步。如下图所示,每个Range有3个副本,同一Range的副本用相同颜色标识,副本之间使用Raft协议同步。
每个物理节点都提供两组基于RPC实现的key-value API:一组用于外部客户端调用(注:目前对外的KV接口已被关闭);另一组用于集群内部节点间交互。两者都支持批量请求和应答。所有节点均使用相同的二进制包部署,每个节点均提供相同的功能和接口,无角色差异。
节点和Range可以根据不同的物理网络拓扑结构进行编排,从而在可靠性和性能之间折衷。例如,一个Range包含三个副本,每个副本可以位于不同的位置:
- 如果副本分布于同一台服务器上的多块磁盘,可以容忍单块磁盘故障。
- 如果副本分布于同一机架上的不同服务器,可以容忍单台服务器故障。
- 如果副本分布于同一个数据中心不同机架,可以容忍单个机架电源和网络故障。
- 如果副本分布于不同数据中心,可以容忍大规模网络中断或断电。
N为总副本数,F为可容忍故障副本数,则N=2F+1。(例如,三副本可以容忍一个副本故障,五副本则可以容忍两个副本故障,以此类推)
三、关键字
CockroachDB key可以是任意字节数组。key有两种类型:系统表key和用户表key。系统表key被CockroachDB用于内部数据结构和元数据。用户表key包含用户表数据(以及索引数据)。系统表key和用户表key通过前缀区分,并保证系统表key始终小于用户表key。
系统表key有以下几种类型:
- Global key存储集群级别的数据,例如“meta1”和“meta2” ,以及系统级别的key,例如节点和Store 的ID标识。
- Store local key用于标识该Store的本地元数据(例如,StoreIdent结构),该部分元数据与所处Store生命周期相关,因此无需复制,即不会通过Raft同步到其它Store。
- Range local key存储Range的元数据,并与Global key(实际存储层的全局Key)相关联。Range local key由一个特殊前缀,加Global key及一个特殊后缀组成。例如,事务记录是Range local key,形如: \x01k<global-key>txn-<txnID>。
- Replicated Range ID local key存储Range元数据(RangeDescriptor),该元数据包含Range所有副本的元信息,这些元数据的变更会通过Raft同步。例如Range lease状态和事务的abort缓存记录。
- Unreplicated Range ID local key存储该Range副本的元数据,例如Raft状态机的状态信息以及持久化后的Raft日志。
用户表key用于存储所有非系统数据。用户表key组织形式请参考章节Data mapping between the SQL model and KV(SQL模型和KV之间映射数据)所述。
四、多版本数据
CockroachDB维护了数据的历史版本,版本之间通过事务的提交时间戳区分。指定快照时间可以读取此时间戳之前的最新版本数据。所有版本都有一个最小有效期,当系统进行compaction时,过期的版本数据会被系统回收。为了防止长时间数据扫描(例如MapReduce)中历史数据被清理,用户也可自行指定数据有效期。
通过RocksDB存储每个key的提交时间戳和GC有效期,支持多版本数据。
五、无锁分布式事务
CockroachDB提供无锁分布式事务。CockroachDB支持两种事务隔离级别:
- 快照隔离级别
- 串行化快照隔离级别
SI隔离级别实现简单,性能较好,但是存在write skew 问题。相比而言,SSI实现上稍微复杂一些,但仍然能保证较高性能(读写冲突严重的情况下稍弱),但是不存在write skew问题。
CockroachDB默认使用SSI隔离级别。在对性能要求较高,并且没有write skew的情况下可使用SI隔离级别。在冲突较少的情况下,SSI和SI性能相当,不需要加锁或额外写操作。在冲突激烈的情况下,SSI仍然不需要加锁,但是会有更多事务被终止。在任何长事务场景中,SI和SSI都能防止事务饿死。
关于SSI实现,可参考Cahill论文 《Serializable Isolation for Snapshot Databases》。关于SSI如何解决读写冲突,可参考论文《Fast Distributed Transactions for Partitioned Database Systems》。此外,CockroachDB的SSI实现主要参考Yabandeh 的论文《A Critique of Snapshot Isolation》。
SI和SSI都要求缓存该Range上发生的读操作结果,如果写操作时间戳比最近一次读操作时间戳要小,则写操作失败。因此,每个Range都有一个缓存(timestamp cache),保存该Range中key被读取的最新时间戳。
读操作会更新相应的timestamp cache, 部分写操作(例如Range删除)也会更新timestamp cache。timestamp cache中最老时间戳会被优先剔除.
每一个CockroachDB事务开始时都会分配一个随机优先级和一个“候选时间戳”。候选时间戳是接收事务请求时节点分配的本地当前时间戳(HLC),作为事务提交的临时时间戳。意味着,如果没有事务冲突,在事务完成所有操作后,该时间戳会成为事务的最终提交时间戳。
在跨多个节点的分布式事务执行过程中,候选时间戳可能会变大,但不会回退。SI和SSI之间的核心区别在于事务提交时,SI允许事务的候选时间戳变大,而SSI不允许。
混合逻辑时钟
每一个CockroachDB节点维持各自本地的混合逻辑时钟(HLC),参见论文《Hybrid Logical Clock》。HLC由物理部分(wall time)和逻辑部分(用于区分物理部分相同的不同事件)组成。类似于vector clock,HLC使我们能够跟踪关联事件的因果关系,但开销更低。本质上,它类似于逻辑时钟:当节点发送事件时,节点会获取本地HLC时间作为该事件的时间戳;当节点接收到事件时,节点会根据接收事件的HLC更新本地的HLC。
CockroachDB使用HLC作为事务时间戳。在本文中,时间戳指的就是HLC。每个节点维护自己本地的HLC,所有读写事件都会更新节点的HLC,且保证HLC >= wall time。从其他节点接收到请求时,该请求(read/write)的时间戳不仅用于冲突处理,而且还会更新节点本地的HLC。这可以保证节点上正在发生的所有数据读写操作的时间戳小于该节点的下一个HLC时间。
事务执行过程
事务执行分为两个阶段:
首先,选择事务对应的KV请求中第一条写操作所在的Range ,并生成一个新的事务记录(初始状态为”PENDING”)写到该Range的保留区域。然后,并行地写入该事务操作的所有记录,临时保存为“intent“。这些intent和普通的MVCC值相比,附加了一个特殊的标识(即“intent“),表明在事务提交之后,该intent会被标识为正常的MVCC值。此外,事务id(在事务开始时由接收该事务的节点指定的唯一值)会被保存在intent中。事务ID用于发生冲突时查找对应的事务记录,并对时间戳相同的事务进行排序。每个节点返回写时间戳(在没有读写冲突的情况下,它是最初的候选时间戳);事务协调器从所有的写时间戳中选择最大值作为最终的提交时间戳。
通过更新事务记录来提交事务。提交内容包含候选时间戳(可能被其它并发执行的读事务更新为更大值)。注意,此时事务可以被认为已经完全提交,并且控制权可能已经返回给客户端。
对于SI隔离级别的事务来说,通过增大提交时间戳以容忍并发读,并且不影响事务提交。但是,对于SSI隔离级别的事务来说,如果候选和提交时间戳不相等则事务重启(注意:事务重启与事务中断是不同的处理流程,详情请参见下文)。
在事务提交之后,所有”intent”标志会被并发清理。事务在此步骤之前可以被认为已经完全提交,不需要等待清理完成后再把控制权返回给事务协调器。
在没有冲突的情况下,事务到此结束,不需要其它操作来保证系统正确性。
冲突解决
当读写操作遇到Intent 记录或新提交的数据时,这种情况被称为事务冲突。根据冲突类型,其中一个事务会被中断或重启。
事务重启:
事务重启是默认(也是更有效)的处理方式,除非事务被中止(例如,被其它事务中止)。实际上,这分为两种情况:第一种,如上面所述,在SSI隔离级别下,事务在提交阶段发现时间戳被修改为一个更大的值;第二种,读写数据时,触发冲突解决机制(详情见下文事务交互描述)。
当一个事务重启时,它的优先级会被改变,同时时间戳可能会被增大,并且复用之前的事务ID. 由于之前的事务可能已经写入一些Write Intent,这些数据需要被清理。事务重新执行时,会显式删除这些Write Intent记录,或隐式把新Intent值覆盖写到相同的Key上。 由于大多数重启的事务都重复之前的操作,所以在提交事务之前,不需要显式删除事务重启前写入的Intent。
事务中断:
当一个事务读取它的事务记录时,发现它被标记为Abort,这种情况称为事务中断。此时,事务不能复用之前的Intent值,并且在清理Intent值(这些Intent值会被后续的读写操作清理)之前,必须把控制权返回到客户端。然后(如果需要的话),开启新事务重新执行。
事务交互:
有以下几种事务交互场景:
- 事务遇到一个时间戳远大于当前事务时间戳的Intent 值或数据:这种情况不涉及事务冲突。读操作可以继续进行,因为它可以读到旧版本数据,因此不会造成冲突。如前文所述,Intent值可以用一个比候选时间戳更大的时间戳提交。注意:在SI隔离级别下,如果读操作遇到当前事务写入的Intent值,即使该Intent的时间戳比当前读操作时间戳大,该Intent对当前读操作依然可见。
- 事务遇到一个时间戳稍大于当前事务时间戳的Intent 值或数据:如果写入该Intent值或数据的事务所在节点时钟比当前读操作的节点时钟稍快,那么在绝对时间上,写入该Intent值的事务可能发生在当前读事务之前。但是,在这种情况无法确定读事务是否可见。所以,读事务会获取一个更大的时间戳重新启动。关于如何确定“更大”时间戳,可参考下文“选择时间戳”。
- 读操作遇到一个时间戳早于当前事务时间戳的Intent时,则根据Intent的事务id查看其事务记录。如果该事务已经提交,则该Intent对当前事务可见。反之,则有两种可能:1. 如果该Intent来自于SI隔离级别的事务,则把该写事务的提交时间戳推迟(因此该Intent对读事务不可见)。实现很简单,只需更新写事务在事务记录表中的事务提交时间戳,从而保证该事务必定使用一个更大的时间戳提交;2. 如果该Intent来自于SSI隔离级别的事务,则先比较两者优先级。如果读事务具有更高优先级,则延后写事务的提交时间戳(该写事务提交时发现提交时间戳被修改,则事务重启);如果优先级相同或更低,则读事务使用新优先级max(新随机优先级,写事务优先级-1)重启。
- 写操作遇到未被提交的Intent:1. 如果写入Intent的事务优先级更低,则该事务被终止;2. 如果写入Intent的事务优先级相同或更高,则该事务随机等待一段时间后,以新优先级max(随机优先级,当前事务优先级-1)重启。
- 写操作遇到新提交数据:无论该已提交的数据是Intent或非Intent,当前事务以相同优先级重启,而且使用该数据时间戳作为候选时间戳。
- 写操作遇到最近刚被读过的Key:每个写操作都会访问所在节点的读缓存(Read Timestamp Cache)。如果写操作的候选时间戳早于缓存的低水位线(最后被换出的时间戳),那么读缓存的低水位线将作为该事务新的候选时间戳;如果写操作遇到一条Key相同且读时间戳晚于当前事务候选时间戳的记录,那么Key的读时间戳将作为该事务新的候选时间戳。只有SSI隔离级别下,新时间戳会导致事务重启。
事务管理
事务由客户端代理(类似微软SQL Azure的网关)进行管理。不同于Spanner,CockroachDB不缓存写操作, 而是直接发送写请给相关Range。所以遇到冲突时,事务能快速终止。客户端代理跟踪记录全部已经写入的Key,用来在事务完成时异步清理Intent。当事务成功提交后,所有Intent都会被更新成已提交状态。如果事务被终止,所有Intent都将被删除。客户端代理不保证所有Intent被清理。
当事务尚未提交时处于pending状态,如果客户端代理重启,则该事务会继续”存活”直到被其它事务终止。CockroachDB通过心跳机制维持事务的存活状态。当其它读写事务遇到Intent时,如果发现其所属事务没有在规定时间内汇报心跳,则终止该Intent所属事务。在事务提交后,Intent异步清理完成前,如果客户端代理重启,那么该事务的Intent将被后续其它事务清理。是否及时清理Intent不影响系统的正确性。
关于竞争重试和废弃中断事务的讨论请参见这里
事务记录
最新结构请参考 pkg/roachpb/data.proto ,建议从message Transaction入手。
优点
- 不依赖代码执行的可靠性来防止 2PC 协议阻塞
- 在SI隔离级别下,事务读不会阻塞;在SSI隔离级别下,事务读可能被终止。
- 比传统两阶段事务提交协议时延更低。因为第二阶只需要更新一次事务表,而不需要在事务参与者之间做同步。
- 优先级机制能防止长事务饿死,因为它始终能在竞争事务(不相互中止)中选出优胜者。
- 写操作不在客户端缓存,失败后能快速终止。
- 与其它SSI实现相比,cockroach的SSI实现没有读锁(read-locking)开销
- 可设置的优先级能灵活地保证任意事务的低时延(例如:通过设置优先级,可以使OLTP事务被终止的可能性比异步调度任务这种低优先级事务小10倍)
缺点
- 从non-lease副本读取仍然需要与lease holder通信,以更新读时间戳缓存
- 已终止的事务可能会阻塞其它竞争写最长不超过一个心跳时间。与2PC需要释放读写锁相比更高效。
- 与其它SI实现不同:先写不一定先提交,短事务不一定总是先完成。这些情况对一些OLTP场景来说可能存在问题。
- 与两阶段锁相比,在竞争系统中,终止事务可能降低系统吞吐量。因为终止和重启会增加读写通信开销,从而增加时延,降低系统吞吐能力。
选择时间戳
在一个存在时钟偏差的分布式系统中,关键问题在于,对于读操作来说,如何保证能选出一个比任何已提交事务的提交时间(指绝对时间)都大的时间戳。不能读取已提交数据的系统无法保证一致性。
在单机系统上实现事务(或单一操作)一致性比较容易。因为时间戳由节点自己产生,不存在时钟偏差,所以可以保证产生的时间戳比节点上已存在数据的时间戳更大。
在多个节点上,假设协调事务的节点使用了时间戳t,那么当前系统中已提交数据的时间戳上限为t+ε(ε是时钟最大偏差)。当事务进行时,遇到任意数据的时间戳大于t但小于t+ε都会导致事务终止然后以时间戳tc 重启(tc > t,tc是导致事务重启的数据的时间戳)。时间戳上限t+ε保持不变。也就是说,时钟偏差会导致事务重启,且重启只发生在长度为ε的时间间隔内。
我们使用另一种优化方案来减少这种不确定性引起的事务重启。当事务重启时,事务不仅要考虑tc (和当前事务发生冲突的已提交数据的时间戳), 也要考虑发生uncertain read所在的节点时间 tnode 。tc 和 tnode 中较大值会被用来作为重启事务的Read Timestamp。并且,将冲突节点标记为 “certain” ,然后,对该事务后续的读操作,通过设置MaxTimestamp = Read Timestamp以防止这种不确定性再次导致事务重启。
上述结论来自以下依据:当读操作发生时,节点上没有任何版本数据的时间戳比tnode更大。当事务在该节点上重启时,如果事务遇到时间戳比tnode更大的版本数据时, 就说明在绝对时间上,该多版本数据的写入是事务重启之后发生的。因此,该事务只能往前读取旧版本数据(tnode之前版本数据)。 从而,保证uncertainty read在一个节点上最多只发生一次。可是,我们可能选出一个比最优时间更大的时间戳,这有可能造成更多冲突。
我们认为事务重启是极少发生的,但如果事务重启频繁发生,则需要其它解决方案。注意,上述uncertainty read的处理方法不适用于历史读(historical read)。 另一个避免事务重启的方法是:预先轮询参与该事务的所有节点的时间戳,并选择其中的最大值作为事务时间戳。但是,预先确定哪些节点会参与该事务是很困难的。 CockroachDB也可以使用全局时钟 (参考Google的Percolator实现 )。但是,全局时钟适用于规模较小,且集群中所有节点地理位置邻近的场景。
六、Strict Serializability
简而言之,strict serializability(也叫linearizability)和CockroachDB的默认隔离级别(可串行化)之间的差异是,对于可线性化事务来说,事务提交的先后顺序是确定的。也就是说,一个事务A等待前一个事务B提交,那么预期事务A的提交时间戳大于B的提交时间戳。但是,在实际的分布式系统中,这个理论可能不成立,原因是分布式系统时钟不完全精确同步,所以对于两个完全不相关的事务,彼此时钟信息也是不相关的,这导致无法确定两个事务的实际提交时间戳先后顺序。
在实践中,CockroachDB的大部分事务在一定的约束条件下是可以保证线性的。
通常情况下,并不需要关心事务的因果关系(即发生的先后顺序)。在一些必需的场景下,CockroachDB通过causality token实现事务的因果关系:当提交一个事务时,可以把token传递给下一个事务,确保这两个事务被分配的逻辑时间戳是递增的。
此外,随着更精准的时钟同步服务成为云计算供应商标配后,CockroachDB可以像谷歌的Spanner一样提供全局linearizability:在提交时等待一个最大时钟偏移量,然后再返回给客户端。
详细信息请参考如下博客:
https://www.cockroachlabs.com/blog/living-without-atomic-clocks/