当前位置: 首页 > 工具软件 > CockroachDB > 使用案例 >

CockroachDB架构-事务层

唐元凯
2023-12-01

本文知识点来源于官网地址https://www.cockroachlabs.com/docs/v22.1/architecture/transaction-layer.html

概览

一致性是数据库最重要的特性。为了提供一致性,CRDB在事务层实现了对ACID事务语义的完全支持。所有语句都是作为事务处理的,包括单个语句——“自动提交模式”。
CRDB支持跨集群的事务(包括跨range和跨表事务),使用一种称为并行提交的分布式原子提交协议来实现正确性。

读和写(阶段1)


当事务层执行写操作时,不是直接写磁盘,而是创建以下内容来促成分布式事务:

  • 事务所有写操作的锁-表示一个临时的、未提交的状态。CRDB有几种不同类型的锁:

    1. 不复制的锁,存储在每个节点内存中的一张锁表中。不通过Raft复制。
    2. 复制的锁(也称为写意向),通过Raft进行复制,包含临时值和独占锁。本质上与标准的多版本并发控制(MVCC)值相同,但也包含一个指向存储在集群上的事务记录的指针。
  • 事务记录,被保存在range里面,其中保存了事务的当前状态(PENDING、STAGING、COMMITTED或ABORTED)。

当创建写意向后,CRDB会检查更新的提交的数据。如果存在更新的提交数据,则可以重新启动事务。如果已经存在相同键的写意向,就认为是事务冲突。
如果事务由于其他原因而失败,例如未能传递SQL约束,则事务将中止。


如果事务没有中止,事务层开始执行读操作。如果一个读操作只是去查询标准MVCC值,则一切正常。但是,如果它遇到任何写意向,则操作必须作为事务冲突进行处理。
CRDB提供了以下类型的读:

  • 强一致读:这是默认的读类型。这些读将经过leaseholder,并查看在读取事务开始之前提交的所有写入操作。它们总是返回正确和最新的数据。
  • 过期读:如果能够读取稍微陈旧的数据以换取更快的读取时,这些方法非常有用。它们只能在使用AS OF SYSTEM TIME子句的只读事务中使用。它们不需要经过leaseholder,因为是从本地读取一个永远比封闭时间戳小的副本来确保一致性。详细参考跟随者读

提交(阶段2)

CRDB检查运行中事务的记录,看它是否被ABORTED;如果有,则重新启动事务。
提交时先将事务记录的状态设置为STAGING,并检查事务中的写意向是否成功(比如是否在整个集群中完成复制)。
如果检查发现写意向都完成,CRDB将响应客户端事务成功,并继续进行写意向清理阶段。此时,事务已经提交,客户端可以开始向集群发送更多请求。

清理(异步阶段3)

事务提交之后,协调节点(知道所写的所有键的信息)将会:
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)。追随者读取在多区域部署中特别有用。

client.Txn 和 TxnCoordSender

正如我们在SQL层提到的,CRDB将所有SQL语句转换为键-值(KV)操作,这是数据最终存储和访问的方式。
所有从SQL层生成的KV操作都使用client.Txn,它是用于CRDB KV层的事务接口——但是所有语句都被视为事务,因此所有语句都使用这个接口。
client.Txn实际上只是TxnCoordSender的包装器, TxnCoordSender从代码上处理的事情有:

  1. 处理事务的状态。事务启动后,TxnCoordSender开始向该事务的事务记录异步发送心跳消息,这表明事务记录应该保持活动状态。如果TxnCoordSender的心跳停止,则事务记录将移动到ABORTED状态。
  2. 在事务过程中跟踪每个写入的键或键范围。
  3. 在事务提交或终止时清除其累计写意向。作为事务的一部分执行的所有请求都必须经过相同的TxnCoordSender来考虑它的所有写意向,这优化了清理过程。

在处理这些之后,请求被传递到分布层中的DistSender。

事务记录

为了跟踪事务执行的状态,我们将一个称为事务记录的值写入键-值存储区。一个事务的所有写意向都指向这个记录,它允许任何事务检查它遇到的任何写意向的状态。这种规范记录对于支持分布式环境中的并发性至关重要。
事务记录总是被写入与事务中的第一个键相同的range,这是TxnCoordSender所知道的。然而,事务记录本身只有在以下条件之一发生时才会被创建:

  1. 写操作提交
  2. TxnCoordSender触发事务
  3. 操作强制事务中止

在这种机制下,事务记录有以下状态:

  • PENDING:表示写意向的事务仍在进行中。
  • COMMITTED:一旦事务完成,这个状态表明写意向可以被视为已提交的值。
  • STAGING:用于启用并行提交特性。根据此记录引用的写意向的状态,事务可能处于已提交状态,也可能不处于已提交状态。
  • ABORTED:表示事务被终止,它的值应该被丢弃。
  • 记录不存在:如果一个事务遇到一个事务记录不存在的写意向,它使用写意向的时间戳来决定如何继续。如果写意向的时间戳在事务活动阈值内,那么写意向的事务将被视为PENDING,否则将被视为ABORTED。

已提交事务的事务记录将保留,直到其所有写意向转换为MVCC值。

写意向

CRDB中的值不会直接写入存储层,而是以一种称为“写意向”的临时状态写入的。这些本质上是带有附加值的MVCC记录,附加值标识了该值所属的事务记录。它们可以被认为是一个复制的锁和一个复制的临时值的组合。
每当操作遇到写意向(而不是MVCC值)时,它就查找事务记录的状态,以了解应该如何处理写意向值。如果事务记录丢失,该操作检查写意向的时间戳,并评估它是否被认为过期。
CRDB使用每个节点内存中的锁表管理并发控制。这个表保存着正在进行的事务获得的锁的集合,并在评估过程中发现写意向时包含有关这些意向的信息。

处理写意向

每当一个操作遇到一个key的写意向时,它会尝试处理它,其结果取决于写意向的事务记录:

  • COMMITTED:该操作读取写意向,并通过删除指向事务记录的写意向指针将其转换为MVCC值。
  • ABORTED:忽略并删除写意向。
  • PENDING:这表示存在事务冲突,必须予以解决。
  • STAGING:这表示操作应该通过验证事务协调器仍然在处理STAGING事务的记录,来检查STAGING事务是否仍在进行中。如果协调器仍在刷新记录,则操作应该等待。
  • 记录不存在:如果写意向是在事务活动阈值内创建的,则与PENDING相同,否则将被视为ABORTED。

并发控制

并发管理器对传入的请求进行排序,并在那些打算执行冲突操作的事务之间提供隔离。也称为并发控制。
并发管理器利用锁管理器和锁表的操作来完成这项工作:
锁管理器对传入的请求进行排序,并在这些请求之间提供隔离。
锁表提供了请求的锁定和排序。它是一个每个节点的、内存中的数据结构,包含正在进行的事务获取的锁的集合。为了确保与现有的写意向系统(也就是复制排他锁)的兼容性,当在评估请求的过程中发现这些外部锁时,它会根据需要引入有关这些外部锁的信息。
并发管理器使用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收到来自事务网关节点的通信,并并行执行以下操作:

  1. 创建写意向并将其发送到它的追随者节点。
  2. 向发送了写意向的事务网关节点响应。注意,在此阶段,意向的复制仍在进行中。
  3. 当尝试提交时,事务网关节点然后等待写意向被并行复制到所有leaseholder的追随者。当它从leaseholder接收到写意向已经传播的响应时,它提交事务。

就上面所示的SQL代码片段而言,所有等待写意向传播和提交的操作都只发生一次,发生在事务的最后,而不是针对每个单独的写操作。这意味着多次写操作的成本不是SQL DML语句数量的O(n);相反,它是O(1)。

并行提交

并行提交是一种优化的原子提交协议,它将事务的提交延迟减少了一半,从两轮共识减少到一轮。结合事务整个流程来看,这使得普通OLTP事务产生的延迟接近理论最小值:所有读延迟加上一轮一致延迟的总和。
在这个原子提交协议下,当事务协调者知道事务中的写操作已经成功时,它可以立即返回客户端。一旦出现这种情况,事务协调者可以将事务记录的状态设置为COMMITTED,并异步处理事务的写意向。
事务协调者能够做到这一点,同时保持正确性保证,因为它向事务记录中填充了足够的信息(通过一个新的STAGING状态和一个动态写入数组),以便其他事务确定事务中的所有写入是否存在,从而证明事务是否已提交。

 类似资料: