Percolator事务的理论基础
Percolator的来源
Percolator事务来源于Google在设计更新网页索引的系统时提出的论文Large-scale Incremental Processing Using Distributed Transactions and Notifications中,Google用它在支持单行事务的分布式数据库Bigtable的基础上实现跨节点的分布式事务。Percolator是一种优化版的2PC,但是与 常见的2PC不同,它并没有一个单独的coodinator的角色,而是作为一个库将所有逻辑放在客户端实现,只需要下层存储支持单行事务即可。原始的Percolator事务模型中,下层的存储节点可以对于上层事务完全无感知。
为了确定事务的先后顺序,Percolator还要求一个全局的授时中心,用于获取全局有序的递增时间戳(比如TiDB中的pd组件)。
隔离级别
Percolator事务实现了SI隔离级别(TiDB中将它作为RR)。每个事务都从授时中心获取两个时间戳:startTS 和 commitTS,startTS 在事务开始时获取,commitTS在事务结束时获取,事务之间通过这两个时间戳来确定先后。例如有两个事务T1和T2,如果T1的commitTS小于T2的startTS,则认为T1发生在T2之前 ,如果两个事务的时间戳区间[startTS, commitTS]存在交叉,则两个事务是并发的。在SI隔离级别下一个事务只应该看到commitTS小于自己的startTS的事务所写入的数据。
存储模型
Percolator的存储基于Bigtable,其存储模型有列族的概念(CF),同一个列族的数据存储在一起。每个逻辑上的行分为多个列族,每个列族可以分为多个列,而其中每一列的数据以时间戳倒序排序。典型的行如下图所示:
Key | Data | Lock | Write |
---|---|---|---|
Bob | 6: 5:$10 | 6: 5: | 6:data@5 5: |
Joe | 6: 5:$2 | 6: 5: | 6:data@5 5: |
key为整个行的key,data为该行的数据。而Percolator要求额外的两个CF为:Lock和Write。Lock顾名思义表示该行的锁,而write的版本号表示写入这行数据的事务提交的时候时间戳commitTS。以Bob行为例,Key为Bob用于唯一确定该行,此时Bob没有被加锁Lock为空,在版本号为6的Write CF中有数据data@5,表示对应的数据在Data CF中版本号为5的地方。写入这行数据的事务startTS为5,commitTS为6。这里的Write CF尽管看上去额外占了一行,并不会占据额外的整行空间。
基本步骤
总体来说,TiKV 的读写事务分为两个阶段:1、Prewrite 阶段;2、Commit 阶段。
客户端会缓存本地的写操作,在客户端调用 client.Commit() 时,开始进入分布式事务 prewrite 和 commit 流程。
Prewrite 对应传统 2PC 的第一阶段
-
首先在所有行的写操作中选出一个作为 primary row,其他的为 secondary rows
-
PrewritePrimary: 对 primaryRow 写入锁以及数据,锁中记录本次事务的开始时间戳。上锁前会检查:
- 该行是否已经有别的客户端已经上锁 (Locking)
- 是否在本次事务开始时间之后,检查versions ,是否有更新 [startTs, +Inf) 的写操作已经提交 (Conflict)
在这两种种情况下会返回事务冲突。否则,就成功上锁。将行的内容写入 row 中,版本设置为 startTs
- 将 primaryRow 的锁上好了以后,进行 secondaries 的 prewrite 流程:
- 类似 primaryRow 的上锁流程,只不过锁的内容为事务开始时间 startTs 及 primaryRow 的信息
- 检查的事项同 primaryRow 的一致
- 当锁成功写入后,写入 row,时间戳设置为 startTs
以上 Prewrite 流程任何一步发生错误,都会进行回滚:删除 Lock 标记 , 删除版本为 startTs 的数据。
当 Prewrite 阶段完成以后,进入 Commit 阶段,当前时间戳为 commitTs,TSO 会保证 commitTs > startTS
Commit 的流程对应 2PC 的第二阶段
- commit primary: 写入 write CF, 添加一个新版本,时间戳为 commitTs,内容为 startTs, 表明数据的最新版本是 startTs 对应的数据
- 删除 Lock 标记
值得注意的是,如果 primary row 提交失败的话,全事务回滚,回滚逻辑同 prewrite 失败的回滚逻辑。
如果 commit primary 成功,则可以异步的 commit secondaries,流程和 commit primary 一致, 失败了也无所谓。Primary row 提交的成功与否标志着整个事务是否提交成功。
事务中的读操作
- 检查该行是否有 Lock 标记,如果有,表示目前有其他事务正占用此行,如果这个锁已经超时则尝试清除,否则等待超时或者其他事务主动解锁。注意此时不能直接返回老版本的数据。
- 读取至 startTs 时该行最新的数据,找到最近的时间戳小于startTS的write CF,从其中读取版本号t,读取为于 t 版本的数据内容。
由于锁是分两级的,Primary 和 Seconary row,只要 Primary row 的锁去掉,就表示该事务已经成功提交,这样的好处是 Secondary 的 commit 是可以异步进行的,只是在异步提交进行的过程中,如果此时有读请求,可能会需要做一下锁的清理工作。因为即使 Secondary row 提交失败,也可以通过 Secondary row 中的锁,找到 Primary row,根据检查 Primary row 的 meta,确定这个事务到底是被客户端回滚还是已经成功提交。
转账示例
下面以论文中转账的一个例子来展示大体流程,以上面的Bob和Joe为例,假设Bob要转账7元给Joe。
- Prewrite
首先需要随机选择一行最为primaryRow ,这里选择Bob。以事务开始时间戳为版本号,写入Lock与数据
Key | Data | Lock | Write |
---|---|---|---|
Bob | 7:$3 6: 5:$10 | 7:I am Primary 6: 5: | 7: 6:data@5 5: |
Joe | 6: 5:$2 | 6: 5: | 6:data@5 5: |
从上图可以看出转账事务的startTS为7,所以写入了版本号为7的Lock与Bob的新数据,Lock中有表示自己是primaryLock的标志。随后进行secondary rows的上锁,这里只有Joe。
Key | Data | Lock | Write |
---|---|---|---|
Bob | 7:$3 6: 5:$10 | 7:I am Primary 6: 5: | 7: 6:data@5 5: |
Joe | 7:$9 6: 5:$2 | 7:primary@Bob 6: 5: | 7: 6:data@5 5: |
Joe的Lock中保存了primary的信息,用于找到这次提交的primary row Bob。
如果在prewrite的过程中检测到了冲突,则整个事务需要进行回滚。例如,在此时另一个事务的startTS为8,试图对Bob进行加锁,发现已经被startTS为7的事务加锁,则该事务会检测到冲突,事务回滚。也有可能发现在自己startTS以后,已经有事务提交了新的数据,出现了大于startTS的write,此时事务也需要回滚。
-
Commit
首先commit primary row,客户端通过Bigtable的单行事务,清除primary行的锁,并且以提交时间戳在write写入提交标志。
key data lock write Bob 8:
7:$3
6:
5:$108:
7:
6:
5:8:data@7
7:
6:data@5
5:Joe 7:$9
6:
5:$27:primary@Bob
6:
5:7:
6:data@5
5:primary row的Write CF的写入是整个事务提交的标志,这个操作的完成就意味着事务已经完成提交了。
write中写入的数据指向Bob真正存放余额的地方。完成这一步就可以向客户端返回事务commit成功了。接下可以异步释放secondary rows的锁。如果在commit阶段发现primary锁已经不存在(可能因为超时被其他事务清除),则提交失败,事务回滚。
key data lock write Bob 8:
7:$3
6:
5:$108:
7:
6:
5:8:data@7
7:
6:data@5
5:Joe 8:
7:$9
6:
5:$28:
7:
6:
5:8data@7:
7:
6:data@5
5:实际上,即使在执行这一步前,客户端挂了而没能处理这些行的锁也没有问题。当其他事务读取到这样的行的数据的时候,通过锁可以找出primary行,从而判断出事务的状态,如果已经提交,则可以清除锁写入提交标志。