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

【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 篇 TSO、rocksdb/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 的 PrewriteRequest(pingcap/kvproto,proto/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 只需要清掉 lock 和 data。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 记录有四种类型:Put、Delete、Rollback、Lock。Lock 类型对应 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_ts 和 commit_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_ts、use_async_commit、secondaries)。所有 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 提交里同时写 data 和 write——这种情况下 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 进程内的冲突检测机制(GetForUpdate、CheckCallback),解决的是”同一个 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 Notifications(OSDI 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/tikv,src/storage/mod.rs(SHORT_VALUE_MAX_LEN = 64、is_short_value)。
  2. tikv/tikv,tikv::storage::mvcc::Lock 结构体(secondaries、min_commit_ts、use_async_commit 字段)。
  3. pingcap/tikv#1407,optimize 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 .