Transaction in TiDB
TiDB 的事务模型参考了 Percolator 论文,Percolator 事务模型详见上一篇文章-Google Percolator 的事务模型
本文将详细介绍事务在 tidb 中实现,主要内容包括
- 基本概念
tidb中一致性事务的实现tikv中事务相关的接口逻辑tidb事务如何做到ACID
TIKV

tidb 为 tikv 的客户端,对外提供兼容 mysql 协议的分布式关系型数据库服务。
pd 提供两大功能
- 提供包括物理时间的全局唯一递增时间戳 tso
- 管理 raft-kv 集群
tikv 对外提供分布式 kv 存储引擎,同时实现了 mvcc 相关的接口--方便客户端实现 ACID 事务。
Columns in TIKV
tikv 底层用 raft+rocksdb 组成的 raft-kv 作为存储引擎,具体落到 rocksdb 上的 column 有四个,除了一个用于维护 raft 集群的元数据外,其它 3 个皆为了保证事务的 mvcc, 分别为 lock, write, default,详情如下:
Lock
事务产生的锁,未提交的事务会写本项,会包含primary lock的位置。其映射关系为
${key}=>${start_ts,primary_key,..etc}
Write
已提交的数据信息,存储数据所对应的时间戳。其映射关系为
${key}_${commit_ts}=>${start_ts}
Default(data)
具体存储数据集,映射关系为
${key}_${start_ts} => ${value}
Primary Key
TiDB 对于每个事务,会从涉及到改动的所有 Key 中选中一个作为当前事务的 Primary Key,事务的状态将保存在这个 Key 上。 在最终提交时,以 Primary 提交是否成功作为整个事务是否执行成功的标识,从而保证了分布式事务的原子性。
有了 Primary key 后,简单地说事务两阶段提交过程如下:
- 从当前事务涉及改动的 keys 选中一个作为
primary key, 剩余的则为secondary keys - 并行
prewrite所有keys。 这个过程中,所有key会在系统中留下一个指向primary key的锁。 - 第二阶段提交时,首先
commitprimary key ,若此步成功,则说明当前事务提交成功。 - 异步并行
commit secondary keys
一个读取过程如下:
- 读取
key时,若发现没有冲突的锁,则返回对应值,结束。 - 若发现了锁,且当前锁对应的
key为primary: 若锁尚未超时,等待。若锁已超时,Rollback它并获取上一版本信息返回,结束。 - 若发现了锁,且当前锁对应的
key为secondary, 则根据其锁里指定的primary找到primary所在信息,根据primary的状态决定当前事务是否提交成功,返回对应具体值。
TIDB 事务处理流程

注意:所有涉及重新获取 tso 重启事务的两阶段提交的地方,会先检查当前事务是否可以满足重试条件:只有单条语句组成的事务才可以重新获取 tso 作为 start_ts。
client向tidb发起开启事务begintidb向pd获取tso作为当前事务的start_tsclient向tidb执行以下请求:- 读操作,从
tikv读取版本start_ts对应具体数据. - 写操作,写入
memory中。
- 读操作,从
client向tidb发起commit提交事务请求tidb开始两阶段提交。tidb按照region对需要写的数据进行分组。tidb开始prewrite操作:向所有涉及改动的region并发执行prewrite请求。若其中某个prewrite失败,根据错误类型决定处理方式:KeyIsLock:尝试Resolve Lock后,若成功,则重试当前 region 的prewrite[步骤7]。否则,重新获取tso作为start_ts启动 2pc 提交(步骤5)。WriteConfict有其它事务在写当前key, 重新获取tso作为start_ts启动 2pc 提交(步骤5)。- 其它错误,向
client返回失败。
tidb向pd获取 tso 作为当前事务的commit_ts。tidb开始commit:tidb向primary所在region发起commit。 若commit primary失败,则先执行rollback keys,然后根据错误判断是否重试:LockNotExist重新获取tso作为start_ts启动 2pc 提交(步骤5)。- 其它错误,向
client返回失败。
tidb向tikv异步并发向剩余region发起commit。tidb向client返回事务提交成功信息。
TiKV 事务处理细节
Prewrite

Prewrite 是事务两阶段提交的第一步,其从pd获取代表当前物理时间的全局唯一时间戳作为当前事务的 start_ts,尝试对所有被写的元素加锁(为应对客户端故障,tidb 为所有需要写的 key 选出一个作为 primary,其余的作为secondary),将实际数据存入 rocksdb。其中每个key的处理过程如下,中间出现失败,则整个prewrite失败:
- 检查
write-write冲突:从rocksdb的write列中获取当前key的最新数据,若其commit_ts大于等于start_ts,说明存在更新版本的已提交事务,向tidb返回WriteConflict错误,结束。 - 检查
key是否已被锁上,如果key的锁已存在,收集KeyIsLock的错误,处理下一个key - 往内存中的
lock列写入lock(start_ts,key)为当前 key 加锁,若当前 key 被选为primary, 则标记为primary,若为secondary,则标明指向primary的信息。 - 若当前
value较小,则与lock存在一起,否则,内存中存入default(start_ts,key,value)。
处理完所有数据后,若存在 KeyIsLock 错误,则向 tidb 返回所有 KeyIsLocked 信息。
否则,提交数据到 raft-kv 持久化,当前 prewrite 成功。
Commit

tidb向tikv发起第二阶段提交commit(keys,commit_ts,start_ts)- 对于每个 key 依次检查其是否合法,并在内存中更新(步骤3-4)
- 检查 key 的锁,若锁存在且为当前 start_ts 对应的锁,则在内存中添加 write(key,commit_ts,start_ts),删除 lock(key,start_ts),继续执行下一个 key(跳至步骤3)。否则,执行步骤 4
- 获取当前 key 的
start_ts对应的数据write(key,start_ts,commit_ts), 若存在,说明已被提交过,继续执行下一个 key(跳至步骤4)。否则,返回未找到锁错误到tidb,结束。 - 到底层 raft-kv 中更新 2-4 步骤产生的所有数据--这边保证了原子性。
tikv向tidb返回commit成功。
Rollback
当事务在两阶段提交过程中失败时, tidb 会向当前事务涉及到的所有 tikv 发起回滚操作。

- `tidb` 向
tikv发起rollback(keys,start_ts), 回滚当前region中start_ts所在的 key 列表 - 对于每个
key,tikv依次检查其合法性,并进行回滚(依次对每个 key 执行 3-4) - 检查当前
key的锁,情况如下:
* 若当前 (start_ts,key) 所对应的锁存在,则在内存中删除该锁,继续回滚下一个key(跳至步骤2)
* 若当前事务所对应的锁不存在,则进入步骤 4 检查提交情况 tikv从raft-kv中获取到当前 (key,start_ts) 对应的提交纪录:- 若 commit(key,commit_ts,start_ts) 存在,且状态为PUT/DELETE, 则返回
tidb告知事务已被提交,回滚失败,结束。 - 若 commit(key,commit_ts,start_ts) 存在,且状态为
Rollback, 说明当前key已被rollback过,继续回滚下一个key(跳至步骤2) - 若 (key,start_ts) 对应的提交纪录不存在,说明当前
key尚未被prewrite过,为预防prewrite在之后过来,在这里留下(key,start_ts,rollback), 继续回滚下一个key(跳至步骤2)
- 若 commit(key,commit_ts,start_ts) 存在,且状态为PUT/DELETE, 则返回
- 将步骤 2-4 中更新的内容持久化到
raft-kv tikv向tidb返回回滚成功。
Resolve Lock
tidb 在执行 prewrite, get 过程中,若遇到锁,在锁超时的情况下,会向 tikv 发起清锁操作。

tidb向tikv发起Resolve(start_ts,commit_ts)tikv依次找出所有lock.ts==start_ts的锁并执行对应的清锁操作(循环执行步骤3-5)。tikv向raft-kv获取下一堆lock.ts==start_ts的锁。- 若没有剩余锁,退出循环,跳至步骤6。
- 对本批次获取到的锁,根据情况进行清锁操作
* 若 commit_ts 为空,则执行回滚
* 若 commit_ts 有值,则执行提交。 - 将2-5 产生的更新持久化到
raft-kv tikv向tidb返回清锁成功。
### Get

tidb向tikv发起get(key,start_ts)操作tikv检查当前key的锁状态:若锁存在且lock.ts<start_ts, 即start_ts这一刻该值是被锁住的,则返回tidb被锁住错误,结束。- 尝试获取
start_ts之前的最近的有效提交,初始化 版本号version为start_ts-1 - 从
write中获取 commit_ts<=version 的最大 commit_ts 对应纪录:
* 若write_type=PUT,即有效提交,返回tidb当前版本对应值。
* 若write 不存在或者 write_type=DEL, 即没有出现过该值,或该值最近已被删除,返回tidb空
* 若write_type=rollback,commit_ts,则 version=commit_ts-1, 继续查找下一个最近版本(跳至步骤3)
GC

tidb向tikv发起GC操作,要求清理safe-point版本之前的所有无意义版本tikv分批进行GCtikv向raft-kv从write列中获取一批提交纪录,若上一批已处理过,则从上一批最后一个开始扫。若未获取到数据,则本次GC完成,返回tidb成功。tikv对本批次的所有key挨个进行GC:- 清理所有小于
safe-point的Rollback和Lock - 若小于
safe-point的除了Rollback和Lock外第一个为delete, 清理所有safe-point之前的数据。 - 若小于
safe-point的除了Rollback和Lock外第一个为PUT, 保留该提交,清理所有该提交之前的数据。
- 清理所有小于
tikv向raft-kv更新步骤 4 的改动。进入下一批清理 (步骤3).
ACID in TIDB
原子性
两阶段提交时,通过 `primary key` 保证原子性。 `primary key` commit 成功与否决定事务成功与否。
一致性
隔离性
通过两阶段提交,保证隔离级别为 RR
持久性
tikv 保证持久化。
Why 2 PC?
1 PC 存在的问题
1 pc 无法保证隔离性为 RR.

假设初始状态下,(A,t0)=>v1, 以下操作有序发生时:
- txn1: 向
pd获取tso1作为当前事务的start_ts。 - txn2: 向
pd获取tso2作为当前事务的start_ts, 此时tso2>tso1 - txn2: 向
pd获取A, 此时获取到A在tikv上的小于等于tso2的最新版本为v1. - txn1: 向
pd更新A为v2, 此时tikv上有记录:(A,tso1)=>v2 - txn2: 向
pd获取A, 此时获取到A在tikv上的小于等于tso2的最新版本为v2.
2 PC 保证隔离级别
两阶段提交简介:
第一阶段 prewrite:
- 获取一个包含当前物理时间的、全局唯一递增的时间戳 t1 作为当前事务的 start_ts
- lock(key,start_ts), save data(key,start_ts,data)
第二阶段 commit 数据: - 获取一个包含当前物理时间的、全局唯一递增的时间戳 t2 作为当前事务的 commit_ts
- commit(key,commit_ts, start_ts)
事务读取数据步骤如下:
- 获取一个包含当前物理时间的、全局唯一递增的时间戳 t1 作为当前事务的 start_ts.
- 检查当前查询 key 的锁,若锁存在,且 lock.ts<t1, 说明这一刻 key 正在被写入,需要等待写入事务完成再读取。
- 到这一步时,说明 要么 锁不存在,要么 lock.ts > t1, 这两种情况都能说明, 下一个该 key 的 commit_ts 一定会大于当前的 t1, 所以可以直接读取当前小于 t1 的最大 commit_ts 对应的数据。
综上,两阶段提交可以保证事务的隔离级别为 RR,示例如下:
假设初始状态下,(A,t0)=>v1, 现有事务1-txn1, 事务2-txn2。其中 txn-1 将会修改 A 的值为 v2。假设 txn-1 的 start_ts=s1, commit_ts=c1, 事务 txn-2 的 start_ts=s2,那么有:
- 若 s1>s2, 那么 txn2 读取的是 s2 之前的数据,无论如何都不会读取到 txn-1 的更新的数据。

- 若 s1 < s2, 则有以下两种情况。
- 若 s2 读取 A 时, 若未发现lock(s1), 如上图中,txn-2 第一次获取数据时,此时 txn-1 尚未提交,由于 commit_ts 只有在 prewrite 完成后才能获取,所以可以保证 c1>s2, 也就是说,s2 读取不到 txn-1 的数据。
- 若 s2 读取 A 时, 若发现了 lock(s1), 如上图中,txn-2 第二次获取数据时的场景, 此时无法确定 c1 与 s2 的大小,所以此时 txn-2 会等待 txn-1 commit, 当 txn-1 commit 结束后,txn-2 才会根据 txn-1 的 commit_ts 确定是否获取 txn-1 的更新数据。也就是说,发现有锁时,txn-2 一定会等待直到确定与 txn-1 的 commit_ts 的大小才会决定获取哪份数据。
综上可见, 两阶段提交能很好的保证事务发生的时序,从而保证了事务的隔离级别为 可重复读(RR)