分布式事务与 2PC
说到分布式事务,不得不提 Two-Phase Commit,简称 2PC。但是,2PC 并没有一篇论文定义其完整的理论,它是从 1970 年代数据库的事务恢复、日志和分布式原子提交实践中逐步形成的。2PC 的实现模型往往跟参与者(如数据库、MQ、业务系统)、快照模型、并发控制协议、客户端-服务端交互、故障恢复模式都息息相关,根据不同的组合会演变出不同的落地模型。我们今天主要介绍其中一个经典实现:Percolator 事务模型。
Percolator 事务模型概述
Percolator 事务模型是 Google 在 OSDI 2010 发表的,当时 Google 网页索引数据存在于 BigTable内,数据规模已经达到数十 PB ,每天处理数十亿次更新。当时 Google 通过 MapReduce 等批处理系统来进行增量数据更新,但是无法逐个处理小更新,时效性始终比较低。
通过在 BigTable 引入 Percolator 事务模型,实现了在超大规模数据集上的增量更新,Google Search 上的文档陈旧时间降低了 50% 以上。Percolator 最初虽然应用于 BigTable,也完全可以应用于 DBMS,包括 TiDB、CockroachDB、YugabyteDB 都深度借鉴了 Percolator 模型。Percolator 大概交互架构如下:

- Bigtable Tabletserver:按 Key 排序的分布式存储,单行原子读改写,多版本数据,Tablet 分片及持久化。
- Percolator Library:可以理解为一种由客户端协调、以 Primary Lock 作为持久化提交点的 2PC 变体 ,实现了 Snapshot Isolation 隔离级别。
除了 "MVCC+2PC",Percolator 真正影响后续分布式数据库的是如下几条特性组合:
- 事务数据和事务元数据一起存入可复制的 KV 存储(vs 中心化事务表、事务日志存储)
- 未提交数据以 Intent/Lock 形式持久化;
- 用一个权威提交点决定整个事务的逻辑可见性(Primary Lock -> Primary Write Record 原子转换)
- 提交后可以异步清理其他参与者 (Roll Forward/Back Secondary Lock);
- 读取者遇到未完成事务时可以帮助恢复(该事务之外的其他请求也可以根据历史事务的状态去 Commit 或Rollback);
- 时间戳同时承担快照选择和版本排序功能(所有事务的
start_ts、commit_ts放在同一个全局有序的时间戳空间里,这套顺序既用来确定"事务读哪个快照",也用来排列"数据版本的先后")。
BigTable 事务相关的三个 Column Family 如下:
注:data 使用 start_ts 定位物理数据,write 使用 commit_ts定义逻辑可见性
| CF | 时间戳 | 含义 | 说明 |
|---|---|---|---|
| data | start_ts | 保存实际数据 | 事务准备写入的实际 Value |
| lock | start_ts | 保存未提交锁、事务信息及 Primary 位置 | 这个 Key 当前是否有未完成事务 |
| write | commit_ts | 表示版本已经提交,并指向对应的 data@start_ts | 某个版本最终是否提交、何时提交,以及对应哪份 data |
大家也可以根据表格里的注释内容及说明里的内容思考下为什么需要 3 个 CF?什么情况下可以使用两个 CF?用哪两个 CF?我们会在「TiDB 对 Percolator 的工程改进」章节揭晓答案。
从 Percolator 到 TiDB:整体事务架构
从 Percolator 事务模型落地的角度来看,TiDB 几乎完整复刻了 Percolator 论文的实现并进行了大量的工程优化。我们可以从组件、内容对照关系上快速感受一下:
| Percolator | TiDB/TiKV |
|---|---|
| Timestamp Oracle | PD TSO |
| Percolator Client | TiDB / client-go |
| Bigtable | TiKV |
| Tablet | Region |
| Primary Lock | Primary Key Lock |
| Secondary Lock | Secondary Lock |
| Prewrite | TiKV Prewrite |
| Lock cleanup | Resolve Lock |
| SI | TiDB Snapshot Isolation |
不同于 Percolator,TiDB 同时支持了乐观事务和悲观事务,且主推悲观事务。TiDB 悲观事务执行流程大致如下:

非数据库从业者未必很清楚分布式事务的发起点:Percolator 的内容是从应用侧 Commit 开始的(图中 E区域),对应分布式事务 Prewrite、Commit两阶段。
TiDB 悲观事务执行过程
接下来,我们以 Percolator 论文里 Bob、Joe 转账的例子,阐述一下 TiDB 悲观事务的执行过程。例子内容如下:
Bob 原有
$10,Joe 原有$2;Bob 向 Joe 转账$7,最终 Bob=$3、Joe=$9
时间戳顺序如下:
ini
旧数据 start_ts = 5
旧数据 commit_ts = 6
转账事务 start_ts = 7
锁 Bob 的 for_update_ts = 8
锁 Joe 的 for_update_ts = 9
最终 commit_ts = 10
为方便说明 TiKV 内几个 CF 的状态变化,我们假设这里的 value 值是大 Value,过程中涉及到 Lock、Write、Default 三个 CF。三个 CF 定义如下(基本同 Percolator,Data CF 改为 Default CF,加入悲观锁逻辑):
| CF | RocksDB Key | RocksDB Value | 作用 | 说明 |
|---|---|---|---|---|
| Default CF | user_key + start_ts | 用户 Value | 保存实际数据 | 事务准备写入的实际 Value |
| Lock CF | user_key | Lock Info | 保存悲观锁 保存未提交锁、事务信息及 Primary 位置 | 用作事务开始前的悲观锁 用作标识当前是否有未完成事务 |
| Write CF | user_key + commit_ts | WriteType + start_ts | 表示版本已经提交,并指向对应的 data@start_ts | 某个版本最终是否提交、何时提交,以及对应哪份 data |
执行步骤
1.初始状态:旧事务 start_ts = 5,旧事务 commit_ts = 6,通过 Write CF 里 commit_ts 检索到 Default CF 里 start_ts 对应的数值。
Default CF
| Key | Value |
|---|---|
| Bob.bal @ 5 | $10 |
| Joe.bal @ 5 | $2 |
Lock CF
| Key | Lock |
|---|---|
| Bob.bal | 无 |
| Joe.bal | 无 |
Write CF
| Key | Write Value |
|---|---|
| Bob.bal @ 6 | Put(start_ts=5) |
| Joe.bal @ 6 | Put(start_ts=5) |
2.执行 DML 更新 Bob,Bob余额减 $7,获取第一把悲观锁
2.1当前读 ,获取 Bob 余额为 10,tidb−serverMemDB内计算10−7=3。
ini
Write CF:
Bob.bal @ 6 → start_ts=5
Default CF:
Bob.bal @ 5 → $10
2.2获取 Bob 的悲观锁,锁 Bob 的 for_update_ts = 8
| Key | Lock |
|---|---|
| Bob.bal | Pessimistic(start_ts=7, for_update_ts=8) |
| Joe.bal | 无 |
3.执行 DML 更新 Joe,Joe 余额加$7,获取第二把悲观锁
3.1当前读 ,获取 Joe 余额为 2,tidb−serverMemDB内计算2+7=9。
ini
Write CF:
Joe.bal @ 6 → start_ts=5
Default CF:
Joe.bal @ 5 → $2
3.2获取 Joe 的悲观锁,锁 Joe 的 for_update_ts = 9
| Key | Lock |
|---|---|
| Bob.bal | Pessimistic(ts=7, forUpdateTS=8) |
| Joe.bal | Pessimistic(ts=7, forUpdateTS=9) |
截止到目前,只有 tidb-server 里的 MemDB 发生了数值变化,TiKV 侧只是加上了两个悲观锁,其余 CF 未发生变化。
4.应用侧执行 Commit,对应数据库侧开始发起 Prewrite,选择 Primary Lock = Bob.bal
5.Prewrite Bob ,检查是否预期内的悲观锁,更新 Default CF,Pessimistic Lock 转为 Put Prewrite Lock,Write CF 不变,结束后状态:
| CF | Key | Value | 说明 |
|---|---|---|---|
| Default | Bob.bal @ 5 | $10 | |
| Default | Bob.bal @ 7 | $3 | Default 新增一条最新记录 |
| Lock | Bob.bal | Put(ts=7, primary=Bob.bal, forUpdateTS=8) | Lock 从悲观锁转为 Prewrite 写锁 |
| Write | Bob.bal @ 6 | Put(start_ts=5) | 为真正 commit,不变 |
6.Prewrite Joe ,检查是否预期内的悲观锁,更新 Default CF,Pessimistic Lock 转为 Secondary Prewrite Lock,Write CF 不变,结束后状态:
6.1 Lock CF
| Key | Lock Type | start_ts | Primary | 含义 |
|---|---|---|---|---|
| Bob.bal | Put | 7 | Bob.bal | Primary Prewrite Lock |
| Joe.bal | Put | 7 | Bob.bal | Secondary Prewrite Lock |
6.2 Default CF
| Key | Value | 状态 |
|---|---|---|
| Bob.bal @ 5 | $10 | 旧数据 |
| Bob.bal @ 7 | $3 | 新数据,尚未提交 |
| Joe.bal @ 5 | $2 | 旧数据 |
| Joe.bal @ 7 | $9 | 新数据,尚未提交 |
7.提交 Primary Bob
ini
删除:
Lock CF[Bob.bal]
写入:
Write CF[Bob.bal @ 10]
= Put(start_ts=7)
7.1 Lock CF
| Key | Lock |
|---|---|
| Bob.bal | 无 |
| Joe.bal | Put(ts=7, primary=Bob.bal) |
7.2 Write CF
| Key | Write Value |
|---|---|
| Bob.bal @ 6 | Put(start_ts=5) |
| Bob.bal @ 10 | Put(start_ts=7) |
| Joe.bal @ 6 | Put(start_ts=5) |
提交 Bob 后,Bob 的 Lock CF 删除记录,Write CF 写入新的commits_ts到 start_ts 的索引记录:Bob.bal@10 = Put(start_ts=7)。对于经典 Percolator 2PC:Primary Bob 提交成功的一刻,就是整个转账事务的提交点,整个事务后续一定会被提交,也可以应答客户端了。
举个例子:Bob 提交后,TiDB 立刻宕机了。重启后,假如有另一个事务要读取 Joe 的余额,它首先会根据 Joe Lock CF 查出 Primary 在 Write CF 的 commit 记录,然后去 roll-forward Joe 的事务,最后读取到最新提交的数据。
8.提交 Secondary Joe
ini
删除:
Lock CF[Joe.bal]
写入:
Write CF[Joe.bal @ 10]
= Put(start_ts=7)
执行过程总览
| 阶段 | TiDB MemDB | Default CF | Lock CF | Write CF | 说明 |
|---|---|---|---|---|---|
| 初始状态 | 空 | Bob@5=10,Joe@5=2 | 空 | Bob@6→5,Joe@6→5 | |
| 更新 Bob | Bob=3 | 不变 | Bob 悲观锁 | 不变 | |
| 更新 Joe | Bob=3,Joe=9 | 不变 | Bob、Joe 悲观锁 | 不变 | |
| Prewrite | 仍保存写集 | 写 Bob@7=3、Joe@7=9 | 悲观锁转换成 Put Prewrite Lock | 不变 | |
| Commit Primary | 等待提交完成 | 不变 | 删除 Bob 锁,保留 Joe 锁 | Bob@10→7 | 提交后事务就算结束了,可以应答客户端、并行提交 Secondary。 |
| Commit Secondary | 事务结束后释放 | 不变 | 删除 Joe 锁 | Joe@10→7 |
TiDB 对 Percolator 的工程化改进
TiDB 几乎完美复刻了 Percolator 论文里分布式事务的内容,同时,也做了非常多的工程优化,这里列举几项比较典型的优化,每一项其实都非常的复杂,这里简单概述一下。
Pessimistic Transaction
Percolator 原文里是乐观事务,TiDB 拓展到了悲观事务,将高冲突场景的失败从 Commit 阶段提前到 DML 加锁阶段,并支持等锁和死锁检测。
Short Value:小 Value 不再访问 Default CF
标准读取需要两次 RocksDB 查询,先查询 Write CF 得到 start_ts,再查询 Default CF 查询到具体的 Value。对于非常小的 Value,这两次 LSM Tree 查询成本可能比 Value 本身还高。
通过在 Prewrite 阶段把 value 内联到 Lock CF 和 Write CF,小 value 不再写入 Default CF,节约了一次 LSM Tree 的查询。这也回答了开篇的问题,对于特定小 value 确实可以用两个 CF。但是,大多数情况下,我们仍然需要通过 Write CF 基于 commit_ts、低成本地控制多版本可见性,再真正需要取值时再去 Default CF 里取到对应的 Value 值。
Async Commit
通过在 Primary Lock 里存入 Secondary Keys及min_commit_ts,在所有 Prewrite 成功并确定事务已经具备可恢复的提交状态后,在 Primary Commit 之前就返回成功给客户端,省掉了 Primary Commit 这一 RPC 过程,进一步降低事务的时延。Async Commit 是 TiDB 里非常好的一个创新,在其他一些 NewSQL 论文里也经常被提及,值得单写一篇文章详述一下。
1PC
所有写入能够由一个 Region 的单个原子请求处理的事务,即直接走 Region 的 Raft 流程。TiDB 的 committer 会在 Prewrite 返回后判断实际采用的是 1PC、Async Commit 还是普通 2PC,如果判断为冲突,还会继续 2PC 的处理过程。
总结
总体来看,Percolator 是 2PC 里面非常优雅的一种实现,把复杂的分布式事务、并发控制变得非常"简单可依赖",但也会有一些潜在的提升方向。比如:
- 如果是多语言技术栈,多个语言的客户端都实现正确的事务流程是非常困难的一件事情;
- 对于长事务的支持天然比较弱,也许可以把 client 侧临时数据和 KV 侧的临时数据结合起来,或者事务的协同也下沉到 KV 侧;
- 把参与者从 Tablet 变成统一事务日志流,每个事务日志流承载多个 Tablet,最大化 1PC 的比例。
- 如何增加本地事务的比例,尽量降低 RPC 数量,对于小集群尤其有利。