本文知识点来源于官网地址https://www.cockroachlabs.com/docs/v22.1/architecture/transaction-layer.html
一致性是数据库最重要的特性。为了提供一致性,CRDB在事务层实现了对ACID事务语义的完全支持。所有语句都是作为事务处理的,包括单个语句——“自动提交模式”。
CRDB支持跨集群的事务(包括跨range和跨表事务),使用一种称为并行提交的分布式原子提交协议来实现正确性。
写
当事务层执行写操作时,不是直接写磁盘,而是创建以下内容来促成分布式事务:
事务所有写操作的锁-表示一个临时的、未提交的状态。CRDB有几种不同类型的锁:
事务记录,被保存在range里面,其中保存了事务的当前状态(PENDING、STAGING、COMMITTED或ABORTED)。
当创建写意向后,CRDB会检查更新的提交的数据。如果存在更新的提交数据,则可以重新启动事务。如果已经存在相同键的写意向,就认为是事务冲突。
如果事务由于其他原因而失败,例如未能传递SQL约束,则事务将中止。
读
如果事务没有中止,事务层开始执行读操作。如果一个读操作只是去查询标准MVCC值,则一切正常。但是,如果它遇到任何写意向,则操作必须作为事务冲突进行处理。
CRDB提供了以下类型的读:
CRDB检查运行中事务的记录,看它是否被ABORTED;如果有,则重新启动事务。
提交时先将事务记录的状态设置为STAGING,并检查事务中的写意向是否成功(比如是否在整个集群中完成复制)。
如果检查发现写意向都完成,CRDB将响应客户端事务成功,并继续进行写意向清理阶段。此时,事务已经提交,客户端可以开始向集群发送更多请求。
事务提交之后,协调节点(知道所写的所有键的信息)将会:
1 将事务记录的状态从STAGING移动到COMMITTED。
2 删除指向事务记录的元素,将事务的写意向处理为MVCC值。
3 删除写意向。
不过,这只是一种优化。如果将来的操作遇到写意向,它们会先检查事务记录——任何操作都可以通过检查事务记录的状态来处理或删除写意向。
在分布式系统中,排序和因果关系是难以解决的问题。虽然完全依赖Raft共识来维持可序列化是可能的,但对于读取数据来说,这样做效率很低。为了优化读取的性能,CRDB实现了混合逻辑时钟(HLC),关于HLC可参考另外一篇文章分布式一致性问题之混合逻辑时钟
网关节点使用HLC为事务选择一个时间戳,这个时间戳就是一个HLC值。这个时间戳既用于跟踪值的版本(通过MVCC),也用于提供事务隔离保证。
当节点向其他节点发送请求时,它们包含由本地HLC生成的时间戳。当节点接收到请求时,它们将发送方提供的事件的时间戳通知本地HLC。这有助于确保节点上所有读/写数据的时间戳小于下一个HLC时间。
这可以确保事务读取数据的HLC时间大于它正在读取的MVCC值(即,读取总是发生在写之后)。
最大允许时钟偏移
CRDB需要时钟同步来保持数据一致性。当一个节点检测到它的时钟与集群中至少一半的其他节点的时钟偏移超过允许的最大偏移的80%时,它会立即崩溃。
通过在每个节点上运行NTP或其他时钟同步软件来防止时钟偏移。
作为提供可序列化的一部分,每当操作读取一个值时,我们将操作的时间戳存储在时间戳缓存中,该缓存显示正在读取的值的高水位标记。
时间戳缓存是一种数据结构,用于存储关于leaseholder执行的读取的信息。这用于确保一旦某个事务t1读取一行,另一个事务t2出现并试图写入该行,它将在t1之后排序,从而确保事务的串行顺序,也就是可序列化。
每当发生写操作时,将根据时间戳缓存检查其时间戳。如果时间戳小于时间戳缓存的最新值,则尝试将其事务的时间戳向前推到较晚的时间。推送时间戳可能导致事务在事务的第二个阶段重新启动。
每个range跟踪一个称为封闭时间戳的属性,意味着在该时间戳或以下不能引入新的写操作。封闭的时间戳在leaseholder上持续前进,并落后于当前时间某个目标间隔。由于封闭时间戳是高级的,通知被发送到每个追随者。如果range在时间戳小于或等于其封闭时间戳时接收到写操作,则写操作将被迫更改其时间戳,这可能导致事务重试错误。
换句话说,封闭的时间戳是range的leaseholder对其追随者副本的承诺,即不接受时间戳以下的写入。一般来说,leaseholder在过去的几秒钟内连续关闭时间戳。
封闭时间戳子系统的工作原理是将信息从leaseholder传播到追随者,方法是将封闭时间戳附加到Raft命令上,以便复制流与时间戳关闭同步。这意味着,一旦follower副本将所有Raft命令应用到由leaseholder指定的Raft日志位置,它就可以开始提供具有封闭时间戳或以下时间戳的读取。
一旦跟随副本应用了上面提到的Raft命令,它就拥有了为时间戳小于或等于封闭时间戳的读取服务所需的所有数据。
请注意,即使leaseholder更改了,封闭时间戳也是有效的,因为它们在跨租约权转移中被保留。一旦发生租约转移,新的leaseholder将不会违反旧leaseholder所做的封闭时间戳承诺。
封闭时间戳提供了用于支持低延迟历史(过期)读取的保证,也称为追随者读取(Follower Reads)。追随者读取在多区域部署中特别有用。
正如我们在SQL层提到的,CRDB将所有SQL语句转换为键-值(KV)操作,这是数据最终存储和访问的方式。
所有从SQL层生成的KV操作都使用client.Txn,它是用于CRDB KV层的事务接口——但是所有语句都被视为事务,因此所有语句都使用这个接口。
client.Txn实际上只是TxnCoordSender的包装器, TxnCoordSender从代码上处理的事情有:
在处理这些之后,请求被传递到分布层中的DistSender。
为了跟踪事务执行的状态,我们将一个称为事务记录的值写入键-值存储区。一个事务的所有写意向都指向这个记录,它允许任何事务检查它遇到的任何写意向的状态。这种规范记录对于支持分布式环境中的并发性至关重要。
事务记录总是被写入与事务中的第一个键相同的range,这是TxnCoordSender所知道的。然而,事务记录本身只有在以下条件之一发生时才会被创建:
在这种机制下,事务记录有以下状态:
已提交事务的事务记录将保留,直到其所有写意向转换为MVCC值。
CRDB中的值不会直接写入存储层,而是以一种称为“写意向”的临时状态写入的。这些本质上是带有附加值的MVCC记录,附加值标识了该值所属的事务记录。它们可以被认为是一个复制的锁和一个复制的临时值的组合。
每当操作遇到写意向(而不是MVCC值)时,它就查找事务记录的状态,以了解应该如何处理写意向值。如果事务记录丢失,该操作检查写意向的时间戳,并评估它是否被认为过期。
CRDB使用每个节点内存中的锁表管理并发控制。这个表保存着正在进行的事务获得的锁的集合,并在评估过程中发现写意向时包含有关这些意向的信息。
每当一个操作遇到一个key的写意向时,它会尝试处理它,其结果取决于写意向的事务记录:
并发管理器对传入的请求进行排序,并在那些打算执行冲突操作的事务之间提供隔离。也称为并发控制。
并发管理器利用锁管理器和锁表的操作来完成这项工作:
锁管理器对传入的请求进行排序,并在这些请求之间提供隔离。
锁表提供了请求的锁定和排序。它是一个每个节点的、内存中的数据结构,包含正在进行的事务获取的锁的集合。为了确保与现有的写意向系统(也就是复制排他锁)的兼容性,当在评估请求的过程中发现这些外部锁时,它会根据需要引入有关这些外部锁的信息。
并发管理器使用SELECT for UPDATE语句通过SQL启用对悲观锁的支持。该语句可用于增加吞吐量和减少竞争操作的延迟。
有关并发管理器如何与锁管理器和锁表一起工作的详细信息,请参见下面的部分:
并发管理器
锁表
锁管理器
CRDB在最强的ANSI事务隔离级别上执行所有事务:SERIALIZABLE。所有其他ANSI事务隔离级别(例如,SNAPSHOT、READ UNCOMMITTED、READ COMMITTED和REPEATABLE READ)自动升级为SERIALIZABLE。较弱的隔离级别历来用于最大化事务吞吐量。然而,最近的研究表明,使用弱隔离级别会导致非常容易受到基于并发的攻击。
CRDB现在只支持SERIALIZABLE隔离。
SERIALIZABLE隔离不允许数据中出现任何异常,如果可能违反可序列化性,则要求客户端重试事务。
CRDB的事务允许以下类型的冲突:
为了更容易理解,我们将第一个事务称为TxnA,而遇到写意向的事务称为TxnB。
CRDB执行以下步骤:
1 如果事务具有显式的优先级设置(即HIGH或LOW),则优先级较低的事务将被中止(在写/写情况下)或将其时间戳推后(在写/读情况下)。
2 如果遇到的事务过期,它将被ABORTED,冲突被解决。以下条件发生说明写意向过期:
3 TxnB进入TxnWaitQueue等待TxnA完成。
此外,可能会出现以下类型的冲突,但不涉及与意向的冲突:
当事务的时间戳被推后时,在允许它按推后的时间戳提交之前需要进行额外的检查:必须检查事务之前读取的任何值,以验证在原始事务时间戳和推后的事务时间戳之间没发生写操作。此检查可防止违反序列化,检查是通过专用的RefreshRequest跟踪所有读取来完成的。如果成功,则允许事务提交(如果事务被其他事务或时间戳缓存推后,则事务在提交时执行此检查,或者当它们立即遇到readwithinunsuretyintervalerror时执行此检查,然后继续)。如果刷新不成功,则必须在推后的时间戳处重试事务。
事务型的写在被复制和写入磁盘时是一个流水线的过程,极大地减少了执行多次写的事务的延迟。例如,考虑以下事务:
-- CREATE TABLE kv (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key VARCHAR, value VARCHAR);
BEGIN;
INSERT into kv (key, value) VALUES ('apple', 'red');
INSERT into kv (key, value) VALUES ('banana', 'yellow');
INSERT into kv (key, value) VALUES ('orange', 'orange');
COMMIT;
通过事务管道,写意向可以从leaseholder那里并行复制,因此等待都发生在事务提交时的最后。
在高层次上,事务管道的工作原理如下:
对于每个语句,事务网关节点与leaseholder(L1, L2, L3,…, Li)表示它想写入的range。由于上表中的主键是uuid,所以范围可能被划分到多个leaseholder(这是一件好事,因为它减少了事务冲突)。
每个leaseholde rLi收到来自事务网关节点的通信,并并行执行以下操作:
就上面所示的SQL代码片段而言,所有等待写意向传播和提交的操作都只发生一次,发生在事务的最后,而不是针对每个单独的写操作。这意味着多次写操作的成本不是SQL DML语句数量的O(n);相反,它是O(1)。
并行提交是一种优化的原子提交协议,它将事务的提交延迟减少了一半,从两轮共识减少到一轮。结合事务整个流程来看,这使得普通OLTP事务产生的延迟接近理论最小值:所有读延迟加上一轮一致延迟的总和。
在这个原子提交协议下,当事务协调者知道事务中的写操作已经成功时,它可以立即返回客户端。一旦出现这种情况,事务协调者可以将事务记录的状态设置为COMMITTED,并异步处理事务的写意向。
事务协调者能够做到这一点,同时保持正确性保证,因为它向事务记录中填充了足够的信息(通过一个新的STAGING状态和一个动态写入数组),以便其他事务确定事务中的所有写入是否存在,从而证明事务是否已提交。