← 返回文章

RocksDB

图解 RocksDB Read Path:一次 Get 如何找到最新值

从当前读视图下的最新可见记录出发,图解 Mutable/Immutable MemTable、Version、L0 与 L1+ 文件候选、Bloom Filter、Index/Data Block 和 Block Cache,建立完整的 RocksDB 点查模型。

橙色查询标记依次穿过逐渐收窄的蓝灰档案门,经过一张双层缓存卡片,最终定位一张高亮有序记录的编辑风插画
本文目录

在 B03 中,我们把 Alice 写进 Mutable MemTable,随后又把它 Flush 到 L0。接着应用执行:

Put("user:42", "Bob")

现在 user:42 可能同时出现在很多地方:

  • Bob 在新的 Mutable MemTable;
  • Alice 在某个 L0 SST;
  • 更早的 Alice 也许还在更深 Level;
  • 一条 Tombstone 可能位于另一个文件;
  • 旧 Snapshot 可能仍然允许读取某个历史版本。

应用只调用:

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

RocksDB 为什么不需要把全部 SST 从头扫一遍?如果 Bloom Filter 说“可能存在”,能不能直接返回成功?Block Cache 命中以后,还要不要判断 Bob 与 Alice 谁更新?

这些问题共同组成 Read Path。

先给结论: Get 的目标不是找到任意一个同名 Key,而是在当前读视图中找到最新可见记录。RocksDB 先考虑 Mutable 与 Immutable MemTable,再根据当前 Version 和文件 Key Range 缩小 SST 候选;对于 Block-Based Table,候选文件还可借助 Bloom Filter、Index Block、Block Cache 与 Data Block 继续收窄。每一层只排除自己能排除的路径,最终仍要比较具体记录的版本和类型。

本文使用“单个 Column Family + 默认 Level Style + Block-Based Table”的教学模型。其他 Compaction Style、Plain Table、自定义 Comparator、Merge Operator、Timestamp 与不同 Filter/Cache 配置,会改变路径细节。Sequence Number、Internal Key 与 Snapshot 的完整规则留到进阶篇 A01。

先看全景:Get 不是一次文件全文搜索

从高处看,一次点查分为两段:

内存段:Mutable → Immutable
磁盘段:Version / Key Range → 候选 SST → Filter / Cache / Block → 记录

Get user:42 先查 Mutable 和 Immutable,再根据 Version 选择 SST 候选,通过缓存和块定位返回结果

读取从最新内存状态开始,再逐层缩小磁盘候选。

这张图很像一条从左到右的固定流水线,但真实实现会根据命中结果提前结束,也会把某些元数据放在内存或缓存中。它表达的是职责顺序:

  1. 先问最新内存状态是否已经能回答;
  2. 如果需要访问 SST,先从数据库当前文件版本中找候选;
  3. 对每个候选继续用 Range、Filter 与 Index 减少工作;
  4. 需要读取块时,先看对应 Cache;
  5. 最后在记录级别判断 Value、Tombstone 与可见性。

“Get 很快”可能来自第一步直接命中 Mutable,也可能来自 Block Cache 命中;“Get 很慢”则可能经过多个 L0 候选、若干 False Positive 和真实设备读取。只记录一个端到端延迟,无法说明走的是哪条路径。

Get 真正寻找的是“最新可见记录”

先把“Key 是否存在”改写成一个更准确的问题:

在这次读取采用的读视图里,同一个 User Key 的哪一条记录最新,而且可见?

假设存储中有:

Seq 103  user:42  Tombstone
Seq 102  user:42  Bob
Seq 101  user:42  Alice

当前普通读视图可以看到 Seq 103,于是最新可见记录是 Tombstone,Get 返回 Not Found。它不能因为另一个文件先读到了 Bob,就把 Bob 返回给应用。

同一个 user:42 的 Tombstone、Bob 和 Alice 按新旧排列,当前读视图选择最新可见 Tombstone 并返回 Not Found

Read Path 的目标是最新可见性,不只是文件中有没有这个 Key。

如果读取使用一个创建于 Delete 之前的 Snapshot,其可见 Sequence 上界早于 103,Bob 仍可能是合法结果。这说明:

  • “最新”不是数据库目录中物理 Sequence 最大就结束;
  • 它必须受到当前 ReadOptions / Snapshot 视图约束;
  • Tombstone 是一种会终止普通查找并产生 Not Found 的记录类型;
  • 同一个数据库、同一时刻的两个不同读视图,可以得到不同但都正确的结果。

本篇不展开 Internal Key 的二进制布局,只保留这个选择原则。否则,后面讲 Bloom 和 Cache 时很容易误以为“命中一个块”就等于“得到业务结果”。

Not Found 背后可能是三条不同路径

对调用方来说,下面三种情况都可能表现为 Not Found:

  1. Mutable 中就有最新可见 Tombstone,读取很早结束;
  2. 内存没有答案,在某个 SST 中找到最新可见 Tombstone;
  3. 内存和所有必要文件候选都没有找到可见记录。

业务结果相同,成本却完全不同。第一种主要是内存查询,第二种可能经历文件、Filter、Cache 与 Block,第三种甚至要证明所有合理候选都不能提供结果。

因此,“不存在的 Key 一定比存在的 Key 查得快”也不是通用结论。有高质量 Bloom 时,大量不存在点查可以快速被过滤;没有合适 Filter 或 False Positive 较多时,不存在查询反而可能穿过多个 Level 才完成。

还要把 Not Found 与读取错误分开。文件损坏、Checksum 失败、I/O 错误或数据库关闭,不应该被应用静默转换成“这个业务 Key 不存在”。RocksJava 抛出的异常与返回 null 是两种语义,错误处理必须保留这条边界。

第一站为什么是 Mutable 与 Immutable MemTable?

SST 生成后不可原地修改。最新 Put、Delete 或 Merge 通常先进入当前 Mutable MemTable,因此它最可能包含更新的记录。

如果活跃 MemTable 已经切换,旧表会成为等待 Flush 的 Immutable MemTable。Immutable 虽然不再接收写入,却仍然参与读取。只有对应 SST 安全发布后,它的读取职责才结束。

假设现在是:

Mutable:   user:42 = Bob      (Seq 102)
Immutable: user:42 = Alice-v2 (Seq 101)
L0 SST:    user:42 = Alice    (Seq 100)

Get 先在更新的 Mutable 找到 Bob,就不应该继续从旧 SST 返回 Alice。即使物理目录里 Alice 的文件更大、更早打开或已经在操作系统缓存中,也不改变版本选择。

MemTable 命中为什么仍不是“HashMap 命中”?

MemTable 里的条目按 Internal Key 组织,不只是 user:42 → Bob 这一对业务字节。相同 User Key 可以存在多个 Sequence 和不同 Value Type。读取要带着 Lookup Key 与读视图寻找合适条目。

默认常见 MemTable 是 Skip List,但实现可以配置。无论具体结构如何,Read Path 依赖的是它能按 Comparator 与内部顺序查询,而不是假设所有 MemTable 都是 Java HashMap。

找到 Tombstone 后为什么可以停?

如果 Mutable 中最新可见条目是 user:42 的 Tombstone,普通读取应返回 Not Found,而不是继续向 Immutable 或 SST 寻找一个更老的 Value。Tombstone 的逻辑职责正是遮蔽旧值。

是否能在后续 Compaction 中物理移除 Tombstone 与旧 Value,是另一个生命周期问题。B06 会把逻辑删除、Snapshot 可见与空间回收分开讨论。

内存没有答案以后,谁告诉 Get 应该看哪些文件?

RocksDB 不能每次 Get 都扫描数据目录,再从文件名猜测层级。数据库维护着当前 Version:它描述哪些 SST 属于当前可读文件集合、位于哪个 Level,并包含文件边界等元数据。

一个 SST 通常记录最小和最大 Internal Key。给定 user:42,如果文件范围完全不覆盖它,就可以先排除该文件,不必读取 Data Block。

例如:

F1: [order:00, order:99]   → 排除
F2: [user:00,  user:39]    → 排除
F3: [user:40,  user:59]    → 候选
F4: [user:60,  user:99]    → 排除

Range 判断是廉价且确定的第一层筛选,但它只能说明目标 Key 是否落在文件边界内。user:42 位于 [user:40, user:59],不等于 F3 一定保存它;中间可能根本没有这个 Key。

文件元数据本身也需要内存。Level、文件数、Table Reader、Index/Filter 缓存策略都会影响这部分开销。读取优化并不是“把所有东西放进 Block Cache”这么简单。

为什么 L0 与 L1 及以后层级查法不同?

在默认 Level Style 中,不同 MemTable 独立 Flush 到 L0,它们的 Key Range 可以重叠:

F3: [a, m]
F2: [h, t]
F1: [m, z]

查询位于重叠区的 Key 时,不能仅凭 Range 在 L0 选中一个文件。多个文件都可能包含相关版本,而且新旧顺序仍然重要。

经过 Leveled Compaction 后,L1 及更深 Level 的同层文件通常组织成不重叠 Key Range。于是,一个目标 Key 在该 Level 通常只有一个范围候选。

默认 Level Style 中 L0 文件范围可能重叠并产生多个候选,L1 及以后同层范围通常不重叠

L0 的重叠解释了为什么它对点查候选数量尤其敏感。

这里必须保留三个限定词:

  • 默认 Level Style: Universal 和 FIFO 的文件组织不同;
  • 同一 Level: 不同 Level 的范围当然可以覆盖同一个 User Key;
  • 通常不重叠: 这是层级组织的不变量直觉,不代表读取一定只访问一个文件或一个块。

即使 L1 某层只有一个范围候选,Bloom False Positive、Index/Filter Cache Miss、压缩块读取和多个 Level 仍会带来工作。反过来,L0 文件多也不代表每次 Get 都真实读设备:Range、Filter 与 Cache 仍可能快速排除或命中。

用一组具体文件推导候选

假设当前 Version 里有这些文件:

L0/F5  [user:40, user:60]  较新
L0/F4  [user:00, user:45]  较旧
L1/F8  [user:20, user:49]
L1/F9  [user:50, user:79]
L2/F2  [order:00, order:99]

查询 user:42 时,Range 可以直接排除 L1/F9 与 L2/F2,却不能在 F5、F4 和 L1/F8 中任选一个。两个 L0 范围重叠,都可能保存相关版本;更深的 L1/F8 也可能保存它们遮蔽的旧版本。

如果 F5 中找到当前可见 Bob,读取可以结束;如果 F5 只有对当前 Snapshot 不可见的新版本,仍要继续判断较旧候选;如果 F5 的 Bloom 阴性,则可跳过它,但不能由此宣布整个数据库不存在 user:42,因为 F4 与 F8 仍未排除。

这个推导展示了“候选文件”和“最终答案”的差异:Version 与 Range 负责缩小搜索空间,文件之间的新旧与层级规则负责安排顺序,具体 Internal Key 才负责给出可见结果。

也因此,排查慢读时不能只问数据库有多少个 SST,而要问目标 Key 的范围实际上会留下多少候选。大量互不相关的文件可能很快被 Range 排除;少量但高度重叠的热点文件,反而可能让某段 Key 付出更多工作。

候选漏斗:每一层分别排除什么?

把文件读取展开,可以看成一个漏斗。

读取从可见文件开始,依次用 Key Range、Bloom Filter、Index Block 和 Data Block 缩小到 user:42 的候选记录

RocksDB 不是扫描全部 SST,而是用多层元数据逐步排除不可能路径。

每一层回答的问题不同:

层次 回答的问题 不能回答的问题
Version / Level 当前有哪些文件可读? 文件中是否真的有目标 Key?
文件 Key Range 目标是否落在边界内? 边界内部是否存在目标 Key?
Bloom Filter 能否确定目标一定不在? 阳性时 Key 是否一定存在?
Index Block 哪个 Data Block 可能包含目标? 块内最终记录是什么?
Data Block 有哪些具体 Internal Key? 仍需结合读视图判断谁可见

漏斗图是概念顺序,不是所有配置下固定不变的函数调用表。例如 Filter 和 Index 可能被缓存或分区,元数据读取也会受到 Table Format 影响。但它非常适合诊断:每多漏过一个不必要候选,后面就多一份潜在块读取与记录比较。

Bloom Filter 能回答什么?

Bloom Filter 是概率型成员判断结构。对于某个候选 SST 和目标 Key,它有两种回答:

一定不存在

如果 Filter 给出阴性,RocksDB 可以跳过该候选,不再读取它的 Index/Data Block 来寻找这个 Key。这是 Bloom 最有价值、也最可靠的能力。

可能存在

阳性只意味着相关位模式没有排除它。目标可能真的存在,也可能是 False Positive。RocksDB 必须继续访问 Index 与 Data Block,找到具体 Internal Key 才能确认。

Bloom Filter 阴性可以跳过文件,阳性只表示可能存在并可能是假阳性,仍需继续读取

Bloom Filter 最可靠的能力是排除,不是确认业务数据存在。

因此,下面几句话都不成立:

  • “Bloom 命中,所以 Get 可以直接返回 Value”;
  • “Bloom 阳性,所以数据库里一定有这个 Key”;
  • “打开 RocksDB 就一定为所有 Level、所有查询自动启用同一种 Bloom”;
  • “Bloom 能替代 Index Block”。

Filter Policy、Whole Key / Prefix、分区 Filter、每层策略和 Table Format 都会影响实际行为。范围扫描对 Bloom 的收益也不同于随机点查。参数与内存预算留到工程篇,本篇只要求记住阴性与阳性的非对称语义。

False Positive 为什么不是错误结果?

假阳性只会让读取多做后续工作,不会直接把不存在的 Key 伪造成 Value。真正返回之前还要读取并比较记录,所以正确性由完整 Read Path 保证;Bloom 只优化“能否早一点跳过”。

假阳性率过高会降低过滤收益,表现为更多不必要的 Index/Data Block 访问和设备读取,但应用结果仍应正确。

Index Block 怎样找到 Data Block?

以 Block-Based Table 为例,一个 SST 的 Data Block 保存有序记录,Index Block 保存能够引导读取到对应 Data Block 的 Key 与 Block Handle。

假设 Index 大致表示:

≤ user:19  → Data Block A
≤ user:49  → Data Block B
≤ user:99  → Data Block C

查询 user:42 时,Index 把候选定位到 Data Block B。RocksDB 读取 B 后,再在块内按顺序寻找具体 Internal Key,判断它是 Value、Tombstone 还是其他类型,并应用读视图。

SST 的 Index Block 根据分隔 Key 定位 Data Block B,读取块后再比较具体 Internal Key 和记录类型

Index 把文件级候选缩小到块级候选,真正的 Value 仍在 Data Block 中。

图里把 Index 画成一张简单表,真实格式更丰富:

  • Block 可以使用前缀压缩与 Restart Point;
  • Data Block 可能压缩,读取后需要解压;
  • Checksum 用于检测块损坏;
  • Footer 与 Metaindex 帮助找到索引和其他元数据;
  • Partitioned Index/Filter 可以把大型元数据继续分区;
  • Two-Level Index 等实现会增加新的定位层次。

这些细节会在 A05《SST 与 Block-Based Table Format》中展开。基础篇只要掌握“文件 → 块 → 记录”三个粒度,就不会把 Bloom、Index 和 Value 混为一谈。

Block Cache 位于哪一段路径?

当 Read Path 已经确定需要某个 SST Block 时,RocksDB 会尝试从 Block Cache 获取它。命中时,可以避免重新从文件系统或存储设备加载对应块;Miss 时,才沿文件读取路径加载、解压和校验,并可能把结果放进 Cache。

候选块先查询 Block Cache,命中跳过设备读取,未命中则从 SST 加载并可能回填缓存

Block Cache 缩短的是 SST 块读取路径,不改变业务 Key 的版本语义。

Block Cache 缓存的不是“另一份业务 KV”

应用不能对 Block Cache 直接执行 get("user:42")。Cache Key 通常与文件、块位置和缓存身份相关,缓存值是解析后的块对象。Read Path 仍然要先确定候选文件与块,再查询 Cache。

即使 Cache Hit,也还要在块内寻找记录、判断类型和可见性。缓存只改变数据从哪里取得,不改变 Bob、Alice 与 Tombstone 的顺序。

Index 和 Filter 一定都在 Block Cache 吗?

不一定。是否缓存 Index/Filter、是否 Pin、是否分区,以及 Table Reader 怎样持有元数据,都与配置有关。不能只用一个 Block Cache 容量数字推导所有读取元数据的驻留情况。

Block Cache 与 OS Page Cache 是一回事吗?

不是。Block Cache 由 RocksDB 管理,理解 SST Block、压缩状态与缓存策略;OS Page Cache 由操作系统管理文件页。使用 Buffered I/O 时,两层可能同时存在;Direct I/O 配置会改变操作系统缓存参与程度。

因此,Block Cache Miss 也不总等于真正访问物理介质:文件数据可能仍在 OS Page Cache。诊断时要把 RocksDB Cache 统计、系统页缓存与设备 I/O 一起看。

Cache 命中率为什么也不能单独做目标?

命中率是“命中次数 / 查询次数”,它没有告诉你命中的块是否重要、Miss 的代价多大,也没有告诉你为了提高命中率占用了多少内存。

例如,小而高频的 Index Block 命中可以减少很多定位 I/O;一次巨大范围扫描把大量只读一次的 Data Block 放进 Cache,却可能挤出真正热点的点查块。表面上扫描期间产生了许多 Cache 访问,业务点查的 P99 反而变差,这类现象常被称为缓存污染。

RocksDB 提供不同的 Cache Priority、Pin、Admission 与 Table 配置来管理块,但没有一个对所有 workload 都正确的容量比例。设计时至少要区分:

  • 点查热点 Data Block 的工作集;
  • Index 与 Filter 元数据;
  • 一次性或低复用范围扫描;
  • 多个 Column Family 是否共享 Cache;
  • JVM Heap、MemTable 与 Block Cache 争用的总内存上限。

同样,命中率高也不保证端到端延迟低。块内可能有大 Value、复杂解压、较多版本比较,业务线程还可能在 CPU 或 JNI 边界排队。反过来,命中率稍低但设备读取稳定,也未必违反延迟目标。

更好的做法是把 Cache Hit/Miss 与“每次读了多少块、多少字节、花了多久、P99 是否改善”一起评价。Cache 是缩短路径的手段,不是独立于业务目标的积分榜。

用四种场景重走 user:42

现在把路径与结果放回同一个 Key。

user:42 位于 Mutable、L0、最新是 Tombstone 或使用旧 Snapshot 时,会得到 Bob、Not Found 或旧视图结果

相同 Key 的延迟和结果都取决于数据位置与读视图。

场景 A:Bob 在 Mutable

Get 在最新 Mutable 中找到可见 Bob,可以直接返回,无需访问 SST。路径短,通常不产生文件 I/O。

场景 B:Bob 已在 L0

内存没有答案,当前 Version 和文件 Range 留下一个或多个 L0 候选。Bloom 不能排除,Index 定位 Data Block;若 Block Cache Hit,跳过文件读取,否则加载块。最终比较记录并返回 Bob。

场景 C:最新记录是 Tombstone

无论 Tombstone 在 MemTable 还是候选 SST,只要它是当前读视图下最新可见记录,普通 Get 返回 Not Found。更旧的 Bob 与 Alice 不能“补位”。

场景 D:读取删除前 Snapshot

Snapshot 的可见上界早于 Tombstone,Read Path 可以跳过对该视图不可见的新删除,并返回仍然可见的 Bob。路径可能与普通读相似,结果却不同。

这四个场景说明两个独立维度:

  • 位置决定需要检查哪些内存结构、文件、缓存与块;
  • 读视图决定遇到多个版本时哪些记录有资格成为结果。

把两者混成“缓存是否命中”,就无法解释 Snapshot、Tombstone 和同 Key 多版本。

用一个小实验亲眼看见四条读取路径

只看静态图很容易记住名词,却不容易感受到数据位置变化。可以在隔离的测试目录中做一个分阶段实验,并在每个阶段执行相同的 Get("user:42")

阶段一:写入后立刻读取

打开一个新数据库,Put Alice 后立即 Get。此时最可能由当前 Mutable MemTable 回答。记录延迟,并确认数据还不要求已经出现在 SST 文件中。

阶段二:等待或显式控制一次 Flush

在测试环境让相关 MemTable Flush,确认 L0 SST 已发布,再读取 Alice。业务结果没有变化,但数据位置从内存职责迁移到文件职责。第一次读取可能需要加载块,紧接着重复读取则可能命中 Block Cache 或 OS Page Cache。

这一阶段不要用“第二次更快”直接证明 Block Cache 命中,仍应查看 RocksDB 统计与设备 I/O;操作系统缓存、CPU 频率和测试噪声也会影响延迟。

阶段三:更新为 Bob

在 Alice 已位于 SST 时 Put Bob,不立即 Flush。普通 Get 应从新的 MemTable 返回 Bob,而不是旧文件中的 Alice。这个阶段验证“内存中的更新记录遮蔽磁盘旧版本”。

阶段四:删除并保留一个旧 Snapshot

先在测试中创建 Snapshot,再 Delete user:42。普通 Get 应返回 Not Found,Snapshot Get 则可能仍返回 Bob。随后释放 Snapshot,才能避免让测试无意中长期保留旧版本。

这个实验至少记录:每阶段的普通结果、Snapshot 结果、SST 文件数量、Block Cache Hit/Miss、Bloom 过滤统计和设备读字节。不要用微秒级单次样本排名,应预热、重复并观察分布。

实验的目的不是跑出一个可复制到生产的性能数字,而是证明三件事:业务结果由读视图决定,读取成本由数据位置决定,Cache 与 Filter 只能优化路径而不能改写可见性。

还可以在阶段二之后关闭并重新打开数据库,再重复第一次读取。进程重启会清空 RocksDB 进程内的 Block Cache,但操作系统页缓存是否仍保留文件数据取决于测试环境。若希望比较真正冷读,需要明确清理哪一层缓存,并承担相应测试权限与风险;不要仅用“重启了 Java 进程”就宣称已经得到物理磁盘冷读结果。

每轮实验都应使用独立目录或可恢复的数据副本,避免把手动 Flush、删除和缓存控制施加到生产数据上。基础机制可以在小数据中观察,生产结论仍要用真实 Key 分布、Value 大小和后台负载验证。

RocksJava 中 Not Found 怎样表达?

一个最小点查可以这样写:

byte[] key = "user:42".getBytes(StandardCharsets.UTF_8);

try (ReadOptions readOptions = new ReadOptions()) {
    byte[] value = db.get(readOptions, key);
    if (value == null) {
        // 当前读视图下 Not Found
    } else {
        // value.length 可以为 0;空 Value 不等于 Not Found
    }
}

这里最容易犯的错误是用 value.length == 0 判断不存在。RocksDB 允许保存空字节数组,所以:

  • null 表示当前读视图下没有可返回的 Value;
  • new byte[0] 是一个合法的空 Value;
  • 底层可能是从未写过,也可能被最新 Tombstone 删除,普通 Get 都以 Not Found 呈现。

ReadOptions 同样持有 native 资源,应该关闭。若绑定 Snapshot,Snapshot 的获取、设置与释放还要遵循数据库生命周期,不能让长生命周期读视图无意中阻止旧版本回收。

一次慢 Get 可能慢在哪里?

“RocksDB Get 慢”不是一个根因,而是整条路径上多种工作的总和。

内存阶段

  • Mutable 与多个 Immutable 都没有答案;
  • 同一 User Key 版本较多,需要更多比较;
  • Comparator 或 Merge Operator 增加 CPU 工作;
  • 业务线程本身在 JNI 前后排队。

文件候选阶段

  • L0 文件较多且范围重叠;
  • 多个 Level 都留下候选;
  • 文件元数据或 Table Reader 产生额外开销;
  • 热点 Key 恰好跨越多份新旧文件。

Filter 与 Index 阶段

  • 没有配置适合点查的 Filter;
  • Bloom False Positive 让无效候选继续前进;
  • Index/Filter 没有驻留,需要额外块读取;
  • Key Schema 与 Prefix 配置不匹配。

Data Block 与 Cache 阶段

  • Block Cache 工作集超过容量,频繁 Miss;
  • 大 Block 或大 Value 增加读取、解压和拷贝;
  • OS Page Cache 也未命中,访问真实设备;
  • 设备队列被 Flush/Compaction 共享 I/O 压满;
  • Checksum、解压与块内查找消耗 CPU。

应该观察哪些证据?

先把 P50、P95、P99 与负载时间线对齐,再结合:

  • L0 文件数与 Pending Compaction Bytes;
  • Block Cache Hit/Miss 与容量;
  • Bloom 有用过滤、阳性与 False Positive 相关统计;
  • 读取的 Block 数、字节数和解压耗时;
  • 设备 IOPS、吞吐、利用率与尾延迟;
  • Snapshot / Iterator 生命周期;
  • 点查、范围扫描与 MultiGet 的请求比例。

RocksDB 的 Statistics、PerfContext、属性与日志可以提供不同粒度的证据,但观测本身也有成本,生产环境应按需要启用。最重要的是把指标放回路径:Cache Miss 高说明哪里没有命中,L0 多说明候选为何增长,设备延迟高说明真正 I/O 为什么慢。

不要仅凭“磁盘利用率不高”就排除 I/O 问题。同步小读可能吞吐不大,却受设备尾延迟影响;反过来,设备吞吐很高也可能是 Compaction 正在占满带宽。

多个 Key 应该循环 Get,还是使用 MultiGet?

如果一次业务请求需要读取多个独立 Key,简单循环 N 次 Get 会重复进入若干查找与文件访问步骤。RocksDB 的 MultiGet 可以共享一部分准备工作,并按 SST 组织批量读取机会;不同版本的 MultiGet 实现也持续优化并行和文件读取。

但 MultiGet 不是把 N 个 Key 变成一次 O(1) 查找:

  • 每个 Key 仍有自己的读视图结果;
  • Key 可能分散在不同文件和 Block;
  • 返回值与错误需要按输入位置对应;
  • Batch 太大可能增加内存、尾延迟与单请求资源占用。

是否使用 MultiGet 应由一批 Key 的规模、分布、缓存命中和延迟目标决定。基础篇只提醒一个边界:一组点查有机会共享路径工作,但不能把它当成范围扫描或事务快照的替代品。

六个常见误区

误区一:Get 找到第一个同名 Key 就返回

它要返回当前读视图下最新可见记录。较旧 Value、更新 Value 与 Tombstone 可能使用同一个 User Key。

误区二:Bloom Filter 阳性证明 Key 存在

阳性只代表可能存在,并且可能是假阳性。阴性才可以可靠排除候选。

误区三:Bloom Filter 返回 Value

Filter 不保存业务 Value。它只能帮助决定是否值得继续查这个候选。

误区四:一次 Get 必然只访问一个 SST 和一个 Block

L0 重叠、多个 Level、False Positive、多版本和 Cache Miss 都可能增加访问。具体数量取决于文件组织与 workload。

误区五:Block Cache 是可直接查询的业务缓存

它缓存的是 SST Block。Read Path 先定位候选 Block,再查询 Cache;Hit 后仍要做记录与可见性判断。

误区六:Block Cache Miss 等于物理磁盘读取

在 Buffered I/O 路径中,OS Page Cache 仍可能命中。必须结合系统与设备指标确认是否真的访问介质。

把 Read Path 压缩成九步

最后再执行一次 Get("user:42")

  1. 确定这次读取的 ReadOptions 与可见 Sequence 上界;
  2. 查询 Mutable MemTable 中最新可见记录;
  3. 再考虑尚未 Flush 的 Immutable MemTable;
  4. 若内存不能回答,从当前 Version 获取可见 SST;
  5. 使用 Level 与文件 Key Range 缩小候选;
  6. Bloom 阴性时跳过文件,阳性时继续;
  7. Index Block 定位可能的 Data Block;
  8. Block Cache Hit 则复用块,Miss 则沿文件路径加载;
  9. 在块内比较 Internal Key、Value Type 与读视图,返回 Value 或 Not Found。

如果只记一句话,可以记成:

RocksDB Read Path 是一条“先确定可见性,再不断排除候选,最后验证具体记录”的路径;Range、Bloom、Index 与 Cache 都是减负工具,不是正确性捷径。

下一篇 B05《有序 KV 入门:Key、Iterator、Prefix Scan 与数据建模》,我们会从单点 Get 走向连续扫描:为什么 user:10 可能排在 user:2 前面,Seek 为什么只定义起点,怎样为 Prefix Scan 设置明确终点,以及复合 Key 的字段顺序怎样直接决定查询能力。

参考资料