
一个疑问:同一行记录 反复修改该如何存储呢?为什么刚写入数据,却查询不出来
本文聚焦:TiKV 数据库的 MVCC(多版本并发控制)机制 通过巧妙的 key 编码和多列族架构实现了完整的 MVCC 支持。 大纲
原生LSM-Tree IO读写过程
为了支持MVCC ,不修改LSM-Tree底层结构基础上,需要设计那些新的数据结构?
基于MVCC机制下IO流程优化?
大家都都知道
Redis是一个内存数据库, 其首要设计目标是将内存中的复杂数据结构(字符串、哈希、列表等)持久化到磁盘文件
内存特点就是:速度快,容量少 ,
Redis采用 fork()加Copy On Write是一种利用操作系统原生能力、实现相对简单且高效的快照方案
假如文件系统 磁盘 想采用提供方式用于实现磁盘卷多版本管理的空间优化型快照技术 肯定不不会这样设计,
哪怕采用全闪磁盘 特点也是 空间容量大,
存储系统采用虚拟化存储技术。 存储池中创建的LUN由元数据卷(Meta Volume)和数据卷(Data Volume)两部分组成
快照生成并激活后,存储系统在源LUN所在的存储池中动态划分一部分存储空间,用于保存写前拷贝数据。同一个源LUN对应的所有快照LUN共享同一个COW数据空间。COW数据空间包括COW Meta区域和COW Data区域:
快照链(Snapshot Chain):多个快照按照时间顺序形成的链式结构。每个快照记录了一个特定时刻的数据状态,并链接到前一个快照,以支持数据恢复和管理。
读操作路径:从最新快照向最旧快照回溯
参考 302-TiDB 高级系统管理 内容

旁白:不考虑 MVCC ,rockdb raft transaction部分内容,只关注rocksdb 本身
IO 写入过程

IO读取过程


所有列族共享同一个预写日志(WAL),这确保了跨多个列族的写入操作可以具有原子性(要么全部成功,要么全部失败)。
每个列族都拥有自己独立的MemTable(内存表)、Immutable MemTable和SST文件
清晰的数据组织:开发者可以将不同类型、不同业务或不同生命周期的数据存入不同的CF
通过CF,TiKV成功地将元数据、主体数据、临时状态数据(锁)和内部协调数据进行了物理分离和逻辑统一管理,符合现实设计意义
高效的独立操作:由于数据物理分离,对某个CF的增、删、改、查以及Compaction(合并)操作,基本不会影响其他CF的性能
第一次去 首都图书馆占地面积3.8万平方米,百万册图书, 简直是刘姥姥进大观园,找一本太难了,假如你是馆长 不会把所有东西都堆在一个房间里,而是分成了四个功能不同的房间, 每个房间就是一个“列族”。
下面我们来“参观”一下这个图书馆的四个房间:
列族 (CF) | 图书馆中的角色 | 具体职责与特点 |
|---|---|---|
write (写) | 图书目录卡 | 这是图书馆的检索核心。它不存整本书,只记录每本书的关键信息:书名(Key)、入库时间(MVCC的开始和提交时间戳)、以及这本书在哪个书架(如果书很薄,甚至会把整本书内容抄在卡片背面)。在TiKV中,它存储真实的写入数据和MVCC信息,如果数据小于255字节,就直接存在这里 |
default (默认) | 藏书库 | 这里是存放实体书的地方。当一本书太厚(数据大于255字节),目录卡(write)上写不下全部内容,就会在这里存一份完整的副本,并在目录卡上备注“藏书库,A区3排2架”。这样,目录卡就能保持轻便,查询更快 |
lock (锁) | 借阅登记处 | 这是一个临时工作台。当有人要修改某本书时,需要在这里登记“此书正在修订中,暂不外借”。这个记录是临时的,一旦修改完成(事务提交),记录就会立刻被擦除。如果这里排了长队,说明系统可能出问题了 |
raft (raft) | 馆长办公室 | 这个房间很小,只存放图书馆的内部管理文件,比如各个区域的分区图、值班表等。普通读者(用户)完全不用关心这里,它只对图书馆管理员(TiKV自身)有用 |
所以,TiKV创建这四个列族,根本目的是对数据进行逻辑上的“分房间管理”:
write:存核心索引和小数据,追求最快的查询速度。
default:存大数据本体,是write的“仓库”。
lock:存临时的事务锁,生命周期短。
raft:存内部元数据,体量极小。
当读者(事务)想找某本书在2024年(read_ts)的最新版本时,
他只需去索引卡片柜(write列族),找到该书在2024年之前最新的出版记录卡片,
然后根据卡片指引,去藏书库找到对应的书籍。
它通过集中管理所有数据的版本元信息(时间戳)和访问路径,使得系统能够以O(log N)的复杂度,快速定位到任意Key在任意时间点下的正确数据版本,从而高效地实现了快照隔离级别

具体存储如下:

TiKV的MVCC(多版本并发控制)机制与底层LSM-Tree(RocksDB)的结合,并非简单的叠加
而是通过一系列精妙的设计与改进,实现了高性能、高并发的分布式事务处理。
其核心改进在于将MVCC版本信息内嵌到Key中,并利用LSM-Tree的特性进行高效管理
LSM-tree 结构具有以下特点:
1、分层存储:数据在内存中的 MemTable 和磁盘上的 SST files 之间分层存储
2、有序排列:Key 按字典序排列,版本号较大的排在前面
3、持久化:所有数据最终持久化到磁盘,不是纯内存存储
TiKV 通过在用户 key 后追加时间戳来实现版本化存储
这种设计利用了 LSM-Tree 的有序性,使得版本号较大的 key 排在前面,便于版本查找。
// 构造带时间戳的 key let k = key.clone().append_ts(start_ts); let val = self.snapshot.get(&k)?;TiKV选择将时间戳编码到Key中,而不是像OceanBase那样直接改造LSM-Tree(RocksDB)的内部结构,是一个深思熟虑的架构权衡。
1、保持兼容性和可维护性
上游兼容: 避免维护自定义 RocksDB 分支,减少维护成本
•版本升级: 可以跟随 RocksDB 官方版本升级,获得性能改进和 bug 修复
•社区支持: 享受 RocksDB 社区的生态支持和优化
相反:直接改造,懂c++不多呀,尤其是研究生 都搞rust去了。
1、极高的工程复杂度和维护成本:需要深入改动一个像RocksDB这样复杂的存储引擎的每一层,包括WAL、MemTable、SSTable格式、Bloom Filter、索引块、Compaction算法等。任何改动都可能引入难以预料的稳定性问题,且需要团队具备极其深厚的存储内核研发能力。
2、与上游社区脱节:改造后的存储引擎将成为一个分支,难以持续、平滑地合并上游RocksDB社区的优化和修复,需要投入巨大精力进行二次维护和融合。
将时间戳编码到Key中的外部编码”方案,其核心优势可高度概括为以下三点,
1、实现高效的历史数据查询:由于LSM-Tree(及同类存储如HBase)的数据在物理上按键的字典序有序存储,将时间戳作为Key后缀,使得同一个逻辑Key的所有历史版本在磁盘上连续排列。这天然地将“按时间点查询特定版本”或“按时间范围扫描”的复杂逻辑,转化为了在有序序列上的高效Seek或范围扫描操作,极大提升了基于时间戳的快照读和范围查询性能。
2、为分布式事务提供原子性基石:该设计完美契合了类似Google Percolator的分布式事务模型。在此模型中,全局唯一、单调递增的时间戳是协调事务状态(开始、提交、回滚)的核心。将start_ts和commit_ts编码到不同数据(如write列族)和锁(lock列族)的Key中,使得跨多行、多表的事务状态变更,可以通过对一组带有特定时间戳的Key进行原子操作来实现,这是构建跨节点一致性的直接且清晰的方式。
3、简化多版本数据的生命周期管理:MVCC机制会产生大量过期数据版本。由于所有版本按时间戳有序存储,垃圾回收(GC)机制变得非常简单直接:
TiKV 采用了 [Google Percolator] 这篇论文中所述的事务模型
https://tikv.org/deep-dive/distributed-transaction/percolator/
Percolator is built based on Google’s BigTable, a distributed storage system that supports single-row transactions. Percolator implements distributed transactions in ACID snapshot-isolation semantics, which is not supported by BigTable. A column c of Percolator is actually divided into the following internal columns of BigTable:
•
c:lock
•
c:write
•
c:data
•
c:notify
•
c:ack_O
Percolator在BigTable中,并非直接将用户数据存入一个简单的键值对。
为了实现事务,它为每一行用户数据引入了三个特殊的“元数据列”(在TiKV中对应为lock, write, default 列族)
•
data列:存储事务写入的实际数据值。其Key的格式为 {用户row, 用户column, start_ts},Value是用户数据。这创建了一个由start_ts标识的、尚未提交的数据版本
•
write列:存储提交记录,标志着某个数据版本已成功提交。其Key的格式为 {用户row, 用户column, commit_ts},Value是对应的start_ts。通过查询write列,可以找到在某个时间点(commit_ts)已提交的最新数据版本指向哪个data记录
•
lock列:存储进行中事务的锁。其Key为 {用户row, 用户column},Value包含锁持有者的信息(如primary lock的位置)。用于在事务提交过程中防止其他事务干扰
将start_ts和commit_ts作为Key的一部分,分别编码到data和write列中。

乐观事务模型
将start_ts和commit_ts作为Key的一部分,分别编码到data和write列中。

请看 302-TiDB 高级系统管理
你会发现
•Default:update 3 =frank,记录最新修改内容,至于3以前内容不记录
•记录lock区域:begin 加锁操作
•记录lock区域:commit 提交 有增加一个D 记录

优化 :小文件存储,小于255字节直接存储到wirte 索引区域

数据存储到多个节点怎么加锁,主锁 和非主锁



写 id=1,不影响读


写写互斥
•TiKV的MVCC实现受到Google Percolator论文的启发
•三列族设计分离了数据、元数据和锁,优化了读写性能
•时间戳由PD(Placement Driver)全局分配,确保事务顺序
•支持乐观锁和悲观锁两种事务模式
TiKV 通过三个列族(Column Family)来存储MVCC数据 txn.rs:163-179 :
列族 | 用途 | 存储内容 |
|---|---|---|
CF_DEFAULT | 存储实际数据值 | key + timestamp → value |
CF_WRITE | 存储事务元数据 | key + commit_ts → Write(start_ts, type) |
CF_LOCK | 存储活跃事务锁 | key → Lock(start_ts, primary, ttl) |
RocksDB 作为 TiKV 的核心存储引擎,用于存储 Raft 日志以及用户数据。 每个 TiKV 实例中有两个 RocksDB 实例,一个用于存储 Raft 日志(通常被称为 raftdb), 另一个用于存储用户数据以及 MVCC 信息(通常被称为 kvdb)。
kvdb 中有四个 ColumnFamily:raft、lock、default 和 write:
•raft 列:用于存储各个 Region 的元信息。仅占极少量空间,用户可以不必关注。
•lock 列:用于存储悲观事务的悲观锁以及分布式事务的一阶段 Prewrite 锁。当用户的事务提交之后,lock cf 中对应的数据会很快删除掉,因此大部分情况下 lock cf 中的数据也很少(少于 1GB)。如果 lock cf 中的数据大量增加,说明有大量事务等待提交,系统出现了 bug 或者故障。
•write 列:用于存储用户真实的写入数据以及 MVCC 信息(该数据所属事务的开始时间以及提交时间)。当用户写入了一行数据时,如果该行数据长度小于或等于 255 字节,那么会被存储 write 列中,否则该行数据会被存入到 default 列中。由于 TiDB 的非 unique 索引存储的 value 为空,unique 索引存储的 value 为主键索引,因此二级索引只会占用 writecf 的空间。
default 列:用于存储超过 255 字节长度的数据。
TiDB 的 MVCC 多版本数据存储实现机制,在 Key 上会标识数据版本。
-- TiDB 数据存储结构示例
Key: table_id{row_id}_timestamp
Value: column_data + transaction_info
-- 实际存储格式:
-- Key: t{123}_r1_v5 (table 123, row 1, version 5)
-- Value: {name: "John", age: 25, start_ts: 5, commit_ts: 10}
TiDB的MVCC机制通过在Key中编码时间戳(版本号)来实现快照隔离和并发控制, 这是一种经典且强大的设计
由于RocksDB(TiKV的底层存储引擎)按Key的字典序存储数据,同一行数据(t{123}_r1)的所有历史版本(v1, v2, v3...)会在物理磁盘上连续排列。[读磁盘就是慢 这个问题本身无法解决]

问题场景举例:
假设表123中行r1被频繁更新了1000次,那么磁盘上就会存在 t123_r1_v1 到 t123_r1_v1000 这1000个Key。
当执行一个需要读取最新数据的查询(例如 SELECT * FROM table WHERE id=1)时,TiKV的读取逻辑(MvccReader)需要:
1、在 write CF 中,定位到 t123_r1 这个前缀。
2、向后扫描(Seek)所有以 t123_r1 开头的Key,直到找到小于或等于当前事务快照时间戳(start_ts)的最大版本号对应的记录。
3、、如果这个最新版本是一个Rollback记录或已被删除,则需要继续向前扫描寻找上一个有效版本。
这个过程意味着,即使你只想读取一行数据的最新状态,存储引擎也可能需要实际扫描该行的数十甚至数百个历史版本。对于范围查询(如 SELECT * FROM table WHERE id BETWEEN 1 AND 1000),这个问题会被指数级放大、
TiKV的优化:正是为了应对这一问题,TiKV在v8.5.0引入了 MVCC内存引擎(In-Memory Engine, IME)。
IME的核心思想是将最新的数据版本缓存在内存中。 当进行扫描时,系统优先从内存中查找数据,如果可以命中, 则能完全避免在磁盘上遍历大量历史版本, 从而大幅提升扫描性能。
TiKV MVCC 内存引擎 (In-Memory Engine, IME) 主要用于加速需要扫描大量 MVCC 历史版本的查询

写入路径:MVCC的写入在TiKV层面变成了带时间戳的新Key的插入。这完美契合了LSM-Tree将随机写转换为顺序写的核心优势。写操作只需追加写入WAL和MemTable,性能很高。
Prewrite命令负责第一阶段的数据写入 prewrite.rs:495-558 :
1、创建MVCC事务:使用start_ts初始化MvccTxn prewrite.rs:539-543
2、冲突检测:检查写冲突和锁冲突
3、写入数据:将数据写入CF_DEFAULT,锁写入CF_LOCK
Commit命令负责第二阶段的提交 commit.rs:52-97 :
1、验证时间戳:确保commit_ts > lock_ts commit.rs:54-59
2、提交每个key:遍历所有key执行提交操作
3、生成WriteResult:返回提交结果
TiKV的MVCC机制并非在LSM-Tree之上做简单封装,而是通过 “编码内化、数据分离、异步回收” 三大核心设计,与LSM-Tree深度集成:
1、将版本号编码进Key,利用LSM-Tree有序存储特性实现高效版本检索。
2、利用Column Family分离数据职责,优化访问模式与缓存效率,适配事务状态管理。
3、构建基于时间戳的异步垃圾回收,与LSM-Tree的Compaction机制协同,控制存储成本。
这些改进使得TiKV在继承LSM-Tree高写入吞吐、高压缩效率优点的同时,具备了处理分布式事务、支持快照隔离级别的能力,从而成为TiDB HTAP架构的坚实存储基石。
。其核心改进在于将MVCC版本信息内嵌到Key中,并利用LSM-Tree的特性进行高效管理,从而在保持LSM-Tree高写入吞吐的同时,支持了复杂的快照读和事务隔离
•https://deepwiki.com/tikv/tikv
•https://tidb.net/blog/614b0fe3
•https://pingkai.cn/docs/tidb/stable/tidb-storage/#mvcc
•https://github.com/feitian124/tidb-course-302
•302-TiDB 高级系统管理
•https://learn.pingcap.cn/learner/course/1290019
•Talent Plan 之 TinyKV 学习推荐课程
•https://learn.pingcap.cn/learner/course/390002
•[3]TiKV 源码解析系列文章(十三)MVCC 数据读取
•[4]TiKV 源码解析系列文章(十二)分布式事务
•TiKV 的 MVCC(Multi-Version Concurrency Contro) https://tidb.net/blog/e51d71b2
•https://tidb.net/blog/8adf5d7d# TiKV MVCC 内存引擎 In-Memory Engine 实现数据多版本场景性能优化