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 里连续排列,且新版本排在旧版本前面:
- 把用户 key 编码成 memcomparable 格式;
- 把 64 位时间戳按大端序编码,再按位取反;
- 把取反后的时间戳字节拼在编码后的 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 的确定时机提前,减少往返次数 |
九、小结
三句话小结:
- TiKV 把 Percolator 论文的
data/lock/write三列原样映射成CF_DEFAULT/CF_LOCK/CF_WRITE,用 memcomparable key + 位反转时间戳编码保证同一 key 的多版本按新旧顺序连续排列。 - Prewrite 的原子性单位从论文里的”Bigtable
单行事务”变成”一个 Region 的一次 Raft 提交”;Rollback
记录、短值优化(≤ 64 字节内联进
CF_WRITE)、Lock类型 write 记录都是论文没有、TiKV 自己补上的工程细节。 - Async Commit / 1PC 靠给
Lock结构增加secondaries/min_commit_ts字段,把两次 TSO 往返压缩到一次 Prewrite,解决的是”论文假设批处理延迟、TiDB 需要在线事务延迟”这个工程间隙,不是发现了论文本身的错误。
下一篇进入 悲观事务与 ResolveLock:TTL、死锁检测的边界,以及为什么”TiKV 无锁”是一个需要纠正的误解。
上一篇:TSO
返回 系列目录
参考资料
论文(A 级)
- Peng, Daniel, Dabek, Frank. Large-scale Incremental Processing Using Distributed Transactions and Notifications. OSDI 2010(Percolator 原始论文,data/lock/write 三列模型、Prewrite/Commit 两阶段)。
规范 / 文档(A 级)
- TiKV Deep Dive, Percolator(三 CF
映射、memcomparable + 位反转时间戳编码、
write列四种类型)。 - TiKV Deep Dive, Optimized Percolator(Rollback 记录、短值优化的动机与流程)。
- The TiKV Blog, How TiKV reads and writes(Prewrite/Commit 对 CF_LOCK/CF_WRITE 的操作示意)。
源码(A 级)
tikv/tikv,src/storage/mod.rs(SHORT_VALUE_MAX_LEN = 64、is_short_value)。tikv/tikv,tikv::storage::mvcc::Lock结构体(secondaries、min_commit_ts、use_async_commit字段)。pingcap/tikv#1407,optimize for short value(短值优化的原始 PR 与动机说明)。
站内对读
- 《Percolator 模型:Google 的乐观事务方案》(论文模型完整拆解,本篇不重复)。
- 《事务与 OptimisticTransactionDB》(RocksDB 单机事务 API,与 TiKV 分布式事务分层对照)。
- 本系列 第 5 篇 raftstore 写路径、第 9 篇 TSO、第 11 篇 悲观事务与 ResolveLock。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】Key 编码与 MVCC:三 CF 与时间戳
拆解 TiKV 的 user key + start_ts/commit_ts 编码规则与 default/lock/write 三 CF 分工,并说明为什么这套 MVCC 与 RocksDB Snapshot 的 sequence 号是两层完全不同的机制。
【TiKV / HTAP 内核】TSO:物理加逻辑的全局时间戳
拆解 PD 的 TSO 如何用 46 位物理毫秒 + 18 位逻辑计数器分配全局单调时间戳,PD leader 如何靠 etcd 时间窗口做故障恢复、客户端如何批量取号;用一句话对照 Spanner TrueTime 的硬件不确定区间路线与 Milvus 的 GuaranteeTs 用法。
【TiKV / HTAP 内核】Region · Multi-Raft · PD · Percolator · TiFlash
补齐 RocksDB 单 Region、Raft 协议与 HTAP 范式之间的工程链路:以 TiKV 7.x/8.x 拆解 Region、Multi-Raft、raftstore、PD/TSO 与 Percolator 事务,并以 TiFlash Learner 收束新鲜度;CockroachDB 对照与选型。
【TiKV / HTAP 内核】HTAP / 分布式 KV 全景:TiDB–TiKV–PD–TiFlash 四件套
定位 TiKV 7.x/8.x 在 RocksDB 单机 LSM、Raft 协议与 HTAP 范式之间补的那一层:TiDB、TiKV、PD、TiFlash 四组件各自的抽象边界,以及本系列 18 篇的读法。