一个业务 Key user:42 在 TiKV 里从来不会以
user:42 这个原始字节串直接落到
RocksDB。它会先被追加事务时间戳,再分别写进三个不同的 Column
Family(CF),且同一个逻辑 Key 在这三个 CF 里的物理 Key
格式还互不相同。这套编码规则第一次读源码或读
pd-ctl/tikv-ctl
输出时很容易让人迷惑:为什么 CF_LOCK 里的 Key
没有时间戳,CF_DEFAULT 和 CF_WRITE
里的时间戳又不是同一个?
这套规则不是随意设计,而是把 Percolator 论文的 lock / data / write 三列模型直接映射成三个 RocksDB CF。本文是「TiKV / HTAP 内核」系列第 2 篇,只讲两件事:Key 到底怎么编码、三 CF 各存什么;以及这一层 MVCC 为什么不能和 RocksDB 内核系列 讲过的 Snapshot/sequence 号混为一谈。Percolator 的 2PC 提交协议本身留给第 10–11 篇,这里只钉住存储格式。
本文是「TiKV / HTAP 内核」系列第 2 篇(共 18 篇)。→ 系列目录
篇目 核心内容 第 1 篇 · 全景 四件套分工 第 2 篇 · Key 编码与 MVCC 三 CF、start_ts/commit_ts 第 3 篇 · Region 模型 range、epoch、peer
版本锚定:TiKV 7.x/8.x(Deep Dive TiKV — Percolator、The TiKV blog: How TiKV reads and writes,A/B 级);Peng, D. & Wei, F., Large-scale Incremental Processing Using Distributed Transactions and Notifications, OSDI 2010(Percolator,A 级,仅引用其 lock/data/write 三列模型定义)。RocksDB 侧对照见 rocksdb/14 事务与 OCC。
一、三 Column Family:从 Percolator 的三列到 RocksDB 的三个 CF
Percolator(OSDI 2010)在 Bigtable
表上给每一行加了三个特殊列:lock、data、write。TiKV
把这三列直接映射成 RocksDB 的三个 Column Family(TiKV
Deep Dive: Percolator,A 级):
| Percolator 列 | TiKV CF | 存什么 | Key 里是否带时间戳 |
|---|---|---|---|
data |
CF_DEFAULT |
用户实际写入的 value | 是,{user_key}{start_ts} |
lock |
CF_LOCK |
未提交事务的锁信息(primary key、start_ts、TTL 等) | 否,{user_key} |
write |
CF_WRITE |
已提交事务的提交记录(指向哪个 start_ts 的数据) | 是,{user_key}{commit_ts} |
CF_LOCK 里的 Key
不带时间戳,原因很直接:同一个 user key
在任意时刻最多只能有一把未提交的锁(否则就是写写冲突,该报错),所以锁信息本身就是
Key 到 Lock
的一对一映射,不需要版本区分。CF_DEFAULT 和
CF_WRITE 则要保留同一个 user key
的多个历史版本,所以都在 Key
后面追加时间戳。
flowchart LR
subgraph default_cf ["CF_DEFAULT"]
d1["a_100 -> jack"]
d2["a_80 -> tom"]
end
subgraph lock_cf ["CF_LOCK"]
l1["b -> primary=a, start_ts=120, ttl"]
end
subgraph write_cf ["CF_WRITE"]
w1["a_110 -> Put, start_ts=100"]
w2["a_90 -> Put, start_ts=80"]
end
w1 -.->|"points to"| d1
w2 -.->|"points to"| d2
1.1
为什么读路径要先查 CF_WRITE 再查
CF_DEFAULT
一次带时间戳 ts 的点查(比如事务
start_ts=120 要读 key
a)分两步:
- 在
CF_WRITE里用{a}{ts}反向扫描(Key 按时间戳降序排列,见 1.2 节),找到第一条commit_ts <= ts的记录,取出它指向的start_ts。 - 用这个
start_ts去CF_DEFAULT里精确查{a}{start_ts},取出真实 value。
TiKV 官方博客 How TiKV reads and writes(B
级)指出一个工程优化:小 value 会直接内联存在
CF_WRITE
记录里,这样点查只需要访问一个 CF
而不是两个——这是牺牲一点 CF_WRITE
的体积换点查的 I/O 次数,不改变上面两步的逻辑模型。
1.2 时间戳为什么要按位取反后拼进 Key
RocksDB 内的 Key
按字节序升序排列。而查询几乎总是要拿到「最新的已提交版本」,如果时间戳按正常大小拼接,最新版本会排在同一
user key 分组的最后,还需要多一次定位。TiKV 把 64
位时间戳按位取反后再拼进 Key(TiKV Deep
Dive: Percolator,A
级):时间戳越大(越新),取反后的值越小,拼出来的完整 Key
在字典序里就越靠前。于是对同一个 user key 做一次
seek,第一条命中的记录天然就是最新版本,不需要额外反向扫描逻辑。
\[ \text{encoded\_ts} = \overline{\text{ts}} \quad (\text{64 位按位取反}) \]
在 pd-ctl/tikv-ctl
或源码讨论中,这类内部 Key 常以前缀 z
出现(区别于 Raft 元数据用的 r 等前缀),即
z{encoded_key}{encoded_ts}——这是 TiKV
存储引擎在 RocksDB 物理 Key
空间里划分命名空间的方式,不影响上面讲的排序逻辑。
1.3 一次 prewrite/commit 的具体变化
用 TiKV 官方博客给出的例子(How TiKV reads and
writes,B 级)具体看一次事务在三 CF
上留下的痕迹:事务把 key a
的值改成某个新值,start_ts = 9,之后以
commit_ts = 10 提交。
| 阶段 | CF_LOCK |
CF_DEFAULT |
CF_WRITE |
|---|---|---|---|
| Prewrite 后 | 写入
a -> {primary, start_ts=9, ttl} |
写入 a_9 -> value |
无变化 |
| Commit 后 | 删除 a 上的锁 |
不变 | 写入 a_10 -> {Put, start_ts=9} |
之后如果同一个 key a 又发生一次
start_ts=11, commit_ts=12
的写入,CF_DEFAULT 和 CF_WRITE
会各自追加一条新记录(a_11、a_12),而不是覆盖旧记录——这正是
MVCC
版本链的物理体现,第二节会把这条链条完整画出来。旧版本什么时候可以被清理,属于
GC 安全水位判断的范畴,不在本篇展开。
1.4 与 TiDB 行/索引编码的接口
再往上一层,TiDB 会把每张表、每个索引的数据编码进各自固定前缀的 user key 区间(TiDB Docs, TiDB Computing,A 级):
行数据 Key: t{TableID}_r{RowID}
唯一索引 Key: t{TableID}_i{IndexID}_{IndexedColumnsValue}
普通索引 Key: t{TableID}_i{IndexID}_{IndexedColumnsValue}_{RowID}
这里的 t{TableID}_r{RowID}
才是本篇讨论的「user_key」,TiKV
存储层在此基础上追加时间戳、分发到三个 CF——TiDB
的表/索引编码规则和 TiKV 的 MVCC
编码规则是两层独立的编码,前者决定同一张表的数据在
Key 空间里连续排列(这也是第 3 篇会讲的「Region
切分与表的关系」的编码基础),后者决定同一个 user key
的多个版本如何组织。
二、MVCC 版本链:一个 Key 上同时活着的信息
把三个 CF 叠在一起看,一个 user key 在某个时刻的完整状态是:
CF_LOCK是否存在这个 Key 的锁(有,说明有未提交事务在写它);CF_WRITE里按commit_ts降序排列的一串提交记录(Put/Delete/Rollback/Lock四种类型,TiKV 相对 Percolator 论文多加了后两种,Deep Dive: Percolator,A 级);CF_DEFAULT里被CF_WRITE的Put记录指向的实际 value。
sequenceDiagram
participant C as Client (start_ts=120)
participant L as CF_LOCK
participant W as CF_WRITE
participant D as CF_DEFAULT
C->>L: check lock on key a
L-->>C: no lock found
C->>W: seek a, ts<=120
W-->>C: a_110 -> Put start_ts=100
C->>D: get a_100
D-->>C: value = jack
2.1 CF_WRITE
里的四种记录类型
TiKV 相对 Percolator 论文原始的三列模型多加了两种
CF_WRITE 记录类型,一共四种(TiKV Deep
Dive: Percolator,A 级):
| 类型 | 含义 | 是否有对应 CF_DEFAULT 记录 |
|---|---|---|
Put |
提交了一次写入 | 是,指向同一 start_ts 的 value |
Delete |
提交了一次删除 | 否 |
Rollback |
事务回滚,本次 start_ts 未真正提交 |
否 |
Lock |
由 SELECT ... FOR UPDATE
一类只加锁不改值的操作产生 |
否 |
Rollback
记录的存在解释了另一个容易困惑的现象:已经回滚的事务在
CF_WRITE
里也会留下痕迹,而不是什么都不留——这是为了让后续任何看到同一
key 上残留锁的读写请求,能够查到「这个 start_ts
已经被回滚过」,避免重复处理或误判事务状态。Lock
类型则说明 TiKV 为了满足快照隔离,把只读但需要防止并发修改的
FOR UPDATE
查询也当作一次「写操作」记录下来,即使它没有真正改变任何
value——这一点在读 TiKV 源码或诊断锁冲突时容易被忽略。
这条链本身就是 MVCC:不同事务在不同 start_ts
写下的版本共存在同一个 Key 空间里,读请求靠自己的时间戳去
CF_WRITE
里过滤,天然实现「快照隔离读不阻塞写」。旧版本不会立刻消失,而是要等所有可能用到它的事务都已经结束(低于某个安全水位的时间戳之后不会再有读请求)才能被后台
GC 清理——这套MVCC 版本回收机制由 TiDB/TiKV
的 GC 组件驱动,与第 6 篇要讲的Raft 日志
GC是完全不同的两套机制:前者清理的是
CF_DEFAULT/CF_WRITE
里过期的用户数据版本,后者清理的是 Raft
复制协议自身的日志条目。两者都叫「GC」,但回收对象、驱动方式、安全判据都不同,本系列不把二者混在同一篇讨论。
常见误解
- 「TiKV 的 MVCC 就是 RocksDB 的 Snapshot
机制」。这是本文要纠正的核心误解。RocksDB 的
Snapshot锚定的是单机 LSM 内部的写入序号(sequence number),只在一个 RocksDB 实例、一次DB::GetSnapshot()调用的生命周期内有意义(详见 rocksdb/14 事务与 OCC)。TiKV 的 MVCC 时间戳(start_ts/commit_ts)是分布式事务层的时间戳,由 PD 的 TSO 统一分配(第 9 篇),跨越所有 Region、所有 RocksDB 实例保持全局单调。一个 TiKV 节点上的多个 Region 各自有独立的 RocksDB 实例和各自的 sequence number,但它们的start_ts/commit_ts语义在整个集群里是统一的。两套版本号分别解决「单机崩溃恢复与快照读」和「分布式事务可见性」两个不同问题,不能互相替代。 - 「
CF_LOCK里有记录就代表数据丢失或损坏」。CF_LOCK里存在锁是正常的中间状态——事务 prewrite 之后、commit 之前,锁本该短暂存在。只有锁长期滞留(超过 TTL 仍未被 Resolve)才是需要关注的信号,这属于第 11 篇悲观事务与 ResolveLock 的话题。 - 「三 CF
是三份独立的数据副本,删一个不影响别的」。三 CF
是同一份逻辑数据的三个必要组成部分,
CF_WRITE里的Put记录依赖CF_DEFAULT里对应start_ts的 value 才能读出完整结果,二者必须联合 GC、联合备份,缺一不可。
三、与 RocksDB 内核的接口边界
这一节把「谁负责什么」钉得更细,方便读者判断遇到问题该翻哪个系列。
| 层面 | 由谁负责 | 对应系列 |
|---|---|---|
| Key 在 CF 内部如何用 SST/MemTable 存储、如何 Compaction | RocksDB 引擎本身 | rocksdb 内核 1–13 篇 |
| 三个 CF 共享同一条 WAL、原子写入保证 | RocksDB WriteBatch 跨 CF 原子性 |
rocksdb/04 WAL/WriteBatch |
| user_key + timestamp 的编码规则、三 CF 各存什么 | TiKV 存储层(本篇) | 本系列第 2 篇 |
| start_ts/commit_ts 由谁分配、如何保证全局单调 | PD TSO | 本系列第 9 篇 |
| prewrite/commit 如何驱动这三个 CF 的写入顺序 | Percolator 事务协调逻辑 | 本系列第 10–11 篇 |
TiKV 官方文档在描述这一层时明确写的是「Region 本地有序 KV」(TiKV Storage,B 级):RocksDB 只知道自己在管理三个有序的 Key-Value 集合,完全不理解「事务」「时间戳」这些语义,这些语义完全是 TiKV 存储层在 Key 编码规则和读写逻辑里叠加出来的。这也是为什么本系列反复强调:排查 TiKV 性能问题时,RocksDB 层面的 write stall/compaction 落后与 MVCC 层面的锁冲突/GC 落后是两类完全不同的故障,混着查很容易找错方向。
四、范围扫描与删除:编码规则如何影响读放大
点查路径(第二节)只覆盖「给定 user_key +
ts
取一个版本」。范围扫描要把同一逻辑前缀下多个 user
key 与每个 key
上的版本链一起遍历,代价模型因此不同:
| 操作 | 主要访问的 CF | 编码带来的行为 |
|---|---|---|
| 点查 | CF_WRITE
→(可选)CF_DEFAULT |
时间戳取反使「最新版本」靠近 seek 起点 |
| 范围 scan | CF_WRITE 上按前缀迭代 |
同一 user key 的多个 commit_ts 在 Key
序上相邻成簇 |
| 已提交删除 | 只在 CF_WRITE 写 Delete
记录 |
无 CF_DEFAULT 对应项;读路径在
CF_WRITE 即判定不可见 |
| 未提交写 | CF_LOCK + CF_DEFAULT |
读请求若遇到锁,需查 primary 或等待 / 回滚判定(第 10–11 篇) |
flowchart TD
scan["Range scan with ts=T"]
scan --> iter["Iterate CF_WRITE keys with prefix"]
iter --> pick["For each user_key pick latest commit_ts <= T"]
pick --> branch{"Record type?"}
branch -->|Put| getdef["Get CF_DEFAULT at start_ts"]
branch -->|Delete| skip["Skip key (tombstone)"]
branch -->|Rollback/Lock| skip2["Treat per visibility rules"]
工程间隙:TiKV 相对 Percolator
论文增加了 Rollback/Lock 两类
CF_WRITE 记录,让「只加锁不改值」与「已回滚
start_ts」在存储层可判定——这是 SQL
语义(SELECT ... FOR UPDATE、冲突重试)落到 KV
层的必要扩展,不是对论文模型的偏离。论文只定义了
lock/data/write
三列;生产系统必须把锁的生命周期与已放弃的版本也编码进可扫描结构,否则读路径无法在无中心锁服务的情况下自洽。
开放问题:当单个 user key
上历史版本极多、且长期低于 GC
安全水位仍无法回收时,CF_WRITE/CF_DEFAULT
的「版本簇」会变长,范围 scan 的 CPU 与 I/O 会被同一 key
的多版本拖住——这与 RocksDB 的 compaction 策略(rocksdb
系列)是不同层的问题:前者是事务时间戳维度的版本堆积,后者是
LSM 层级维度的文件堆积。排障时需先分清是
MVCC GC 落后还是 LSM compaction 落后(第 17
篇决策树会回链)。
五、小结
三句话小结
- TiKV 把 Percolator 的 lock/data/write 三列直接映射为
CF_LOCK/CF_DEFAULT/CF_WRITE三个 RocksDB CF,Key 编码规则(是否带时间戳、时间戳是否取反)由各自的读写模式决定。 - TiKV 的 MVCC 时间戳来自 PD 的全局 TSO,与 RocksDB 单机 Snapshot 的 sequence number 是两层完全独立的机制,前者跨 Region 全局一致,后者只在单个 RocksDB 实例内有意义。
- 排查问题先分层:三 CF 的编码与内容属于 TiKV 存储层,SST/Compaction 行为属于 RocksDB 引擎层,二者接口清晰但不能互相替代解释故障现象。
下一篇进入分布式路由层:Region 模型:range、epoch、peer。
参考资料
核心论文
- Peng, D. & Wei, F., Large-scale Incremental Processing Using Distributed Transactions and Notifications, OSDI 2010(Percolator,lock/data/write 三列模型定义)。
规范 / 文档
- TiKV, Deep Dive TiKV — Distributed Transaction: Percolator(TiKV 7.x/8.x,A 级)。
- TiKV Blog, How TiKV reads and writes(B 级,读写路径与 value 内联优化)。
- TiKV Documentation, Storage(Region 本地有序 KV 定位,B 级)。
站内
上一篇:HTAP / 分布式 KV 全景
下一篇:Region 模型:range、epoch、peer
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】Percolator 乐观事务落地:prewrite、commit 与三 CF
拆解 TiKV 如何把 Percolator 论文的 data/lock/write 三列映射到 RocksDB 的 CF_DEFAULT/CF_LOCK/CF_WRITE,key 用 memcomparable + 位反转时间戳编码;说明 short value、Rollback 记录、Lock 类型 write 等论文之外的工程补丁,以及 Async Commit 如何压缩提交路径;对照 distributed/30 的论文模型与 rocksdb/14 的单机事务 API 分层。
【RocksDB 内核机制】生产嵌入对照:Flink · TiKV · Kafka Streams
从 RocksDB 内核侧补全 Flink KeyGroup 前缀与增量 checkpoint SST 上传机制,对照 TiKV Region 引擎与 Kafka Streams changelog 容错;不重复 stream/12 作业侧全文,给出 Docker 复现步骤而不伪造 checkpoint 指标。
【RocksDB 内核机制】LevelDB · WAL · Compaction · Column Family · 生产嵌入
补全存储引擎三角最后一角:从 LevelDB 基线与 RocksDB 架构演进,到 WAL/MemTable/SST 写路径、Get/Iterator 读路径、Leveled/Universal compaction 与 write stall,再到 Column Family、事务、Checkpoint 与 Flink/TiKV 嵌入对照。
【TiKV / HTAP 内核】TSO:物理加逻辑的全局时间戳
拆解 PD 的 TSO 如何用 46 位物理毫秒 + 18 位逻辑计数器分配全局单调时间戳,PD leader 如何靠 etcd 时间窗口做故障恢复、客户端如何批量取号;用一句话对照 Spanner TrueTime 的硬件不确定区间路线与 Milvus 的 GuaranteeTs 用法。