发展路线
Percolator 基于 Bigtable 的单行事务提供了多行事务的能力(bigTable:
数据模型的角度,Bigtable 可以理解为是一个稀疏的,多维的持久化的键值对Map,一个键值对的格式如下: (row:string, column:string,timestamp:int64)->string)
在 Bigtable 中,一行 row 可以包含多个 column,Bigtable 提供了针对单行的跨 column 的事务能力,Percolator 中多处利用了 Bigtable 单行事务来保证对同一个 row 多个操作的原子性。
Prewrite
- 在事务开启时会从 TSO 获取一个 timestamp 作为 start_ts。
- 选择一个 Write 作为 primary,其它 Write 则是 secondary;primary 作为事务提交的 sync point 来保证故障恢复的安全性(详情见 Failover 章节)。
-
先 Prewrite primary,成功后再 Prewrite secondaries;先 Prewrite primary 是 failover 的要求(详情见 Failover 章节)。 对于每一个 Write:
3.1. Write-write conflict 检查: 以 Write.row 作为 key,检查 Write.col 对应的 write 列在 [start_ts, max) 之间是否存在相同 key 的数据 。如果存在,则说明存在 write-write conflict ,直接 Abort 整个事务。
3.2. 检查 lock 列中该 Write 是否被上锁,如果锁存在,Percolator 不会等待锁被删除,而是选择直接Abort 整个事务。这种简单粗暴的冲突处理方案避免了死锁发生的可能。
3.3. 步骤 3.1,3.2 成功后,以 start_ts 作为 Bigtable 的 timestamp,将数据写入 data 列。由于此时 write 列尚未写入,因此数据对其它事务不可见。
3.4. 对 Write 上锁:以 start_ts 作为 timestamp,以 {primary.row, primary.col} 作为 value,写入 lock列 。{Priamry.row, Primary.col} 用于 failover 时定位到 primary Write。
对于一个 Write,3 中多个步骤都是在同一个 Bigtable 单行事务中进行,保证原子性,避免两个事务对同一行进行并发操作时的 race;任意 Write Prewrite 失败都会导致整个事务 Abort;Prewrite 阶段写入 data 列的数据对其它事务并不可见。
在 prewrite 时,我们用
来表示每一个 key 的写入。Mutation
Mutation
分为 Put
,Delete
,Lock
和 Insert
四种类型。Put
即对该 key 写入一个 value,Delete
即删除这个 key。Insert
与 Put
的区别是,它在执行时会检查该 key 是否存在,仅当该 key 不存在时才会成功写入。Lock
是一种特殊的写入,并不是 Percolator 模型中的 Lock
,它对数据不进行实际更改,当一个事务读了一些 key、写了另一些 key 时,如果需要确保该事务成功提交时这些 key 不会发生改变,那么便应当对这些读到的 key 写入这个 Lock
类型的 Mutation
。比如,在 TiDB 中,执行 SELECT ... FOR UPDATE
时,便会产生这种 Lock 类型的 Mutation
。
for update 的作用和目的:
select for update 是为了在查询时,对这条数据进行加锁,避免其他用户以该表进行插入,修改或删除等操作,造成表的不一致性。就是它读取的是 记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进⾏加锁。可以读取到最新的数据,进行当前读。
Commit
如果 Prewrite 成功,则进入 Commit 阶段:
- 从 TimeOracle 获取一个 timestamp 作为 commit_ts。
-
先 Commit primary , 如果失败则 Abort 事务:
2.1. 检测 lock 列 primary 对应的锁是否存在,如果锁已经被其它事务清理(触发 failover 可能导致该情况),则失败。
2.2. 步骤 2.1 成功后以 commit_ts 作为 timestamp,以 start_ts 作为 value 写 write 列。读操作会先读 write 列获取 start_ts,然后再以 start_ts 去读取 data 列中的 value。
2.3. 删除 lock列中对应的锁。 - 步骤 2 成功意味着事务已提交成功,此时 Client 可以返回用户提交成功,再异步的进行 Commit secondary 。Commit seconary 无需检测 lock 列锁是否还存在,一定不会失败,只需要执行 2.2 和 2.3
处理残留的锁
如果客户端在进行事务的过程中崩溃,或者由于网络等原因无法完整提交整个事务,那么可能会有残留的锁留在 TiKV 中。
在 TiKV 一侧,当一个事务(无论是读还是写)遇到其它事务留下的锁时,如上述 prewrite 的过程一样,会将遇见锁这件事情返回给 client。Client 如果发现锁没有过期,便会尝试 backoff 一段时间重试;如果已经过期,则会进行 ResolveLocks
。
ResolveLocks 时,首先获取该锁所属的事务目前的状态。它会对该锁的 primary (primary 存储在锁里)调用 kv_cleanup
这一接口。Cleanup 的执行逻辑在 这里。它其实是调用 MvccTxn::rollback
。如果对一个已经提交的事务调用 rollback,会返回 Committed
错误,错误信息中会带上该事务提交的 commit_ts
。Cleanup 会在响应中传回该 commit_ts
。这里调用 cleanup 的意义是,检查 primary 是否已提交,如果没有则回滚;如果已经提交则取得其 commit_ts
,用于 commit 该事务的其它 key。接下来便可以根据调用 cleanup 得到的信息处理当前事务遇见的其它锁:调用 TiKV 的 kv_resolve_lock
接口将这些锁清掉,而具体清理时是提交还是回滚则取决于之前的 cleanup 给出的结果。
Scheduler 与 Latch
我们知道,Percolator 的事务算法建立在 BigTable 支持单行事务这一基础之上。在 TiKV 中,发往 engine 的每一个写操作(WriteBatch)都会被原子地写入。但是,显然我们上面说的 prewrite 和 commit 操作,都需要先读后写,那么仅仅支持原子的写入肯定是不够的,否则存在这种情况:
- 事务 A 尝试 prewrite key1,读取之后发现没有锁
- 事务 B 尝试 prewrite key1,读取之后也发现没有锁
- 事务 A 写入 prewrite
- 事务 B 写入 prewrite
这样的话,事务 A 写入的锁会被覆盖,但是它会以为自己已经成功地写入。如果接下来事务 A 提交,那么由于事务 A 的一个锁已经丢失,这时数据一致性会被破坏。
Scheduler
调度事务的方式避免了这种情况。Scheduler
中有一个模块叫做 Latches
,它包含很多个槽。每个需要写入操作的任务在开始前,会去取它们涉及到的 key 的 hash,每个 key 落在 Latch
的一个槽中;接下来会尝试对这些槽上锁,成功上锁才会继续执行取 snapshot、进行读写操作的流程。这样一来,如果两个任务需要写入同一个 key,那么它们必然需要在 Latches
的同一个槽中上锁,因而必然互斥
SnapshotRead
Percolator 实现的隔离级别是Snapshot Isolation,Snapshot Isolaton 是一种在多个数据库被广泛采用的隔离级别,它最早见于数据库领域的经典论文 [3],从隔离性的角度完胜 Read-Committed,和 Repeatable-Read 互有胜负,Snapshot Isolation 存在写偏斜(Write-skew)
(补充:幻读:一个事务内,多次读取满足指定条件的数据,读出来的结果不一样。 写倾斜:事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。 当事务提交时,支持决定的前提条件已不再成立。快照隔离级别比较特殊,图上没有。
)
但是不存在幻读(Phantom), 而 Repeatable-Read 正好相反。在介绍 Percolator 如何保证 Snapshot-Isolation 之前先来看 Snapshot Isolation 的要求,根据 [3] 的描述,Snapshot Isolation要求:
- 当一个事务 T1 准备提交时获取一个 Commit-timestamp(commit_ts),大于所有已存在的 Commit-timemstap 和 Start-timestamp(start_ts)。
- First-committer-wins。一个事务T1能够提交成功当且仅当在
[T1.start_ts, T2.commit_ts]
范围内不存在另一个事务T2,T2 和T1 修改了同一行,T1.start_ts < T2.commit_ts < T1.commit_ts,即 T2 在 [T1.start_ts, T1.commit_ts] 之间提交,且T2提交成功。不然 T1 应当 Abort。 - Snapshot-read。事务的读操作都满足 Snapshot-read:即对于一个事务T1而言,所有 commit_ts <= T1.start_ts的事务的修改都对T1可见,所有commit_ts > start_ts的事务的修改都对T1不可见。
Failover
对于分布式事务一个完备的 failover 机制要求满足两点:
- Safety:针对 Percolator场景即是同一个事务的两个 Write,不存在一个 Write 的状态为 Committed,另一个则是 Aborted。
- Liveness: 所有 Write 最终都会处于 Committed 或者 Aborted。
Liveness 要求 failover 最终能够被触发,对此 Percolator 采用 lazzy 的方式,一个事务的 failover 由另一个事务的读操作触发:如果一个事务读某个 key 时发现该 key 被锁住,Snapshot-read 要求等待锁删除后才能发起读取;在等待超过一段时间后,会认为锁住该 key 的事务可能由于 Client crash 或者网络分区等原因而 hang 住,尝试通过回滚或者继续提交锁对应的事务的方式来清理锁。
对于一个分布式事务,保证 Safety 并不容易;针对 Percolator 的场景,由于异步网络的存在,永远都无法确定 Client 是否真正 crash。Failover 机制需要处理 failover 和 Client 的正常提交流程并发执行的问题,需要避免发生 failover 触发导致事务 T1 回滚,但是实际上存活的 Client 则继续提交 T1。在异步网络下,这样的 race 需要处理的状态空间通常是比较庞大,在任意情况下保证 Safety 并不容易。
保证 Safety 的一个核心点是如何判定一个分布式事务是否已经被提交:即什么情况下,可以判定事务已 Committed,什么情况下判定事务未 Committed,可以发起 rollback。Percolator 的解法是选择一个 Write 作为 primary,以 primary 作为整个事务的 sync point。具体的:
- 在 Prewrite 阶段先 Prewrite primary
- 在 Commit 阶段先 Commit primary。
- Primary committed 意味着事务 Committed;在 failover 触发时,尝试清理事务 T1 的某个 Write T1.w1 的锁之前会先确认 T1 primary Write 的状态;如果判定 T1 primary Write 未 Committed,则判定 T1 未Committed,会执行 rollback:先清理 primary lock,然后在清理 T1.w1 的锁。如果 判定 T1 primary Write 已 Committed,则判定 T1 已 Committed,执行 roll-forward:从 primary 获取 commit_ts,提交 T1.w1,也达到清理锁的目的。
针对 3 展开描述,具体的:
-
通过 lock 列存储的 T1.primary 的信息 {primary.row, primary.col} ,并获取 T1.start_ts。
-
通过 {primary.row, primary.col, T1.start_ts} 查询 lock 列上 T1.primary 的锁是否存在,如果锁存在那么 T1 一定未 Committed,可以执行 rollback,先清理 T1.primary 的锁,再清理 T1.w1 的锁。检查 lock 列上 primary 锁是否存在和清理 T1.primary 的锁在一个 Bigtable 单行事务中执行,保证原子性。 由于先清理 primary 锁,即使此时 Client 此时已经 Prewrite 成功,进入 Commit 阶段, Commit primary 也会由于锁已被清理而失败(见 Commit 流程步骤2)。
-
如果步骤 2 判定 primary 的 lock 列不存在,需要考虑如下三种可能:
a. primary 未 Prewrite
b. primary 已 Committed
c. primary 已 Aborted
在 Prewrite 阶段先写 primary 确保 a 不可能发生,T1.w1 存在意味着 primary 必定 Prewrite 成功。笔者并未发现 Percolator 论文关于区分 b, c 的细节,也未发现 Abort 时所执行的动作的细节。故下面是笔者按自己的理解补充的可行方案:此时需要去检查 primary 的 write 列是否存在,由于此时并不知道 commit_ts,因此需要:检查 write 列 [T1.start_ts, max] 范围内是否存在 row 相同且 value 等于 T1.start_ts 的数据。由于 start_ts 的唯一性,存在即可判定 primary 的 write 列存在,即 T1 已 Committed,不存在则认为 T1 已经 Aborted。这也意味着 Percolator 在 Abort 时可以只清理 lock 列,无需持久化一条额外的 Aborted 记录。
-
如果步骤 3 判定 T1 已 Committed,那么需要执行 roll-forward,从 primary 的 write 列获取 T1 的 commit_ts,针对 T1.w1 写入 write 列后清理锁;如果步骤 3 判定 T1 未 Committed,进行 rollback ,过程同步骤2。
TiDB5.0 新feature:异步commit
原因:
TiDB 至少要经历以下的步骤才能向用户返回提交结果:
-
并发地 prewrite 所有的 keys;
-
从 PD 获取时间戳作为 Commit TS;
-
提交 primary key。
整个提交过程的关键路径包括了至少两次 TiDB 和 TiKV 之间的网络交互。只有提交 secondary keys 的操作在 TiDB 后台异步完成。
Async Commit 力图实现的,就是把确定事务状态的时间提前到完成 prewrite 的时候(思考:
在 TiDB 的事务模型中,我们可以大致将 TiDB 节点认为是事务的协调者,而 TiKV 节点是事务的参与者。传统二阶段提交中我们一般默认协调者的数据存储在节点本地,但 TiDB 事务模型中,所有的事务相关数据都在 TiKV 上。也正是因此,传统二阶段提交中,第一阶段结束后事务状态即可确定;而 TiDB 需要完成第二阶段的一部分,将事务状态存储到 TiKV 上,才能向用户响应。
不过这也意味着,TiDB 之前的事务模型是有提升空间的。能否改进 Percolator 事务模型,让事务的状态在完成第一阶段后,无需额外的网络交互即可确定呢?
),让整个提交的第二阶段都异步化进行。也就是说,对于 Async Commit 事务,只要事务所有的 keys 都被成功 prewrite,就意味着事务提交成功。
-
如何确定所有 keys 已被 prewrite。
-
如何确定事务的 Commit TS。、
1.建立一个primary指向secondary的指针,原来只有secondary指向primary。判断 Async Commit 事务则需要知道所有 keys 的状态,所以我们需要能从事务的任意一个 key 出发,查询到事务的每一个 key。于是我们做了一点小的修改,保留从 secondary key 到 primary key 指针的同时,在 primary key 的 value 里面存储到到每一个 secondary key 的指针:
2. Async Commit 事务的状态在 prewrite 完成时就必须确定了,Commit TS 作为事务状态的一部分也不例外。
于 Async Commit 事务的每一个 key,prewrite 时会计算并在 TiKV 记录这个 key 的 Min Commit TS,事务所有 keys 的 Min Commit TS 的最大值即为这个事务的 Commit TS。
快照隔离级别举例
如果一个事务只更新一条记录的非唯一索引,或是只插入一条没有二级索引的记录,它只会涉及到单个 Region。在这种只涉及一个 Region的场景下,是不是可以不使用分布式事务提交协议,只用一个阶段完成事务的提交?这当然是可行的,但困难就在于一阶段提交的事务的 Commit TS 如何确定。
SSI和SI
Snapshot Isolation(SI)和Serializable Snapshot Isolation(SSI)区别
所谓的Strict Serializable(强一致)模型,就是同时满足Serializable和Linearizable,即所有事务按串行化隔离级别执行(注:不一定是真的串行化执行,而是最终效果和某种串行执行效果相同)、并能立刻读出“正确”(注:正确表示能立刻读到一个对象最近写入的数据)数据的一致性模型,而Serializable Snapshot Isolation(SSI)只满足了Serializable却不具备Linearizable的语义,即它只满足了事务按某种顺序串行执行,却无法满足能立刻读到“正确”的数据
SSI跟SI的不同在于:在读数据时,SSI记录事务读取数据的集合,再使用算法进行检测,如果检测到可能会有不可串行化的发生,则abort。 这种算法可能会有误判,但不会有遗漏,因此SSI不存在写偏序问题。 SSI是真正的Serializable隔离级别
参考资料:
Async Commit 原理介绍丨 TiDB 5.0 新特性 | PingCAP