在河源村,全村只有一本用石墙拼成的账本,每块石板是一个村民的账户。账房先生可以一口气把王婶石板上的数字改掉,因为单块石板的刻写是原子的——要么完成,要么原样。可一旦遇到以物易物:王婶给粮、李叔给布、赵伯给斧头,他就必须同时改三块石板。手只有一支笔,改到第二块时如果断电,大家就分不清这笔交易到底算不算数。 Google 的 Percolator 就是为这种问题设计的。它不发明新的数据库,而是在 BigTable 这面“巨墙”上立了一套记账规矩:先插红布条占位、再盖时间戳、最后用“总布条”拍板。这套规矩让跨行事务可以原子提交,还把协调者状态写进存储层,避免了传统两阶段提交的协调者单点故障。后来 TiKV 等 NewSQL 系统也借鉴了它。下面我们用这个“村里账房”的故事,把 Percolator 拆开看明白。
理解 Percolator,等于握住了 TiKV 等 NewSQL 存储的分布式事务钥匙。对做数据库、中间件和后端架构的 AI 工程师来说,这是让分布式系统既一致又可扩展的必备素养。
目录
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
为什么难:单页好写,跨页难
河源村只有一面大石墙账本,每块石板是一个村民的账户。账房先生可以一口气把王婶石板上的 100 改成 70,因为单块石板的刻写是原子的——要么完成,要么原样;但一次以物易物要同时改王婶、李叔、赵伯三块石板。他只有一支笔,改到第二块时如果卡住,村民们就会看到一半新账、一半旧账。
把石墙换成 BigTable,村民换成数据行,问题完全一样:BigTable 有能力保证单行操作原子性,却没有跨行事务原语。Percolator 要做的,就是在这层存储之上补出跨行 ACID。原子性、一致性、隔离性、持久性,就像一张借条的四要素,缺了任何一项,账本都无法让人放心。
难点来自三处:原子性无法一次覆盖多行;协调动作发生在网络另一端,通信可能丢失;任何进程都可能随时崩溃。因此,需要一套让所有参与者都认可的记账规矩。
所以,问题的本质不是“写不快”,而是“怎么让多个远程写动作看起来像一个动作”。
- 事务成功指客户端收到“记账完成”的最终确认。
- 等价关系
\iff表示左边成立,当且仅当右边三个条件同时成立。 - 逻辑与
\land读作“并且”,是对多行更新必须同时满足的约束。
核心思想:把事务状态写进账本
账房先生定下三条规矩:第一,改账前先插红布条占位;第二,新值必须带上墨迹年号;第三,所有相关红布条中必须指定一条“总布条”,总布条拔掉才算成交。
换成技术语言,这三条规矩就是锁、时间戳和主锁。Percolator 不是一个独立数据库,它更像一组客户端逻辑,坐在 BigTable 肩上。事务状态不在某个协调者进程的私有内存里,而在 BigTable 的行里。这样一来,状态天然持久化,不需要额外的日志服务器来记住“我正在做什么”。
这套思想我们叫它事务状态机:一次跨行操作,要么停留在预写,要么走到提交,要么退回回滚。真正聪明的不是“不犯错”,而是“犯了错之后,账本上还留着足够的信息让别人补救”。
这就是 Percolator 的立身之本:把事务的“记忆”放进存储,而不是放进容易丢失的协调者内存。
- 事务实例
Txn表示一次跨行记账动作。 - 属于关系
\in表示事务状态只能取集合里的一个值。 - 三种状态预写、已提交、已回滚是事务的三种归宿。
数据模型:石板上三个凹槽
每块石板被分成三个凹槽:数据区、红布条区、年号区。数据区写真实账目,红布条区挂锁标记,年号区记录“这笔账是什么时候正式落印的”。
在 BigTable 中,一个 row key 就是一块石板的编号,一行可以包含很多列。Percolator 要求每个需要事务保护的单元格都有三种列:data 列存业务值,lock 列存锁记录,write 列存提交时间戳。每个列还可以保存多个时间戳版本,就像同一块石板上能留下不同年份的墨迹。
更准确地说,data 列在预写阶段写入新值,但版本挂在 start_ts 上;write 列在提交阶段写入,版本挂在 commit_ts 上,并且指向对应的 start_ts;lock 列则是在预写时插入、在提交或回滚时删除。
这个模型让“跨行事务”变成“跨行操作列”,而列本身又是 BigTable 和分布式存储擅长管理的对象。
- 行键映射
row是石板编号,\mapsto表示一个行对应一组列。 - 三种列数据列存值,锁列存红布条,写列存提交时间戳。
- 时间戳维度
\times \text{timestamp}表示每个列都可以多版本共存。
快照隔离:戴上一副时间眼镜
账房先生给每个查账的人发一副“时间眼镜”,设定截止年号。戴上后,只能看到截止年号之前已经正式落印的内容;那些红布条还没拔、只是预写的墨迹,在眼镜里自动消失。这样,即便账房先生正在处理一笔跨三块石板的交易,查账的人也不会看到一半新一半旧的怪象。
技术上的名字叫快照隔离,底层是 MVCC(多版本并发控制)。每个事务开始时申请一个 start_ts,之后所有读取都基于这个时间戳做快照。因为每个值都有版本,读操作可以绕过正在写的锁,直接读旧版本。
于是读者和写者不再互相堵路:写者用锁处理冲突,读者用时间戳处理一致。
- 读取函数
Read(k, read_ts)表示在快照时间read_ts下读取键k。 - 某个版本
v_t是时间戳t上已经提交的值。 - 取最大值
\max从满足条件的版本里选出最新一个。 - 约束条件
ts <= read_ts保证不会读到未来的提交。
两阶段提交:先举旗,再落印
第一阶段是预写:账房先生先在王婶、李叔、赵伯三块石板上写下新值,再各插一根红布条。如果有一块石板已经被别人插了旗,他就知道冲突了,赶紧把自己刚插的旗拔掉,整个交易不算。
第二阶段是提交:只有所有旗都插好之后,账房先生才会去拔“总布条”——也就是主锁。主锁所在的石板被称为 primary row。主行提交成功,事务就有了不可撤销的结论;之后再去拔其他次行的旗,只是完成收尾。
这个“先写主行、再写次行”的顺序,是两阶段提交的灵魂。Percolator 的优化在于:主锁和主写记录都放在 BigTable 的行里,而不是放在一个孤零零的协调者日志里。协调者进程崩溃了,后来者可以看主锁自己判断。
这样,两阶段提交不再依赖某个“永不崩溃”的协调者,而是依赖“账本上总布条的状态”。
- 主行写记录
WritePrimary表示在 primary row 的 write 列留下提交时间戳。 - 删除主锁
DeleteLock表示拔掉总布条,这一步和写记录在同一个行原子操作里完成。 - 提交后续行
CommitSecondaries表示主行确定后,次行按同样方式提交。
故障恢复:主锁一锤定音
如果账房先生写到一半,雷暴断电,下一位账房先生看到某块石板上插着红布条,该怎么办?他不需要给前任打电话,只需要去看“总布条”。
如果总布条还在,说明事务还没到提交点,于是把所有相关石板上预写的新值和红布条都擦掉,完成回滚。如果总布条已经不在了,并且主行年号区有正式写记录,说明这笔交易已经成交,他便主动替前任把其他次行的红布条拔掉,完成提交。
这就是主锁一锤定音的威力。因为主锁状态存在 BigTable 里,任何一个后来者都可以接管恢复过程,系统不再有单点协调者。
所以,Percolator 的故障恢复不是靠“谁的电力足”,而是靠“账本上留下了足够多的线索”。
- 恢复决策
decide(x)是任意一个后来者观察事务x时做的判断。 - 回滚分支只要主锁在,说明没到提交点,直接回滚所有预写。
- 完成分支只要主写记录在,说明已提交,继续帮次行收尾。
TiKV 实践:从账本规矩到工业级系统
TiKV 把 Percolator 的规矩搬到了 Rust 实现的分布式 Key-Value 层中。它的每个 key 对应一行,事务同样使用 start_ts、commit_ts、锁列和主锁。跨 Region 的写入,靠的就是同一套两阶段提交骨架。
但 TiKV 不是无脑照搬,它做了不少工程优化:异步提交可以让客户端少等一次次行提交的网络往返;悲观事务对热点行提前加锁,减少乐观重试的代价;大事务支持则让更多业务场景敢于让多个 key 同时参与同一个事务。
正因为 TiKV 在工业界验证了这套思路,Percolator 才成为 NewSQL 与分布式数据库绕不开的设计蓝本。它真正迷人的地方,不是两阶段提交本身,而是“把决策状态放进存储”这个看似简单却影响深远的优化。
从河源村石墙到 TiKV,Percolator 用一套朴素的规矩,解决了分布式系统里最难的“多行一致性”问题。
- 近似关系
\approx表示 TiKV 基本沿用 Percolator 的骨架,而不是另起炉灶。 - 异步提交
AsyncCommit指减少客户端等待次行提交的网络往返。 - 悲观事务
PessimisticTxn指对热点行提前加锁,避免冲突重试。
AI 应用场景
AI 实验平台的状态原子更新
阶段:训练与实验管理
解决的问题:AI 平台每次实验要同时更新实验配置、数据集版本、checkpoint 状态和监控指标。传统做法是分开写多个数据库,一旦写了一半进程崩溃,就会出现“配置说用 v2,指标却来自 v1”的脏状态。用 TiKV 这类基于 Percolator 思想的存储,可以让这些状态作为一个事务原子提交。
具体案例:训练框架启动一轮新实验时,把 experiment_status、dataset_version、checkpoint_path 三个 key 放入同一个分布式事务。任何一个写入失败,整条实验记录都不会出现半新半旧的状态,平台可以安全重试。
向量检索服务中的用户画像一致性
阶段:特征存储与在线推理
解决的问题:推荐系统更新用户向量时,经常要同步修改用户画像、历史行为摘要和人群标签。如果这些数据落在不同节点上,一旦部分成功,模型推理就会读到“向量是新的、标签是旧的”的错位输入。
具体案例:在向量数据库背后的分布式 KV 层用类似 Percolator 的事务,一次提交完成“用户向量 + 行为摘要 + 实验分桶”三键更新。读取端基于快照隔离拿到一致的旧版本或新版本,不会读到中间碎片。
Python 演示
class PercolatorSim: def __init__(self): self._ts = 10 self._data = {} self._locks = {} self._writes = {} self._data[('wang', 'balance')] = 100 self._data[('li', 'balance')] = 50 def _next_ts(self): self._ts += 10 return self._ts def _prewrite(self, row, col, new_value, start_ts, primary): key = (row, col) if key in self._locks: return False self._locks[key] = { 'new_value': new_value, 'start_ts': start_ts, 'primary': primary } return True def _rollback(self, key): self._locks.pop(key, None) def _commit_row(self, row, col, commit_ts): key = (row, col) lock = self._locks.pop(key, None) if lock is not None: self._data[key] = lock['new_value'] self._writes[key] = commit_ts def transfer(self, from_row, to_row, amount): start_ts = self._next_ts() col = 'balance' primary = (from_row, col) from_value = self._data[(from_row, col)] - amount to_value = self._data[(to_row, col)] + amount if not self._prewrite(from_row, col, from_value, start_ts, primary): return None if not self._prewrite(to_row, col, to_value, start_ts, primary): self._rollback((from_row, col)) return None commit_ts = self._next_ts() self._commit_row(from_row, col, commit_ts) self._commit_row(to_row, col, commit_ts) return commit_ts def read(self, row, col): return self._data.get((row, col)) if __name__ == '__main__': sim = PercolatorSim() print('before:', sim.read('wang', 'balance'), sim.read('li', 'balance')) ts = sim.transfer('wang', 'li', 30) print('after:', sim.read('wang', 'balance'), sim.read('li', 'balance'), 'commit_ts:', ts)
if key in self._locks: return False如果锁列已有红布条,说明冲突,预写失败。self._locks[key] = {'new_value': new_value, ...}把预写的新值和事务开始时间记在锁里。if not self._prewrite(to_row, ...): self._rollback((from_row, col))任一预写失败,就清理已经插上的旗。lock = self._locks.pop(key, None)提交时先拔掉锁,模拟主行提交。self._data[key] = lock['new_value']拔锁后把新值正式写进账本。self._writes[key] = commit_ts在写列留下提交时间戳,表示这件事已经落印。self._data.get((row, col))读操作只读 data 列,永远看不到还没提交的锁内新值。
常见误区
误区以为 Percolator 像 MySQL 一样是一个完整数据库,需要单独安装和启动一个服务。
正解Percolator 更像一顶“事务帽子”,戴在 BigTable 头上。它由客户端库加一组列约定组成,底层存储仍是 BigTable。没有 BigTable 的分布式规模化,它就没有生存土壤。
为何常见:因为它和 BigTable 经常一起被提及,名字又像个系统,很容易被当成独立组件。
误区理解成必须有一个永远在线的协调者进程保存所有状态,否则参与者不知道是否提交。
正解Percolator 的优化是把协调者状态放进存储层。主锁和主写记录都写在 BigTable 行里,任何后来者都可以执行恢复,所以不存在必须保持不死的单点协调者。
为何常见:教科书里的两阶段提交经常强调协调者日志,容易让人忽略“状态可以外置”这个关键优化。
误区以为快照隔离等于每次读取都拿当前数据库的最新状态。
正解快照隔离让每个事务绑定一个 start_ts,读到的永远是“该时间戳之前已提交”的版本。即使后面有更新提交,也不会影响当前事务的视图。
为何常见:“快照”听起来像拍照当下的最新照片,但实际是“按指定时间戳取版本”,而不是“永远取最新”。
误区以为两阶段提交的预写阶段一旦全部成功,事务已经板上钉钉。
正解预写只代表“所有参与者都愿意配合”,真正提交点是主行写记录落盘并删除主锁。如果主行还没提交,任何后续崩溃都会导致整体回滚。
为何常见:把“准备成功”和“提交成功”混为一谈,忽略了主锁这个唯一决策点。
误区以为锁和传统单机数据库一样,只是进程内存里的一把互斥量。
正解Percolator 的锁是写在 BigTable 的 lock 列里的,属于持久化存储的一部分。进程崩溃后,锁还在账本上,后来者可以据此判断回滚或继续提交。
为何常见:平时写并发代码时锁都活在内存里,容易把这种印象迁移到分布式存储上。
面试题
Q1. Percolator 运行在哪一种底层分布式存储之上?
- A.BigTable 存储← 正确答案
- B.MySQL 数据库
- C.Redis 缓存
- D.HDFS 文件系统
答案:A。Percolator 是构建在 BigTable 之上的事务层,它利用 BigTable 的单行原子性和分布式扩展能力。MySQL 是单机数据库,Redis 是缓存,HDFS 偏文件系统,都不符合。
Q2. Percolator 把事务锁放在哪一类列里?
- A.数据列 data
- B.锁列 lock← 正确答案
- C.写列 write
- D.时间戳列 ts
答案:B。lock 列相当于石板上的红布条,负责记录“这块石板被哪个事务预定了”。data 列存业务值,write 列存提交时间戳,两者都不承担锁职责。
Q3. 在 Percolator 中,事务真正的提交点是什么?
- A.所有行预写完成
- B.主行写记录并删锁← 正确答案
- C.所有锁全部删除
- D.协调者内存刷新
答案:B。所有行预写完成只是第一阶段完成。提交点是主行在 write 列留下提交记录,并删除主锁;这个动作让整个事务拥有不可撤销的结论,之后才提交次行。
Q4. 下面哪些属于 Percolator 模型中用于事务的核心列?
- A.data 数据列← 正确答案
- B.lock 锁列← 正确答案
- C.write 写列← 正确答案
- D.query 查询列
答案:ABC。data、lock、write 是事务三件套:新值、红布条、提交记录。query 不是 Percolator 定义的事务列。
Q5. 快照隔离读取时,哪个时间戳之前的已提交版本可见?
- A.commit_ts 之后
- B.start_ts 之前← 正确答案
- C.lock_ts 之后
- D.end_ts 之后
答案:B。事务读取基于开始时间戳 start_ts,只能看到提交时间戳不晚于它的版本。比 start_ts 更新的提交不可见,这正是快照隔离的意义。
Q6. 哪些机制使 Percolator 避免了传统协调者单点故障?
- A.协调者状态写入 BigTable← 正确答案
- B.使用主锁作为决策依据← 正确答案
- C.只依赖协调者内存
- D.参与者定期心跳同步
答案:AB。Percolator 把协调者状态放到 BigTable 的主锁和写记录里,让任何后来者都能恢复决策,因此不需要一个永不宕机的协调者进程。内存和心跳都不是关键设计。
Q7. 事务崩溃后,如果检测到主锁仍然存在,正确做法是什么?
- A.继续提交所有次行
- B.回滚该事务所有预写← 正确答案
- C.忽略主锁直接读取
- D.只清理部分次行锁
答案:B。主锁存在说明事务还没有到达提交点,它从未真正提交。因此应回滚所有预写数据,避免把半成品当成正式账目。
Q8. TiKV 的异步提交主要想优化什么问题?
- A.降低一次事务的网络往返← 正确答案
- B.提高单机 CPU 频率
- C.彻底替代两阶段提交
- D.消除所有时间戳需求
答案:A。异步提交让客户端不必等所有次行都提交完就能返回,从而减少跨节点网络等待。它仍然建立在 Percolator 的主锁和写记录模型上,并非替代两阶段提交。
Q9. 当预写过程中发现某个行锁冲突时,正确的处理方式有哪些?
- A.回滚已获取的锁← 正确答案
- B.选择新事务重试← 正确答案
- C.提交所有已预写行
- D.忽略冲突继续执行
答案:AB。预写冲突意味着另一个事务正在改同一块石板。正确的做法是回滚自己已插的旗,然后由上层选择重试;提交部分行或忽略冲突都会破坏原子性。
Q10. Percolator 普通读取为什么通过 write 列找到数据版本,而不是直接读 data 列?
- A.write 列只存当前值
- B.write 列指向可见版本← 正确答案
- C.data 列在提交后清空
- D.data 列不允许读取
答案:B。write 列中的提交时间戳指向对应的 start_ts,读取时先查 write 列,就能确认该版本是否已提交,并据此定位 data 列中的值。直接读 data 列可能读到预写但未提交的脏数据。