1. 数据布局:三个 Column Family
TiKV 用 RocksDB 做单机引擎,但 RocksDB 本身只提供单机 KV 接口,没有事务概念。TiKV 的做法是把 MVCC 所需的元信息(锁、提交记录、多版本值)编码进 key,然后分到三个 CF 中存放,靠 key 的组织顺序来加速扫描。
入口处(src/storage/mvcc/reader/reader.rs:7)直接引用了三个常量:
use engine_traits::{CF_DEFAULT, CF_LOCK, CF_WRITE};
- CF_LOCK:存未提交事务持有的锁。key 是原始用户 key,不做版本拼接。一个 key 同一时刻最多只有一个锁(或者一个共享锁集合)。value 是
Lock结构体,里面记录了事务的start_ts、锁类型、primary key(用于冲突时反向找到协调者)、min_commit_ts(限制该数据的可见时间窗)。 - CF_WRITE:存已提交事务的写入记录。key 编码为
用户key + commit_ts(commit_ts 取反后拼接,使得同一个 key 的 Write Record 按 commit_ts 降序排列,scan 时最先看到最新版本)。value 里存WriteType(Put/Delete/Lock/Rollback)以及一个start_ts指针,指向 CF_DEFAULT 中实际的数据版本。 - CF_DEFAULT:存用户数据的多个历史版本。key =
用户key + start_ts,value 就是实际数据。如果值很短(<= 64B),TiKV 会直接把值内联到 Write Record 中,避免多一次 CF_DEFAULT 的查询。
这套布局的本质是把锁和数据版本都"挂"在 key 身边,读的时候可以在同一个 region 内完成全部查找,不存在横跨多个节点的锁表查询。每个 region 自管自的锁和数据,没有单点瓶颈。
2. 核心数据结构
2.1 Write 记录
Write Record 是 MVCC 里最重要的结构——它同时承担了两个职责:一是标记某个 (key, start_ts) 的事务最终状态(提交、回滚还是 just lock),二是指向实际的数据版本。
// components/txn_types/src/write.rs
pub enum WriteType {
Put, // 插入或更新
Delete, // 删除
Lock, // 空锁,用于防止 GC 错误清理
Rollback, // 显式回滚标记
}
Put 和 Delete 都是事务已提交的正常结果。Rollback 比较特殊,它专门用来告诉后来的读事务"这个 start_ts 的事务已经回滚了,别等了也别误当成 dangling lock"。如果没有 Rollback 标记,后来的事务碰到一把没有 commit record 的锁时,无法区分"这笔事务还没提交完"和"已经回滚但锁没清干净",只能干等着,最终靠 TTL 超时再去 resolve。Lock 类型用得少,主要是在某些需要锁住 key 但又没有实际数据变更的场景下(比如 SELECT FOR UPDATE 没有实际修改时),防止 GC 把版本清掉。
2.2 Lock 记录
// components/txn_types/src/lock.rs
pub enum LockType {
Put, // 写入锁,对应 Mutation::Put / Insert
Delete, // 删除锁,对应 Mutation::Delete
Lock, // 空锁,对应 Mutation::Lock / SharedLock
Pessimistic, // 悲观锁,由 acquire_pessimistic_lock 写入
Shared, // 共享锁(读锁),允许多个事务同时持有一个 key 的共享锁
}
Lock 结构体里存了几个关键字段:primary 是字节数组,指向该事务的 primary key。当其他事务在本 key 上碰到锁冲突时,可以通过 primary key 反向找到持有锁的事务,决定是等待、回滚还是强制清理。min_commit_ts 用于限制可见性——在 async commit 的协议里,多个 key 可能分属不同 region,commit_ts 不是预先确定的。Prewrite 阶段会给锁写一个 min_commit_ts,告诉读事务"这个锁对应的数据最早要到 min_commit_ts 之后才能读到",防止读到未决事务的中间结果。
Pessimistic 锁和乐观锁不同,它的 Prewrite 和 Commit 是两个独立的步骤(Pessimistic 锁 + 后续的 Pessimistic Prewrite),因此 Lock 里专门有 LockType::Pessimistic 和 Shared 两个 variant。
2.3 MvccTxn:事务的执行上下文
// src/storage/mvcc/txn.rs:60
pub struct MvccTxn {
start_ts: TimeStamp, // 事务标识
write_size: usize, // 累积写入大小,超出阈值则拒绝
modifies: Vec<Modify>, // 暂存的 RocksDB 修改,提交时才批量应用
locks_for_1pc: Vec<...>, // 1PC 优化:单 region 事务跳过 2PC
new_locks: Vec<LockInfo>, // 本次新获取的锁,用于死锁检测模块
concurrency_manager: ..., // 管理内存锁表,记录 key 的锁持有者
guards: Vec<KeyHandleGuard>, // 锁在 concurrency_manager 中的 handle
}
MvccTxn 最值得注意的设计是 modifies:MVCC 层的所有操作(Prewrite、Commit、Rollback)都不会直接写 RocksDB。它们把操作转化成 Modify 枚举值(Put/Delete/DeleteRange),塞进 modifies 向量里。等到整个 Raft 命令执行完毕,确认日志已在多数派复制后,这批 Modify 才被 apply 到 RocksDB。
这个"先攒后写"的模式把 MVCC 逻辑和 Raft 共识彻底解耦了。MVCC 层不关心下面的 engine 是不是 raft-backed,也不关心有没有 follower、会不会 split-brain——它只管产出对的 Modify,持久化和一致性全丢给外面。
3. 分布式两阶段提交
TiKV 的二阶段提交直接借鉴了 Google Percolator 的设计。客户端(TiDB)充当协调者,从要修改的 key 里选一个作为 primary,剩下的全是 secondary。primary 的事务状态代表全局事务状态:primary 提交了,整个事务就算提交了;secondary 的不一致状态可以靠 rollback 或 resolve 来修复。
3.1 Prewrite 阶段
入口:src/storage/txn/actions/prewrite.rs:37。代码里 prewrite 方法委托给 prewrite_with_generation,多了 generation 参数——这是为 pipelined DML 准备的,同一个 key 可能被同一事务重复 flush 多次。
Prewrite 要做三件事:
(1)检查写冲突。 读 CF_WRITE,看这个 key 在 start_ts 之后有没有新的提交。具体来说是在 [start_ts, INF] 范围内 scan write record,如果找到非 Rollback 的 record,就说明有并发事务在"我"的 start_ts 之后修改了同一条数据——典型 WriteConflict,直接拒绝。
这里有个细节:读到 Rollback record 不算冲突,因为那笔事务并没真正提交。但如果 Rollback record 本身被 GC 清了(过了 safe point 后清理旧版本),那就可能在 scan 时"看不到"原本的 Rollback,后面的逻辑需要小心处理这种情况。
(2)检查锁冲突。 读 CF_LOCK,确认 key 上没有别的事务的锁。如果碰到了:
- 一把独占锁(
Lock或Pessimistic),直接返回KeyIsLocked,把锁信息带回去,让协调者决定等待或 rollback。 - 一个共享锁集合(
SharedLocks):- 如果当前事务也在共享锁里(说明之前拿过 Shared 类型的悲观锁),可以升级成独占锁继续。
- 如果当前事务不在共享锁里,且操作类型不是 Shared,说明被其他人的共享锁挡住。
- 如果操作类型是 Shared 但已有其他共享锁,需要合并。
这部分逻辑在 prewrite.rs 的第 96-181 行,光是检查锁的 match 分支就有七八种情况——乐观事务、悲观事务、共享锁升级、pipelined DML 的重入,都在这段代码里分叉处理。
(3)写入锁和值。 冲突检查通过后,在 CF_LOCK 里写一把锁(key = 原始 key,value = Lock),在 CF_DEFAULT 里写值(key = 用户key + start_ts,value = 用户数据)。两把操作都塞进同一个 modifies 向量,等 Raft apply 时一起生效。
Insert 操作还有一个特殊情况:MVCC 的快照隔离不能天然处理 phantom 问题——"这个 key 在我读快照时不存在,但可能有未来的事务插入了它"。为了补上这个洞,Insert 的 Prewrite 会调用 concurrency_manager.update_max_ts,把 max_ts 推到当前 start_ts:
// src/storage/txn/actions/prewrite.rs:73
if mutation.should_not_exist {
txn.concurrency_manager
.update_max_ts(txn_props.start_ts, || {
format!("prewrite-{}", txn_props.start_ts)
})?;
}
max_ts 的作用是限制后续事务分配 start_ts 的下界——如果 TiDB 上来就拿了一个小于 max_ts 的 start_ts 做读取,它读到的 snapshot 可能不包含"已经被 Prewrite 但还没 Commit 的 Insert",违反了线性一致性。
失败处理: primary 的 Prewrite 失败直接判整个事务死刑,协调者需要发起 rollback。secondary 的 Prewrite 失败暂时不管——只要 primary 锁还在,协调者可以重试 secondary。极端情况下 primary 锁因为 TTL 被其他事务清理掉,那该事务就变成孤儿,后续 cleanup 会补刀 rollback。
3.2 Commit 阶段
入口:src/storage/txn/actions/commit.rs:64。Commit 的本质是"把锁转成 Write Record",三步走:
(1)读 Lock。 先读 CF_LOCK 里的锁。如果锁的 start_ts 匹配当前事务:
- 普通锁直接往下走。
- Pessimistic 锁不应该出现——悲观事务的提交是 Prewrite(写 Lock)+ Commit(写 Write Record)两步,Commit 时锁类型应该是
Put/Delete/Lock,不是Pessimistic。如果碰到Pessimistic,说明 acquire lock 之后 Prewrite 没成功,这边只能做个 rollback。 - 共享锁的情况:需要把当前事务从
SharedLocks里摘出来,换成一个独占 Lock 再 commit,或者如果共享锁集合里只剩自己,就正常走 commit 流程。
如果锁不存在了(CF_LOCK 里查不到匹配的锁),情况就比较微妙了。handle_lock_not_found 函数会去查 CF_WRITE 看这个 start_ts 上有没有记录:
- 如果有
Put/Deleterecord——说明已经被并发事务提前 commit 了(重复命令,或者事务的另一个 write 列提前到达),视为成功,直接返回 Ok。 - 如果有
Rollbackrecord 或者啥都没有——报TxnLockNotFound。primary 碰到这个情况是致命错误(意味着事务状态混乱),secondary 碰到的概率不高,但如果发生也会报错并携带 MVCC debug info。
另外还有一个 min_commit_ts 的检查:commit_ts 必须大于等于锁里写的 min_commit_ts,否则报 CommitTsExpired(commit.rs:138-166)。这个检查在 primary 上可能通过(primary commit 前可以推高 min_commit_ts),但 secondary 不应该碰到不满足的情况。
(2)写 Write Record。 CF_WRITE 里写一条记录:key = 用户key + commit_ts,value = Write { write_type, start_ts }。这一条就是事务的原子提交点。 一旦这条 Write Record 出现在 CF_WRITE 里,任何读事务都能看到它的 commit_ts,从而通过 start_ts 指针找到数据版本。底层靠 RocksDB 单 key 原子写保证——不需要额外的事务提交日志。
(3)清理 Lock。 从 CF_LOCK 中删除对应的锁,释放 ReleasedLock。ReleasedLock 会通知 LockManager,后者唤醒所有在这个 key 上等待的事务,让它们重新竞争。
primary 先 commit,secondary 后 commit。primary 的 Write Record 一落地,事务就是 committed 状态——secondary 后续即使失败也可以通过 retry 或 resolve 安全完成,因为 secondary lock 的 primary key 引用指向已提交的 primary,其他事务 resolve 时能判断出事务的实际状态。
3.3 Rollback 与 Cleanup
Rollback 逻辑相对直接:在 CF_WRITE 里写一条 WriteType::Rollback 记录,告诉后来的读者这笔事务已放弃,然后清理 CF_LOCK 里的锁。
之所以需要显式写 Rollback 记录而不是直接删锁了事,是因为 TiKV 没有中心化的事务状态管理器。如果只有锁没清理干净,过一个 GC 周期锁被丢弃,后来的事务扫到 CF_WRITE 时既找不到 commit record 也找不到 rollback record,就无法判断事务状态,只能按 TTL 等锁过期。
Cleanup 是 Rollback 的对偶操作——在事务已经提交但 secondary lock 还没清掉的情况下被触发。Cleanup 过程中去读 primary 的 commit record,如果 primary 已提交,就把 secondary 也 commit;如果 primary 已回滚,就把 secondary 回滚。
4. 读路径:MVCC 快照读
SnapshotReader(src/storage/mvcc/reader/reader.rs:40)封装了 MVCC 的读逻辑。它持有一个引擎层的 MvccReader 和一个 start_ts(读事务的快照时间戳)。读操作的约束是:只看到 commit_ts <= start_ts 的已提交版本。
读一条 key 的流程(对应 PointGetter,src/storage/mvcc/reader/point_getter.rs):
- 读 CF_LOCK,检查该 key 上有没有未提交的锁。如果有,需要看锁的
start_ts是否在 bypass list 里(同一事务自己的锁可以忽略),以及min_commit_ts是否 <=start_ts。如果锁的min_commit_ts>start_ts,说明这个锁代表的数据"要等到未来某个时间点才能读到",当前快照不该看到它——直接跳过这把锁继续读旧版本。如果没有min_commit_ts(普通乐观锁),那就先假设锁还没提交,碰到锁时返回KeyIsLocked让上层等待或 resolve。 - 读 CF_WRITE,向后 scan,找第一个
commit_ts <= start_ts的 Write Record。Put/Delete:该 key 在目标时间戳有数据,continue。Rollback:对应的事务已回滚,该版本无效,继续向前找更早版本。Lock:锁类型的 write record,说明有事务执行了 Select For Update 但没实际改值,继续找。
- 根据 Write Record 的
start_ts定位 CF_DEFAULT 中的数据版本。短值(<= 64B)直接内联在 Write Record 中,省一次 CF_DEFAULT 的查询。长值则需要按用户key + start_ts去 CF_DEFAULT 查。- 如果 Write Type 是
Delete,返回 "key 不存在"。 - 如果遇到
WriteType::Lock,此时已经读到了对应start_ts的版本,检查last_change元数据看该版本是否有数据变更,可能返回空。
- 如果 Write Type 是
MvccReader 还维护了三个 CF 上的 cursor(data_cursor、lock_cursor、write_cursor),连续扫描同一个 key 的不同版本时可以复用 cursor 位置,不用每次重新 seek。
5. 为什么要这样设计
抛开代码细节,这套架构有几个贯穿始终的设计选择,每个都对应了分布式事务中的具体痛点:
锁存在数据旁边,不建全局锁表。 如果搞一个集中的锁管理器(类似传统 2PL 的锁表),那就必须解决两个问题:锁管理器本身的单点瓶颈,以及锁管理器与数据存储之间的一致性。TiKV 的选择是把锁直接写在被锁的 key 所在的 region 里——region 分裂时锁跟着走,读写都在本地完成,没有跨 region 的锁查询开销。
Write Record 就是 commit point。 传统 2PC 需要协调者先写 commit log,再通知参与者 commit。TiKV 把这两个合并成一步:在 primary key 上写 Write Record。RocksDB 本身保证单个 key 的写入是原子的——你的 Write Record 要么在、要么不在,不存在写到一半的情况。这一条 Write Record 同时承担了"commit log"和"数据索引"两个角色。省掉了额外的日志存储,也避免了协调者日志与参与者日志之间的一致性问题。
两阶段操作都设计为可重试。 Prewrite 和 Commit 的代码里到处都是 lock_status == Locked 之后的短路返回——如果锁已经在了(说明之前一次 Prewrite 成功但协调者没收到响应),重试直接成功。Commit 也是一样:如果发现 Write Record 已经在 CF_WRITE 里了,直接返回 Ok。这种幂等设计是应对网络不可靠的基础——没法区分"请求失败了"和"请求成功了但响应丢了",唯一安全的做法就是每种操作都能安全重试。
MVCC 层和 Raft 层不互相依赖。 MVCC 层生成 Modify 列表,Raft 层负责把 Modify 复制到多数派。MVCC 层不需要感知 Raft 的状态(有没有 leadership、log index 是多少、是否在 snapshot installing),Raft 层也不理解 MVCC 事务语义。这种分层使得两边的维护和测试可以独立进行——事务协议的正确性验证不依赖 Raft 的复杂状态机,Raft 的正确性验证只需关心日志一致性和 Apply 顺序。