← 返回文章

RocksDB

Update 与 Delete:为什么旧数据不会立即消失

从新版本、Tombstone、Snapshot 与 Compaction 出发,图解 RocksDB 更新和删除的真实含义,分清逻辑不可见、版本保留、文件淘汰与磁盘空间回收。

新旧记录、删除标记、历史快照与后台回收依次排列的编辑风插画
本文目录

假设 user:42 最初保存的是 Alice:

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

随后把名字改成 Bob,再删除这个 Key:

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

此时普通 Get("user:42") 已经返回 Not Found。可是查看数据库目录,却可能发现空间没有立即减少,甚至短时间还变大了。Alice、Bob 和删除记录也可能同时存在于不同文件中。

这不是删除失败,而是 LSM Tree 有意把“逻辑结果”和“物理整理”放在不同阶段完成。

理解这个现象,需要先把四件事分开:

  1. PutDelete 什么时候成功返回;
  2. 普通读取现在能看见什么;
  3. 已经创建的 Snapshot 还能看见什么;
  4. 旧记录与旧文件什么时候真正释放空间。

如果只记一句话,可以先记住:

RocksDB 的 Update 是写入一个更新的版本,Delete 是写入一个删除标记;逻辑结果可以立刻改变,物理空间通常要等安全的 Compaction 与文件淘汰。

本文沿用基础篇的默认边界:主要讨论常见的 Point Put、Point Delete 与 Level Compaction 心智模型。Sequence Number、Internal Key 编码、Range Tombstone 的碎片化算法和不同 Compaction Style 的精确行为,会留到进阶篇。

Update:所谓“覆盖”,并不是回头修改旧 SST

在关系型数据库的直觉里,更新一行常被想象成找到原位置并改写。RocksDB 的 SST 却是不可变文件:一旦生成,正常更新不会回头把里面的 Alice 改成 Bob。

对同一个 User Key 再执行一次 Put,RocksDB 会写入一条更新的记录。概念上可以画成:

user:42, Seq 102, Value = Bob
user:42, Seq 101, Value = Alice

Sequence Number 可以先理解为全局的新旧顺序标签。具体二进制布局并不影响基础结论:同一个 User Key 可以拥有多个版本,读取时要从当前读视图中选出最新可见的那一条。

同一个 user:42 下,Seq 102 的 Bob 位于 Seq 101 的 Alice 之上,普通 Get 选择新版本

Update 的“覆盖”首先发生在读取语义里,旧 SST 中的记录不需要被原地改写。

新记录仍沿着上一篇介绍的 Write Path 前进:进入这次写入的日志与内存状态,之后由 Flush 生成新 SST。旧版本可能在旧 MemTable、旧 SST 或 Compaction 输入中继续存在一段时间。

因此,下面两句话可以同时成立:

  • 应用刚刚读取到的值已经是 Bob;
  • 文件中仍然能找到 Alice 对应的旧字节。

旧字节不再是普通读取应该返回的结果,但它们暂时存在仍有用途:已有 Snapshot 可能需要历史版本;后台还没有取得足够输入来证明某个旧版本永远不会再次成为可见结果;不可变文件也必须通过生成新文件来完成整理。

Delete:写入 Tombstone,而不是当场擦掉所有 Value

Point Delete 可以理解为给这个 User Key 写入一种特殊记录:Tombstone,也就是删除标记。

user:42, Seq 103, Type = Deletion
user:42, Seq 102, Value = Bob
user:42, Seq 101, Value = Alice

Tombstone 和普通 Put 一样参与 Write Path:它需要进入写入处理,可能写 WAL,进入 MemTable,经 Flush 落入 SST,再在后续 Compaction 中与更老版本相遇。它不是一条立即扫描所有层级并擦除旧字节的命令。

Seq 103 的 Tombstone 位于 Bob 和 Alice 之上,普通读取命中删除标记后返回 Not Found

Tombstone 让较旧 Value 在普通读视图中失效,但它自己也要先作为一条记录存在。

普通 Get 查询 user:42 时,会寻找当前读视图下最新可见的记录。如果它先看到的是 Seq 103 的 Tombstone,就返回 Not Found,而不会继续把 Seq 102 的 Bob 当成当前值。

这里至少有四个不同的时间点:

时刻 能说明什么 不能说明什么
Delete 成功返回 这次删除写入已按配置完成 所有旧 Value 已从 SST 消失
普通 Get 返回 Not Found 当前普通读视图看见 Tombstone 历史 Snapshot 也看不见旧值
Compaction 丢弃旧版本 参与整理的范围内,相关记录已满足淘汰条件 目录占用此刻一定下降
旧文件被删除 对应文件不再被当前 Version 或引用持有 文件系统统计会以固定速度立刻变化

把这四步混成一句“Delete 会删除数据”,就是空间排查中最常见的误会。

删除一个不存在的 Key 会怎样?

基础用法中,应用不应把 Point Delete 当成“先查询、确认存在、再擦除”的复合操作。RocksDB 可以记录删除意图,即使当前位置没有可见 Value,也不代表要同步搜索整个数据库证明它从未出现过。

这也提醒我们:逻辑删除次数不能直接换算成可回收字节。某些 Tombstone 后面有大 Value,某些没有旧值,某些旧版本分布在尚未参与 Compaction 的文件中。空间估算必须观察真实文件与 Compaction 状态。

普通读取与 Snapshot 为什么会得到不同答案?

假设时间线如下:

T1  Put user:42 = Alice
T2  Put user:42 = Bob
T2  创建 Snapshot S
T3  Delete user:42

T3 之后,当前普通读取看到最新 Tombstone,因此返回 Not Found;使用 T2 时刻的 Snapshot S 读取,却仍应得到 Bob。

当前普通读取在删除之后返回 Not Found,而删除之前创建的 Snapshot 仍返回 Bob

“最新”必须带上读视图:当前视图的最新结果与历史 Snapshot 的最新结果可以不同。

Snapshot 保存的是一个时间点的读取视图,而不是把整个数据库复制一份。它很轻量,却带来明确的版本保留责任:只要 Snapshot 仍然存在,Compaction 就不能丢弃它还需要读取的历史版本。

这不等于“有 Snapshot 就阻止所有文件删除”。准确说法是:Snapshot 参与判断哪些版本仍必须保留;没有被它需要的记录和文件,仍可能按正常规则整理。问题通常出在 Snapshot 生命周期过长或释放路径遗漏,让本应短暂的历史读取需求变成长期约束。

Iterator 也要及时释放

Iterator 通常也建立在一个一致的点时间视图上,并引用这次遍历需要的文件。长期持有 Iterator 可能让旧文件即使已经不属于最新 Version,仍不能马上删除。

Snapshot 与 Iterator 的影响不能简单合并成一句“都锁住整个数据库”:

  • Snapshot 主要影响版本可见性与历史记录保留判断;
  • Iterator 除了读视图语义,还会持有遍历相关的文件引用;
  • 它们的影响范围取决于存活时间、访问范围、当前文件布局与 Compaction。

工程上最安全的习惯是缩短生命周期,使用明确的 try/finally 或资源管理结构关闭 Iterator、ReadOptions 与 Snapshot,而不是等待 GC。

下面的 RocksJava 示例把三种结果放在一起:更新后的普通读取为 Bob,创建 Snapshot 后再删除,当前读取为 null,旧 Snapshot 仍读取到 Bob。

import java.nio.charset.StandardCharsets;
import org.rocksdb.Options;
import org.rocksdb.ReadOptions;
import org.rocksdb.RocksDB;
import org.rocksdb.Snapshot;

public final class UpdateDeleteDemo {
    static {
        RocksDB.loadLibrary();
    }

    public static void main(String[] args) throws Exception {
        byte[] key = "user:42".getBytes(StandardCharsets.UTF_8);

        try (Options options = new Options().setCreateIfMissing(true);
             RocksDB db = RocksDB.open(options, args[0])) {
            db.put(key, "Alice".getBytes(StandardCharsets.UTF_8));
            db.put(key, "Bob".getBytes(StandardCharsets.UTF_8));

            try (Snapshot snapshot = db.getSnapshot();
                 ReadOptions history = new ReadOptions().setSnapshot(snapshot)) {
                db.delete(key);

                byte[] current = db.get(key);
                byte[] oldView = db.get(history, key);

                System.out.println(current == null); // true
                System.out.println(new String(oldView, StandardCharsets.UTF_8)); // Bob
            }
        }
    }
}

代码只验证可见性,不证明文件何时回收。即使程序打印 trueBob,Alice、Bob 与 Tombstone 仍可能处于 MemTable 或不同 SST 中。

旧版本什么时候才可以安全丢弃?

看到一个更老版本,不代表 Compaction 可以无条件删掉它。后台至少要拥有足够信息,保证删除之后不会破坏任何仍被允许的读取结果。

可以用四个问题建立安全直觉:

  1. 是否还有活跃 Snapshot 需要这个历史版本?
  2. 这次 Compaction 的输入与更深层文件覆盖是否足以证明不会“复活”更老 Value?
  3. 对应 Key 范围内,保留新版本或 Tombstone 后的读取结果是否仍正确?
  4. 新输出文件是否已经完整生成并成功发布为新的 Version?

旧 Snapshot 不再需要、相关范围被充分覆盖、不会复活旧值且新输出安全发布后,旧记录才具备回收条件

回收的前提不是“看见旧”,而是“能够证明丢弃后仍然正确”。

“下一次 Compaction 一定删除所有旧值”因此是不准确的。一次 Compaction 只处理选中的输入文件与范围;可能仍有 Snapshot,需要保留 Tombstone 来遮住更深层旧值,也可能相关文件根本未被选中。

对于 Tombstone,尤其要防止“旧值复活”。如果上层删除标记被过早丢弃,而更深层仍有 Bob,之后读取可能错误地再次看到 Bob。只有当 Compaction 能根据范围、层级和可见性确认删除标记不再承担遮蔽责任时,才可以把它与对应旧版本一起淘汰。

Compaction 为什么可能先让目录变大?

Compaction 不是在原 SST 上删几行。它会读取若干输入文件,合并并生成新的 SST 输出,校验和落盘成功后发布新的 Version,然后旧输入文件才成为 obsolete files,等待没有引用时删除。

Compaction 读取 A B C 三个输入 SST,生成 D,发布新 Version 后旧输入文件才进入可淘汰状态

安全发布要求新输出先就绪,所以新旧文件会有一段共存窗口。

这意味着 Compaction 期间可能同时存在:

  • 仍服务旧 Version 或 Iterator 的输入 SST;
  • 正在生成的临时输出;
  • 已经发布、服务新 Version 的输出 SST;
  • 尚待后台删除的 obsolete files。

所以,删除大量数据后立刻观察目录大小,可能先看到上升,再在旧文件解除引用并删除后下降。这个短暂峰值是不可变文件安全切换带来的成本之一,容量规划必须给 Compaction 输出留出余量。

从 Put、Update、Delete、逻辑 Not Found 到 Compaction 和旧文件删除,目录大小与逻辑结果沿不同时间线变化

普通读取变成 Not Found 通常早于旧记录淘汰,也早于目录空间下降。

三种“大小”不要混用

排查空间时,至少要明确正在讨论哪一个量:

  • 逻辑数据量:业务当前认为仍有效的 Key/Value 总量;
  • Live SST Size:当前数据库 Version 所引用的 SST 大小,具体属性口径要按所用 RocksDB 版本确认;
  • 数据库目录占用:文件系统看到的整个目录大小,可能还包含 WAL、MANIFEST、临时输出、旧但仍被引用的 SST 与日志。

逻辑数据量下降,并不要求另外两项同步下降;Live SST Size 下降,也不保证目录统计同一瞬间变化。不同监控若使用不同口径,却都标成“DB size”,结论会互相矛盾。

还要区分“文件已 unlink”和“磁盘空间已可复用”。在某些文件系统语义下,进程仍持有文件句柄时,目录列表中看不到文件,底层空间仍可能暂时占用。诊断应同时看 RocksDB 属性、目录文件、进程引用和文件系统容量,而不是依赖单个数字。

DeleteRange 是区间删除意图,不是区间文件擦除

RocksDB 还提供 DeleteRange(begin, end)。它使用半开区间:删除满足 begin <= key < end 的 Key,并写入 Range Tombstone 来表达整段删除意图。

半开区间很重要。假设:

DeleteRange("order:2026-08-01", "order:2026-09-01")

它覆盖八月起点,但不包含九月起点。上界必须由 Key 编码规则构造,不能随便给字符串末尾加一个字符。B05 中讨论的字节顺序、exclusive upper bound 和 Schema 契约在这里仍然适用。

Range Tombstone 能避免为区间内每个 Key 都发出一次 Point Delete,但它也不是同步打开所有 SST 并擦掉区间字节。普通读取需要按可见性考虑它,物理回收仍依赖后续 Compaction。其碎片化、跨文件处理和性能权衡比 Point Tombstone 更复杂,基础篇先停在这条边界。

手动 CompactRange 能不能马上释放空间?

当业务刚完成一轮大删除,直觉上会想立即调用手动 CompactRange。它可以请求 RocksDB 对指定范围执行 Compaction,是运维工具之一,但不能承诺“调用返回就释放指定数量的字节”。

原因包括:

  • 指定范围与真正包含旧版本的文件边界不一定完全一致;
  • 活跃 Snapshot 仍可能要求保留历史版本;
  • Iterator 或旧 Version 可能仍引用输入文件;
  • 新输出与旧输入存在共存窗口;
  • Compaction 只会按正确性规则丢弃允许淘汰的记录;
  • 目录里还可能有 WAL、日志和其他不属于目标 SST 的文件。

手动 Compaction 还会消耗 CPU、读写带宽与临时空间,可能和在线请求、Flush、自动 Compaction 竞争资源。它应该基于明确范围、容量水位和低峰窗口评估,而不是每次 Delete 后立即调用。

更稳妥的判断是:先确认删除意图已写入、目标 Key 范围确实落在哪些文件与 Level,再检查阻碍回收的引用和 Compaction backlog,最后决定等待自动整理还是安排受控的手动 Compaction。

Tombstone 只是占空间吗?

Tombstone 本身通常很小,但大量删除仍会改变读写成本。它要像其他记录一样写 WAL、进入 MemTable、Flush 到 SST,并在 Compaction 中与旧版本合并,因此会贡献写入流量和写放大。若后台整理跟不上,逻辑上已经删除的数据、旧版本和 Tombstone 会一起积累。

读取也可能受到影响。点查为了确认一个 Key 已被删除,需要找到当前读视图下有效的 Tombstone;如果删除标记与旧值分散在多个文件或 Level,候选查找仍然有成本。范围扫描还要跳过不可见的旧版本与删除记录,删除密集的范围可能出现“扫描了很多内部记录,返回的业务记录却很少”的现象。

这不代表 Tombstone 是设计缺陷。它正是不可变 SST 能低成本表达删除,并在后台安全整理历史的关键。真正需要关注的是删除速率是否长期超过 Compaction 的处理能力,以及 Key Schema 是否让大批过期数据形成可管理的连续范围。

例如,按天过期的事件如果在 Key 中按租户和日期聚集,应用更容易描述删除范围,也更容易观察目标文件;如果过期数据随机散落在整个 Key 空间,一次业务清理可能触碰大量 SST。是否使用 Point Delete、DeleteRange、TTL 或 Compaction Filter,要结合查询语义、版本与运维要求评估,不能只比较 API 调用次数。

还有一个更严格的边界:RocksDB 的逻辑 Delete 不是“安全擦除”承诺。旧字节可能暂时存在于 SST、WAL、备份、Checkpoint 或文件系统介质中。如果业务有隐私合规、密钥销毁或不可恢复删除要求,需要单独设计加密、备份保留与介质处置流程,不能把普通 Delete 的 Not Found 当成取证意义上的清除证明。

删除后空间不降,应该怎样排查?

不要从“Delete 失效了”开始猜。沿着时间线逐层问,通常更快:

空间排查依次区分指标口径、目标范围、Snapshot 与 Iterator、Tombstone 层级、Compaction backlog 和文件引用

先确认测量对象,再查回收条件;否则很容易拿逻辑结果解释目录波动。

第一步:普通读真的已经不可见吗?

用与线上相同的 Column Family、ReadOptions 与 Key 编码验证。若当前普通 Get 仍返回旧值,问题在写入目标、Key Schema、读视图或错误处理,不应直接进入空间诊断。

第二步:看的究竟是什么指标?

记录逻辑有效数据量、Live SST、目录各文件类型与文件系统剩余空间。把变化画到同一时间轴,标出 Flush、Compaction 和业务删除批次。

第三步:目标范围参加 Compaction 了吗?

Delete 可能还在 MemTable,Tombstone 可能只到 L0,而旧 Value 位于更深层;也可能目标文件从未被当前 Compaction Picker 选中。结合各 Level 文件、Compaction 日志和范围判断,而不是只看总 Compaction 次数。

第四步:是否有长生命周期 Snapshot 或 Iterator?

检查创建与释放是否成对、请求超时后是否仍持有资源、后台全量扫描是否运行数小时。应统计存活数量和最长年龄,而不只是累计创建次数。

第五步:后台整理跟得上吗?

观察 Pending Compaction Bytes、L0 文件数、Flush/Compaction 吞吐、写入 Stall、设备利用率和尾延迟。若写入持续快于后台整理,旧版本和 Tombstone 会积累,空间放大只是表现之一。

第六步:旧文件是否仍被引用或等待删除?

对照当前 Version、Iterator 生命周期、日志与打开文件。不要为了追求目录数字直接手工删除 SST;绕过 RocksDB 的文件生命周期可能破坏数据库。

一个可重复的小实验

如果想亲眼观察“逻辑删除早于物理回收”,可以在测试库做这个实验:

  1. 写入一批固定大小的 Value,记录逻辑 Key 数、各 Level 文件和目录大小;
  2. 显式 Flush,确保产生可观察的 SST;
  3. 创建一个 Snapshot,并抽样验证它能读到旧值;
  4. 更新其中一部分 Key,再删除另一部分;
  5. 验证当前普通读已返回新值或 Not Found,同时旧 Snapshot 仍看到旧值;
  6. 再次 Flush,观察新增 SST 与目录大小;
  7. 记录 Compaction 过程,但先保持 Snapshot;
  8. 释放 Snapshot 和所有 Iterator,等待或受控触发目标范围 Compaction;
  9. 再次记录 Live SST、目录文件和文件系统空间。

实验应使用独立测试目录,并记录 RocksDB 版本、Options、Value 大小、写入批次、是否启用 WAL、Compaction Style 与设备环境。不要拿一次目录采样推导固定回收时延,也不要在生产库为了截图频繁执行手动 Compaction。

你大概率会观察到:第五步时逻辑结果已经变化,第六步目录可能增大,第七步旧数据仍因 Snapshot 保留,释放资源并完成合适的 Compaction 后,旧文件才逐步退出。

七个常见误区

误区一:Put 相同 Key 会原地覆盖旧 Value

不会回头修改已生成的 SST。它写入更新版本,由读取可见性和后续 Compaction 完成覆盖语义。

误区二:Delete 返回成功,旧字节就已经消失

成功代表删除写入按配置完成,不代表所有旧 Value 已从不可变文件回收。

误区三:Get 返回 Not Found,Snapshot 也一定读不到

删除前创建的 Snapshot 可能仍看到当时最新的 Value,这是时间点读取的契约。

误区四:有 Snapshot 就一份旧文件也删不了

Snapshot 约束它仍需要的历史版本,不应扩大成“冻结整个数据库”。长期 Iterator 还可能通过文件引用影响删除。

误区五:下一轮 Compaction 必定清空所有 Tombstone

是否可丢弃取决于 Snapshot、Key 范围、层级覆盖、输入文件与更深层旧值。未参与这次 Compaction 的记录不会凭空消失。

误区六:手动 CompactRange 返回后,目录必降到逻辑大小

输出与输入可能共存,旧文件可能仍被引用,目录还包含其他文件。手动 Compaction 也不承诺释放固定字节数。

误区七:DeleteRange 会立即擦除整个区间

它写入半开区间的 Range Tombstone。逻辑不可见与物理回收仍是两件事。

把整条链路压缩成八步

最后再回到 user:42

  1. Put Alice 写入第一个版本;
  2. Put Bob 写入更新版本,普通读选择 Bob;
  3. 创建 Snapshot S,固定当时的读取视图;
  4. Delete 写入更高 Sequence 的 Tombstone;
  5. 当前普通读看到 Tombstone,返回 Not Found;
  6. Snapshot S 仍读取到 Bob,因此相关历史版本不能被错误丢弃;
  7. 释放 Snapshot 与 Iterator 后,合适的 Compaction 合并相关范围并安全发布输出;
  8. 没有引用的旧输入文件被删除,目录空间才可能下降。

这也是整个 RocksDB 基础篇最后一块拼图:B02 用 LSM Tree 解释成本为何从前台搬到后台,B03 追踪 WAL、MemTable 与 Flush,B04 沿着 MemTable、SST、Bloom、Index 和 Cache 完成一次读取,B05 把字节顺序变成 Key Schema 与范围查询,而本文把更新、删除、历史视图和物理回收放回同一条时间线。

基础篇建立的核心心智模型可以归结为:

RocksDB 用有序的新记录表达状态变化,用读视图决定当前答案,再由后台 Compaction 在安全边界内整理历史;API 的逻辑时刻与文件系统的物理时刻不能混为一谈。

后续进阶篇可以继续深入 Internal Key 与 Sequence Number、Snapshot 的精确可见性、Range Tombstone、Compaction Picker、SST Block Format 以及不同 Compaction Style 的权衡。

参考资料