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

【TiKV / HTAP 内核】Key 编码与 MVCC:三 CF 与时间戳

文章导航

分类入口
databasestorage
标签入口
#tikv#mvcc#column-family#percolator#rocksdb#key-encoding#timestamp

目录

一个业务 Key user:42 在 TiKV 里从来不会以 user:42 这个原始字节串直接落到 RocksDB。它会先被追加事务时间戳,再分别写进三个不同的 Column Family(CF),且同一个逻辑 Key 在这三个 CF 里的物理 Key 格式还互不相同。这套编码规则第一次读源码或读 pd-ctl/tikv-ctl 输出时很容易让人迷惑:为什么 CF_LOCK 里的 Key 没有时间戳,CF_DEFAULTCF_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 TiKVPercolatorThe 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 表上给每一行加了三个特殊列:lockdatawrite。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_DEFAULTCF_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)分两步:

  1. CF_WRITE 里用 {a}{ts} 反向扫描(Key 按时间戳降序排列,见 1.2 节),找到第一条 commit_ts <= ts 的记录,取出它指向的 start_ts
  2. 用这个 start_tsCF_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_DEFAULTCF_WRITE 会各自追加一条新记录(a_11a_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 在某个时刻的完整状态是:

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」,但回收对象、驱动方式、安全判据都不同,本系列不把二者混在同一篇讨论。

常见误解

  1. 「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 语义在整个集群里是统一的。两套版本号分别解决「单机崩溃恢复与快照读」和「分布式事务可见性」两个不同问题,不能互相替代。
  2. CF_LOCK 里有记录就代表数据丢失或损坏」CF_LOCK 里存在锁是正常的中间状态——事务 prewrite 之后、commit 之前,锁本该短暂存在。只有锁长期滞留(超过 TTL 仍未被 Resolve)才是需要关注的信号,这属于第 11 篇悲观事务与 ResolveLock 的话题。
  3. 「三 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_WRITEDelete 记录 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 篇决策树会回链)。


五、小结

三句话小结

  1. TiKV 把 Percolator 的 lock/data/write 三列直接映射为 CF_LOCK/CF_DEFAULT/CF_WRITE 三个 RocksDB CF,Key 编码规则(是否带时间戳、时间戳是否取反)由各自的读写模式决定。
  2. TiKV 的 MVCC 时间戳来自 PD 的全局 TSO,与 RocksDB 单机 Snapshot 的 sequence number 是两层完全独立的机制,前者跨 Region 全局一致,后者只在单个 RocksDB 实例内有意义。
  3. 排查问题先分层:三 CF 的编码与内容属于 TiKV 存储层,SST/Compaction 行为属于 RocksDB 引擎层,二者接口清晰但不能互相替代解释故障现象。

下一篇进入分布式路由层:Region 模型:range、epoch、peer


参考资料

核心论文

  1. Peng, D. & Wei, F., Large-scale Incremental Processing Using Distributed Transactions and Notifications, OSDI 2010(Percolator,lock/data/write 三列模型定义)。

规范 / 文档

  1. TiKV, Deep Dive TiKV — Distributed Transaction: Percolator(TiKV 7.x/8.x,A 级)。
  2. TiKV Blog, How TiKV reads and writes(B 级,读写路径与 value 内联优化)。
  3. TiKV Documentation, Storage(Region 本地有序 KV 定位,B 级)。

站内

  1. rocksdb/14 事务与 OCC:Snapshot MVCC 对照
  2. 第 1 篇 全景本系列 index

上一篇HTAP / 分布式 KV 全景

下一篇Region 模型:range、epoch、peer

返回 系列目录 · 数据库索引

同主题继续阅读

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

2026-07-16 · database / storage

【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 分层。

2026-07-16 · database / storage

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

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


By .