在之前文档学习中,对事务层(Transaction Layer)有简单描述了 CRDB 事务处理的基本概念,但自己在查看代码过程中感觉还是比较晕,单独开一篇来仔细看下 CRDB 事务的实现。
CRDB 实现了 I(Isolation) 级别为 Serializability 的分布式 ACID 事务,本问主要关注 A - Atomic 和 I - Isolation 在 CRDB 的实现。
A(atomic),即对于“一组操作结果只能是全部成功或全部失败”, 在 CRDB 中修改的数据可能会涉及多个 range 而这些 range 可能会分布在多个 store 上, 所以 CRDB 是使用 Two-Phase Commit 来保证事务在多个 store 同时进行事务修改操作的 atomic, 在 CRDB 中可以简单的认为在 Begin 之后 Commit 之前的所有 DML 都是 Phase1, 而在 Commit 之后进入 Phase2(虽然 Phase2 开始时也可能会因为冲突冲突做一些类似 Phase1 的活)。 CRDB 在实现时做了很多优化,不过下面我们先从不考虑任何优化逻辑的情况开始,之后会看下其中一些有意思的优化。

Phase 1(writes and reads):

CRDB 的 2PC 在 begin 开始事务后每次执行写 DML (比如: insert/update/delete) 时就开始部分的进行 Phase 1 操作, DML 修改的数据会以被称为 WriteIntent 的特殊 MVCC 形式作为”未提交数据”存到 rocksdb 中, 这部分数据对于其他事务不可见(对自己本事务是需要可见,后面也会介绍这部分实现),如文档所述除了 WriteIntent 外引入一条 Transaction Record 来作为一个 central 的开关来控制 intent 是应该做已提交数据对待还是看做未提交数据对待,故 Phase2 会通过修改 Transaction Record 来的集中 atomic 的控制 WriteIntent 转换为所有人可见的”已提交数据”。所以,我们这里首先看下 Phase1 可能会写入几条 kv 数据:
写 Transaction Record
CRDB 每个 Transaction 会需要有一条 Transaction Record, 通过他作为一个 central 开关来确定整个分布式事务的状态, 后面会看到当我们在遇到 write intent 的时候需要找到 intent 指向的 transaction record 并根据 record 状态确定怎么处理 intent (在当前版本中这条 record 是可被 lazy 创建的来优化减少写入并且进一步支持 pipelining 优化实现,不过逻辑上每个 Transaction 都有一个 record,所以”不存在的 record” 可以理解为其中一种 record 的特殊状态)。
每个 txn 会选取读取或写入的第一个 range 的 start key 作为 transaction 的 anchor key, 而保存 Transaction record 的 key 的位置是通过 TransactionKey 方法基于当前 txn anchor key, txn-suffix 和事务 UUID 组合的特殊 range-local key(这样的好处是一个 range 中的所有 txn record 都有序的聚集在一起,方便找所有 records ,并且在正常扫数据的时候也用关心这些 key, 值得注意的是 CRDB 作者不太喜欢用 rocksdb 的 column family 目前所有数据都在一个 cf 里,所以通过 range-local key 一定程度上达到了单独 cf 的类似效果)。
而对于 transaction record 的 key 对应的的 value 则是 TransactionRecord 结构体,其中 embedded 了 TxnMeta, 可以看到除了 TransactionStatus 还有 LastHeartbeatIntents, Timestamp, OrigTimestamp 等信息后面我们一一来看, 这里先感受下会存这些东西就好了~
写 Write Intents
在 Phase 1 执行的 DML 在 CRDB 中会直接发向 range 对应的存储节点,并作为特殊的 mvcc 保存, write intent 能知道 transaction record 的所在, write intent 作为特殊的 mvcc 可以理解为”未提交的临时数据”, Phase1 中写入在 Phase2 提交后会转换为正常的 mvcc 数据而真正”生效”
我们首先来看下 CRDB 中 mvcc 的存储方式, 之前文档学习中的 Storage Layer 有介绍 CRDB 中 key 的编码方式,所以在考虑 mvcc 后,对于提交后的数据实际存到 rocksdb 中在编码后的 key 之后还会附加 version 也就是事务提交的 timestamp 到 key 中,最后类似这样:

  1. key0 + version1 -> v01
  2. key1 + version2 -> v12
  3. key1 + version1 -> v11
  4. key2 + version1 -> v21

而 write intent 数据的 key 则比较特殊不会带 version, 而这个不带 version 的 key 对应的 value 会保存 MVCCMetadata 结构体(这个结构体会 embedded TxnMeta 里面有 anchor key 和 UUID 可以帮找到 txn record),另外除了这条以 key -> MVCCMetadata 的 kv 外,还会将临时(provisional)数据作为 key1 + versionTs(txn.Timestamp) 作为 mvcc kv 写入, 写入会用一个 provisional timestamp, 并且这个 ts 会被记录在 MVCCMetadata 的 timestamp 字段, 所以在后续打算读取/跳过/覆写 intent 时,可以首先通过 MVCCMetadata.timestamp 来选择操作 mvcc 的 timestamp, 所以如果在一次写入未提交数据后类似这样:

key0 + version1  -> v01
key1             -> MVCCMetadata{..Timestamp=versionTs..} 
key1 + versionTs -> v13
key1 + version2  -> v12
key1 + version1  -> v11
key2 + version1  -> v21

相比之前多了 2 个 kv pair:

  • 以 key1 为 key, value 为 MVCCMetadata 的 intent 信息,其中有临时(provisional) ts (如果没 push 这个 ts 也可能成为实际 ts)和能获得 txn record 的 anchor key 等元数据信息(有种极端情况还会将 value 也 inline 到 meta 里,不过这里先看正常逻辑,是没有 value的)
  • 以 key1 + 临时 ts 为 key, value 为实际临时写的 value, 通过 MVCCMetadata 能找到这个临时 ts 下的 key

因为此可以知道对于一个 key 只可能存在一个 intent,因此和 intent 配对的临时 mvcc 记录也只会有一个,要产生新的 intent 必须等之前的记录提交或或解决冲突被废弃之后才有可能生成。
在每次大家在事务中执行 DML 时会做 Phase1 如果是新更新会写入这两个 2 kv, 前面提到的是最简单的情况,而现实中引入 Intent 需要处理一些场景。
读取事务自己写的 WriteIntent
如果是当前事务写入的 write-intent 应该被当前事务读取到,比如:

truncate t;
begin;
insert into t(id, v) value(1, 1);
select * from t;
select * from t where id = 1;

虽然(1,1)因为没提交别的事务看不到但当前事务中不管最后 2 条语句执行 seek 或 scan 都应该能获取这条记录。
CRDB 中 write-intent 是写入到 mvcc 的,所以实现就是在扫到 key1 -> MVCCMetadata 后从 MVCCMetadata 中的 Txn 字段和当前执行事务对比如果是 Txn.ID(前面提到过每个事务有一个 UUID 来唯一标示)看是不是同一个事务,并检查当前 stmt 是不是在写 write-intent 的语句之后(txn.sequence) 和是不是有重试(txn.epoch), 后通过 MVCCMetadata.Timestamp 去 seek 找到临时数据返回, 需要注意的是这部分逻辑对于 scan 是实现在 libroach(C 代码)中找代码时需要注意~(大家都说 CGO 性能很差,CRDB 目前的做法是将很多频繁使用的逻辑 Batch 下推到 C 代码中来减少 CGO 调用开销)。
因为 DML 每次都将数据作为 write-intent 写入存储,所以 CRDB 要读取当前事务中数据只要正常 scan intent 时考虑 txn.id 即可知道是不是自己当前自己写的 intent,相比一些 OCC 实现在内存中 buffer intent 数据,这样避免了做将内存和存储数据合并的复杂过程,另外后面可以看到 intent 除了数据非常关键的作用是用于冲突检测(OCC 如果希望在 commit 之前感知冲突也只能写一些类似 lock 的元数据,本质上类似不带数据的 intent,但,都写了为什么不把数据先带下去呢?),提前写入存储节点并在存储节点做冲突检测是可以一定程度解决 OCC 的冲突需要很重的重试事务的问题。
更新事务自己写的 WriteIntent
考虑如下 SQL snippet:

truncate t;
begin;
insert into t(id, v) value(1, 1);
update t set v = 2 where id = 1;

后面的 update 因为在同一个事务所以不会报 WriteIntentError 错误, 之后如果 update 语句执行的 write timestamp 和上一条 insert 写入的 write-intent meta 中的 timestamp 一致则可以直接覆盖 put 两个 kv 值, 但如果 update 时 timestamp 变了(介绍 isolate 部分会看到 CRDB 的 push 机制)则需要将上一条 intent 对应临时 MVCC (key1+versionTs)记录删除,之后用新值新 ts 写入 mvcc 和更新 meta.timestamp。
对于 intent 被更新的情况,需要注意的是还需记录 IntentHistory 到 MVCCMetadata 中,这样在重试重放事务的时候会用这些中间过程的 intent(重试是非常值得单独描述且复杂的细节后面我们单独看~)。
读取或更新遇到其他事务的 Intent
如果是读遇到其他事务 intent 即表明有 W/R 冲突, 而如果是写遇到其他事务 intent 即表明有 W/W 冲突 (另外两种无法通过 intent 检测的冲突是: 通过 Timestamp Cache 识别的 R/W 冲突和因为使用 HLC 而出现的 ReadInUncertaintyInterval)。
对于每个事务都会在开始的时候分配一个 Timestamp 并赋值到 TxnMeta.Timestamp 和 Transaction.OrigTimestamp(前者可以理解为负责写,后者处理读),因为 CRDB 的模式是 DML 就写入 intent 所以类似传统 MVOCC 的 validation phase 可以部分提前到 DML 执行过程中, 在 Get/Put 或 Scan(scan 还是在 libroach) 会将遇到的 range 内的所有 intent (也就是 intent ts < txn ts, 因为必须解决所有 intent 才有意义所以一次返回全部一起解决) 遇到的 intent 列表会以 WriteIntentError 方式返回并在当前子节点进行处理,对放置 intent 的冲突事务发起 PushReq 到冲突事务 transaction record 所在 store 进行 Push, 根据当前 DML 是写还是读会尝试进行两种不同类型的 Push:

  • DML 是写,即 W/W 冲突,PushReq 处理会较优先级决定 abort pushee 还是 pusher
  • DML 是读,即 W/R 冲突, PushReq 会尝试将和自己冲突的写事务(pushee) 的 TxnMeta.Timestamp 推后到当前事务(pusher) 的读取 ts 之后

注意到,对于”W/R 冲突”直接把写事务的提交 ts 给推迟,其实主要是为了解决类似写入事务执行时间很长导致读都被阻塞的问题(这里举个例子: A t1时在脚本全表改订单状态我,B t2时在只读点查某个订单, Push 即让 A 的 intent 的写入时间推迟到 t2+1,这样 B 就可以没冲突的直接返回数据了), 但因为是 serializable 被 Push 的写事务最终提交不一定能成功需要通过 refreshing span 在提交时进一步检查(接上面的例子: 写事务在 t2+1 可以提交的前提是 t1 到 t2 + 1 之间自己读过的所有数据没有更新写入[update 也是先读后写]), 为了能在 Phase2 commit 时做 refreshing span 会维护事务 read 过的信息,并且在提交时如果 Push 过需要发送 RefreshRequest 到其他节点来查(邪恶的分布式系统 - -) ,这里简单提下后面细看。
对于”W/W 冲突”在发生冲突后会根据优先级选择 abort 其中一个,CRDB 可以在 begin 事务的时候指定优先级,并且在后续发生 retry 后会提升优先级来让被重试的事务更容易”胜出”。
通过等待减少 Intent 导致的重试
遇到冲突后直接 abort 或 push timestamp 在高冲突场景需要应用做大量重试并因冲突重试影响整体性能,所以 CRDB 节点目前在收到收到 push 请求后会先尝试通过类似传统数据库悲观等锁的方式尝试等待冲突事务先执行完再继续执行来减少冲突,具体代码是 maybeWaitForPushee, 其中会将 pusher 放到 pushee 的 txnwait.Queue 中, 当 pushee 事务执行完成后会通知 Queue,来结束等待,等待完成后会看下如果 pushee 已经 Commit 或 abort 或者 当前是读 且 pushee 的时间戳已经被退后则 push 完成, 否则继续走上面那个过程。
一旦引入的了等锁,不可避免需要解决的是 deadlock 问题, txnwait.Queue 是在事务各自 transaction record 所在的 replica leader store 上,这样没有单点存在,但因此在单个节点并没有完整全局事务存在,此外还有个问题是 pusher 事务在等锁的过程中可能自己会挂掉或 abort (虽然 transaction 是有心跳到 pusher 事务自己的 transaction record 节点,但冲突等待的 txnwait.Queue 节点并不知道这个),为了解决上述问题,在 CRDB 在进入 Queue 等锁的同时会启动一个 QueryPusherTxn 的 goroutine,定期从所有 pusher 事务查询事务状态,且顺带收集所有 pusher 依赖的其他事务到自己那来发现 dependency cycle 进行死锁检测。``
所以在当前版本的 CRDB 中执行如下脚本:

Session1:                              Session2:
begin;
                                       begin;
update t set v = 0 where id = 1;
                                       select * from t where id = 1; // block
commit;                                
                                       // select return util session1 committed
                                       // read value v == 0
                                       update t set v = 2 where id = 1;
                                       commit; // success

会看到读和写在遇到 intent 时会卡主等待另一个事务提交(如果 session 在 update 前 session2 select 下让 ts 比 intent 小就不会有卡主),并且能获取到最新的数据且最后更新也能提交不冲突。
通过排队减少单 key 的重复 Push
有些场景会有量大事务在某个 key 上有很高的并发的冲突, 按照上面的方式每个请求遇到这个 key intent 都会向 transaction record 发送 PushTxnReq 进行等锁或 Push 虽然这些请求效果是重复的,所以 CRDB 中使用 ContentionQueue, 在遇到 intent 的存储节点进行排队去重等待,避免重复向 intent 的 transaction record 所在 server 重复发 Push,这个是在 2018 年这个 PR 引入的可以看下~
Phase1 小结
CRDB 的 Phase1 阶段发生在 begin 后每次执行 DML 语句的时候,写入数据类似 OCC 将先以 WriteIntent 的形式保存到一个临时 private workspace,不过 CRDB 的 private workspace 实现是在存储上的特殊 MVCC;每次 DML 写入的 write-intent 除了临时数据外还会有指向 Transaction Record 的指针来将事务状态 central 到一个集中的记录来方便整体修改;WriteIntent 除了要修改的数据也会记录 timestamp 等元数据(这个功能非常类似 lock)来在事务发送冲突时来协助处理(本节也有列举一些例子),对于 Phase1 的数据可以被当前事务读取更新但对其他事务不可见,且因为是 serialize 隔离级别,其他事务看到 intent 后会尝试悲观等待,Abort 事务 或 Push 冲突写的时间戳。

Phase 2(commit or rollback)

在 begin 之后执行完成各种 DML 语句后,最后应用会发起 commit 或 rollback(如果不发就挂了会有 TxnHeartbeat 来将事务 abort), 我们以为 commit 为例, 会向执行节点发送 EndTransactionRequest, 这时候进行 2PC 的 Phase 2 处理。
Read Refreshing
在前面我们有看到在 W/R 冲突时,其他的读事务可能会把当前写事务的 Timestamp(TxnMeta.Timestamp) 给往后 Push 来避免读被写回滚,而当前的写事务除了写冲突 key 外可能会有其他读操作,那些读操作都是基于事务开始时的 Timestamp(Transaction.OrigTimestamp)读的数据,如果希望在事务最终提交时不违反 Serializable 需要保证从 OrigTimestamp 到 Push 后的 Timestamp 之间对于事务中读过的数据没有修改,如果能保证则可以将读 Timestamp 也 refresh 到和 TxnMeta.Timestamp 一样的值处理后续请求, CRDB 为了方便 resolve 编码上会将 refresh 成功的 ts 放到 RefreshedTimestamp 来进行后续处理, 所以在每次提交时需要如果被其他事务或 timestamp cache push 过都需要进行 refresh 处理(另外的情况是ReadWithinUncertaintyIntervalError 也需要)
在接收 SQL 节点在使用 TxnCoordSender(有写一篇<闲逛>文章会描述请求处理过程但还没写完) 发送 EndTransactionRequest 请求到执行节点时会被一个叫做 txnRefresher 的的特殊 intercepter 进行后置处理。
收集当前事务中读过的 span: 在 txnRefresher 中会将所有读或写过的 span(可以理解就是 start + end 的 range)记录到到 TxnCoordMeta 中, 即在事务中标记了自己读过的所有 key range,后面提交的时候如果有 push 或 uncertainty interval 会对这些 range 进行 refresh。
提交时检查是否需要 refresh: 之后在处理节点收到 Commit 请求后会看当前读 Timestamp 如果和 Commit Timestamp 不一致则代表有发生过 Push 需要返回协调者节点进行 refresh 处理(一个 BatchRequest 中除了 commit 可能会有其他 req 可能 push 所以这个判断需要在执行节点判断)。
当返回到的协调节点,会被 txnRefresher 的后置处理拦截, 进入 maybeRetrySend 方法, 在检查错误类型是需要 refresh 的类型后对当前事务中读过的所有 span 对应的 store 发送 refresh 或 refreshRange 请求;存储节点收到 refresh 后通过读取存储 kv 看有没有更新的 commit 数据,如果有则报错,回到协调节点如果没有报错即刷新成功, 刷新成功则设置 RefreshTimestamp 并重发请求。如果协调节点刷新失败则返回客户端冲突错误需要应用进行冲突重试。
EndTransaction
对于普通的 commit 最终会对应 EndTransaction 的 eval 处理。处理过程即:

  • 通过 anchor key 找到 transaction record
  • 当前 range 做 resolveLocalIntents
  • updateTxnWithExternalIntents 修改 record 状态为 commit, 并将剩下的 intent 更新到 txn-record, 如果所有 intent 都 resolve 完成则删除 txn record
  • 将剩下的其他 range 的 intents 返回出去, 在稍微外面一点的地方由当前节点发异步 resolve 请求到其他节点进行 resolve intent,这些异步如果失败了后面读写遇到会清理, 主处理流程可以直接返回用户处理成功
  • resolveIntent 的处理主要位于 mvccResolveWriteIntent,主要完成对 writeIntent metakey 的清理,并且在有 push 时将 provisional 的 kv 删除替换为新的 timestamp 下的 kv,这个方法比较复杂的是处理事务重试和 pipeline 优化后的问题,后面有空再分析, 逃~

Phase2 小结
提交阶段主要处理 refresh 保证之前的 push 可以提交或提示应用进行冲突重试,另外完成了 WriteIntent 到最终 MVCC 的转换并最终清理 txn record。

1PC Optimize

前面的 2PC 提交普通情况 CRDB 写入数据需要进行分布式事务操作,但通过前面过程大家也可以体会到 2PC 需要多次网络 roundtrip,而实际中一些 OLTP 场景可能都是更新很少数据的小 SQL, 然后极致的 OLTP 应用都会设法让多数事务不要去做分布式 2PC(可以看下 CRDB 自己的 TPCC CI 用的 partition 和 scatter)。对于能在单个存储处理整个事务情况的 CRDB会使用 1PC 处理。
首先前面也看到在 CRDB 从协调节点(收 SQL 节点)向下下发的请求都是 Batch 起来的多个 Command(e.g. Put/ConditionPut/EndTransaction…) 而不是 KV requests,虽然 CRDB 底层存储引擎是 rocksdb(目前看有 PR 在替换为 pebble), 但从协调发送到 range 所属 store 会是更抽象的: 执行计划的子 plan tree(见前文 dist planner) 或 一组 command。
所以 CRDB 中的 1PC 实现正是判断 Batch 中的 comand 是否完整来决定是否直接 1PC 写数据不写 intent,在 replica 收到的 BatchReq 后会检查,如果 batch 中的包含完整的事务则进行表明这个事务是一个完整可以在单节点执行的 1PC 事务,则将 begin cmd 和 commit cmd 去掉后 然后用 strippedBa.Txn = nil下发, 直接去 put 不写 intent 而是直接写数据(以 put 为例这样会走 mvccPutInternal 的 txn 为 nil 的逻辑),因为是单节点执行能保证都成功或都失败可以直接写 mvcc(遇到冲突等情况会回退回 2PC)。

More About Conflict

限于文章结构在前面 Phase1 部分我们看到通过 Intent 能发现和处理 W/R 和 W/W 冲突, 但对于 serializable 还有个情况是 R/W 即别人读取过我希望写的更老的数据的冲突问题,此外还有因为引入 HLC 后需要解决的 ReadInUncertaintyInterval 错误,这里单独再看下~
Timestamp Cache
在前面我们看到通过 intent 处理成功阻止了 W/R 和 W/W 的发生,对于 Serializable 还需要避免的的是 R/W 即读的东西在后面修改提交前被覆盖, CRDB 通过在执行节点 store 上维护包含对对应 key range 最大读水位和写水位的 tsCache, 在每次执行 WriteBatch 时会记录对应 keys 被读取和写入的最大 Timetamp。如果发现当前 W 的 ts 比 R 或 W 水位还低,会将当前事务的 ts push 到水位 ts + 1(和前面 W/R 类似被推的是还是 W),如果比水位更新则会将 Cache 的水位提高,这块代码位于 applyTimestampCache
tsCache 作为在 replica 上的纯内存结构,不可避免需要解决的问题是在发生 lease holder 变化(或 range merge) 时内存信息丢失的问题, CRDB 的做法是当 replica 成为新当选 lease holder 后会重新设置 tsCache 的 lowWater, lowWater 会作为在 key 获取不到水位时的返回值,这样保证在 lease 转移前未 laid down 的事务都会去 push 并检查是否 abort 或重试(也就是发生转移后对所有 key 标记一次读写水位, 这个措施可能会在 lease 转移时导致一些事务 push 和重试但正确性好像没有问题)
在向后推后,等事务 Phase2 提交的时候会被 refreshing 机制检查 push 的 ts 是否有效来决定是否需要报错让用户进行重试。
另外个检查 tsCache 地方是为了对 txn record 进行 lazy 优化创建的地方, 前面我们提到在事实上 begin 后事务 CRDB 为了减少写的 key 数量不会立刻写 txn record 那个 kv, 这个和 pipeline 优化也相关,比较复杂感觉后面可以单独再来看下~
Uncertainty Interval
HLC 通过搜索有很多大佬的解释这里就不太多重复了,简单说就是希望用一个时间戳 ts 做快照读, 如果时间是完全准确的(比如用 time oracle)那直接读取比 ts 小于的 key 就好了,但 HLC 多点授时需要考虑时间不同步误差,所以可能比 ts 大的某个 ts2 才是该读的,所以在 CRDB 事务启动时除了之前看到的用于控制读的 Transaction.OrigTimstamp 会赋值成 now, 同时会设置 Transaction.MaxTimestamp 为 now + maxOffset(默认500ms), 在快照读的时候除了正常的找 mvcc 比 ts 小的外,还要看下是不是有比 ts 大 但小于 MaxTimestamp 的其他 mvcc 记录(以 scan 为例可以看下这里),如果有则会报 ReadWithinUncertaintyIntervalError, 并附带那个更新 mvcc 记录的时间戳。
再回到 txnRefresher, 对于 ReadWithinUncertaintyIntervalError 会尝试把当前读的 ts 推后到更新 mvcc 的时间戳之后, 并做 refreshing 来保证这段时间内没相关 span 没有其他写入来避免重试,如果刷新失败还是会报错让客户端等一会儿再来重试。
除了上面的 maxOffset 机制,在代码中会发现 CRDB 还试图通过维护事务中之前看到过的NodeIDs 到 timestamps as observed 的映射来所辖 uncertainly 的 window,具体可以看下这段注释~

总结

本文我们看了下 CRDB 的事务实现,主要关注 2PC 和冲突处理, 总体感觉比较复杂 CRDB 很多地方都选择了 hard way 在实现 serializable 隔离级别下避免冲突提高性能上下了很多功夫,可能看错欢迎大家讨论指出我再看再改哈(其实已经看错改了好几版…orz)~
另外只代表当前(2019.10)实现,在后续您看到这篇文章的时候上面的一些设计可能已经被更改(比如: 希望把 write-intent 不和 mvcc 混一起希望支持单 key 多 intent…), 总体感觉 CRDB 的事务模型从提交记录看也是从各种修改不断优化中的(比如前面 contentionQueue)
总结下个人觉得比较有意思的是:

  • DML 执行就写 WriteIntent 来检测冲突和提前下发数据(虽然一旦冲突还要改 mvcc 记录)
  • 通过 Push 机制来让冲突的事务时间戳后移
  • 提交增加 refreshing 步骤来让 Push 可以更加激进(如果后面有更新那就提交冲突回到重试的老路)
  • 在上面的基础上引入类似等锁的机制(虽然测试看有些场景效果和 mysql 不大一样,比如 a/b 同时 begin;update t set v = v + 1 where id = x;commit;也许我没测对);并且用 contentionQueue 利用事务关系避免无效 Push
  • 1PC

本文对 resolve lock, observedTimestamp, 异常处理,重试 等部分没有详细描述(因为我还没看完),另外跳过了 transaction record lazy 创建和 pipeline 优化(减少每次 DML 写 WriteIntent 开销)的部分逻辑,后面等看懂了再来补充。

参考