你在运营一个搜索引擎。全网有万亿级别的网页,你需要维护一份倒排索引(inverted index)。一种做法是 MapReduce 全量重建——把整个网页库扫一遍,从头生成索引。这确实能保证一致性,但问题是:全量重建一次需要几天,而在这几天里,新抓取的网页无法被检索到。
换一种做法:增量更新。每次只处理新抓取到的网页,更新受影响的索引条目。这要快得多,但引入了一个棘手的问题——一个网页的变更可能影响多个索引条目(多个关键词的倒排列表),你需要保证这些条目要么全部更新,要么都不更新。否则,索引会处于不一致状态:某些关键词能搜到这个网页,另一些搜不到。
这就是分布式事务(distributed transaction)的需求:跨越多行、多机器的原子更新。
Google 的 Bigtable 不提供跨行事务。它只保证单行操作的原子性。2010 年,Google 发表了 Percolator 论文,描述了如何在 Bigtable 之上构建一个支持快照隔离(Snapshot Isolation)的跨行事务系统。这个系统不是一个通用数据库——它是一个增量处理框架(incremental processing framework),专门用来解决上述的索引更新问题。事务功能是手段,不是目的。
Percolator 的设计思路有一个核心洞察:把事务的协调状态编码到数据本身中。 传统两阶段提交(2PC)依赖一个专用的协调者(coordinator)节点来记录事务状态。如果协调者挂了,事务的命运就不确定了——参与者不知道该提交还是回滚。Percolator 把”事务是否已提交”这个信息写在 Bigtable 的行里,任何客户端都能读到,从而消除了对专用协调者的依赖。
这篇文章完整拆解 Percolator 的设计:三列(column)结构如何编码事务状态、两阶段提交的详细流程、冲突检测与锁清理、Go 语言简化实现、TiDB 对 Percolator 模型的工程改进,以及 Percolator 与经典 2PC 的本质区别。
一、Percolator 论文的背景与动机
Google 的索引更新问题
Google 在 2010 年发表了论文 “Large-scale Incremental Processing Using Distributed Transactions and Notifications”。论文的第一作者是 Daniel Peng,第二作者是 Frank Dabek。
论文要解决的问题很具体:Google 的网页索引(web index)需要持续更新。互联网每天都有大量网页被创建、修改、删除。搜索引擎的索引必须反映这些变化,否则用户搜到的就是过时的信息。
在 Percolator 之前,Google 用 MapReduce 来构建索引。MapReduce 是批处理(batch processing)模型——读取整个输入数据集,处理后输出完整的结果。对于网页索引来说,就是把所有已知网页扫一遍,重新生成整个倒排索引。
这个做法的问题是延迟。论文中提到的数据规模:数十 PB 的数据,分布在数千台机器上。全量 MapReduce 任务需要运行数天。一个网页从被抓取到出现在搜索结果中,延迟可能长达数天。
增量处理的核心诉求是:当一小部分输入数据发生变化时,只重新计算受影响的输出部分。对于索引来说,如果一个网页被修改了,只需要更新这个网页相关的倒排列表,而不需要重建整个索引。
为什么 Bigtable 不够用
Bigtable 是 Google 的分布式存储系统,提供了一个按行键(row key)排序的键值存储(sorted key-value store)。每一行可以有多个列族(column family),每个列下面可以存多个版本(version),用时间戳(timestamp)区分。
Bigtable 的关键特性是单行原子性:对同一行的读-修改-写(read-modify-write)操作是原子的。但 Bigtable 不提供跨行事务——你无法原子地修改两行数据。
对于索引更新来说,这是不够的。考虑一个具体场景:
网页 P 的标题从 "分布式系统入门" 变为 "分布式事务入门"
需要的原子操作:
1. 从关键词 "系统" 的倒排列表中删除 P
2. 向关键词 "事务" 的倒排列表中添加 P
3. 更新网页 P 的元数据记录
这三个操作涉及三个不同的行(三个不同的 row key)。
如果只完成了操作 1 和 3,但操作 2 失败了,
搜索 "事务" 找不到 P,搜索 "系统" 也找不到 P——网页 P 从索引中"消失"了。
这就是 Percolator 要解决的问题:在 Bigtable 之上构建跨行事务。
关键设计约束
Percolator 的设计有几个重要约束,理解这些约束才能理解它为什么长成这样:
第一,必须建立在 Bigtable 之上。 Google 已经在 Bigtable 上投入了巨大的基础设施。Percolator 不能要求替换存储层,只能在现有的 Bigtable API 上做文章。这意味着 Percolator 只能利用 Bigtable 提供的原语:单行原子读写、列族、多版本时间戳。
第二,规模优先,延迟其次。 Percolator 处理的是后台的增量索引任务,不是在线 OLTP 请求。单个事务的延迟可以在数十毫秒的量级,但系统必须能处理数十 PB 的数据规模。
第三,事务冲突率低。 在索引更新场景中,大部分网页的更新是独立的——两个不同网页的索引条目很少冲突。这意味着乐观并发控制(Optimistic Concurrency Control,OCC)是合理的选择:先假设没有冲突,提交时再检查。如果冲突率高,乐观方案会导致大量回滚,性能反而不好。
第四,Percolator 不是通用数据库。 它不提供 SQL 接口,不支持交互式事务(用户在事务中间做决策),不支持跨表查询。它是一个编程框架,开发者用它来编写增量处理逻辑——每个处理逻辑是一个 Observer,当某些数据发生变化时被触发执行。
论文中 Percolator 的吞吐量约为 MapReduce 的 1/30,但索引更新的平均延迟从数天缩短到了数十分钟。这个权衡对 Google 的索引更新场景是值得的——搜索结果的时效性远比处理吞吐量重要。
二、Bigtable 上的事务:三列设计
Percolator 的核心思想是利用 Bigtable
的多版本机制和列族机制,在每一行上附加额外的元数据列,用来跟踪事务状态。具体来说,Percolator
为每一行维护三个列族:data、lock、write。
Bigtable 的版本机制
先回顾 Bigtable 的版本模型。Bigtable
中,每个单元格(cell)由三部分定位:(row_key, column, timestamp)。同一个
(row_key, column)
下可以存在多个版本,每个版本由一个时间戳标识。读取时可以指定读取某个时间戳的版本,或者读取最新版本。
Percolator 用这个多版本机制来实现多版本并发控制(Multi-Version Concurrency Control,MVCC)。每个事务有一个开始时间戳(start_ts)和一个提交时间戳(commit_ts),事务只能看到 start_ts 之前已经提交的数据版本。
三个列族的角色
data 列:存储实际的数据值。
每个版本用 start_ts 作为时间戳。当事务在 Prewrite
阶段写入数据时,它把新值写到 data 列的 start_ts
版本下。注意,此时数据已经写入了,但事务还没提交——这个值对其他事务来说暂时不可见,因为还没有对应的
write 记录。
data 列内容示例:
timestamp=7 → "bal=3" (事务 T1 在 Prewrite 阶段写入)
timestamp=5 → "bal=10" (更早的事务写入的已提交值)
lock 列:存储锁信息。
当事务在 Prewrite 阶段锁定某行时,它在 lock
列写入一条记录,时间戳为
start_ts。锁的内容包含一个指向主键(primary
key)的引用。如果当前行本身就是主键,锁的内容标记为
“primary”。
lock 列内容示例:
timestamp=7 → "primary" (这行是主键)
timestamp=7 → "→Bob.bal" (这行是副键,主键是 Bob.bal)
锁的存在意味着有一个事务正在修改这行数据,但还没决定是否提交。其他事务遇到这个锁时,需要等待或者尝试清理(如果锁持有者已经死了)。
write 列:存储提交记录。
当事务提交时,它在 write
列写入一条记录,时间戳为 commit_ts,内容是
start_ts。这条记录的含义是:“在 commit_ts
这个时间点,有一个起始于 start_ts
的事务提交了数据。你要读实际的值,去 data 列的
start_ts 版本找。”
write 列内容示例:
timestamp=8 → "data@7" (commit_ts=8 的提交指向 data 列 ts=7 的版本)
timestamp=6 → "data@5" (更早的提交记录)
为什么三列是必要且充分的
三列的设计看起来有些冗余——为什么不直接在
data 列里加个标志位表示”已提交”?
原因在于原子提交点(atomic commit point)的需求。一个分布式事务涉及多行数据,但 Bigtable 只保证单行原子性。Percolator 需要一种方式来表达”整个事务已经提交”这个全局状态,而且这个状态的变更必须是原子的。
三列的设计把这个问题解决了:
- Prewrite 阶段写入
data和lock——这是试探性的,事务还没提交。 - Commit 阶段对主键的操作是:删除
lock+ 写入write——这是一次单行操作,Bigtable 保证其原子性。这一步就是整个事务的原子提交点。 - 其他事务在读取数据时,通过检查
write列来判断哪些data版本是已提交的。
如果只有 data 和 write
两列,没有 lock 列,就无法实现 Prewrite
阶段的冲突检测——你不知道某个未提交的 data
版本是被哪个事务写入的,也无法判断那个事务是否还活着。
如果只有 data 和 lock
两列,没有 write
列,就无法高效地实现快照读——你不知道某个 data
版本是否已经被成功提交,需要检查锁是否已经被清理,这会使读路径变得复杂且低效。
列的视觉化展示
下图展示了一个转账事务(Bob 向 Joe 转 7 元)在执行过程中,三个列族的状态变化:
在事务开始前(阶段 ①),Bob 的余额为 10,Joe 的余额为
0,这些值都已经提交,write
列中有对应的提交记录。Prewrite 后(阶段
②),新的数据值已经写入 data 列(Bob 的新余额
3,Joe 的新余额 7),同时 lock 列写入了锁——Bob
行标记为 primary,Joe 行的锁指向 Bob 行。Primary Commit
后(阶段 ③),Bob 行的锁被删除,write
列写入提交记录,此时事务已经不可撤销地提交了,但 Joe
行的锁还在。Secondary Commit 后(阶段 ④),Joe
行的锁也被清理,所有数据对外完全可见。
start_ts 与 commit_ts 的关系
每个事务有两个关键时间戳:
- start_ts:事务开始时从时间戳预言机(Timestamp Oracle,TSO)获取。决定了事务的快照读视图——事务只能看到 commit_ts <= start_ts 的已提交数据。
- commit_ts:事务准备提交时从 TSO 获取。必须大于 start_ts。决定了这个事务的写入对后续事务可见的时间点。
时间戳预言机是一个集中式服务,负责生成全局严格单调递增的时间戳。这是 Percolator 的一个已知瓶颈——所有事务都需要联系 TSO 获取时间戳。但在 Google 的场景中,这个瓶颈是可以接受的,因为 TSO 的负载基本上只是一个计数器的递增操作,单机每秒可以生成数百万个时间戳。
三、两阶段提交流程详解
Percolator 的事务提交分为两个阶段:Prewrite 和 Commit。这和经典 2PC 的 Prepare/Commit 结构类似,但实现机制完全不同。
Prewrite 阶段
Prewrite 阶段的目标是:锁定所有要修改的键,写入新的数据值,检查是否有冲突。
事务开始时,客户端从 TSO 获取 start_ts。然后在本地缓冲所有的写操作,直到事务准备提交。Prewrite 阶段,客户端把缓冲区里的写操作逐一发送到对应的 Bigtable 行。
步骤如下:
第一步:选定主键。 从事务涉及的所有键中选一个作为主键(primary key),其余的是副键(secondary key)。主键的选择是任意的,但主键在 Commit 阶段有特殊角色——主键的提交是整个事务的原子提交点。
第二步:对每个键执行 Prewrite 操作。 先处理主键,再处理副键。对每个键执行以下步骤(这是一次 Bigtable 单行事务):
Prewrite(key, value, start_ts, primary_key):
1. 冲突检测 — 写写冲突:
检查 write 列,是否存在 timestamp > start_ts 的记录。
如果存在,说明在本事务开始之后,已经有其他事务修改并提交了这个键。
→ 中止事务(write-write conflict)
2. 冲突检测 — 锁冲突:
检查 lock 列,是否存在任何时间戳的锁记录。
如果存在,说明有其他事务正在修改这个键(还未提交或中止)。
→ 中止事务(或者等待/尝试清理,取决于实现策略)
3. 写入数据:
在 data 列写入 (start_ts → value)
4. 写入锁:
在 lock 列写入 (start_ts → primary_key 的引用)
如果当前键就是 primary,内容标记为 "primary"
如果任何一个键的 Prewrite 失败(检测到冲突),整个事务中止。客户端需要清理已经 Prewrite 成功的键上的锁和数据。
为什么先 Prewrite 主键?因为主键的锁是”事务存活”的标志。如果主键的 Prewrite 失败,事务一定失败,不需要继续处理副键。
Commit 阶段
Prewrite 全部成功后,事务进入 Commit 阶段。
第一步:获取 commit_ts。 从 TSO 获取一个新的时间戳作为 commit_ts。commit_ts 必须大于 start_ts。
第二步:提交主键。 这是整个事务中最关键的一步。对主键执行以下操作(一次 Bigtable 单行事务):
Commit_Primary(primary_key, start_ts, commit_ts):
1. 检查 lock 列在 start_ts 是否还存在属于本事务的锁。
如果锁不存在(被其他事务清理了),→ 事务失败。
2. 在 write 列写入 (commit_ts → start_ts)
这条记录告诉读者:"commit_ts 时刻提交了一个写操作,数据在 data 列的 start_ts 版本"。
3. 删除 lock 列在 start_ts 的锁记录。
步骤 2 和 3 在同一个 Bigtable 单行事务中执行,是原子的。
这一步是整个分布式事务的原子提交点。
一旦主键的 write
记录写入成功且锁被删除,事务就被视为已提交——即使副键上的锁还没清理。
第三步:提交副键。
对每个副键执行类似操作:写入 write
记录,删除锁。这些操作可以异步执行,甚至可以在后台慢慢做。因为事务的提交状态已经由主键决定了,副键的提交只是”收尾工作”。
Commit_Secondary(secondary_key, start_ts, commit_ts):
1. 在 write 列写入 (commit_ts → start_ts)
2. 删除 lock 列在 start_ts 的锁记录
如果客户端在提交副键的过程中崩溃了呢?没关系。副键上残留的锁会被后续访问这些键的其他事务发现并清理(roll forward),我们在下一节详细讨论这个机制。
原子提交点的精妙之处
经典 2PC 的原子提交点是”协调者在自己的日志中写下 commit 决定”。这个决定存储在协调者自己的持久化存储中。如果协调者挂了,其他节点在协调者恢复之前无法确定事务的状态。
Percolator 的原子提交点是”主键行上的 Bigtable 单行事务:写入 write 记录 + 删除 lock”。这个状态存储在 Bigtable 中——一个高可用、持久化的分布式存储系统。任何客户端都可以读取主键行来判断事务的状态:
- 主键上有锁 → 事务正在进行中(或已被中止但锁未清理)
- 主键上有 write 记录 → 事务已提交
- 主键上既没有锁也没有 write 记录 → 事务已被中止或从未存在
这就是 Percolator 消除专用协调者的关键:事务状态被编码在数据中,可以被任何参与者读取。
快照读(Snapshot Read)
读操作同样依赖三列结构。当事务以 start_ts 读取某个键时:
Get(key, start_ts):
1. 检查 lock 列,是否存在 timestamp <= start_ts 的锁。
如果存在,说明有一个更早的事务正在修改这个键,但还没提交。
等待该锁被清理(或在超时后尝试清理)。
2. 在 write 列中找到 timestamp <= start_ts 的最新记录。
假设找到的记录是 (commit_ts=6 → start_ts=5)。
3. 根据 write 记录中的 start_ts(本例为 5),
到 data 列中读取 timestamp=5 的版本。
4. 返回该值。
这就是快照隔离的实现:每个事务只看到 start_ts 之前已提交的数据。write 列中 commit_ts > start_ts 的记录对本事务不可见。
完整事务生命周期
下图展示了一个写事务(Client)和一个读事务(Reader)的完整交互时序,覆盖 Prewrite、Commit 和 Snapshot Read 三个阶段:
sequenceDiagram
participant Client
participant TSO
participant Bigtable
participant Reader
rect rgb(220, 235, 250)
Note over Client, Bigtable: Prewrite 阶段
Client->>TSO: get start_ts
TSO-->>Client: start_ts = 5
Client->>Bigtable: prewrite key1(primary)<br/>写 data@5 + lock@5
Bigtable-->>Client: ok
Client->>Bigtable: prewrite key2(secondary)<br/>写 data@5 + lock@5(指向 primary=key1)
Bigtable-->>Client: ok
end
rect rgb(220, 250, 220)
Note over Client, Bigtable: Commit 阶段
Client->>TSO: get commit_ts
TSO-->>Client: commit_ts = 10
Client->>Bigtable: commit primary key1<br/>写 write@10→5 + 删除 lock@5
Bigtable-->>Client: ok
Note right of Client: 原子提交点完成
Client->>Bigtable: commit secondary key2<br/>写 write@10→5 + 删除 lock@5
Bigtable-->>Client: ok
end
rect rgb(250, 240, 220)
Note over Reader, Bigtable: Snapshot Read
Reader->>TSO: get start_ts
TSO-->>Reader: start_ts = 12
Reader->>Bigtable: 读取 key1:检查 lock(无 ts<=12 的锁)
Bigtable-->>Reader: 无锁
Reader->>Bigtable: 读取 key1:查找 write 列 ts<=12 的最新记录
Bigtable-->>Reader: write@10→5
Reader->>Bigtable: 读取 key1:读取 data@5
Bigtable-->>Reader: 返回值
end
该时序图清晰地呈现了 Percolator 事务的三个核心阶段。Prewrite 阶段先写主键再写副键,每次写入同时创建 data 和 lock 两条记录;Commit 阶段先提交主键(这是原子提交点),再提交副键,每次提交写入 write 记录并删除对应的 lock。Snapshot Read 遵循”检查锁 - 查找 write - 读取 data”的三步流程,保证读事务只能看到 start_ts 之前已提交的版本。
四、冲突检测与锁清理
写写冲突检测
Percolator 实现的是快照隔离(Snapshot Isolation,SI),而不是可串行化(Serializability)。快照隔离只阻止写写冲突(write-write conflict)——如果两个并发事务修改了同一个键,只有先提交的那个成功,后提交的那个被中止。
冲突检测发生在 Prewrite 阶段。对每个要写入的键,检查
write 列中是否存在 timestamp > start_ts
的提交记录。如果存在,说明在本事务的快照之后,已经有其他事务修改并提交了这个键。这构成写写冲突,当前事务必须中止。
注意一个微妙的点:这个检查只检查 write
列,不检查 data 列。因为 data
列中可能存在其他事务 Prewrite
但尚未提交的数据,这些数据不构成冲突——那个事务可能最终会被中止。真正构成冲突的是已经提交的写入。
快照隔离不阻止写偏斜(write skew)异常。例如两个事务 T1 和 T2,T1 读 x 写 y,T2 读 y 写 x,两个事务修改的键不同,不会触发写写冲突检测,但可能导致不一致的结果。如果需要防止写偏斜,应用层必须使用显式的 SELECT FOR UPDATE 或其他机制(TiDB 的悲观锁模式可以解决这个问题)。
锁冲突与锁清理
当一个事务在 Prewrite 阶段遇到另一个事务的锁时,它不能简单地等待——因为持有锁的事务可能已经崩溃了。如果无条件等待,就可能死锁或永久阻塞。
Percolator 的锁清理机制基于以下推理链:
情况一:检查主键的状态。 副键上的锁包含一个指向主键的引用。通过读取主键行的状态,可以判断原事务的命运:
遇到副键上的锁 (start_ts=T, primary=P):
读取主键 P 在时间戳 T 的状态:
Case A: P 的 lock 列在 T 没有锁,但 write 列有 commit 记录
→ 原事务已经提交(主键已经 commit,但副键的 commit 还没完成)
→ Roll forward(前滚):为这个副键写入 write 记录,删除锁
Case B: P 的 lock 列在 T 没有锁,write 列也没有 commit 记录
→ 原事务已经被中止(主键的锁被清理了,但没有 commit)
→ 清理这个副键的锁和 data
Case C: P 的 lock 列在 T 有锁,且锁未超时
→ 原事务可能还在运行中
→ 等待或退避(backoff)
Case D: P 的 lock 列在 T 有锁,但锁已超时(TTL expired)
→ 原事务的执行者可能已经崩溃
→ 清理主键的锁,然后清理这个副键的锁
情况二:遇到主键上的锁。 如果一个事务在 Prewrite 阶段遇到的锁就是另一个事务的主键锁,处理逻辑类似但更简单——因为主键锁的状态直接反映了事务的状态。如果锁未超时,等待;如果已超时,清理。
TTL 机制
每个锁都有一个存活时间(Time-To-Live,TTL)。锁的创建者(事务的执行者)在写入锁时设置 TTL。如果在 TTL 到期后锁还没有被清理(即事务还没有提交或中止),其他事务可以安全地认为锁的持有者已经崩溃,并强制清理锁。
TTL 的设置需要权衡:
- 太短:正常运行的慢事务可能被误杀。
- 太长:崩溃的事务留下的锁需要更长时间才能被清理,阻塞其他事务。
实践中,TTL
通常设置为几秒到几十秒,并且可以被锁的持有者通过心跳(heartbeat)机制续期。TiDB
的实现中,事务在运行过程中会定期更新锁的
TTL(通过更新主键锁的 min_commit_ts
字段),防止长事务被误清理。
锁的生命周期状态机
下图描述了 Percolator 中锁从创建到消亡的所有可能路径:
stateDiagram-v2
[*] --> NoLock: 初始状态
NoLock --> Locked: Prewrite 成功<br/>写入 lock 列 + data 列
Locked --> Committed: 持有者执行 Commit<br/>删除 lock + 写入 write
Locked --> RolledBack: 持有者主动 Abort<br/>清理 lock + data
Locked --> CleanedByOther: TTL 过期<br/>其他事务强制清理 lock + data
Committed --> [*]
RolledBack --> [*]
CleanedByOther --> [*]
note right of Locked
锁持有者可通过心跳续期 TTL,
防止慢事务被误判为崩溃。
end note
note right of CleanedByOther
清理前必须检查主键状态:
主键已提交则前滚(roll forward),
主键未提交则回滚(rollback)。
end note
这个状态机展示了锁的四种终态路径。正常情况下,锁由持有者自己提交(Committed)或主动回滚(RolledBack);异常情况下,如果持有者崩溃且 TTL 过期,其他事务会介入清理(CleanedByOther)。值得注意的是,CleanedByOther 的清理操作并非简单删除——清理者必须先检查主键的 write 列来判断原事务是否已在主键上完成提交,据此决定是前滚还是回滚。
崩溃时刻的边界条件分析
Percolator 的容错设计需要处理事务执行者在不同阶段崩溃的情况。以下逐一分析各种边界条件。
场景一:Prewrite 完成后、Commit 之前崩溃
假设事务 T1(start_ts=5)要修改 key A(主键)和 key B(副键)。T1 完成了 Prewrite 阶段后崩溃:
T1 崩溃前的状态:
key A (primary): data@5 = valueA, lock@5 = {primary: A, ttl: 30s}
key B (secondary): data@5 = valueB, lock@5 = {primary: A, ttl: 30s}
write 列:无新记录(Commit 未执行)
此时 key A 和 key B 上都留有锁,但由于主键 A 的 write 列没有 commit_ts=5 的提交记录,任何检查者都可以确定 T1 未提交。当锁的 TTL 过期后,其他事务可以安全回滚 T1:清理 lock 列和 data 列中 timestamp=5 的记录。
场景二:事务 T2 遭遇 T1 的残留锁
接上例,事务 T2(start_ts=20)想写入 key B。T2 在 Prewrite 阶段检查 key B 时发现 T1 的锁:
T2 的锁解析过程:
1. T2 读取 key B 的 lock 列,发现 lock@5 = {primary: A, ttl: 30s}
2. T2 检查当前时间是否超过锁的 TTL
- 如果 TTL 未过期:T2 退避等待(backoff),稍后重试
- 如果 TTL 已过期:进入步骤 3
3. T2 读取主键 A 的状态:
a. 检查 A 的 write 列是否有 start_ts=5 的提交记录
→ 没有(T1 未提交)
b. 检查 A 的 lock@5 是否存在
→ 存在(T1 的主键锁还在)
4. T2 清理主键 A 的 lock@5 和 data@5(回滚 T1 的主键)
5. T2 清理副键 B 的 lock@5 和 data@5(回滚 T1 的副键)
6. T2 现在可以安全地对 key B 执行自己的 Prewrite
整个过程中,T2 通过读取 Bigtable 中的数据来判断 T1 的命运,无需联系任何协调者节点。
场景三:主键已提交、副键未提交时崩溃(前滚)
这是最微妙的边界条件。假设事务 T3(start_ts=30, commit_ts=35)修改 key C(主键)和 key D(副键)。T3 成功提交了主键 C,但在提交副键 D 之前崩溃:
T3 崩溃后的状态:
key C (primary): data@30 = valueC, write@35 = {start_ts: 30}
lock@30 已被删除(Commit 时清理)
key D (secondary): data@30 = valueD, lock@30 = {primary: C, ttl: 30s}
write 列无新记录
此时事务 T3 实际上已经提交——因为主键 C 的 write 列已经有了提交记录。副键 D 上残留的锁是一个”过时”的锁。当事务 T4 遇到 key D 上的锁时:
T4 的前滚过程:
1. T4 读取 key D 的 lock@30,得到 primary=C
2. T4 读取主键 C 的 write 列,发现 write@35 = {start_ts: 30}
→ T3 已经提交!
3. T4 执行前滚(roll forward):
为 key D 写入 write@35 = {start_ts: 30},删除 lock@30
4. key D 的状态与正常提交完全一致
这就是 Percolator 的优雅之处:前滚操作使副键达到了与正常提交完全相同的最终状态,不会丢失任何已提交的数据。这也解释了为什么 Percolator 要求所有副键的锁都指向主键——主键的状态是判断事务命运的唯一权威来源。
时间戳预言机(TSO)
时间戳预言机是 Percolator 的核心组件之一。它提供一个全局严格单调递增的时间戳序列。每个事务需要两次访问 TSO:
- 事务开始时获取 start_ts
- 事务提交时获取 commit_ts
TSO 的实现相对简单——本质上是一个计数器。为了容错,计数器的值需要持久化(或者使用 lease 机制确保恢复后不会回退)。Google 内部的 TSO 实现细节论文没有详细公开,但 TiDB 的 PD(Placement Driver)中的 TSO 实现是开源的,采用的是 etcd lease + 批量分配的方案。
TSO 是一个潜在的性能瓶颈和单点故障:
- 性能:所有事务都需要联系 TSO,TSO 的吞吐量决定了系统的最大事务速率。实践中,TSO 可以通过批量分配时间戳(一次请求分配一批 timestamp)来缓解压力。
- 可用性:TSO 不可用时,所有事务都无法开始或提交。需要通过主备切换或 Raft 复制来保证 TSO 的高可用。
垃圾回收
随着时间的推移,data 列和 write
列会积累大量的历史版本。Percolator 需要一个垃圾回收(Garbage
Collection,GC)机制来清理不再需要的旧版本。
GC 的基本策略:选定一个 safe point(安全时间点),保证所有活跃事务的 start_ts 都大于这个 safe point。然后清理 safe point 之前的旧版本——只保留每个键在 safe point 之前的最新已提交版本,删除更旧的版本。
GC 规则:
对于每个键 K:
找到 write 列中 commit_ts <= safe_point 的所有记录
保留其中 commit_ts 最大的那条(及其对应的 data 版本)
删除其余的 write 记录和对应的 data 版本
删除任何 commit_ts <= safe_point 且没有对应 write 记录的 data 版本(已中止事务的残留)
TiDB 中的 GC 由一个专门的 GC Worker 后台进程执行,通过 PD 协调 safe point 的推进。
五、Go 语言简化实现
下面是 Percolator 事务逻辑的简化 Go 实现。这段代码展示了核心算法,使用了一个简化的存储接口来抽象 Bigtable 操作。
package percolator
import (
"errors"
"fmt"
)
// 简化的存储接口,对应 Bigtable 的单行原子操作
type Storage interface {
// 在指定列写入一个版本
Put(row, col string, ts uint64, val []byte) error
// 读取指定列中 ts <= maxTS 的最新版本
Get(row, col string, maxTS uint64) (val []byte, ts uint64, ok bool)
// 删除指定列的某个版本
Delete(row, col string, ts uint64) error
// 检查指定列中是否存在 ts 在 [minTS, maxTS] 范围内的记录
HasAnyInRange(row, col string, minTS, maxTS uint64) bool
}
// Write 表示事务中的一次写操作
type Write struct {
Row string
Val []byte
}
// Transaction 表示一个 Percolator 事务
type Transaction struct {
store Storage
startTS uint64
writes []Write
}
// NewTransaction 创建一个新事务,startTS 从 TSO 获取
func NewTransaction(store Storage, startTS uint64) *Transaction {
return &Transaction{
store: store,
startTS: startTS,
writes: nil,
}
}
// Set 缓冲一次写操作(事务提交前不实际写入存储)
func (txn *Transaction) Set(row string, val []byte) {
txn.writes = append(txn.writes, Write{Row: row, Val: val})
}
// Get 执行快照读:读取 startTS 时刻的已提交值
func (txn *Transaction) Get(row string) ([]byte, error) {
// 1. 检查是否有 ts <= startTS 的锁(说明有未提交事务)
if _, _, ok := txn.store.Get(row, "lock", txn.startTS); ok {
// 简化处理:实际实现中应尝试清理或等待
return nil, fmt.Errorf("row %s is locked by another transaction", row)
}
// 2. 在 write 列找到 ts <= startTS 的最新提交记录
writeVal, _, ok := txn.store.Get(row, "write", txn.startTS)
if !ok {
return nil, nil // 该行不存在或从未被写入
}
// 3. 解析 write 记录中的 startTS,到 data 列读取实际值
dataTS := decodeWriteRecord(writeVal)
dataVal, _, ok := txn.store.Get(row, "data", dataTS)
if !ok {
return nil, fmt.Errorf("data missing for row %s at ts %d", row, dataTS)
}
return dataVal, nil
}
// Prewrite 对所有缓冲的写操作执行 Prewrite
// primaryRow 是主键行(通常选 writes[0].Row)
func (txn *Transaction) Prewrite(primaryRow string) error {
for _, w := range txn.writes {
if err := txn.prewriteRow(w.Row, w.Val, primaryRow); err != nil {
return fmt.Errorf("prewrite failed on row %s: %w", w.Row, err)
}
}
return nil
}
func (txn *Transaction) prewriteRow(row string, val []byte, primaryRow string) error {
// 冲突检测 1:检查 write 列中 ts > startTS 的记录(写写冲突)
if txn.store.HasAnyInRange(row, "write", txn.startTS+1, ^uint64(0)) {
return errors.New("write-write conflict: newer commit exists")
}
// 冲突检测 2:检查 lock 列中是否存在任何锁
if _, _, ok := txn.store.Get(row, "lock", ^uint64(0)); ok {
return errors.New("lock conflict: another transaction holds a lock")
}
// 写入 data 列
if err := txn.store.Put(row, "data", txn.startTS, val); err != nil {
return err
}
// 写入 lock 列
lockVal := encodeLockRecord(primaryRow)
if err := txn.store.Put(row, "lock", txn.startTS, lockVal); err != nil {
return err
}
return nil
}
// Commit 提交事务,commitTS 从 TSO 获取
func (txn *Transaction) Commit(commitTS uint64) error {
if len(txn.writes) == 0 {
return nil
}
primaryRow := txn.writes[0].Row
// 第一步:提交主键(原子提交点)
if err := txn.commitPrimary(primaryRow, commitTS); err != nil {
return fmt.Errorf("primary commit failed: %w", err)
}
// 第二步:提交副键(可以异步执行,失败可容忍)
for _, w := range txn.writes[1:] {
// 副键提交失败不影响事务结果,后续读者会 roll forward
_ = txn.commitSecondary(w.Row, commitTS)
}
return nil
}
func (txn *Transaction) commitPrimary(row string, commitTS uint64) error {
// 检查主键锁是否还在(可能被其他事务清理了)
lockVal, _, ok := txn.store.Get(row, "lock", txn.startTS)
if !ok {
return errors.New("primary lock missing, transaction aborted by another")
}
// 确认锁属于本事务
if decodeWriteRecord(lockVal) != 0 {
// 简化:实际实现需验证锁确实是自己的
}
// 原子操作:写入 write 记录 + 删除 lock
writeVal := encodeWriteRecord(txn.startTS)
if err := txn.store.Put(row, "write", commitTS, writeVal); err != nil {
return err
}
if err := txn.store.Delete(row, "lock", txn.startTS); err != nil {
return err
}
return nil
}
func (txn *Transaction) commitSecondary(row string, commitTS uint64) error {
writeVal := encodeWriteRecord(txn.startTS)
if err := txn.store.Put(row, "write", commitTS, writeVal); err != nil {
return err
}
return txn.store.Delete(row, "lock", txn.startTS)
}
// --- 辅助函数 ---
// encodeWriteRecord 编码 write 列记录:commit_ts → start_ts 的映射
func encodeWriteRecord(startTS uint64) []byte {
buf := make([]byte, 8)
for i := 7; i >= 0; i-- {
buf[i] = byte(startTS & 0xff)
startTS >>= 8
}
return buf
}
// decodeWriteRecord 解码 write 列记录,获取 start_ts
func decodeWriteRecord(data []byte) uint64 {
var ts uint64
for _, b := range data {
ts = (ts << 8) | uint64(b)
}
return ts
}
// encodeLockRecord 编码 lock 列记录:指向 primary key 的引用
func encodeLockRecord(primaryRow string) []byte {
return []byte(primaryRow)
}这段代码约 140 行,展示了 Percolator 事务的三个核心操作:
Get:快照读。先查lock列确认没有未提交事务阻塞,再查write列找到最新的已提交版本,最后从data列读取实际值。Prewrite:对每个键检测写写冲突和锁冲突,然后写入数据和锁。Commit:先提交主键(原子提交点),再异步提交副键。
一个典型的使用流程:
// 使用示例:Bob 向 Joe 转账 7 元
func Transfer(store Storage, tso TSO) error {
startTS := tso.GetTimestamp()
txn := NewTransaction(store, startTS)
// 读取当前余额
bobBal, err := txn.Get("Bob.bal")
if err != nil {
return err
}
joeBal, err := txn.Get("Joe.bal")
if err != nil {
return err
}
// 计算新余额
bobAmount := decodeAmount(bobBal) - 7
joeAmount := decodeAmount(joeBal) + 7
// 缓冲写操作
txn.Set("Bob.bal", encodeAmount(bobAmount))
txn.Set("Joe.bal", encodeAmount(joeAmount))
// Prewrite(主键选择 Bob.bal)
if err := txn.Prewrite("Bob.bal"); err != nil {
return err // 事务中止
}
// Commit
commitTS := tso.GetTimestamp()
return txn.Commit(commitTS)
}需要强调的是,上述 Go 代码是简化版本,省略了以下生产环境必需的逻辑:
- 锁清理(lock resolution):遇到其他事务的锁时,应该检查主键状态并决定 roll forward 还是清理。
- 重试机制:事务中止后的自动重试。
- TTL 和心跳:长事务需要定期续期锁的 TTL。
- 批量时间戳分配:减少与 TSO 的交互次数。
- 错误恢复:客户端崩溃后的事务恢复。
六、TiDB 的 Percolator 实现分析
TiDB 是目前最知名的 Percolator 模型的工程实现。TiDB 是一个兼容 MySQL 协议的分布式数据库,其存储引擎 TiKV 使用 Percolator 的事务模型来实现分布式事务。理解 TiDB 的实现,可以看到 Percolator 从论文到生产之间需要跨越的工程鸿沟。
存储层的差异
Google 的 Percolator 建立在 Bigtable + GFS 之上。TiKV 建立在 RocksDB + Raft 之上。
RocksDB 是一个单机的 LSM-tree 存储引擎,提供有序键值存储。TiKV 在 RocksDB 之上实现了 Percolator 的三列结构,使用三个 Column Family(CF)来对应 Percolator 的三列:
| Percolator 列 | TiKV CF | 说明 |
|---|---|---|
data |
CF_DEFAULT | 存储实际数据值,key 编码为
{user_key}{start_ts} |
lock |
CF_LOCK | 存储锁信息,key 编码为 {user_key} |
write |
CF_WRITE | 存储提交记录,key 编码为
{user_key}{commit_ts} |
注意 TiKV 中 lock 列的 key
不包含时间戳——因为同一个键在同一时刻最多只能有一把锁(多把锁意味着冲突),所以不需要时间戳来区分版本。
Raft 用于数据复制。TiKV 把数据分成多个 Region(类似于分片),每个 Region 是一个 Raft 组。Percolator 的每一步操作(Prewrite、Commit)都需要通过 Raft 复制到多数副本后才算成功。这意味着 Percolator 的每一步操作的延迟至少包含一次 Raft 提交的延迟(通常是一到两次网络往返)。
从正确性角度看,Raft 复制确保了即使单个 TiKV 节点崩溃,Percolator 的列数据也不会丢失。这相当于 Google 原始设计中 Bigtable + GFS 提供的持久性保证。
PD:TiDB 的时间戳预言机
TiDB 使用 PD(Placement Driver)组件来提供 TSO 服务。PD 是一个基于 etcd 的集中式调度器,TSO 只是其功能之一。
PD 的 TSO 实现采用了预分配策略:PD leader 启动时,向 etcd 写入一个 lease,预分配一段时间戳范围(例如 3 秒对应的逻辑时间戳数量)。在 lease 有效期内,PD 可以直接从内存中分配时间戳,不需要每次都写 etcd。当 lease 快到期时,续约并预分配新的范围。
这个设计的好处是:
- TSO 分配的热路径是纯内存操作,延迟极低(微秒级)。
- PD leader 崩溃后,新 leader 从 etcd 读取上一次的 lease 值,确保不会分配出重复或回退的时间戳。
客户端(TiDB server)也会批量获取时间戳——一次 RPC 请求获取一批时间戳,在本地分配给多个事务,减少与 PD 的交互次数。
悲观锁模式
这是 TiDB 相对于原始 Percolator 最重要的改进。
原始 Percolator 是纯乐观事务(Optimistic Transaction)——事务执行期间不加锁,所有的写操作只是缓冲在客户端内存中,提交时才检查冲突。这意味着:
- 如果两个事务修改了同一行,直到提交时才发现冲突,之前的所有计算都白费了。
- 对于冲突率高的场景(例如热点行的更新),乐观事务会导致大量回滚和重试,性能很差。
- MySQL 的应用开发者习惯了
SELECT FOR UPDATE(悲观锁),迁移到纯乐观模式需要修改应用代码。
TiDB 在 Percolator 的基础上增加了悲观锁模式(Pessimistic Transaction)。在悲观模式下:
乐观模式流程:
BEGIN → 读写操作(本地缓冲)→ Prewrite → Commit
悲观模式流程:
BEGIN → 每次写操作时立即加悲观锁 → Prewrite(将悲观锁升级为 Prewrite 锁)→ Commit
悲观锁的实现方式是在 TiKV 的 CF_LOCK
中写入一种特殊类型的锁(LockType::Pessimistic),这种锁不写入
data 列的数据。在 Prewrite
阶段,如果发现已经持有悲观锁,就直接升级为 Prewrite
锁并写入数据,不需要做冲突检测(因为加悲观锁时已经检测过了)。
悲观锁的好处:
- 冲突早发现:在执行
UPDATE语句时就加锁,如果有冲突立刻返回错误或等待,不会等到提交时才发现。 - 兼容 MySQL 行为:
SELECT FOR UPDATE的语义和 MySQL 一致。 - 减少无效计算:冲突率高的场景下,避免了大量计算后在提交阶段回滚。
Async Commit 优化
原始 Percolator 的 Commit 阶段有一个延迟瓶颈:必须等待主键 commit 成功后才能向客户端返回”事务已提交”。这意味着 commit 的延迟至少包含一次 Raft 写入的延迟。
TiDB 引入了 Async Commit 优化,核心思想是:在 Prewrite 阶段就确定 commit_ts,并把 commit_ts 写入所有的锁中。 这样,当所有键的 Prewrite 都成功后,事务实际上已经可以被视为已提交——因为所有需要的信息都已经持久化了。
传统 Percolator:
Prewrite all keys → Get commit_ts → Commit primary → Return success
延迟 = Prewrite + Commit Primary(至少 2 次 Raft 写入)
Async Commit:
Prewrite all keys(每个锁带上 commit_ts 和 secondary key 列表)→ Return success
后台异步:Commit primary → Commit secondaries
延迟 = Prewrite only(1 次 Raft 写入)
Async Commit 的 commit_ts 计算稍有不同。由于 commit_ts
需要在 Prewrite 阶段就确定,它使用的是
max(所有 Prewrite 返回的 min_commit_ts)
而不是从 TSO 获取的新时间戳。这需要 TiKV 在 Prewrite
时返回一个 min_commit_ts(基于该 Region
最近的读时间戳计算),以确保快照隔离的正确性。
1PC 优化
如果一个事务的所有键都落在同一个 TiKV Region 中,TiDB 可以跳过 Prewrite 阶段,直接在一次 RPC 中完成事务——即 1PC(One Phase Commit)。
1PC 的原理:既然所有键在同一个 Raft 组中,Raft 本身就保证了原子性(Raft 日志要么全部 apply,要么都不 apply)。不需要 Percolator 的两阶段协议来在多个 Raft 组之间协调。
1PC 直接写入 data 和 write
列,跳过 lock 列。延迟只有一次 Raft 写入。
Pipelined 悲观锁
TiDB 还优化了悲观锁的加锁延迟。在标准流程中,加悲观锁需要等待 Raft 提交(确保锁持久化)。Pipelined 悲观锁允许 TiKV 在锁写入本地内存后就返回成功,不等待 Raft 提交。
这降低了加锁延迟(从 Raft 延迟降低到本地内存写入延迟),但代价是:如果 TiKV leader 在锁持久化之前崩溃,锁会丢失,事务需要重试。对于大部分场景来说,这个权衡是值得的——Raft leader 崩溃是低概率事件。
性能特征
TiDB 官方文档中给出的典型延迟数据(供参考,实际数据取决于硬件配置和网络环境):
| 操作 | 典型延迟 | 瓶颈 |
|---|---|---|
| TSO 获取 | < 1ms | PD leader 内存分配 + 网络 RTT |
| Prewrite(单 Region) | 2-5ms | Raft 提交(1 次 fsync + 网络 RTT) |
| Commit Primary | 2-5ms | Raft 提交 |
| 小事务端到端(乐观) | 10-20ms | 2 次 TSO + Prewrite + Commit |
| 小事务端到端(Async Commit) | 5-10ms | 1 次 TSO + Prewrite |
| 小事务端到端(1PC) | 3-5ms | 1 次 TSO + 1 次 Raft 写入 |
可以看到,TiDB 的优化方向是持续减少事务提交路径上的网络往返和 Raft 提交次数。从最初的 Percolator 两阶段(4 次 Raft 写入以上)到 1PC 的单次 Raft 写入,延迟降低了一个数量级。
七、Percolator 与经典 2PC 的本质区别
Percolator 本质上是一种两阶段提交协议,但它和教科书里的经典 2PC 有根本的架构差异。
经典 2PC 的架构
经典 2PC 有一个专用的协调者(coordinator)节点和多个参与者(participant)节点。协调者负责驱动事务的提交:
经典 2PC:
阶段 1 (Prepare):
协调者 → 所有参与者:PREPARE
参与者 → 协调者:YES/NO
协调者把 PREPARE 结果记录到自己的 WAL(Write-Ahead Log)
阶段 2 (Commit/Abort):
协调者决定 COMMIT 或 ABORT
协调者把决定记录到自己的 WAL ← 这是原子提交点
协调者 → 所有参与者:COMMIT 或 ABORT
参与者执行并回复 ACK
关键问题在于:协调者的 WAL 是事务状态的唯一来源。 如果协调者在写入 COMMIT 决定之后、发送 COMMIT 消息之前崩溃了,参与者们不知道事务的最终状态——它们已经投了 YES 票,锁定了资源,但不知道该提交还是回滚。这就是经典 2PC 的阻塞问题(blocking problem)。
Percolator 的架构
Percolator 没有专用的协调者节点。事务由客户端(或称 worker)驱动,但客户端不是”协调者”——因为事务状态不存储在客户端上。
Percolator:
阶段 1 (Prewrite):
客户端 → Bigtable:对每个键执行 Prewrite(冲突检测 + 写 data + 写 lock)
阶段 2 (Commit):
客户端 → Bigtable:提交主键(删 lock + 写 write) ← 这是原子提交点
客户端 → Bigtable:提交副键(删 lock + 写 write) ← 可异步,可容忍失败
关键差异:原子提交点在 Bigtable 中(主键行的单行事务),不在客户端上。 客户端崩溃后,事务的状态可以通过读取主键行来确定。任何客户端或后台清理进程都可以接管未完成的事务:
- 主键有锁 → 事务未提交,可以清理(如果 TTL 过期)
- 主键有 write 记录 → 事务已提交,可以补全副键的提交
这意味着 Percolator 不存在经典 2PC 的阻塞问题。事务的恢复不依赖于某个特定节点的状态——任何能访问 Bigtable 的进程都可以执行恢复。
对比表
| 维度 | 经典 2PC | Percolator |
|---|---|---|
| 协调者 | 专用节点,维护事务状态 | 无专用节点,事务状态在数据层 |
| 原子提交点 | 协调者 WAL 中的 COMMIT 记录 | 主键行的单行 Bigtable 事务 |
| 协调者崩溃 | 参与者阻塞,等待恢复 | 任何进程可接管恢复 |
| 事务恢复 | 只有原协调者(或其备份)能恢复 | 任何客户端可读取主键状态并恢复 |
| 锁粒度 | 参与者上的资源锁 | 每行的 lock 列 |
| 通信模式 | 协调者与参与者之间的请求-响应 | 客户端直接与存储层交互 |
| 隔离级别 | 取决于实现 | 快照隔离(SI) |
| 全局时钟 | 通常不需要 | 需要 TSO(时间戳预言机) |
| 并发控制 | 通常是悲观锁 | 乐观并发控制(原始设计) |
| 参与者状态 | In-doubt 状态,需锁定资源 | 锁在 Bigtable 中可被外部检查 |
| 容错代价 | 需要协调者高可用(如 Raft) | 依赖存储层高可用(如 Bigtable/GFS) |
| 延迟 | 2 次网络往返 | 至少 2 次 TSO 访问 + 数次存储访问 |
数据即协调:Percolator 的核心范式转换
Percolator 与经典 2PC 的区别不仅仅是”去掉了协调者”这么简单。它代表了一种根本性的范式转换:事务协调元数据与应用数据共存于同一个存储系统中。
在经典 2PC 中,协调者是一个独立于数据存储的实体。它在自己的 WAL 中记录事务状态(prepare、commit、abort),这份状态与参与者上的实际数据是物理分离的。协调者的 WAL 是一个”影子系统”——它不存储任何业务数据,只存储关于事务的元信息。这种分离导致了一个根本性问题:当协调者不可达时,数据层无法自行推断事务的最终状态。
Percolator 彻底消除了这种分离。lock 列、write 列和 data 列存储在同一个 Bigtable 行中。事务是否已提交、锁是否还有效、数据的哪个版本是最新的——所有这些信息都可以通过读取数据行本身来获得。数据存储就是协调存储,读取数据的能力等同于读取协调状态的能力。
这种”数据即协调”的设计带来了深刻的容错含义:
恢复能力的民主化。 在经典 2PC 中,只有协调者(或其热备)能够恢复悬挂事务。在 Percolator 中,任何能访问 Bigtable 的进程——不管是原始的事务发起者、另一个客户端、还是后台清理线程——都具备完整的恢复能力。恢复不再是特权操作。
故障域的合并。 经典 2PC 的正确性依赖两个独立组件的可用性:协调者和数据存储。Percolator 将故障域合并为一个:只要 Bigtable 可用,事务系统就可用。这不是消除了故障的可能性,而是将两个独立的故障源合并为一个,简化了运维模型。
状态的可审计性。 由于事务协调状态就在数据中,运维人员可以直接扫描 Bigtable 的 lock 列来发现所有悬挂事务——不需要连接到某个协调者节点去查询它的内部状态。这种透明性在大规模生产环境中极为重要。
这种范式的代价是性能开销:每次事务操作都需要额外的列读写,而经典 2PC 的协调者可以将事务状态维护在内存中。但在 Google 的场景下——万亿级网页的增量索引更新——容错性和运维简单性的优先级远高于单事务延迟。
Percolator 的权衡
Percolator 不是对经典 2PC 的”全面改进”,它是在特定约束下的设计选择:
牺牲了什么: - 延迟:每个事务需要多次访问 TSO 和 Bigtable,延迟高于中心化协调者直接发消息。 - 隔离级别:只提供快照隔离,不是可串行化。可串行化需要额外的机制(如 TiDB 的悲观锁模式可以接近但不完全等同于可串行化)。 - TSO 依赖:引入了一个集中式的时间戳服务,虽然这个服务很轻量,但它是一个潜在的单点故障和性能瓶颈。
获得了什么: - 消除协调者单点故障:事务状态在数据层,不在任何特定节点上。 - 客户端无状态:客户端崩溃不会导致事务状态丢失。 - 利用现有基础设施:不需要构建新的存储系统,复用 Bigtable 的可靠性和可扩展性。 - 简单的恢复逻辑:事务恢复只需要读取主键状态,不需要复杂的日志回放。
Snapshot Isolation 的局限
Percolator 提供的是快照隔离,而不是可串行化。快照隔离存在以下已知的异常现象:
写偏斜(Write Skew):两个事务 T1、T2 各自读了对方要修改的键,然后各自写了不同的键。两个事务都能成功提交,但结果可能违反应用层的约束。
初始状态:x = 1, y = 1
约束:x + y > 0
T1: read(x)=1, read(y)=1 → write(x = -1) → commit
T2: read(x)=1, read(y)=1 → write(y = -1) → commit
最终结果:x = -1, y = -1 → x + y = -2,违反约束
T1 和 T2 各自检查时约束成立,但组合后约束被破坏。
Percolator 不检测这种异常,因为 T1 和 T2 修改的键不同(T1 改 x,T2 改 y),不触发写写冲突检测。
在实践中,这意味着依赖 Percolator(或 TiDB 的乐观事务模式)的应用需要在设计层面避免写偏斜,或者使用 TiDB 的悲观锁模式来防止。
八、结论
Percolator 的核心贡献可以用一句话概括:把分布式事务的协调状态编码进数据本身。
这个设计的精妙在于它充分利用了已有基础设施(Bigtable 的单行原子性和多版本机制),用三个列族(data、lock、write)编码了完整的事务生命周期信息。主键的提交操作是一次 Bigtable 单行事务——这是整个分布式事务的原子提交点。任何能读取主键状态的进程都可以判断事务的结局,从而消除了对专用协调者的依赖。
但 Percolator 也有明确的局限:
时间戳预言机(TSO)是集中式瓶颈。 所有事务都需要从 TSO 获取时间戳,TSO 的吞吐量和可用性直接制约系统的事务处理能力。虽然 TSO 的操作非常轻量(本质上是一个递增计数器),但它仍然是一个需要高可用部署的关键组件。
原始设计仅支持乐观并发控制。 在冲突率高的场景下,大量事务会在 Prewrite 或 Commit 阶段被中止,导致严重的计算浪费。TiDB 通过增加悲观锁模式解决了这个问题,但这也增加了系统的复杂度。
快照隔离不等于可串行化。 写偏斜等异常在快照隔离下是合法的,应用层需要自行处理或使用额外的锁机制。
Percolator 的影响远超 Google 内部。TiDB/TiKV 直接采用了 Percolator 的事务模型,并在此基础上做了大量的工程优化(悲观锁、Async Commit、1PC)。CockroachDB 的事务模型也受到了 Percolator 的启发(虽然具体设计有显著差异,CockroachDB 使用了混合逻辑时钟而不是 TSO)。YugabyteDB 同样借鉴了 Percolator 的思路。可以说,Percolator 定义了一种在分布式键值存储上构建事务的范式。
下一篇文章将讨论 Google Spanner——另一种截然不同的分布式事务方案。Spanner 不使用集中式 TSO,而是用 TrueTime(基于原子钟和 GPS 的物理时钟方案)来生成全局一致的时间戳。Percolator 把事务状态放在数据中,Spanner 把时间的确定性放在硬件中——两种思路,两种权衡。
导航
- 上一篇:3PC 与 Saga
- 下一篇:Spanner 与 TrueTime
参考资料
论文
- Peng, D., Dabek, F. “Large-scale Incremental Processing Using Distributed Transactions and Notifications.” OSDI, 2010.(Percolator 原始论文)
- Chang, F., Dean, J., Ghemawat, S., et al. “Bigtable: A Distributed Storage System for Structured Data.” OSDI, 2006.(Bigtable 论文,Percolator 的底层存储)
- Corbett, J. C., Dean, J., Epstein, M., et al. “Spanner: Google’s Globally-Distributed Database.” OSDI, 2012.(Spanner 论文,另一种分布式事务方案)
- Berenson, H., Bernstein, P., Gray, J., et al. “A Critique of ANSI SQL Isolation Levels.” SIGMOD, 1995.(快照隔离的形式化定义)
- Mohan, C., Lindsay, B., Obermarck, R. “Transaction Management in the R* Distributed Database Management System.” ACM TODS, 11(4):378-396, 1986.(经典 2PC 的工程实现参考)
源码
- TiKV: https://github.com/tikv/tikv (Rust 实现的分布式事务键值存储,Percolator 模型)
- TiDB: https://github.com/pingcap/tidb (Go 实现的分布式 SQL 数据库,事务层基于 TiKV 的 Percolator 实现)
- PD: https://github.com/tikv/pd (TiDB 的 Placement Driver,包含 TSO 实现)
博客与文档
- Huang, D. et al. “TiDB: A Raft-based HTAP Database.” VLDB, 2020.(TiDB 的系统论文,描述了 Percolator 在 TiDB 中的具体实现)
- PingCAP. “TiKV Transaction Model.” https://tikv.org/deep-dive/distributed-transaction/introduction/ (TiKV 事务模型文档)
- PingCAP. “Async Commit Design.” https://github.com/tikv/sig-transaction/tree/master/design/async-commit (Async Commit 优化的设计文档)
书籍
- Kleppmann, M. Designing Data-Intensive Applications. O’Reilly Media, 2017.(第七章和第九章讨论了分布式事务和快照隔离)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统实战】分布式事务不是你以为的那个 2PC
「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。
【分布式系统百科】2PC 的真实失败模式:阻塞、脑裂与恢复
2PC 在 Coordinator 崩溃时会阻塞所有参与者;本文精确分析三类故障窗口,拆解 Presumed Abort 优化原理,对比 Spanner/CockroachDB/TiDB 的现代方案。
【分布式系统百科】3PC 的理论改进与 Saga 的工程妥协
2PC 阻塞时协调者宕机怎么办?3PC 试图用额外一轮消息解决,却撞上网络分区。Saga 放弃全局锁,用补偿事务换取可用性。本文从 Skeen 论文推导 3PC 的非阻塞证明,分析其分区缺陷,再到 Saga 编排/协同、补偿陷阱、Temporal 工程实践和 TCC 资源预留——把分布式事务的理论与工程讲清楚。
【分布式系统百科】etcd 深度解剖:从 Watch 机制到 MVCC 存储引擎
深入剖析 etcd 的核心机制:持久化 Watch 与 Revision 追溯、Lease 租约机制、基于 BoltDB 的 MVCC 存储引擎、与 Raft 共识的联动方式,以及在 Kubernetes 中的关键角色。涵盖性能调优策略、容量限制与规模化方案。