如果让你在一个 Java 服务里保存几百万条状态,你可能会先想到 HashMap。
它的确简单:把 Key 放进去,随后就能快速取出来。但只要进程退出,数据就没了;如果为了保住数据而定期把整个 Map 序列化到文件,新的问题马上又会出现:
- 写到一半崩溃怎么办?
- 每次只更新一个 Key,难道都要重写整个文件?
- 同一个 Key 被修改了很多次,怎样找到最新值?
- 文件越来越大,怎样删除旧数据?
- 除了按完整 Key 查询,怎样扫描一段连续的 Key?
继续往下做,你会发现自己并不是在实现一个“可以落盘的 Map”,而是在实现一个小型存储引擎。
RocksDB 就位于这个问题的答案附近。
这篇文章不会一上来把 LSM Tree、WAL、SST 和 Compaction 的定义全部扔给你。我们只追踪一条数据:
Put("user:42", "Alice")
Get("user:42")
Put("user:42", "Bob")
Delete("user:42")
Seek("user:")
等这条数据完整走完一次写入、读取、更新和删除,你会发现,那些看起来互不相关的名词其实都在同一条路径上。
先给结论: RocksDB 默认把一次更新记录到 WAL,同时写入 MemTable;MemTable 达到切换条件后由后台 Flush 成为 SST,越来越多的 SST 再由 Compaction 合并和整理。读取时优先检查内存中的最新状态,再逐步缩小磁盘文件候选。Update 和 Delete 也不会回头修改旧 SST,而是继续写入更新的记录。
本文使用“单个 Column Family + 默认 Level Style”的简化图建立直觉。它不是 RocksDB 所有配置的完整内部调用图;被省略的 Sequence Number、Snapshot、MANIFEST、Table Format 与 Compaction 策略会在后续文章单独展开。
先别背名词:我们到底要解决什么问题?
假设我们的应用有四个要求:
- 数据保存在本机,调用不必经过网络;
- 进程重启后,已经接受的状态能够恢复;
- 可以按完整 Key 点查,也可以从某个 Key 开始扫描;
- 数据规模不要求全部常驻 JVM Heap。
HashMap 只能覆盖其中一部分。自己管理普通文件则意味着应用还要处理日志、索引、崩溃恢复、旧版本清理和后台 I/O。
当然,我们也可以部署 MySQL、Redis 或其他独立数据库服务。但这会引入另一组边界:网络调用、服务部署、鉴权、连接管理和独立运维。它们并不是坏事,只是与“在当前进程内保存本地状态”解决的是不同问题。
RocksDB 采取了嵌入式路线。
嵌入式意味着调用留在应用进程内,但数据模型、资源与运维边界仍由应用负责。
所谓嵌入式,说的是 RocksDB 以库的形式和业务代码运行在同一个进程中。Java 应用通常通过 RocksJava/JNI 调用 native RocksDB,RocksDB 再访问本机文件。
这会带来几个直接结果:
- 没有一次天然存在的网络 RPC;
- RocksDB 的 CPU、native memory、后台线程和磁盘 I/O 都属于当前应用的资源预算;
- 应用进程异常也会中断正在运行的存储引擎;
- 数据目录、备份、升级和故障恢复最终仍要由使用方设计。
所以,“嵌入式”既不是“零成本”,也不是“只适合小数据”。它只是先把运行边界说清楚。
RocksDB 是什么,又不是什么?
从官方 Overview 的表述出发,RocksDB 可以拆成四个关键词:
它是一个存储引擎库
RocksDB 的核心实现是 C++ 库。它提供打开数据库、写入、读取、删除、遍历、快照等 API,但默认不提供一个让其他机器连接的数据库服务进程。
如果使用 RocksJava,一个最小调用大致是这样:
RocksDB.loadLibrary();
try (Options options = new Options().setCreateIfMissing(true);
RocksDB db = RocksDB.open(options, "./data/rocksdb")) {
byte[] key = "user:42".getBytes(StandardCharsets.UTF_8);
byte[] value = "Alice".getBytes(StandardCharsets.UTF_8);
db.put(key, value);
byte[] result = db.get(key);
}
代码看起来很像操作 Map,但背后已经包含日志、内存结构、文件版本和后台任务。
它保存 Key-Value
在引擎层,Key 和 Value 都是字节序列。RocksDB 不知道 user:42 代表用户,也不知道 Alice 是姓名。
这意味着:
- Java 对象怎样序列化,由应用决定;
- Value 的版本兼容,由应用决定;
- Key 中各字段怎样编码,由应用决定;
- 哪些查询可以高效完成,很大程度上也由 Key Schema 决定。
它是有序的
RocksDB 会按照 Comparator 定义的全序组织 Key。默认比较规则可以先理解为按字节顺序比较。
“有序”让 RocksDB 不只支持 Get,还支持:
- Seek 到第一个大于等于目标的 Key;
- 使用 Iterator 向后遍历;
- 扫描某个前缀;
- 扫描一个 Key Range。
这也是 RocksDB 与“磁盘上的 HashMap”最重要的差异之一。
它不等于完整的关系数据库服务
RocksDB 不会自动替应用提供 SQL、表结构、Join、账号体系、网络协议、分布式副本或业务级索引。它可以成为这些系统的底层组件,但“上层数据库拥有什么能力”和“底层存储引擎拥有什么能力”不能混为一谈。
同样,RocksDB 提供 WriteBatch、Snapshot 和 TransactionDB 等能力,也不意味着它天然拥有 MySQL 的全部事务语义。每种能力都要放回具体 API 和并发边界中理解。
先看一张 RocksDB 全景地图
在继续之前,先把整条路径摆在一张图上。
WAL、MemTable、SST 与 Compaction 不是四个孤立名词,而是一条连续的数据生命周期。
先用一句话认识图里的每个角色:
| 组件 | 暂时只记住这一句话 |
|---|---|
| WAL | 保存尚未 Flush 的更新记录,为恢复提供依据 |
| Mutable MemTable | 接收当前写入,并让最新数据立即可查 |
| Immutable MemTable | 已停止接收新写入,等待后台 Flush |
| SST | 磁盘上的不可变有序文件 |
| Flush | 把 Immutable MemTable 写成 L0 SST |
| Compaction | 读取、合并并重写一组 SST |
| Bloom / Index / Cache | 帮助读取逐步缩小工作量 |
现在不要急着问每个组件内部怎样实现。先抓住两条主线:
- 写入主线: Put → WAL/MemTable → Immutable → Flush → L0 SST → Compaction;
- 读取主线: Get → 最新内存状态 → 候选 SST → Value 或 Not Found。
接下来,我们把地图逐块放大。
一条数据为什么可以同时“在很多地方”?
初学 RocksDB 时,一个很自然的问题是:user:42 到底在 WAL、MemTable,还是 SST?
更准确的答案经常是:它在不同意义上同时存在于多个位置。
WAL 保存的是更新记录,用于恢复;MemTable 保存的是当前可查询的内存状态;SST 保存的是某个时刻生成的不可变有序记录。它们不是三份完全等价的业务副本。
例如 Alice 已经进入旧 SST,Bob 刚写入当前 MemTable,而对应更新仍记录在有效 WAL 中。此时:
- 业务读取应该返回 Bob;
- 崩溃恢复需要 WAL;
- 后台整理仍可能读取包含 Alice 的旧 SST;
- 目录中同时出现相关文件并不代表 Get 会随机返回一个。
RocksDB 需要一套顺序与可见性规则判断“哪个记录更新、当前读视图能看见谁”。本篇先用“最新可见记录”描述它,后续再用 Sequence Number 与 Internal Key 展开。
因此,阅读后面的图片时要同时问三个问题:
- 这份信息承担的是恢复、当前查询,还是持久化职责?
- 当前读请求应该选择哪个版本?
- 哪些旧记录虽然逻辑失效,但暂时还不能物理回收?
这三个问题能避免把“文件里还有旧值”误判成“数据库返回了旧值”。
一次 Put,为什么默认要写两个地方?
执行 Put(“user:42”, “Alice”) 时,RocksDB 默认会让同一份更新走向两个地方:WAL 和 Mutable MemTable。
WAL 解决恢复问题,MemTable 解决当前读写问题;两条路径职责不同。
MemTable 负责“现在就能读写”
MemTable 是内存中的数据结构。新的 Put、Delete、Merge 会先形成内存中的最新状态,普通读取也会优先检查这里。
为什么不每次 Put 都直接修改磁盘文件?
因为 RocksDB 采用的 LSM 思路不会在旧 SST 中原地寻找并修改某一条记录。前台先把写入放进适合更新的内存结构,达到条件后再批量写成新的有序文件。
这让前台写入不必为每次更新都完成一次“定位旧文件 → 修改旧位置 → 重组磁盘结构”的过程。
但内存有一个显而易见的问题:进程退出后,内存状态会消失。
WAL 负责“尚未 Flush 时还能恢复”
WAL 是 Write-Ahead Log,也就是写前日志。
如果 Alice 还只在 MemTable 中,尚未进入任何 SST,此时进程崩溃,RocksDB 可以在重新打开数据库时读取有效 WAL,并重放其中的更新,重建还没有 Flush 的状态。
这就是为什么 MemTable 和 WAL 缺一不可:
- 只有 MemTable:当前读写快,但进程崩溃后未落盘状态没有恢复依据;
- 只有 WAL:可以记录操作,但每次 Get 都从日志头重放到日志尾显然不现实;
- 两者配合:MemTable 服务当前读写,WAL 服务恢复。
这里需要把“写了 WAL”和“所有故障下都已持久化”分开。
WriteOptions 会改变写入边界:
- disableWAL=false 是常见默认路径,更新会记录 WAL;
- disableWAL=true 会跳过 WAL,尚未 Flush 的数据不再拥有同样的崩溃恢复保障;
- sync=true 会要求在返回前对 WAL 发起更强的同步持久化动作;
- sync=false 不等于没有 WAL,它只是没有为这次写入等待同样的同步过程。
进程崩溃、操作系统崩溃、机器重启和突然断电并不是同一种故障。WAL 写入、操作系统 Page Cache、文件系统和存储设备也不是同一个持久化层级。
因此,本篇只建立职责边界:
默认 WAL 为未 Flush 更新提供进程崩溃恢复依据;精确到断电、fsync、设备缓存和 RPO 的承诺,需要结合 WriteOptions、操作系统与硬件单独讨论。
这部分会在工程篇的 WAL 与故障模型中完整展开。
用一次进程崩溃重新理解双路径
可以做一个思想实验。
假设 T1 时刻执行 Put Alice,数据已经进入 WAL 和 MemTable,但还没有 Flush;T2 时刻进程突然退出。
重新打开数据库时,旧进程的 MemTable 已经不存在,RocksDB 只能从持久化文件恢复状态。已经完成的 SST 可以直接纳入当前版本;仍然有效的 WAL 则被顺序读取,其中的更新重新应用到内存结构。
恢复结束后,应用再次 Get user:42,仍有机会读到 Alice。
再改变一个条件:Put 时设置 disableWAL,且崩溃发生前没有 Flush。此时既没有包含 Alice 的 SST,也没有同等的 WAL 恢复依据,Alice 就可能丢失。
这个对比说明,WAL 保护的是一个明确窗口:
更新已经被接受并进入内存,但还没有被 SST 覆盖的阶段。
它并不是 SST 的永久副本。随着对应内存状态安全 Flush,旧 WAL 的恢复职责会逐步结束;数据库也必须避免让日志无限增长。
真正部署时还需要继续追问:日志只是进入操作系统缓存,还是已经同步到持久介质?突然断电时设备缓存怎样表现?这些问题不能只靠“启用了 WAL”四个字回答。
MemTable 满了以后怎么办?
MemTable 不可能无限增长。
当活跃的 Mutable MemTable 达到切换条件后,RocksDB 会把它变成 Immutable MemTable。这里的 Immutable 意味着“不再接收新写入”,并不意味着“不能被读取”。
与此同时,一个新的 Mutable MemTable 会接管后续写入。
MemTable 切换把前台写入与后台 Flush 解耦,但后台长期跟不上仍会形成积压。
假设旧 MemTable 里有:
user:42 = Alice
它变成 Immutable 后,应用又执行:
Put("user:42", "Bob")
Bob 会进入新的 Mutable MemTable。此时 Alice 和 Bob 位于两个不同的内存结构中,读取需要优先看到更新的 Bob。
这个切换机制解决了一个关键问题:后台写文件时,前台不必停下来等待同一张表继续接收更新。
但它不是无限缓冲器。如果写入速度长期高于 Flush 和 Compaction 的处理速度,Immutable MemTable 会积压,L0 文件也会增加。为了防止内存与文件数量失控,RocksDB 可能减慢甚至暂停新的写入。
所以,写入吞吐不能只看 Put 的短时速度,还要看后台是否能持续消化。
Flush:从内存状态到 L0 SST
后台 Flush 会读取 Immutable MemTable 的内容,并构造一个新的 SST 文件。
SST 是 Sorted String Table。这里的 String 是历史命名,不代表只能存 Java String;它保存的仍然是字节形式的 Key 和 Value。
Flush 建立了内存状态到不可变有序文件的边界,后续更新不会回头修改这个 SST。
图中把 SST 简化成三类信息:
- Data Block: 保存有序的 Key-Value 记录;
- Index: 帮助定位可能包含目标 Key 的 Data Block;
- Filter: 帮助排除不可能包含目标 Key 的候选。
真实的 Block-Based Table Format 还有 Footer、Meta Block、压缩、Checksum、Restart Point 等更多细节。B01 只需要知道:SST 内部不是一段无结构的文本,而是为了查找和扫描组织过的不可变文件。
Flush 完成后,新 SST 会进入 Level 0,也就是 L0,并通过数据库元数据变成可读取文件的一部分。
需要注意两点。
第一,已经生成的 SST 不会被后续 Put 原地修改。新的更新进入新的 WAL、MemTable 和未来的新 SST。
第二,WAL 与 SST 不是简单的一一对应关系。只有当相关内存状态已经获得安全的持久化覆盖,并满足数据库整体条件时,旧 WAL 才可能成为可回收对象。多 Column Family 下这个关系会更复杂。
SST 越来越多,为什么必须 Compaction?
如果系统只 Flush、不做其他事情,会发生什么?
每次 Flush 都会增加一个 SST。同一个 User Key 的新旧记录可能分散在不同文件中;删除标记也可能与被删除的旧值位于不同文件。随着文件变多,读取需要考虑的候选会增加,空间中的旧记录也可能长期保留。
因此,Compaction 不是偶尔手动运行的“磁盘清理”,而是 LSM 设计中的持续后台过程。
Compaction 用后台 CPU、I/O 与临时空间换取更可控的文件组织、读取与回收。
为什么 L0 比较特殊?
不同 MemTable 各自 Flush 成 SST 时,它们覆盖的 Key Range 可能重叠。
例如:
F1: [a, m]
F2: [h, z]
Key h 到 m 同时落在两个范围内。一次 Get 不能只根据 Range 选择其中一个文件。
在默认 Level Style 的简化模型中,L1 及更深 Level 的同层文件通常会被组织成互不重叠的 Key Range。这样,给定一个 Key,在同一 Level 中通常只需要定位一个范围候选。
这里一定要加上“默认 Level Style”和“通常”。Universal、FIFO 以及自定义策略不能直接套用同一张图。
Compaction 做了什么?
可以先把它理解成四步:
- 选择一组需要处理的输入 SST;
- 按内部顺序读取并进行有序合并;
- 根据版本可见性决定哪些记录仍要保留;
- 生成新的输出 SST,安全发布后再淘汰旧文件。
在这个过程中,Compaction 可以整理文件范围,并在满足条件时丢弃已经被覆盖或删除的旧记录。
但“满足条件”很重要。某个旧 Snapshot 可能仍要读取 Alice;某个 Tombstone 下面可能还有尚未覆盖到本次 Compaction 的旧值。如果太早丢弃删除标记,旧值甚至可能重新暴露。
所以不能把 Compaction 理解成“看到旧记录就删”。
Compaction 付出了什么代价?
它需要读取旧文件、做合并、写出新文件,并在一段时间内让新旧文件共存。因此会消耗:
- CPU;
- 磁盘读取与写入带宽;
- 后台线程;
- 临时磁盘空间。
LSM Tree 并没有让成本消失,而是把一部分前台随机更新成本转移成了内存写入、批量落盘和后台合并。
也不要把这句话继续压缩成“RocksDB 的所有磁盘写都是顺序写”。真实 I/O 会受到 Flush、Compaction、文件系统、Direct I/O、设备和工作负载影响。更准确的说法是:RocksDB 避免为每次逻辑更新都原地修改旧 SST,并通过批量生成和重写有序文件来组织持久化数据。
读放大、写放大和空间放大从哪里出现?
LSM 相关资料经常同时提到三种“放大”。现在已经可以不用背定义,直接从数据路径推导它们。
读放大来自“一次逻辑读取需要检查多少位置”。同一个 Key 可能分布在 MemTable 和多个 SST 中;文件范围、Bloom Filter、Index 与 Cache 都在努力减少无效候选,但它们无法让所有工作负载都恒定只读一个位置。
写放大来自“一份逻辑数据在生命周期里被写了多少次”。Alice 先进入 WAL,随后进入 L0 SST,又可能在多次 Compaction 中被重写到更深 Level。应用只调用了一次 Put,存储设备却可能接收多轮物理写入。
空间放大来自“物理占用比当前逻辑数据多多少”。旧版本、Tombstone、尚未淘汰的输入 SST、Compaction 正在生成的输出文件,都可能让目录占用高于当前有效 Value 的总大小。
三者彼此牵连:
- 减少文件重叠和候选,可能需要更多 Compaction 写入;
- 降低写放大,可能接受更高的读放大或临时空间;
- 更积极地整理空间,可能挤占前台 I/O;
- 保留 Snapshot 越久,旧版本的回收越可能被推迟。
所以调优不是把三个数字同时旋到最低,而是在目标 workload 下分配成本。B02 会专门用 LSM Tree 把这组关系展开。
一次 Get 怎样找到最新值?
现在数据库里可能同时有:
- 新的 Mutable MemTable;
- 一个或多个 Immutable MemTable;
- 多个 L0 SST;
- L1、L2 等后续 Level 的 SST;
- Block Cache 中已经读取过的块。
执行 Get(“user:42”) 时,RocksDB 的目标不是“找到任何一个 user:42”,而是找到当前读视图下最新可见的那条记录。
Bloom Filter 只能可靠排除不存在;“可能存在”仍需继续读取和比较。
这张图是教学用简化路径,可以拆成五步。
第一步:先查 MemTable
Mutable MemTable 包含最新写入,Immutable MemTable 包含等待 Flush 的较新状态。它们都可能比磁盘 SST 中的记录更新。
如果当前内存结构中已经找到最新可见的 Value 或 Tombstone,就能直接确定结果。
第二步:通过文件元数据找候选
RocksDB 不会打开每个 SST 并从头扫描。当前 Version 记录了数据库可见文件和各文件的 Key Range 等元数据。
目标 Key 不在某个文件范围内,这个文件就不需要继续检查。
L0 文件范围可能重叠,所以可能留下多个候选;默认 Level Style 的后续 Level 通常能更直接地定位范围。
第三步:使用 Bloom Filter 排除
Bloom Filter 是一种概率型数据结构。
对于一个候选文件,它有两种有用结论:
- 一定不存在: 可以放心跳过这个文件;
- 可能存在: 还要继续查。
它不会返回 Value,也不能告诉你“Key 一定存在”。因为 Bloom Filter 允许 False Positive:它可能说“可能存在”,最终却没有找到。
因此,看到“Bloom 命中率”时,不能把“命中”理解成业务数据命中。
第四步:通过 Index 找到 Data Block
文件仍可能包含很多 Data Block。Index 帮助定位目标 Key 可能位于哪个 Block,再在 Data Block 中读取和比较具体记录。
完整 SST 格式比这复杂得多,但这一步的核心作用很清楚:候选从“某个文件”继续缩小到“某个块”。
第五步:看看 Block Cache
Block Cache 缓存从 SST 读取的块。命中时,可以避免再次从存储设备读取同一块。
它不是另一层业务 Key-Value 数据库,也不等同于 OS Page Cache。根据配置,数据块、索引块和过滤器块怎样进入缓存也可能不同。
最后,RocksDB 结合记录的新旧顺序和类型返回结果:
- 最新可见记录是 Value:返回 Value;
- 最新可见记录是 Tombstone:返回 Not Found;
- 当前候选没有目标:继续检查必要的候选;
- 所有候选都没有:返回 Not Found。
Sequence Number、Internal Key、Snapshot 与精确文件搜索顺序,会在 Read Path 和 Snapshot 文章中继续展开。
为什么两次相同的 Get 延迟可能不同?
即使 Key 完全相同,两次 Get 走过的实际路径也可能不同。
第一次读取时,目标 Data Block 可能不在 Block Cache 中,需要访问存储设备、读取块、校验并解压;第二次读取时,同一个块已经进入 Cache,路径会明显缩短。
另一个时刻,user:42 可能刚写入 Mutable MemTable,读取在内存就结束;Flush 以后,它进入 L0,读取开始依赖文件候选;Compaction 以后,文件范围与层级又发生变化。
因此,一次 Get 的延迟可能由这些部分组成:
- 内存结构查找;
- 文件元数据与 Key Range 判断;
- Bloom/Index/Filter 访问;
- Block Cache 命中或 Miss;
- 存储 I/O;
- 解压、Checksum 与记录比较;
- 同一 Key 的多版本处理。
这也是为什么性能分析不能只记录一个平均延迟。需要结合 P50、P95、P99、缓存命中、L0 文件数、实际 I/O 与 Compaction 状态,才能知道慢在哪里。基础篇先记住路径,工程篇再学习指标。
Update 为什么不是原地覆盖?
第一次执行:
Put("user:42", "Alice")
Alice 随后可能被 Flush 到旧 SST。
第二次执行:
Put("user:42", "Bob")
RocksDB 对外表现为 Bob 覆盖了 Alice,但物理过程不是去旧 SST 中找到 Alice 的字节位置并改写。
Bob 会作为更新的记录进入当前写路径。
API 上的覆盖不等于原地修改旧 SST,新值先遮蔽旧值,回收发生在后续阶段。
普通 Get 会根据新旧顺序返回 Bob。Alice 可能仍位于旧 SST 中,占据物理空间,直到 Flush 或 Compaction 在可见性允许的情况下把它清理掉。
这里使用“可能”是因为具体结果与两个版本是否位于同一 MemTable、是否在 Flush 阶段已经能够安全去重、是否存在 Snapshot、哪些文件进入 Compaction 等条件有关。
API 语义与物理状态要分开:
- 逻辑上: Alice 已被 Bob 覆盖;
- 物理上: Alice 的旧记录不保证在 Put Bob 返回时立即消失。
这也是理解 Delete 的前提。
Delete 为什么不会立即释放磁盘空间?
执行:
Delete("user:42")
RocksDB 通常不会立刻找到所有包含 user:42 的 SST,然后在每个文件中挖掉那一条记录。
Delete 会沿着正常写路径写入一个更新的删除记录,也就是 Tombstone。
Delete 先改变逻辑可见性,磁盘空间只有在安全 Compaction 后才可能回收。
此时同一个 User Key 可以想象成从新到旧的版本栈:
T3 Tombstone
T2 Bob
T1 Alice
普通 Get 先看到最新 Tombstone,于是返回 Not Found。对应用来说,删除已经生效。
但 Bob 和 Alice 可能还存在于旧文件中。为什么不能立刻删掉?
因为旧版本可能仍然合法可见
如果某个 Snapshot 固定在 Delete 之前,它可能仍然需要读取 Bob。只要旧版本仍可能被一个合法读视图看到,RocksDB 就不能随意清理。
因为 Tombstone 下面可能还有未参与本次合并的文件
假设当前 Compaction 只看到了 Tombstone,却没有覆盖更深 Level 中的 Alice。如果这时把 Tombstone 丢掉,未来读取走到更深文件时,Alice 可能重新出现。
因此,删除标记本身也要保留到足够安全的时刻。
因为重写文件需要临时空间
Compaction 会先生成新文件,确认新版本安全可用后再淘汰旧文件。新旧文件短时间共存时,目录占用可能先增加再下降。
所以,“Delete 已成功”“Get 返回 Not Found”“磁盘空间下降”是三个不同时间点。
当你在线上看到“删除了大量数据但目录没变小”,先不要立刻得出 RocksDB 删除失效的结论。至少要继续确认:
- 相关 Key Range 是否已经参与合适的 Compaction;
- 是否存在长生命周期 Snapshot 或 Iterator;
- Tombstone 与旧值分布在哪些 Level;
- 后台 Compaction 是否积压;
- 你观察的是逻辑数据量、Live SST Size,还是文件系统目录占用。
有序 Key 带来了哪些查询能力?
到目前为止,我们一直使用完整 Key 做 Get。
但 RocksDB 的 Key 是有序的,这让它还可以从某个位置开始连续扫描。
RocksDB 的有序性把 Key Schema 变成查询设计:字段顺序和编码会直接决定可扫描范围。
假设有这些 Key:
order:9
user:41
user:42
user:43
version:1
执行 Seek(“user:”) 后,Iterator 会定位到第一个大于等于目标的 Key,也就是 user:41。
随后不断调用 Next,可以连续得到 user:41、user:42、user:43。当 Key 不再以 user: 开头时,扫描停止。
于是我们得到三种基础查询:
- Get: 已知完整 Key,查询一个点;
- Seek + Iterator: 从某个 Key 开始有序遍历;
- Prefix/Range Scan: 在明确边界内读取连续 Key。
这里很容易出现一个新误区:Key 看起来可读,就等于排序正确。
例如按字符串字节比较时:
user:10
user:2
user:10 可能排在 user:2 前面,因为比较不是先把 10 和 2 解析成整数。
真正的数据建模还要考虑:
- 数字是否定长;
- 二进制整数使用什么字节序;
- 有符号数怎样保持顺序;
- 复合 Key 的字段顺序;
- 分隔符是否可能出现在字段值中;
- 怎样计算扫描上界;
- Iterator 怎样及时关闭。
这些内容会在 B05《有序 Key、Iterator 与数据建模》中系统展开。
RocksDB 的性能从哪里来,又把成本放到了哪里?
现在可以重新审视 RocksDB 的核心取舍。
它没有为每次逻辑 Update 原地修改旧 SST,而是:
- 把前台更新放进 WAL 和内存结构;
- 批量生成新的有序文件;
- 在后台持续合并和重写文件;
- 使用文件范围、Filter、Index 与 Cache 缩小读取成本。
这会带来几种成本转移:
| 想获得的能力 | 依赖的机制 | 需要承担的代价 |
|---|---|---|
| 快速接收更新 | MemTable | 内存预算与 Flush |
| 未 Flush 数据可恢复 | WAL | 日志写入与同步权衡 |
| 有序持久化 | SST | 文件数量与多版本 |
| 控制读取与空间 | Compaction | 后台 CPU、I/O、写放大和临时空间 |
| 减少无效读取 | Bloom / Index / Cache | 内存、构建成本与 False Positive |
因此,“RocksDB 写入很快”只是半句话。
完整问题应该是:
- 短时还是持续写入?
- Value 多大?
- 点查还是范围扫描?
- 可接受多少读放大、写放大和空间放大?
- 后台线程与磁盘带宽是否足够?
- 对进程崩溃、机器重启和断电的 RPO 要求是什么?
脱离 workload 讨论“最佳参数”,往往只是把一个环境的偶然结果复制到另一个环境。
RocksDB 适合什么场景?
当系统具有下面这些特征时,RocksDB 通常值得评估:
- 需要在应用进程内保存本地持久化状态;
- 写入或更新比较频繁;
- 以点查、前缀扫描和范围扫描为主;
- 可以由应用定义 Key Schema 与 Value 编码;
- 能够为 native memory、后台线程、磁盘和备份建立运维边界;
- 上层系统本身负责网络协议、复制或分布式一致性。
常见方向包括本地状态存储、流处理状态、缓存或索引的持久层、分布式系统节点的内部存储,以及其他数据库系统的底层引擎。
如果系统核心需求是下面这些,则不能只因为“RocksDB 快”就直接采用:
- 多个进程任意共享写同一数据目录;
- 需要开箱即用的 SQL、Join 与复杂查询;
- 需要独立数据库服务的鉴权、审计与远程治理;
- 团队不准备管理 native resource 和后台 I/O;
- 业务需要的事务语义尚未与 RocksDB API 边界对齐。
选择存储方案时,最先比较的应该是系统边界,其次才是参数和跑分。
采用之前,至少回答五个问题
第一,数据由谁拥有? 如果数据只是当前节点可以重建的本地状态,RocksDB 与应用同生命周期可能很自然;如果它是唯一一份关键业务数据,就必须先设计备份、复制和恢复。
第二,查询模式能否映射到有序 Key? 已知完整 Key 的点查、固定前缀和连续范围比较合适;如果核心查询需要任意字段组合、Join 与临时聚合,上层往往还需要额外索引或数据库能力。
第三,可靠性目标是什么? 只要求进程崩溃后恢复,和要求突然断电时尽量不丢最近一次写入,并不是同一套 WriteOptions 与性能代价。
第四,后台工作能获得多少资源? Flush 与 Compaction 需要稳定的 CPU、线程、I/O 和临时空间。只为前台设置内存上限,却不观察后台积压,无法得到持续性能。
第五,谁来维护格式与版本? Key 编码、Value 序列化、Column Family 和 Comparator 都会进入长期数据兼容边界。设计一旦写入磁盘,就不能像普通 Java 类重构那样随意修改。
如果这五个问题都没有答案,先跑出一个漂亮的 db_bench 数字也不足以证明方案可以上线。
六个常见误区
误区一:RocksDB 就是磁盘 HashMap
HashMap 没有 Key 的全序;RocksDB 的有序性直接影响 SST 组织、Seek、Iterator 和范围扫描。
误区二:Put 返回时数据一定已经在 SST
数据可能仍位于 MemTable。未 Flush 数据怎样恢复,取决于 WAL;返回前是否等待更强同步,取决于 WriteOptions。
误区三:有 WAL 就代表任何断电场景绝不丢数据
WAL、操作系统缓存、fsync、文件系统和设备缓存是不同层级。可靠性结论必须明确故障模型。
误区四:Bloom Filter 命中说明 Key 一定存在
Bloom Filter 的阳性只是“可能存在”。它最可靠的能力是把“一定不存在”的候选过滤掉。
误区五:Delete 返回后空间应该立即下降
Delete 先写 Tombstone 改变逻辑可见性。Snapshot、文件覆盖范围和 Compaction 共同决定何时可以物理回收。
误区六:Compaction 只是压缩文件
Compaction 的核心是读取、合并、版本处理和重写 SST。Snappy、LZ4、Zstd 等数据压缩算法是另一个维度。
再走一遍 user:42
现在重新看全景图。
把完整路径压缩成一句话:前台接收更新,WAL保留恢复依据,内存服务最新读写,后台把状态变成并整理为有序文件。
我们可以这样复述这条数据的旅程:
- Put Alice: 默认记录 WAL,并插入 Mutable MemTable;
- MemTable 切换: 旧表成为 Immutable,新表继续接收写入;
- Flush: Immutable 被写成新的 L0 SST;
- Compaction: 一组 SST 被合并、重写并整理到后续 Level;
- Get: 先找内存中的最新状态,再逐步缩小 SST 候选;
- Put Bob: Bob 作为更新的记录遮蔽 Alice,不原地修改旧 SST;
- Delete: Tombstone 让普通 Get 返回 Not Found;
- 安全回收: 后续 Compaction 在满足可见性和文件条件后清理旧记录;
- Seek user:: Iterator 从第一个匹配位置开始连续扫描用户 Key。
如果你能不看图说出这九步,就已经建立了 RocksDB 最重要的第一层心智模型。
接下来读什么?
B01 只负责地图,后续基础篇会依次放大其中五个区域:
-
B02|LSM Tree 为什么适合写密集型存储
解释原地更新与 Sorted Run、Merge、读放大、写放大和空间放大。
-
B03|图解 RocksDB Write Path
深入 Writer、WAL、Sequence Number、Mutable/Immutable 与 Flush。
-
B04|图解 RocksDB Read Path
深入文件候选、Bloom Filter、Index Block、Data Block 与 Block Cache。
-
B05|有序 Key、Iterator 与数据建模
解释字节序、复合 Key、Prefix Scan、Range Scan 与 Cursor。
-
B06|Update 与 Delete:旧数据为什么不会立即消失
深入 Internal Key、Tombstone、Snapshot 与安全空间回收。
等基础篇完成后,我们再回到 Apache Seata File Mode:为什么要从追加日志走向物化状态,怎样设计 Column Family、Key Schema、二级索引、WriteBatch、迁移和崩溃恢复。
