---
name: rocksdb-deep-dive
title: RocksDB 架构设计与核心机制(2/2)
date: 2026-04-11
---

[前一篇文章](/post/rocksdb-1)，我们把 RocksDB 的整体骨架搭起来了：写入先走 WAL 和 MemTable，后面 Flush 成 SST，再通过 Compaction 慢慢整理；读取从 MemTable 一路查到各层 SST；启动时恢复元信息和 WAL。

如果只到这里，其实已经够入门了。但上一篇有几个点我是刻意只讲到"能建立直觉"为止，没有继续往下挖。这些细节不补上，后面碰到线上问题分析、架构讨论，大概率还是会有一种"好像知道在说什么，但又没有真正连起来"的感觉。

如果你还没读上一篇，建议先回去看一下[整体架构](/post/rocksdb-1)。不然这一篇虽然也能看，但读起来会有点跳。

好了，废话说完，开始进入正文。

## 从一个问题说起：一次读取到底应该读到什么

上一篇在讲读取流程的时候，我写了一句话：

> 读取不是简单地在很多地方找一个 key，而是在很多地方找这个 key 的最新可见版本

当时没展开，因为要讲明白得先铺不少东西。这一篇我们就从这句话开始往下挖。

先抛一个非常具体的场景。假设你写了这样一段代码，要扫描所有 `user:` 开头的 key：

```java
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。它更像一个数据库内部的逻辑时钟，只管先后，不管具体几点几分。

比如我们有下面几次操作：

```text
1. Put(name, "javadoop")
2. Put(name, "mac")
3. Delete(name)
4. Put(name, "rocksdb")
```

在 RocksDB 内部，可以把它理解成：

```text
Put(name, "javadoop")   seq=100
Put(name, "mac")        seq=101
Delete(name)            seq=102
Put(name, "rocksdb")    seq=103
```

有了这个编号之后，很多事情一下就顺了：

1. 同一个 key，`seq` 更大的那个版本更新
2. Delete 不需要立刻物理删除，先记一条删除语义就行
3. 读取时只要知道"我这次最多能看到哪个 seq"，就能判断结果

说白了就是，Sequence Number 解决的是同一个 key 的多个版本谁新谁旧。

### Sequence Number 什么时候分配

这个地方读者很容易有疑问，我们顺手说一下。

大方向上，真正进入写入路径时，RocksDB 会为这批写操作分配 sequence number。比如一个 `WriteBatch` 里有三条操作：

```text
Put(k1, v1)
Put(k2, v2)
Delete(k3)
```

它拿到的不是一个模糊的"这批数据很新"，而是一段连续的 sequence：

```text
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

先给结论：

```text
Internal Key = user key + sequence number + value type
```

这里的 `value type` 表示这条记录是什么语义：

- `kTypeValue`：一条普通 value
- `kTypeDeletion`：一条删除标记
- 还有 merge 等其他类型，这里先不展开

所以同一个 user key `name`，在 RocksDB 内部更接近这个样子：

```text
(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 的比较规则，大方向上可以理解成：

1. 先按 user key 排
2. 如果 user key 相同，再按 sequence number 倒序排
3. 如果还相同，再按 type 排

重点，重点，重点：同一个 user key 下，sequence number 越大，越靠前。

比如对于 `name` 这个 user key，内部顺序是：

```text
(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：

```text
snapshot_seq = 500
```

后面别的线程继续写：

```text
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 在内部有这些记录：

```text
(name, seq=103, value="rocksdb")
(name, seq=102, deletion)
(name, seq=101, value="mac")
(name, seq=100, value="javadoop")
```

不同 snapshot 下，结果分别是：

```text
snapshot_seq = 103 -> "rocksdb"
snapshot_seq = 102 -> NotFound（碰到 deletion 直接返回不存在）
snapshot_seq = 101 -> "mac"
snapshot_seq = 100 -> "javadoop"
```

逻辑是不是非常统一？这就是为什么 RocksDB 可以不用复制数据，只靠版本号和排序规则就完成一致性视图。

### 什么时候需要 Snapshot

很多文章一上来就说 Snapshot 很重要，但不说你到底什么时候需要它。我们直接落到场景。

普通单点 Get，通常不需要显式用 Snapshot。

```java
byte[] value = db.get(key);
```

这种单次读取，RocksDB 自己会在那个时刻给你一个一致的结果。你一般不需要额外先 `getSnapshot()` 再去读。单次点查，关心的就是"这一刻数据库里这个 key 的值是什么"，直接查就行了。

这个结论大家先记住，后面不要一遇到"读"就条件反射想到 Snapshot。

长时间遍历，怕遍历过程中数据一直变，这时候需要 Snapshot。

这个就是开头那个场景，也是 Snapshot 最典型的用途。如果你只记住 Snapshot 的一个使用场景，大概率就是这个。正确的写法是这样：

```java
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 的主流程其实就这几步：

1. 先拿到这次读取的可见上界 `visible_seq`（没有 snapshot 就是当前最新 sequence）
2. 按 internal key 的排序规则，找到这个 user key 对应的一批版本——因为同一个 user key 下 seq 大的排前面，所以先碰到的就是更新版本
3. 一条一条往下看：`sequence > visible_seq` 的直接跳过，假装没看见
4. 命中第一条 `sequence <= visible_seq` 的记录后，根据 type 决定返回什么——是 `kTypeValue` 就返回 value，是 `kTypeDeletion` 就直接返回 NotFound，不再往下找更老的版本

整个过程没有额外的"版本判断"，全都是顺着排序规则走一遍就完事。这就是前面把 sequence 和 type 揉进 internal key 的好处——读取逻辑特别干净。

到这里，读取和可见性这条线就讲得差不多了。下面回到一个前面一直没彻底展开的老问题：Delete 为什么不直接删。

## Delete 与 Compaction：旧版本什么时候才能删

### 为什么 Delete 不是立刻物理删除

有了前面 Sequence Number、Internal Key、Snapshot 这些基础，再回头看 Delete 就很自然了。

比如：

```text
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 之所以敢删，是因为它同时掌握了三类信息：

1. 每条记录的 sequence number
2. 每条记录的 type
3. 当前系统里最老还活着的 snapshot 边界

我们来看一个例子：

```text
(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 目录里通常会有很多文件：

```text
000101.sst
000102.sst
000103.sst
000120.sst
...
```

但目录里有什么文件，不代表这些文件都是当前数据库"在用"的。有些已经被 Compaction 淘汰了只是物理删除还没做，有些是新生成的，有些属于 L0 有些属于 L2。所以 RocksDB 必须单独维护一份当前数据库的文件视图，告诉自己现在到底有哪些 SST 是有效的、分别属于哪一层。这就是 Version、VersionSet、MANIFEST 在解决的问题。

### Version 和 VersionSet

**Version** 指的是某一时刻数据库文件布局的快照。比如：

```text
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 的做法是：

1. 把每次元数据变更描述成一个 **VersionEdit**
2. 把这个 edit 追加到 MANIFEST
3. 内存里的 VersionSet 应用这个 edit，生成新的 current Version

这个思路和 WAL 很像，一个管数据，一个管"数据库长什么样"。一次 Compaction 结束后的 VersionEdit 大概长这样：

```text
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()` 里那条恢复链路就清楚了：

1. 先找到 `CURRENT` 文件
2. `CURRENT` 里面记录了当前正在使用的 MANIFEST 文件名
3. 打开对应的 MANIFEST
4. 顺序回放其中的 VersionEdit
5. 在内存里重建出 VersionSet 和 current Version
6. 根据 current Version 确定当前有效 SST 集合
7. 再结合 WAL 恢复尚未 Flush 的数据

也就是说，恢复过程不是扫目录里所有 `.sst` 文件然后全拿来用，没有这么草率。完整链路是：

```text
CURRENT
  -> MANIFEST
    -> VersionEdit 日志
      -> VersionSet
        -> current Version
          -> 当前有效 SST 文件集合
```

目录里有某个 `.sst` 文件，不代表它一定属于当前数据库逻辑状态。只有当前 Version 引用到的 SST，才算当前数据库的一部分。

## 总结

如果你觉得这篇比上一篇更绕一些，这很正常。上一篇是搭骨架，这一篇是补筋骨。

核心要点：

1. Sequence Number 解决同一个 key 的多个版本谁新谁旧
2. Internal Key 把 user key、版本信息、操作类型统一编码，让排序和比较只走一套逻辑
3. 普通单点读通常不需要显式使用 Snapshot；长扫描或一组相关读取需要统一视图时才用
4. Delete 不是立刻物理删除，旧版本和 tombstone 要等到对所有活着的读取视图都不可见时，才会在 Compaction 中被清理
5. Version / VersionSet / MANIFEST 解决的是文件视图管理和启动恢复：当前到底有哪些 SST 算数、重启时怎么一步步把它拼回来

RocksDB 里很多看起来互不相干的机制，背后都在解决同一个问题——怎么管理版本，怎么判断可见性。

说实话，这部分内容第一次看，确实很容易被各种 Version、Snapshot、Edit 绕晕。我第一次看这块的时候也觉得挺别扭。不过也没关系，先把这篇的主线放到脑子里，后面碰到相关的问题，就不会觉得无从下手了。

（全文完）
