在 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 → 记录
读取从最新内存状态开始,再逐层缩小磁盘候选。
这张图很像一条从左到右的固定流水线,但真实实现会根据命中结果提前结束,也会把某些元数据放在内存或缓存中。它表达的是职责顺序:
- 先问最新内存状态是否已经能回答;
- 如果需要访问 SST,先从数据库当前文件版本中找候选;
- 对每个候选继续用 Range、Filter 与 Index 减少工作;
- 需要读取块时,先看对应 Cache;
- 最后在记录级别判断 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 返回给应用。
Read Path 的目标是最新可见性,不只是文件中有没有这个 Key。
如果读取使用一个创建于 Delete 之前的 Snapshot,其可见 Sequence 上界早于 103,Bob 仍可能是合法结果。这说明:
- “最新”不是数据库目录中物理 Sequence 最大就结束;
- 它必须受到当前 ReadOptions / Snapshot 视图约束;
- Tombstone 是一种会终止普通查找并产生 Not Found 的记录类型;
- 同一个数据库、同一时刻的两个不同读视图,可以得到不同但都正确的结果。
本篇不展开 Internal Key 的二进制布局,只保留这个选择原则。否则,后面讲 Bloom 和 Cache 时很容易误以为“命中一个块”就等于“得到业务结果”。
Not Found 背后可能是三条不同路径
对调用方来说,下面三种情况都可能表现为 Not Found:
- Mutable 中就有最新可见 Tombstone,读取很早结束;
- 内存没有答案,在某个 SST 中找到最新可见 Tombstone;
- 内存和所有必要文件候选都没有找到可见记录。
业务结果相同,成本却完全不同。第一种主要是内存查询,第二种可能经历文件、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 通常只有一个范围候选。
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 付出更多工作。
候选漏斗:每一层分别排除什么?
把文件读取展开,可以看成一个漏斗。
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 命中,所以 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 还是其他类型,并应用读视图。
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 块读取路径,不改变业务 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。
相同 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"):
- 确定这次读取的 ReadOptions 与可见 Sequence 上界;
- 查询 Mutable MemTable 中最新可见记录;
- 再考虑尚未 Flush 的 Immutable MemTable;
- 若内存不能回答,从当前 Version 获取可见 SST;
- 使用 Level 与文件 Key Range 缩小候选;
- Bloom 阴性时跳过文件,阳性时继续;
- Index Block 定位可能的 Data Block;
- Block Cache Hit 则复用块,Miss 则沿文件路径加载;
- 在块内比较 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 的字段顺序怎样直接决定查询能力。
