前一篇文章,我们把 RocksDB 的整体骨架搭起来了:写入先走 WAL 和 MemTable,后面 Flush 成 SST,再通过 Compaction 慢慢整理;读取从 MemTable 一路查到各层 SST;启动时恢复元信息和 WAL。
如果只到这里,其实已经够入门了。但上一篇有几个点我是刻意只讲到”能建立直觉”为止,没有继续往下挖。这些细节不补上,后面碰到线上问题分析、架构讨论,大概率还是会有一种”好像知道在说什么,但又没有真正连起来”的感觉。
如果你还没读上一篇,建议先回去看一下整体架构。不然这一篇虽然也能看,但读起来会有点跳。
好了,废话说完,开始进入正文。
从一个问题说起:一次读取到底应该读到什么
上一篇在讲读取流程的时候,我写了一句话:
读取不是简单地在很多地方找一个 key,而是在很多地方找这个 key 的最新可见版本
当时没展开,因为要讲明白得先铺不少东西。这一篇我们就从这句话开始往下挖。
先抛一个非常具体的场景。假设你写了这样一段代码,要扫描所有 user: 开头的 key:
try (RocksIterator iterator = db.newIterator()) {
for (iterator.seek("user:".getBytes());
iterator.isValid();
iterator.next()) {
// 处理扫描结果
}
}
这个扫描可能要花几秒甚至更久。而在你扫描的过程中,别的线程很可能一直在写入、更新、删除数据。问题就来了——你这次扫描看到的结果,到底应该是什么样子?
- 前半段看到老数据,后半段看到新数据?
- 扫到一半的 key 被删了,后半段就看不到了?
- 扫描过程中新插进来的 key,算不算这次扫描的结果?
如果真是这样,那整次扫描看到的根本不是一个”世界”,而是好几个不同时刻拼起来的东西。多数业务都受不了这种前后不一致。
那怎么办?RocksDB 给的答案是 Snapshot:先把”此时此刻的世界”固定住,然后整次扫描都基于这个固定视图来读。后面别的线程怎么写、怎么删,跟你这次读取都没关系。
听起来很美,但要落地这个东西,RocksDB 必须先解决一个非常底层的问题:它得能判断,某条记录到底是不是你这次读取”应该看到”的那个时刻的记录。
再往下推一步,这其实就是在问:每条记录写入时都要带上某种”版本号”——不是墙上钟的那种时间,而是一个单调递增的逻辑值,让读取时可以顺着这个值去做可见性判断。
正是这个需求,把下面一整套机制串了起来:Sequence Number 解决”某条记录是哪个版本”,Internal Key 把版本信息和操作语义揉进 key 里,Delete 的 tombstone、Compaction 的清理时机,全都是在为这件事服务。
所以这一篇我们不会直接堆概念,而是顺着”怎么支持一次一致性读取”这条线一层一层往下挖。先从最基础的版本号说起。
Sequence Number:给每条记录盖一个版本号
大方向上,RocksDB 会给每一条写入分配一个单调递增的 Sequence Number。
注意,这个东西不要理解成系统时间,不是 wall clock。它更像一个数据库内部的逻辑时钟,只管先后,不管具体几点几分。
比如我们有下面几次操作:
1. Put(name, "javadoop")
2. Put(name, "mac")
3. Delete(name)
4. Put(name, "rocksdb")
在 RocksDB 内部,可以把它理解成:
Put(name, "javadoop") seq=100
Put(name, "mac") seq=101
Delete(name) seq=102
Put(name, "rocksdb") seq=103
有了这个编号之后,很多事情一下就顺了:
- 同一个 key,
seq更大的那个版本更新 - Delete 不需要立刻物理删除,先记一条删除语义就行
- 读取时只要知道”我这次最多能看到哪个 seq”,就能判断结果
说白了就是,Sequence Number 解决的是同一个 key 的多个版本谁新谁旧。
Sequence Number 什么时候分配
这个地方读者很容易有疑问,我们顺手说一下。
大方向上,真正进入写入路径时,RocksDB 会为这批写操作分配 sequence number。比如一个 WriteBatch 里有三条操作:
Put(k1, v1)
Put(k2, v2)
Delete(k3)
它拿到的不是一个模糊的”这批数据很新”,而是一段连续的 sequence:
k1 -> seq=200
k2 -> seq=201
k3 -> seq=202
batch 内部依然有明确顺序,后面读写、恢复、Compaction 都能统一按版本规则处理。
整条写入路径的主线其实就一句话:拿当前最新 sequence → 给本次 batch 分配新 sequence → 写 WAL → 写 MemTable → 把全局最新 sequence 往前推。逻辑很直。
不过光有 sequence number 还不够。因为 RocksDB 内部真正比较和排序的,并不是裸的 user key。
Internal Key:RocksDB 真正拿来排序的 key
上一篇里我们说 RocksDB 存的是 key-value,对外当然可以这么说。但下到内部实现,真正参与排序、比较、查找的,是 Internal Key。
什么叫 Internal Key
先给结论:
Internal Key = user key + sequence number + value type
这里的 value type 表示这条记录是什么语义:
kTypeValue:一条普通 valuekTypeDeletion:一条删除标记- 还有 merge 等其他类型,这里先不展开
所以同一个 user key name,在 RocksDB 内部更接近这个样子:
(name, seq=100, type=value)
(name, seq=101, type=value)
(name, seq=102, type=deletion)
(name, seq=103, type=value)
看到这里,前面很多模糊的地方就开始清楚了。我们不再只是说”同一个 key 可能有多个版本”,现在知道这些版本在内部大概长什么样了。
为什么要把 type 也编码进去
如果 Internal Key 里只有 user key + sequence number,那 Delete 这种操作就很尴尬。因为 Delete 不是一个普通 value,它表达的是:从这个版本开始,这个 key 对后续某些读取来说应该视为不存在。
所以 RocksDB 不只是要存”值”,还要存”操作语义”。这也是为什么 LSM 存储很多时候不是原地修改,而是不断追加新版本、新语义。这个概念大家一定要先有,不然后面看到 tombstone、Compaction 清理这些逻辑,会一直觉得别扭。
Internal Key 的排序规则
下面进入最重要的地方。
Internal Key 的比较规则,大方向上可以理解成:
- 先按 user key 排
- 如果 user key 相同,再按 sequence number 倒序排
- 如果还相同,再按 type 排
重点,重点,重点:同一个 user key 下,sequence number 越大,越靠前。
比如对于 name 这个 user key,内部顺序是:
(name, seq=103, value)
(name, seq=102, deletion)
(name, seq=101, value)
(name, seq=100, value)
这个顺序非常妙。因为它意味着:
- 你想看最新值,先碰到的就是更新版本
- 你想做带版本限制的读取,可以顺着往下找
- 你想判断某个删除标记是不是应该生效,也有统一规则
说白了就是,RocksDB 把”版本信息”和”操作语义”都揉进了 key 里,于是很多模块都能复用同一套排序逻辑。你会发现 RocksDB 很多设计都是这个味道:先把底层抽象统一起来,后面很多模块就顺手了。
编码长什么样
编码其实非常朴素:先把 user key 原样放进去,后面再拼一个 8 字节的尾巴,高 7 个字节放 sequence,最低 1 个字节放 type。就这么一拼,version 和操作语义就全带上了。
这个设计我个人觉得很漂亮。从这一刻开始,MemTable、SST、Iterator、Compaction,都不需要反复问”这是不是最新版本""这是 put 还是 delete”——比较一下 Internal Key,很多事就有统一答案了。
好了,写入这条线先收一收。下面我们进入最容易把人绕晕的部分:读取与可见性。
读取与可见性
有了 Sequence Number 和 Internal Key 打底,我们终于可以回头把开头那个 Snapshot 讲具体了。前面我们已经知道它的作用是”固定一次读取的视图”,但到底是怎么固定的?为什么只需要记一个数字就够?下面我们一个一个说。
Snapshot 到底记了什么
Snapshot 本质上就是给一次读取规定一个可见版本上界。比如当前数据库最新 sequence 是 500,这时候你创建了一个 snapshot:
snapshot_seq = 500
后面别的线程继续写:
Put(k1, v_new) seq=501
Put(k2, v_new) seq=502
Delete(k3) seq=503
你拿着刚才那个 snapshot 去读时,依然只能看到 seq<=500 的版本。501、502、503 这些后来的写入,对你来说就像不存在一样。
注意,注意,注意:Snapshot 不是把数据复制一份出来。它只是记住一个 sequence number,然后读取时按这个上界做可见性判断。
为什么只记一个数字就够了?因为前面我们已经把路铺好了:每条记录都有 sequence number,同一个 user key 下更新版本排在前面,Delete 也被编码成一种明确的记录类型。所以读取时,只要沿着版本往下找,找到第一条 sequence <= snapshot_seq 的记录,就知道当前读取该返回什么。
我们看一个具体的例子。假设某个 key 在内部有这些记录:
(name, seq=103, value="rocksdb")
(name, seq=102, deletion)
(name, seq=101, value="mac")
(name, seq=100, value="javadoop")
不同 snapshot 下,结果分别是:
snapshot_seq = 103 -> "rocksdb"
snapshot_seq = 102 -> NotFound(碰到 deletion 直接返回不存在)
snapshot_seq = 101 -> "mac"
snapshot_seq = 100 -> "javadoop"
逻辑是不是非常统一?这就是为什么 RocksDB 可以不用复制数据,只靠版本号和排序规则就完成一致性视图。
什么时候需要 Snapshot
很多文章一上来就说 Snapshot 很重要,但不说你到底什么时候需要它。我们直接落到场景。
普通单点 Get,通常不需要显式用 Snapshot。
byte[] value = db.get(key);
这种单次读取,RocksDB 自己会在那个时刻给你一个一致的结果。你一般不需要额外先 getSnapshot() 再去读。单次点查,关心的就是”这一刻数据库里这个 key 的值是什么”,直接查就行了。
这个结论大家先记住,后面不要一遇到”读”就条件反射想到 Snapshot。
长时间遍历,怕遍历过程中数据一直变,这时候需要 Snapshot。
这个就是开头那个场景,也是 Snapshot 最典型的用途。如果你只记住 Snapshot 的一个使用场景,大概率就是这个。正确的写法是这样:
try (Snapshot snapshot = db.getSnapshot();
ReadOptions readOptions = new ReadOptions().setSnapshot(snapshot);
RocksIterator iterator = db.newIterator(readOptions)) {
for (iterator.seek("user:".getBytes());
iterator.isValid();
iterator.next()) {
// 整次扫描都基于 snapshot 创建时的那一刻
}
}
说白了就是,先把”这一刻的世界”固定住,然后你再慢慢扫。
多次相关读取需要一致视图,也需要 Snapshot。
比如你的业务要连着做好几次读:先读用户信息,再读账户余额,再读订单摘要。如果这三个读之间其他线程在更新数据,三次读可能看到的是三个不同时间点的世界。第一步看到”旧用户状态”,第二步看到”新余额”,第三步看到”更新后的订单”,整组结果就前后不一致了。
这时候拿一个 Snapshot,把这几次读都绑在同一个可见版本上就好了。不是每次读都追最新,而是这一组读先约定好看同一个时间点。
Snapshot 不是用来”读历史版本”的。有些读者会把 Snapshot 理解成”专门用来读历史版本”,这个理解容易跑偏。更准确的说法是:Snapshot 的核心作用是固定读取视图,“读到过去时刻的数据”只是这个机制带来的副产品。对大多数业务来说,它最常见的用途就是让一组读在逻辑上基于同一个时刻。这个理解就够了,真的够了。
读取的主流程
把前面所有逻辑串起来,一次 Get 的主流程其实就这几步:
- 先拿到这次读取的可见上界
visible_seq(没有 snapshot 就是当前最新 sequence) - 按 internal key 的排序规则,找到这个 user key 对应的一批版本——因为同一个 user key 下 seq 大的排前面,所以先碰到的就是更新版本
- 一条一条往下看:
sequence > visible_seq的直接跳过,假装没看见 - 命中第一条
sequence <= visible_seq的记录后,根据 type 决定返回什么——是kTypeValue就返回 value,是kTypeDeletion就直接返回 NotFound,不再往下找更老的版本
整个过程没有额外的”版本判断”,全都是顺着排序规则走一遍就完事。这就是前面把 sequence 和 type 揉进 internal key 的好处——读取逻辑特别干净。
到这里,读取和可见性这条线就讲得差不多了。下面回到一个前面一直没彻底展开的老问题:Delete 为什么不直接删。
Delete 与 Compaction:旧版本什么时候才能删
为什么 Delete 不是立刻物理删除
有了前面 Sequence Number、Internal Key、Snapshot 这些基础,再回头看 Delete 就很自然了。
比如:
Put(name, "javadoop") seq=100
Snapshot A seq=100
Delete(name) seq=101
对于一个普通最新读来说,name 已经不存在了。但对于 Snapshot A 来说,name 还应该等于 "javadoop"。
所以 Delete 之后,RocksDB 不能立刻把旧值物理删掉,因为还有更老的读取视图可能需要它。
这就是 tombstone 存在的根本原因:
- 逻辑上先表示”这个 key 从这里开始被删了”
- 物理上暂时不急着把更老版本清掉
- 等后面确认再也没有读会看到那些老版本时,再在 Compaction 中真正回收
说白了就是,Delete 先解决语义正确,再慢慢解决物理清理。这个思路很朴素,只不过第一次看容易不习惯。
Compaction 凭什么敢删旧版本
上一篇说过,Compaction 不只是合并文件,它还会清理旧版本和可以回收的删除标记。那它怎么知道自己删的是”可以删的”,而不是”删早了”的?
大方向上,Compaction 之所以敢删,是因为它同时掌握了三类信息:
- 每条记录的 sequence number
- 每条记录的 type
- 当前系统里最老还活着的 snapshot 边界
我们来看一个例子:
(name, seq=103, value="rocksdb")
(name, seq=102, deletion)
(name, seq=101, value="mac")
(name, seq=100, value="javadoop")
假设当前系统里最老还活着的 snapshot 是 oldest_snapshot_seq = 102。
那说明还有某些读取,可能会看到 seq<=102 的世界。这时候 seq=101 的旧值不能删,seq=100 也不能删,seq=102 的 deletion 标记也不能动——因为这些版本对某些旧 snapshot 依然有意义。
反过来,如果系统里已经没有任何老 snapshot 了,或者最老 snapshot 已经推进到 103 之后,那 Compaction 才能放心地把那些再也不可能被读到的旧版本清掉。
所以大家一定要记住:Compaction 不是看到旧版本就删,它删的是”对任何还活着的读取视图都已经不可见”的版本。Compaction 的”删”,本质上不是看谁旧,而是看谁对现存读取已经没有意义了。
长时间持有 Snapshot 会拖住清理
这个地方顺带提一下,非常现实,也是线上很容易踩到的一个点。
如果你的程序把一个 Snapshot 拿住很久不释放,RocksDB 就得一直保守,不敢把某些老版本提前清掉。结果就是旧版本堆积更多、tombstone 回收更慢、Compaction 压力更大、空间放大可能更明显。
所以 Snapshot 不是拿了就不管,该释放还是要尽快释放。这个很像数据库事务里的长事务会拖住 MVCC 清理,思路上挺像的。大家先有这个感觉在心里就好。
文件视图管理与启动恢复
最后补一下上一篇留的尾巴:RocksDB.open() 时读取的 MANIFEST 到底是什么。这块不算核心,我们描述清楚就行,不展开太多。
RocksDB 目录里通常会有很多文件:
000101.sst
000102.sst
000103.sst
000120.sst
...
但目录里有什么文件,不代表这些文件都是当前数据库”在用”的。有些已经被 Compaction 淘汰了只是物理删除还没做,有些是新生成的,有些属于 L0 有些属于 L2。所以 RocksDB 必须单独维护一份当前数据库的文件视图,告诉自己现在到底有哪些 SST 是有效的、分别属于哪一层。这就是 Version、VersionSet、MANIFEST 在解决的问题。
Version 和 VersionSet
Version 指的是某一时刻数据库文件布局的快照。比如:
Level 0: 000101.sst, 000102.sst
Level 1: 000090.sst
Level 2: 000070.sst, 000071.sst
这就是某一时刻数据库的文件视图。后面发生一次 Compaction——删除 000101.sst、删除 000090.sst、新增 000120.sst——数据库就进入了一个新的 Version。
VersionSet 就是一组 Version 的管理器,同时维护当前正在生效的那个 Version,核心维护的状态其实就三样:当前生效的 current Version(读取和 Compaction 都看它)、下一个可分配的文件编号、当前数据库最新 sequence。每次元数据变化都会通过一个统一的 LogAndApply 方法走一遍——写 MANIFEST + 更新内存里的 current Version。
VersionSet 的核心职责就一件事——知道当前数据库长什么样。
MANIFEST 与 VersionEdit
MANIFEST 是一个元数据变更日志文件。它记录的不是用户数据,而是”文件视图怎么变了”——新增了哪些 SST、删除了哪些 SST、每个文件属于哪一层、当前 log number 和 last sequence 等。
为什么设计成日志而不是每次重写完整状态?因为大多数变化只是删两个文件、加一个文件、更新一下 sequence,每次重写整份状态代价太高。所以 RocksDB 的做法是:
- 把每次元数据变更描述成一个 VersionEdit
- 把这个 edit 追加到 MANIFEST
- 内存里的 VersionSet 应用这个 edit,生成新的 current Version
这个思路和 WAL 很像,一个管数据,一个管”数据库长什么样”。一次 Compaction 结束后的 VersionEdit 大概长这样:
DeleteFile(level=0, file=000101.sst)
DeleteFile(level=1, file=000090.sst)
AddFile(level=2, file=000120.sst, smallest=..., largest=...)
SetLastSequence(103)
SetLogNumber(56)
很直白:它记录的就是”文件视图该怎么变”。
启动恢复流程
有了这些铺垫,上一篇 open() 里那条恢复链路就清楚了:
- 先找到
CURRENT文件 CURRENT里面记录了当前正在使用的 MANIFEST 文件名- 打开对应的 MANIFEST
- 顺序回放其中的 VersionEdit
- 在内存里重建出 VersionSet 和 current Version
- 根据 current Version 确定当前有效 SST 集合
- 再结合 WAL 恢复尚未 Flush 的数据
也就是说,恢复过程不是扫目录里所有 .sst 文件然后全拿来用,没有这么草率。完整链路是:
CURRENT
-> MANIFEST
-> VersionEdit 日志
-> VersionSet
-> current Version
-> 当前有效 SST 文件集合
目录里有某个 .sst 文件,不代表它一定属于当前数据库逻辑状态。只有当前 Version 引用到的 SST,才算当前数据库的一部分。
总结
如果你觉得这篇比上一篇更绕一些,这很正常。上一篇是搭骨架,这一篇是补筋骨。
核心要点:
- Sequence Number 解决同一个 key 的多个版本谁新谁旧
- Internal Key 把 user key、版本信息、操作类型统一编码,让排序和比较只走一套逻辑
- 普通单点读通常不需要显式使用 Snapshot;长扫描或一组相关读取需要统一视图时才用
- Delete 不是立刻物理删除,旧版本和 tombstone 要等到对所有活着的读取视图都不可见时,才会在 Compaction 中被清理
- Version / VersionSet / MANIFEST 解决的是文件视图管理和启动恢复:当前到底有哪些 SST 算数、重启时怎么一步步把它拼回来
RocksDB 里很多看起来互不相干的机制,背后都在解决同一个问题——怎么管理版本,怎么判断可见性。
说实话,这部分内容第一次看,确实很容易被各种 Version、Snapshot、Edit 绕晕。我第一次看这块的时候也觉得挺别扭。不过也没关系,先把这篇的主线放到脑子里,后面碰到相关的问题,就不会觉得无从下手了。
(全文完)
0 条评论