土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】Percolator 乐观事务落地:prewrite、commit 与三 CF

文章导航

分类入口
databasestorage
标签入口
#tikv#percolator#prewrite#commit#mvcc#cf-lock#cf-write#cf-default#async-commit#osdi2010

目录

distributed/30 已经完整拆过 Percolator 论文本身的模型:data/lock/write 三列如何编码事务状态,Prewrite/Commit 两阶段如何在 Bigtable 单行事务上找到”原子提交点”。这里不重复那套推导,只钉一个问题:同一套模型换到 TiKV 上,哪些地方原样照搬,哪些地方被工程现实逼着改了。答案不是”细节实现不同、思路一样”这种空话——TiKV 至少在三处明确偏离了论文:write 列多了论文里没有的 Rollback 记录类型,短小的值会绕过 data 列直接塞进 write 记录,SELECT ... FOR UPDATE 这类读锁会在 write 列留下一条不写任何数据的 Lock 记录。这些偏离都写在 TiKV 官方 Optimized Percolator 文档里,不是道听途说。

本篇钉住 TiKV 侧的 key 编码、Prewrite/Commit 如何落在 Region 的 Raft 提交路径上,以及三处工程补丁各自解决什么问题;同时说明它和 第 9 篇 TSOrocksdb/14 事务与 OCC 的分层关系。

本文是「TiKV / HTAP 内核」系列第 10 篇(共 18 篇)。上一篇TSO下一篇悲观事务与 ResolveLock→ 系列目录

版本锚定:TiKV 7.x–8.x。Async Commit / 1PC 自 TiDB 5.0 起 GA,本文只描述已经稳定的路径,不展开更早版本的历史演进细节。


一、三个 CF 与 key 编码

TiKV 把一个 RocksDB 实例分成三个 CF,直接对应 Percolator 论文的三列(TiKV 官方 Percolator 深度剖析文档):

Percolator 列 TiKV CF 内部 key → value
data CF_DEFAULT (key, start_ts) → value
lock CF_LOCK key → lock_info不带时间戳——同一 key 同一时刻至多一把锁)
write CF_WRITE (key, commit_ts) → write_info

(key, ts) 的编码分三步,目的是让同一用户 key 的多个版本在 RocksDB 里连续排列,且新版本排在旧版本前面

  1. 把用户 key 编码成 memcomparable 格式;
  2. 把 64 位时间戳按大端序编码,再按位取反
  3. 把取反后的时间戳字节拼在编码后的 key 之后。

按位取反是关键:RocksDB 默认按字节升序排列 key,时间戳数值越大、取反后的字节值越小,于是commit_ts/start_ts 越大的版本在 RocksDB 里排得越靠前,定位”最新版本”只需要一次 seek,不必扫描整个版本链。举例(TiKV 官方文档):key "key1" 在时间戳 3 下编码为 "key1\x00\x00\x00\x00\xfb\xff\xff\xff\xff\xff\xff\xff\xfe",前 9 字节是编码后的 key,后 8 字节是取反后的大端时间戳。

flowchart LR
  subgraph Perc["Percolator paper (Bigtable)"]
    D1["data column<br/>(row, ts) -> value"]
    L1["lock column<br/>(row, ts) -> primary ref"]
    W1["write column<br/>(row, ts) -> data ts"]
  end
  subgraph TiKV["TiKV (RocksDB CF)"]
    D2["CF_DEFAULT<br/>(key, start_ts) -> value"]
    L2["CF_LOCK<br/>key -> lock_info (no ts)"]
    W2["CF_WRITE<br/>(key, commit_ts) -> write_info"]
  end
  D1 -.maps to.-> D2
  L1 -.maps to.-> L2
  W1 -.maps to.-> W2

CF_LOCK 不带时间戳这一点是 TiKV 对模型的简化,不是对论文的偏离:Bigtable 的 lock 列本来就靠”同一 key 同一时刻只能有一把锁”这条不变量工作,TiKV 直接把这条不变量落实成”不需要时间戳维度”,省一次编码。


二、Prewrite:从”单行事务”到”Region 内 Raft 提交”

论文里 Prewrite 的原子性单位是 Bigtable 的单行事务;TiKV 没有等价的”单行事务”原语,Prewrite 的原子性单位变成了一个 Region 的一次 Raft 日志提交第 5 篇 raftstore 写路径)。对客户端(TiDB)来说,Prewrite 一个事务涉及的多个 key,本质上是按 key 所在的 Region 分组,每组发一次 RPC

PreWrite(TiKV 官方 blog《How TiKV reads and writes》示意):
  CF_LOCK: 写入 key -> lock_info(包含 primary 引用、TTL)
  CF_DEFAULT: 写入 (key, start_ts) -> value(除非触发短值优化,见 §三)

同一个事务如果涉及 5 个不同 Region,就要向 5 个 Region 的 Leader 各发一次 Prewrite 请求,每次请求各自走一遍 propose → append → commit → apply。这和论文里”逐个 key 做 Bigtable 单行事务”的顺序执行不同——TiKV 客户端(client-go并发向多个 Region 发送 Prewrite,只要求它们都成功才进入 Commit 阶段;论文本身也允许并发 Prewrite(先处理 primary,其余 secondary 可并发),TiKV 只是把”一个 key 一次 RPC”变成了”一个 Region 一次 RPC”,减少了往返次数。

冲突检测的判断逻辑和论文一致:Prewrite 时检查 CF_WRITE 是否存在 commit_ts > start_ts 的记录(写写冲突),检查 CF_LOCK 是否已有锁(锁冲突)——这部分 distributed/30 第四节 已经拆过,此处不重复推导。

Prewrite RPC 携带的字段:论文之外的元信息

论文里 Prewrite 只需要 key、value、primary 引用。TiKV 的 PrewriteRequestpingcap/kvprotoproto/kvrpcpb.proto)多出一批字段,每一个都对应一个论文没有涉及的工程问题:

字段 作用
mutations 本次请求要写的一批 (key, value, op)
primary_lock 客户端选定的 primary key(和 SQL 里的主键概念无关)
start_version / lock_ttl 对应论文的 start_ts 与锁的存活时间(第 11 篇 展开)
for_update_ts 悲观事务专用:校验发生冲突的锁是否已经提交
min_commit_ts Async Commit 用;大事务的最终 commit_ts 由所有 key 的 min_commit_ts 取最大值推出
use_async_commit / secondaries 是否启用 Async Commit;启用时随 primary 的 Prewrite 一并带上全部 secondary key 列表
try_one_pc 该事务所有 key 是否都落在一个 Region,可以尝试跳过两阶段协议
max_commit_ts 限制 1PC/Async Commit 推出的 commit_ts 上限,避免和并发的 DDL/schema 变更冲突

这张字段表本身就是”论文模型 → 生产系统”这条工程链路的一个缩影:mutations/primary_lock/start_version 是论文原生概念的直接映射,for_update_ts 是悲观锁模式带来的新增字段,min_commit_ts/use_async_commit/secondaries/try_one_pc/max_commit_ts 五个字段全部服务于 §五 要讲的 Async Commit / 1PC——没有一个字段是为了”让协议更像论文”而存在的,全部是为了压缩生产延迟或者兼容 DDL


三、三处工程补丁:论文里没有的东西

TiKV 官方 Optimized Percolator 文档明确列出了几处相对论文的偏离,都不是”锦上添花”,而是解决具体的正确性或性能问题:

3.1 Rollback 记录

论文里回滚一个 key 只需要清掉 lockdata。TiKV 的做法是CF_WRITE 留一条 Rollback 类型的记录,标记”这个 start_ts 对应的事务已经被回滚”。原因是网络会延迟或重排请求:如果一个 Prewrite 请求因为网络问题晚于对应的 Rollback 请求到达,没有这条标记,迟到的 Prewrite 会重新把 key 锁上——这个 key 会一直被锁到 TTL 过期才能恢复,而不是立刻被识别为”这个事务已经死了”。留下 Rollback 记录之后,迟到的 Prewrite 一查 CF_WRITE 就能立即发现”此 start_ts 已回滚”,从而拒绝执行,不需要等 TTL。

3.2 短值优化(short value)

正常情况下读一个已提交的 key 要访问两个 CF:先在 CF_WRITE 找到指向的 start_ts,再去 CF_DEFAULT(key, start_ts) 取值——两次 RocksDB 查找。如果 value 很短(≤ 64 字节,TiKV 源码 SHORT_VALUE_MAX_LEN),这个开销相对 value 本身显得浪费:一个 8 字节的索引列值也要付两次查找的代价。

TiKV 的优化:Prewrite 阶段短值不写进 CF_DEFAULT,而是嵌进 CF_LOCK 的锁信息里;Commit 阶段把这个短值从锁信息里取出,直接内联进 CF_WRITE 的 write 记录。这样读路径只需要查一次 CF_WRITE 就能拿到值,完全绕开 CF_DEFAULT。TiKV 早期 issue(pingcap/tikv#1407)给出的动机很具体:表索引的 key 常常 30 字节以上,value 只有 1 到 8 字节,双 CF 存储的空间放大接近 2 倍。

3.3 write 列的第四种类型:Lock

论文的 write 列只表达”提交”;TiKV 的 CF_WRITE 记录有四种类型:PutDeleteRollbackLockLock 类型对应 SELECT ... FOR UPDATE 这类只读但要保证快照隔离下不被并发修改的读锁——它不写任何实际数据,但要让这个 key 表现得”像一次写操作”,从而在 CF_WRITE 留下痕迹,阻止其他事务在这个 key 上抢跑。这类记录服务的是 TiDB 的悲观锁语义(第 11 篇 展开)。


四、Commit:primary 提交点不变,落地方式变了

Commit 阶段的逻辑核心和论文完全一致:先提交 primary,这是整个事务的原子提交点;再提交 secondary,允许异步、允许失败后被其他事务前滚。TiKV 官方 blog 用一段示意总结了 Commit 阶段对 CF_LOCK/CF_WRITE 的操作:

Commit(示意,start_ts=9,commit_ts=11):
  读 CF_LOCK:确认 key a 上仍持有本事务的锁
  写 CF_WRITE:(a, 11) -> 指向 start_ts=9 的数据
  删 CF_LOCK:清掉 key a 的锁

变化的只是”提交”这个动作背后的持久化保证:论文里一次 Bigtable 单行事务就是提交点;TiKV 里,对 primary key 的这次”写 write + 删 lock”要经过它所在 Region 的一次 Raft 提交才算数——多数副本确认之后,这个提交点才是不可撤销的。这正是 第 5 篇 raftstore 写路径 讲的 propose→append→commit→apply 链路在事务层的落点。


五、Async Commit / 1PC:把两次 TSO 往返压缩成一次

标准 Percolator 提交要付出的延迟包括至少两次 TSO 往返(第 9 篇start_tscommit_ts)加上 Prewrite、Commit Primary 两次 Raft 提交。TiDB 的 Async Commit 优化把 commit_ts 的确定提前到 Prewrite 阶段:每个锁除了原有的 primary 引用,还带上 secondaries(完整的 secondary key 列表)和 min_commit_ts(TiKV 官方 tikv::storage::mvcc::Lock 结构体字段:min_commit_tsuse_async_commitsecondaries)。所有 key 的 Prewrite 都成功后,事务的 commit_ts 取所有 min_commit_ts 的最大值——不需要再单独问 PD 要一次 commit_ts,Commit Primary 和 Commit Secondary 都可以放到后台异步完成,客户端在 Prewrite 全部成功后就能返回。

这不是免费的:判断一个 Async Commit 事务是否真的已提交,需要能从任意一个 key 找到事务涉及的所有 key(否则无法确认所有 secondary 是否都 Prewrite 成功),这也是为什么锁信息里要保留完整的 secondaries 列表,而不是像论文里那样 secondary 只单向指向 primary。如果所有 key 都落在同一个 Region,TiDB 还能进一步跳过两阶段协议,直接用 1PC 在一次 Raft 提交里同时写 datawrite——这种情况下 Raft 本身的原子性已经足够,不需要 Percolator 的跨 Region 协调。这两项优化在 distributed/30 第六节 已有更详细的延迟对比,这里只强调它们依赖的字段来自 TiKV 侧对 Lock 结构的扩展,不是论文原生设计。


六、三篇分层:谁负责论文模型,谁负责单机事务,谁负责生产映射

flowchart TB
  PAPER["distributed/30<br/>Percolator paper model<br/>(Bigtable data/lock/write)"]
  TIKV["本篇:TiKV 落地<br/>CF_DEFAULT/CF_LOCK/CF_WRITE<br/>+ Rollback/short-value/Lock 记录"]
  ROCKS["rocksdb/14<br/>TransactionDB / OptimisticTransactionDB<br/>(单机 LSM 上的 OCC,与 TiKV 事务不是同一层)"]
  PAPER -- "移植到 RocksDB CF + Raft" --> TIKV
  ROCKS -. "同名不同层:RocksDB 事务 API 只管本地引擎<br/>不提供跨 Region 协调" .-> TIKV

rocksdb/14 讲的 TransactionDB/OptimisticTransactionDB 是 RocksDB 进程内的冲突检测机制(GetForUpdateCheckCallback),解决的是”同一个 RocksDB 实例内的读改写冲突”,TiKV 没有用这套 API 实现分布式事务——TiKV 的 MVCC 靠自己在 key 里编码时间戳、靠三个 CF 表达锁和提交状态,Raft 层面再叠加复制。三者关系是:论文定义模型,TiKV 把模型映射到”RocksDB CF + Region 内 Raft”,RocksDB 自带的事务 API 是另一条完全独立的本地能力,恰好三者都叫”transaction”,但解决的问题和适用范围完全不同,不能互相替代。


七、学术谱系、争论与开放问题

谱系:Peng, Daniel 和 Dabek, Frank 在 Large-scale Incremental Processing Using Distributed Transactions and NotificationsOSDI 2010)里定义了”把事务协调状态编码进数据本身”这个核心思路,用来解决 Google 网页索引的增量更新问题,处理的是批量增量处理场景,不是交互式 OLTP。TiKV 把这套模型搬到 OLTP 场景(TiDB 的在线事务)时,遇到的第一个假设不匹配就是冲突率:论文假设”大部分更新彼此独立、冲突率低”,这在 OLTP 热点行更新下不成立——这正是第 3.3 节 Lock 类型 write 记录,以及 第 11 篇 要讲的悲观锁模式存在的原因:纯乐观模型在高冲突场景下会不断退化成大量无效计算后回滚。

工程间隙:论文实验设定是单一数据中心内的批处理任务,容忍分钟级的处理延迟;TiDB 面对的是用户可感知的在线事务延迟(毫秒到几十毫秒)。这个差距直接催生了 Async Commit / 1PC——它们做的事情本质上是”压缩论文模型里天然存在、但批处理场景不在乎的往返次数”,而不是发现了论文的错误。TiKV 官方文档自己承认 Rollback 记录、短值优化都是论文之外的补丁,这是少见的、由实现者自己写清楚”我们和论文哪里不一样”的案例,值得作为读论文对照实现的范本。

开放问题rocksdb/14 第七节 提到的”高 skew 下 OCC 的 abort 率何时劣于悲观锁”这个问题,在 TiKV 的场景下换了个问法:Async Commit 依赖的 secondaries 全量列表在超大事务(涉及上万 key)下会不会让锁信息本身变成新的瓶颈?TiKV 社区在 issue 讨论里提到大事务的 Prewrite 数据量和锁信息大小是已知的运维痛点之一,但没有公开的、系统性的阈值研究——本文不编造具体数字,只指出这是乐观事务模型在”论文场景”之外被拉伸出的一个未被论文覆盖、也未被业界充分测量的边界。


八、常见误解

误解 事实
「TiKV 就是照抄了 Percolator 论文」 三 CF 映射思路一致,但 Rollback 记录、短值优化、Lock 类型 write 都是论文之外的工程补丁
「Prewrite 的原子性单位是一个 key」 论文里是 Bigtable 单行事务;TiKV 是一个 Region 的一次 Raft 提交,可能覆盖该 Region 内多个 key
「短值优化只是省空间」 同时省一次 RocksDB 查找——读路径从”查 CF_WRITE 再查 CF_DEFAULT“降到只查 CF_WRITE
「TiKV 的事务和 RocksDB 的 TransactionDB 是同一层能力」 RocksDB 事务 API 管单进程内的冲突检测;TiKV 的 Percolator 实现管跨 Region 的分布式事务,TiKV 没有用前者实现后者
「Async Commit 是另一种协议」 仍是 Percolator 的 Prewrite/Commit 两阶段,只是把 commit_ts 的确定时机提前,减少往返次数

九、小结

三句话小结

  1. TiKV 把 Percolator 论文的 data/lock/write 三列原样映射成 CF_DEFAULT/CF_LOCK/CF_WRITE,用 memcomparable key + 位反转时间戳编码保证同一 key 的多版本按新旧顺序连续排列。
  2. Prewrite 的原子性单位从论文里的”Bigtable 单行事务”变成”一个 Region 的一次 Raft 提交”;Rollback 记录、短值优化(≤ 64 字节内联进 CF_WRITE)、Lock 类型 write 记录都是论文没有、TiKV 自己补上的工程细节。
  3. Async Commit / 1PC 靠给 Lock 结构增加 secondaries/min_commit_ts 字段,把两次 TSO 往返压缩到一次 Prewrite,解决的是”论文假设批处理延迟、TiDB 需要在线事务延迟”这个工程间隙,不是发现了论文本身的错误。

下一篇进入 悲观事务与 ResolveLock:TTL、死锁检测的边界,以及为什么”TiKV 无锁”是一个需要纠正的误解。


上一篇TSO

下一篇悲观事务与 ResolveLock

返回 系列目录


参考资料

论文(A 级)

  1. Peng, Daniel, Dabek, Frank. Large-scale Incremental Processing Using Distributed Transactions and Notifications. OSDI 2010(Percolator 原始论文,data/lock/write 三列模型、Prewrite/Commit 两阶段)。

规范 / 文档(A 级)

  1. TiKV Deep Dive, Percolator(三 CF 映射、memcomparable + 位反转时间戳编码、write 列四种类型)。
  2. TiKV Deep Dive, Optimized Percolator(Rollback 记录、短值优化的动机与流程)。
  3. The TiKV Blog, How TiKV reads and writes(Prewrite/Commit 对 CF_LOCK/CF_WRITE 的操作示意)。

源码(A 级)

  1. tikv/tikvsrc/storage/mod.rsSHORT_VALUE_MAX_LEN = 64is_short_value)。
  2. tikv/tikvtikv::storage::mvcc::Lock 结构体(secondariesmin_commit_tsuse_async_commit 字段)。
  3. pingcap/tikv#1407optimize for short value(短值优化的原始 PR 与动机说明)。

站内对读

  1. 《Percolator 模型:Google 的乐观事务方案》(论文模型完整拆解,本篇不重复)。
  2. 《事务与 OptimisticTransactionDB》(RocksDB 单机事务 API,与 TiKV 分布式事务分层对照)。
  3. 本系列 第 5 篇 raftstore 写路径第 9 篇 TSO第 11 篇 悲观事务与 ResolveLock

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-16 · database / storage

【TiKV / HTAP 内核】TSO:物理加逻辑的全局时间戳

拆解 PD 的 TSO 如何用 46 位物理毫秒 + 18 位逻辑计数器分配全局单调时间戳,PD leader 如何靠 etcd 时间窗口做故障恢复、客户端如何批量取号;用一句话对照 Spanner TrueTime 的硬件不确定区间路线与 Milvus 的 GuaranteeTs 用法。


By .