在前面文章中我们有介绍 CRDB 如何基于时间戳进行并发控制,并且也有看到 CRDB 通过 forward timestamp + commit refreshing 和 txnwait.Queue 等机制能在 Serialize 隔离级别下减少冲突,但 CRDB 中帮等待只是在部分场景下的“优化”,并不能避免在遇到冲突时 Retry,本文我将集中边学习边记录下 CRDB 的事务重试处理机制。

重试种类

其实对于所有数据库尤其是不使传统锁机制的的数据库希望避免客户端重试,都希望业务应用能”面向无脑编码”就能用上自己,而不是需要应用过多处理重试,进行”面向异常编码”(虽然处理异常是程序员的价值所在),所以目前多数类似数据库在需要重试时进行等待或自动重试减少客户端介入,然而不是所有的事务都可以自动重试,一般来说满足 Transaction’s statements are not conditional on the results of previous statements 情况的事务,数据库可以帮助客户端自动完成重试,具体对于 CRDB 来说:

  • 对于隐式事务单条语句执行: 如果没有结果集返回可以自动重试(i.e. 单条 insert);如果有结果集在第一条结果集合返回客户端之前可以自动重试,如果已经有返回则不可以重试,因为 CRDB 支持产生一定结果集(sql.defaults.results.buffer.size)后提前返回部分数据给客户端,这样的好处是可以减少 CRDB 自己自身需要 buffer 的内存,代价就是如果这条查询如果需要重试只能报错让客户端重新查询,如果自动重试无法保证新查的数据和之前部分返回的数据一致
  • 对于 PG 协议支持的 Batch 或多个 ; 连接的 Multi-Statement: 可以 CRDB 可以进行自动重试,因为只要能做 Batch 或 Multi, 则表明应用程序不可能有那种 if 查处结果 A then 更新1 or 更新2 的逻辑,数据库可以帮助客户端重拾
  • 对于多条语句非 Batch 的情况:即大家熟悉的 begin; select1; update1; update2;... commit的事务数据库在遇到冲突错误时则不能重试,只能让客户端回滚并重新发起事务; 主要问题如果自动重试其中查询或基于更新影响行的判断条件会发生变化为和之前应用程序所获得的信息不一致的样子(e.g. 应用代码是账户余额有 100 块以上就发个优惠券, 数据库只能自动重试”查询余额”和”插入优惠卷”的 SQL 不能重拾”if amout > 100” 的代码逻辑,所以需要客户端事务代码重试 ); CRDB 对于客户端重试还提供了一些高级 SQL 语法并提供了一些语言的客户端 Driver 来提高重试效率并简化客户端编码
  • 除此之外有些错误在更低层次(比如请求维度)如果能直接重试(这也将是本文前半部分主要介绍的内容)

另外,重试机制高效与否会影响数据库在高冲突负载下的吞吐(比如: 秒杀,热点账户),对于 CRDB 来说越靠上层的重试越昂贵越靠下层的重试越轻快,下面我们从根据重试代价从低到高看下 CRDB 在各个层级进行的重试处理。

Replica 重试

一个客户端在 coordinator-side (接收 SQL 的节点)被转换为 key 维度的 BatchCommand 之后根据 range 分布被发送到对应 replica-side 进行处理,在 replica-side 被实际处理并进行冲突检测,所以可以想到的是重试如果能越来靠下层提前处理不用回到上层越好,比如本节介绍的错误可以在当前 replica-side 直接处理不用返回 coordinator-side 就进行重发可以避免大量的网络交互开销。
1)Batch Eval 部分重试 [in replica-side]
这里主要重试的是 WriteTooOldError, 在《CockroachDB Transaction 小记 - III(时间戳并发控制)》的那状态图中可以看到这个错误在事务是一个较老的时间戳向一个已经有更新时间戳的 Key 写数据时发生( WriteTooOldError 比较特殊在产生的同时还是会写一个比当前 Key 最新 Committed 时间戳更新的临时 Key 并返回错误,见这里)。
在发生 WriteTooOldError 后:

  • 如果当前请求是 1PC 的请求,则需要看下当前请求是否需可以不做 refresh 也可以直接向后 forward 提交 timestamp(即当前事务根本没有需要去 refresh 的 span),因为是 1PC 当前事务包含一个事务中完整的写且没有需要去检测被写冲突的读,可以直接将在当前最新的committed timestamp 之后写入 mvcc(举个实际例子:事务中只有写没有前置判断读且写按照客户端的意思就是覆盖写所以时间戳直接比当前最新的 committed ts 之后写就可以)
  • 如果请求并非 2PC 则需要看请求是不是不在事务中的请求,如果是则在发生 WriteTooOldError 后也也可以推写入时间戳到最新的 commited ts 之后重试 Batch

这个重试目前只会 try 1 次,重试后产生的是新的 raft proposal 重试过程需要持有 Latch,相比之后的介绍的重试机制相对更靠下层,如果这多尝试的这一次能成功是比较划算的。
2)Batch 整体重试 [in replica-side]
往上就是在 replica 侧的在 store 的维度对以下 Error 进行重拾重试:

  • WriteIntentError: 读或写请求处理过程中遇到其他事务的 WriteIntent 时会报这个 Error,他首先会在 replica 本地尝试进行处理,处理过程在 maybePushIntents 将向遇到的 intent 指向的 transation record author key 所在 replica 发送 PushTxnReq 请求并在请求返回后更新 intent 的事务状态并 resolveIntent, 如果都成功则在本地重试 Batch
  • TransactionPushError: 上一条发送的 PushTxnReq 到达目标 replica 后如果不是因为优先级高可以去 abort 其他事务或刚好其他事务刚好已经被 abort 外都会先报一个这个错来说明没法直接 Push, 处理过程主要解一些不可重拾情况,否则 Push 请求会在当前 replica 先 txnWaitQueue.Enqueue 下, 然后立刻重试到 maybeWaitForPushee 里等待被 Push 的 Txn 完成后再继续检查能否 Push
  • IndeterminateCommitError:还是关于第一条里发送的 PushTxnReq,主要处理 Parallel Commit 的情况如果在等 txnwait.Queue 后结果 pushee 已经 abandon 且是 Stage 状态则会报这个错误,并在当前 replica 进行 Status Resolution Process 然后再重试 Push,关于 ParallelCommit 之前文章对这部分有一些分析.
  • MergeInProgressError: 如果当前 range 在进行 range merge 也会在当前 replica 等待 merge 完成后重新获取 replica 然后重试。

这几个错都是冲突后需要等待处理相关的错误, CRDB 选择在当前 replica 进行等待并在唤醒后本地重试可以节省一些回到 Coordinator 再重新发回来的开销。

Coordinator 重试

1)TxnSpanRefresher 重试 [in coordinator-side]
txnSpanRefresher 是 TxnCoordinator 的一个 txnInterceptor, 在这个 interceptor 中会会对 EndTransactionRequest 的 resp 进行检查错误类型,并在添加 RefreshReq 到请求 Batch 后进行带最大尝试次数限制的递归重试,主要处理其中的三种 Error:

  • TransactionRetryError: 且原因是 RETRY_SERIALIZABLE(代表 txn.Timestamp 被 forward) 或 RETRY_WRITE_TOO_OLD(代表被 tscache 或 mvcc 的其他更加新的committed 记录 foward 过), 这些情况都代表写入被推后相关读的数据需要 refresh 检查。
  • WriteTooOldError: 即”Replica 重试 - Batch Eval 部分重试” 部分不能处理 WriteTooOldError 在这里也会尝试用最新 committed ts +1 进行尝试 refresh (虽然大概率这个重试结果还是失败)
  • ReadWithinUncertaintyIntervalError: 因为 HLC 导致的 uncertainty 也会在 refresher 这 foward 到新的 ts,因为有 forward 所以这里会进行 refresh 重试

这里处理的维度其实还是 Batch 请求,但和之前 replica-side 的不同这里需要在 Batch 中新添加 Refresh 请求(这些新加的会涉及多个 replica),另外如获取 forward uncertainty ts 也所以需要在 coordinator 端才行。
如果事务在前面遇到了以上冲突错误,通过 refresh 能最后挽回 Batch 请求,如果 refresh 还是失败,则会用 TransactionRetryError(RETRY_SERIALIZABLE), 向外返回,之后只能进行事务级别的重试。
2) 处理语句报错 [in coordinator-side]
在 refresh 失败后其和剩下的和其他一些错误向上返回,并在 Txn 层处理准备好事务状态等待接下来的重试或其他处理。
在 replica-side 错误相应在通过整个 interceptorStack 后如果还是有 error 会在 txnCoordinator 中进行更新发送请求后 Coordinator 的事务状态, 其中如果最终 replica response 中的 Error 是“可重试”的则会准备接下来重试需要使用的重试 Txn。
对于 Error 是否是可重试的定义是通过各个 Error 实现的 transactionRestartError#canRestartTransaction 方法返回结果来决定, 当前代码(2020.01.17)中可以 can restart 的 error 有:

  • IntentMissingError
  • ReadWithinUncertaintyIntervalError
  • TransactionAbortedError
  • TransactionPushError
  • TransactionRetryError
  • WriteTooOldError

在 TxnCoordinator 层,遇到这些错误后会进行可以 retry 的特殊处理:

  • 首先不重试 leafTxn, leaf/root 是 dist sql 的概念,因为 CRDB 能将 sub-plan 放到其他节点执行,在那些分发节点上执行的事务就是 leafTxn 不需要重试,因为 root txn 的重试会重新启动新的 leafTxn
  • 根据可重试错误类型生成用于重试的 Txn:
    • TransactionAbortedError: 事务被 abort 会用最新的 HLC 时间戳创建一个新的 txn
    • ReadWithinUncertaintyIntervalError: 对于 uncertaintyInterval 会对原 txn forward 时间戳到 observe 的最新时间戳并 restart
    • TransactionPushError: 对 push 失败会在原 txn 上 forward 时间戳到 pushee 的时间戳之后 restart
    • WriteTooOldError: 对 writeTooOld 同样会对原 txn forward 到 tooOldError 里的时间戳之后(即最新的 committed ts 之后) 并 restart
    • TransactionRetryError: 则会直接进行 restart
  • 上一步的 restart 过程实现在 transaction.Restart 这个方法中, 主要逻辑是对 txn 的 epoch +1, 提高优先级(这样避免客户端重试的饿死问题),并清空重置 inflight / intentSpan 的状态信息
  • 最后将准备好的重试事务(可能新事务可能老事务但被增加了 epoch)通过 TransactionRetryWithProtoRefreshError 向上层返回,并清理重置相关 txnCoordinator 的 interceptor 的状态,并在非 abort 的情况下更新 coordinator 的 txn 信息

之后在更上 Txn 层 会处理 TxnCoordinator 返回的 TransactionRetryWithProtoRefreshError, 使用 Error 中的“新 Txn”来新建 Meta 并更新 Txn 中的 TransactionSender(Epoch/优先级和各种被清空的状态更新)。
到这里当前事务的状态已经被准备为“等待 retry” 的样子,但 Error 会继续上浮到上层语句状态机。
3) 通过连接状态机自动重试
上一篇文章中我们看到对于每个连接 CRDB 会通过一个状态机来管理连接上的事务状态,在前面的重试都没法成功后,Error 会在 connExecutor#makeErrEvent 转换为驱动状态机的两种 Event(eventRetriableErreventNonRetriableErr) 之一。
Error 本身是否可以重试还是根据 TransactionRetryWithProtoRefreshError 来进行判断,如果 error 可以重试且当前没有给客户端返回过任何数据, 则可以返回 eventRetriableErr{CanAutoRetry: true} 的可自动重试 event。
状态机收到 event 后会使用当前状态和 event 组合匹配查找应该执行的 Transition 并执行, 对 eventRetriableErr:

  • 如果 eventRetriableErr 是 CanAutoRetry = True, 则会让状态机继续 rewind 并 restart 重置状态机上维护的 cache 和 prepare 等语句等信息
  • 如果这个 eventRetriableErr 是在 commit 的时候报错,且是 CanAutoRetry = FALSE 会进行正常的清理和返回错误操作
  • 如果 eventRetriableErr 是 CanAutoRetry = False 且当前事务 RetryIntent = False(即没用 savepoint),也是会清理和返回错误操作, 不过这里根据当前事务是不是隐式提交会有些不同: 隐式提交的事务时接下来会 stateNoTxn 继续执行下一条语句并做简单清理; 而非隐式提交则需要扭转到 abort 状态回滚事务且将当前 batch 里剩下的语句都 skip 掉
  • 如果 eventRetriableErr 是 CanAutoRetry = False 但当前事务 RetryIntent = False 即有 savepoint 过则会进入 stateRestartWait 状态只会将当前 batch skip 并重置下状态机的临时状态

本节我们主要看下第一种情况,即当前事务没有返回过结果给客户端, 则会利用 cmdBuff 中的游标 rewind 直接自动重试执行刚才失败的那批 SQL(因为有 batch),当前事务没有返回过结果给客户端的情况可能有:

  • autocommit = true 执行的语句一定是当前事务中的第一条语句
  • 主动 begin 的事务的第一条语句或第一条非 no return 的第一条语句
  • batch 执行的语句可以先执行最后执行完成后一次返回在最后之前可以没有相应
  • 对于一条语句或一个 batch 需要等执行完成或结果超过 results_buffer_size 才会向客户端返回

自动进行的重试如果重试成功就可以避免向客户端返回错误信息。 这里在补充说下 stmtbuf 的游标,首先 CRDB 对于每个客户端连接> 维护了 lastFlushed 的 offset 信息,并在每次 flush 时推动 offset,对于普通执行的语句每个语句都会 flush 结果让客户端看到,对于一个语句内如果已经产生了超过 sql.defaults.results.buffer.size 的数据会提前 flush; 此外在连接状态机上也维护一个叫 txnRewindPos 的 offset,他会在> 事务 start 或 restart 的时候进行设置,另外普通语句中类似设置 savepoint 或 设置事务优先级或进行 prepare 等不会实际执行的语句也会将 txnRewindPos 推动,而自动重试判断则根据是否 lastFlushed < txnRewindPos 来决定,效果是 begin 之后 set transaction 然后在去执行 update 如果遇到冲突这个 update 可以自动重试也,自动重试实现即将 stmtBuf 中在 txnRewindPos 之后的命令的结果 trim 掉,并重置 buf 的 curPos 为 txnRewindPos 下次消费 cmdBuf 会重复执行语句。 4) 通过连接状态机手动重试
对于连接状态机无法进行自动重试的情况,则需要客户端进行 Retry, 最普通的重试方式类似这样:

  1. begin;
  2. select * from t where id = 1; // query data
  3. // do some biz logic in client code
  4. update t set v = 12 where id = 1; // response a not retriable error(PG: 40001 + CRDB: CR000)
  5. rollback; // rollback old transaction
  6. begin; // reissue a new transaction
  7. select * from t where id = 1 // re-query data
  8. // redo biz logic in client code
  9. update t set v = 12 where id = 1; // resend update stmt
  10. ....

遇到错误后 rollback 并开始一个新事务并重新执行事务的代码和相关 SQL 的模式基本适用于所有类 OCC 的数据库, 不过直接重新开启事务有一些不太好的地方:

  • 重新执行的事务还是可能会冲突,高冲突场景很可能出现部分事务一直重试但一直没机会执行,事务被“饿死”的情况
  • 重试执行的事务可以复用先前执行事务已经完成的部分工作, 比如之前准备的 sender..

所以 CRDB 提供通过增加几个语法提供了一种更高效的客户端重试方法,因为是自定义语法 CRDB 推荐的是类似 ORM 或 Driver 开发者使用这种特殊语法,来屏蔽普通开发者的开发改造难度(在 CRDB 选择 PG 协议也是觉得其实大家都用 ORM 而 ORM 一定程度上屏蔽了 mysql 和 pg 的差异….不过这个情况在国内好像并不是这样,不知道国外- -?), 增加的语法有:

  • SAVEPOINT: 开启一个 savepoint, CRDB 目前的 savepoint 只是为了客户的重试并没有完整实现 PG 的 savepoint, 默认情况下 savepoint 的名字只能是 cockroach_restart(可以通过配置修改) ,另外需要在 begin 之后第一个操作数据语句之前执行(包括写也包括读)
  • RELEASE SAVEPOINT: 和 savepoint 配合使用,对于事务中语句未报错等同于 commit 的效果(执行后 intent 转 committed kv),如果事务中语句有过报过可重试错误则等同于 rollback 的效果
  • ROLLBACK TO SAVEPOINT:和 savepoint 配合使用可以在不结束当前 txn 的情况下将事务重置 Epoch +1 后直接用于后续重试,此外会提高事务优先级,避免重试后还继续被饿死问题

还是上面的例子如果使用 savepoint 重试会变成类似这样:

begin;
savepoint cockroach_restart; // set savepoint
select * from t where id = 1; //  query data
// do some biz logic in client code
update t set v = 12 where id = 1; // response a not retriable error(PG: 40001 + CRDB: CR000)
rollback to savepoint cockroach_restart; // rollback  to savepoint to prepare retry
select * from t where id = 1 // re-query data and no need to renew txn
// redo biz logic in client code
update t set v = 12 where id = 1;  // resend update stmt
release savepoint cockroach_restart;
commit;

即在 begin 后进行 savepoint cockroach_restart 并在返回 CR00040001错误时通过 rollback to savepoint cockroach_restart重置当前事务之后就可以将事务中的语句和代码逻辑重试执行。
代码实现上看,在 begin 之后执行 savepoint cockroach_restart 的会将连接状态机会修改状态,从 stateOpen{ImplicitTxn: fsm.False, RetryIntent: *} 变更为 stateOpen{ImplicitTxn: fsm.False, RetryIntent: True}
在这个 RetryIntent = true 状态下发生 eventRetriableErr (即有出现可重试错误)会流转状态为 stateRestartWait 状态(而普通没 savepoint 情况下是 stateAborted)。
而在 stateRestartWait 状态下,如果执行 rollback to savepoint 则会通过 eventTxnRestart 推动状态机重置 txn 流转为 stateOpen 并准备执行接下来的重试语句
而在 RetryIntent = true 状态下执行 release savepoint 会进行事务提交,并不过产生的是 eventTxnReleased 事件(正常 commit 是 eventTxnFinish) 推动流转到 stateCommitWait, 而之后在 stateCommitWait 下执行 commit 或 rollback 都会将状态正常流转到 stateNoTxn 等待后续命令的执行。
总结下在使用 savepoint 后可以在发生可重试事务时,会进入 RestartWait 状态,并在 rollback to savepoint 后使用前面“根据可重试错误类型生成用于重试”那准备好的可重试事务直接重试执行事务中的语句,并通过 release savepoint 来完成事务提交, 需要注意的是在 release savepoint 提交时可能也会报可重试 Error(我们知道类似 refresh 等检查需要到 commit 才会触发),release savepoint 遇到可重试错误会也可以继续 rollback to savepoint 并重试,所以这正是要在有 commit 的情况下还要加一个 release savepoint 的原因哈- -?

总结

本文我们自底向上看了重试代价从低到高的几种在 CRDB 中进行事务重试的机制,CRDB 会首先尝试在 replica-side 进行本地重试,并通过 txnwait.queue 来减少非必要重试,对于 replica-side 无法处理的错误会回到 coordinator-side 处理,但也会先做 batch 请求的 refresh 尝试,如果不行则会根据当前事务语句情况看能否自动重放语句重试,如果不能自动重试则需要客户端应用介入,为了避免饿死和提高效率 CRDB 还提供了阉割版 savepoint 机制来让 ORM 或 Driver 开发者编写更高效的客户端重试代码。过年放假回家下一篇打算看下 CRDB 的 dist plan 相关的逻辑~