RocksDB BlobDB 分析

BlobDB 是 RocksDB 针对大 Value 场景的优化方案。通过将大 Value 分离存储到独立的 Blob 文件中,可以显著减少写放大和空间放大。本文将深入分析 BlobDB 的实现原理、配置调优、成熟度评估,并探讨其在图数据库 key-only 查询(如 COUNT)场景下的优化潜力。


为什么需要 BlobDB?

传统 LSM-Tree 的写放大问题

RocksDB 使用 LSM-Tree(Log-Structured Merge-Tree)作为核心数据结构。写入数据时,数据首先进入内存中的 MemTable,随后被 Flush 为磁盘上的 SST 文件。这些 SST 文件在后台通过 Compaction 不断地合并和重写,以消除过期的键值对。

这种反复重写数据的过程导致了写放大(Write Amplification)——实际写入磁盘的数据量远大于用户写入的数据量。当存储的 Value 较大时,这个问题尤为严重:每一次 Compaction 都需要拷贝这些大 Value,即使它们没有任何变化。

具体来说,假设 LSM-Tree 最大层级为 5,放大因子为 10,总写放大约为 1+1+10+10+10+10=42 倍。如果单个 Value 为 10KB,那么一个键值对在整个生命周期内被实际写入磁盘的数据量可能高达 420KB。

WiscKey

BlobDB 的设计思想源自 2016 年发表的 WiscKey 论文。核心理念非常简洁:将大 Value 从 LSM-Tree 中分离出来,单独存储在 Blob 文件中,而 LSM-Tree 中只保留 Key 和指向 Blob 文件的小型指针(BlobIndex)

这样做的好处是:Compaction 过程中不再需要反复拷贝大 Value,只需要处理小型的 Key + BlobIndex,写放大因此大幅降低。


二、Integrated BlobDB 架构解析

两代 BlobDB 的演进

RocksDB 中存在两代 BlobDB 实现:

特性Legacy BlobDBIntegrated BlobDB(推荐)
API独立的 rocksdb::blob_db::BlobDB标准 rocksdb::DB API
配置方式独立配置Column Family Options
一致性有限完整的 WAL/同步写入支持
写入方式前台线程同步写 Blob后台 Flush/Compaction 异步写
功能完备性仅支持 FIFO/TTL接近 RocksDB 完整功能
GC 策略独立 GC集成到 Compaction 过程
状态已废弃生产推荐

2020 年,RocksDB 团队决定从头重新架构 BlobDB,创建了新的 Integrated BlobDB。新版本通过标准 rocksdb::DB API 访问,使用 Column Family 级别的选项配置,消除了旧版的诸多限制。

整体架构

┌──────────────────────────────────────────────────────────┐
│                    Application Layer                     │
│                  (标准 rocksdb::DB API)                   │
├──────────────────────────────────────────────────────────┤
│                       MemTable                           │
│              (Key + Value 完整写入)                        │
├──────────────────────────────────────────────────────────┤
│                      Flush / Compaction                  │
│     ┌─────────────────────┬───────────────────────┐      │
│     │    SST Files         │    Blob Files        │      │
│     │  (Key + BlobIndex)   │  (Large Values)      │      │
│     │                      │                      │      │
│     │  ┌───────────────┐  │  ┌─────────────────┐  │      │
│     │  │ Key1 → Ptr1   │  │  │ BlobRecord1     │  │      │
│     │  │ Key2 → Ptr2   │  │  │ BlobRecord2     │  │      │
│     │  │ Key3 → Value3 │  │  │ BlobRecord3     │  │      │
│     │  │ (小Value内联)  │  │  │ ...             │  │      │
│     │  └───────────────┘  │  └─────────────────┘  │      │
│     └─────────────────────┴───────────────────────┘      │
├──────────────────────────────────────────────────────────┤
│                     MANIFEST                             │
│         (统一管理 SST + Blob 文件元数据)                    │
└──────────────────────────────────────────────────────────┘

写入流程

  1. 用户通过 Put/Write API 写入 Key-Value 对
  2. 数据写入 WAL 和 MemTable(此时 Value 完整保存在内存中)
  3. MemTable 满后触发 Flush
    • 若 Value 大小 >= min_blob_size,Value 被写入 Blob 文件,SST 中记录 <Key, BlobIndex>
    • 若 Value 大小 < min_blob_size,Value 直接内联存储在 SST 中(与传统 RocksDB 一致)
  4. Compaction 过程中同理处理 Value 的分离

关键设计:Blob 文件的构建被下放到了 RocksDB 的后台线程(Flush/Compaction),而不是前台应用线程。这带来了几个重要优势:

读取流程

Point Lookup (Get/MultiGet)

  1. 在 LSM-Tree 中查找 Key
  2. 如果 Value 是 BlobIndex,则解析出 Blob 文件号和偏移量
  3. 从对应 Blob 文件中读取实际 Value

Iterator (Seek/Next)

  1. 遍历 LSM-Tree 中的 Key
  2. 对每个 BlobIndex,需要额外读取 Blob 文件获取实际 Value
  3. 可以利用预读(readahead)优化顺序扫描性能

新版 BlobDB 将 RocksDB 的 Version 概念扩展为同时包含活跃的 SST 文件和 Blob 文件,读取路径利用线程本地存储(Thread-Local Storage),基本实现了无锁读取。

垃圾回收(GC)机制

Key-Value 分离带来了一个新问题:当一个 Key 被覆盖或删除时,其对应的 Blob 变成了无效的垃圾数据。BlobDB 的 GC 机制集成在 Compaction 过程中:

  1. 在 Compaction 过程中遇到旧 Blob 文件中的有效 Blob 时,将其重新定位(relocate)到新的 Blob 文件
  2. 当一个 Blob 文件中所有 Blob 都变成垃圾后,整个文件被标记为过时并最终删除
  3. BlobDB 在 MANIFEST 中精确跟踪每个 Blob 文件的垃圾数据量

这种方式比 WiscKey 原始的 GC 更高效——WiscKey 需要对每个 Blob 执行 Get 检查是否仍被引用,然后执行 Put 更新引用,这会与应用写入产生竞争和冲突。

GC 关键配置参数:


核心配置参数详解

基础配置

// 启用 BlobDB
options.enable_blob_files = true;

// Value 大于此阈值才分离到 Blob 文件(默认为 0,即所有值都分离)
// 建议设为 256B ~ 4KB,具体取决于业务场景
options.min_blob_size = 1024;  // 1KB

// 单个 Blob 文件大小上限(默认 256MB)
options.blob_file_size = 268435456;  // 256MB

// Blob 文件压缩算法(默认无压缩)
options.blob_compression_type = kLZ4Compression;

GC 配置

// 启用集成 GC
options.enable_blob_garbage_collection = true;

// GC 年龄截断:只有最老的 25% Blob 文件会参与 GC
options.blob_garbage_collection_age_cutoff = 0.25;

// 强制 GC 阈值:垃圾率超过此值的 Blob 文件被强制回收
options.blob_garbage_collection_force_threshold = 0.5;

缓存配置

// Blob 缓存(与 Block Cache 共享或独立配置)
options.blob_cache = NewLRUCache(512 * 1024 * 1024);  // 512MB

// Compaction 时的预读大小
options.blob_compaction_readahead_size = 256 * 1024;  // 256KB

建议

场景min_blob_sizeblob_file_size压缩GC
大 Value(>10KB)4KB256MBLZ4开启,cutoff=0.25
中等 Value(1-10KB)1KB128MBLZ4开启,cutoff=0.5
混合负载512B256MBLZ4开启,force_threshold=0.5
图数据库(key-heavy)256B~1KB64MBLZ4/Snappy开启,cutoff=0.5

CompactionFilter 与 Key-Only 优化

FilterBlobByKey:一个被忽视的利器

BlobDB 的 CompactionFilter 支持一个非常重要的优化——FilterBlobByKey。当 Compaction 过程中遇到一个存储在 Blob 文件中的键值对时,RocksDB 会首先调用 FilterBlobByKey 方法。如果该方法仅凭 Key 就能做出最终决策(如 kKeep、kRemove 等),就无需从 Blob 文件中读取 Value

这意味着在 Compaction 阶段,如果业务逻辑可以基于 Key 判断是否保留数据(例如基于 Key 中编码的时间戳进行 TTL 过滤),就可以完全避免读取大 Value,显著提升 Compaction 效率。

class MyCompactionFilter : public CompactionFilter {
 public:
  // 仅基于 Key 做决策,避免读取 Blob
  Decision FilterBlobByKey(int level, const Slice& key,
                           std::string* new_value,
                           std::string* skip_until) const override {
    // 基于 Key 编码的信息做判断
    if (IsExpiredByKey(key)) {
      return Decision::kRemove;
    }
    return Decision::kUndetermined;  // 需要读取 Value 才能决定
  }
};

Key-Only 查询的当前限制

RocksDB 官方 FAQ 中明确提到了一个重要信息:

“在 BlobDB 中,Key 和大 Value 分开存储,因此仅遍历 Key 可能是有益的,但目前尚未支持。未来可能会增加这一功能。”

这是一个关键限制:当前 BlobDB 的 Iterator 在遍历时,即使你只访问 key() 而不访问 value(),底层仍然会从 Blob 文件中读取 Value。 这意味着对于 COUNT、key-exists 等只需要 Key 的查询,BlobDB 当前版本无法自动跳过 Value 的读取。

不过,由于 Key-Value 分离,SST 文件的体积显著减小(只包含 Key 和 BlobIndex 指针),这使得:


图数据库场景下的 BlobDB 探索

图数据库的存储特征

基于 RocksDB 的图数据库(如 NebulaGraph、JanusGraph 等)通常具有以下存储特征:

在统计查询场景中(如 COUNT(*)COUNT(edge)MATCH (v) RETURN count(v)),查询引擎通常只需要遍历 Key 来计数,而不需要读取完整的属性 Value。

BlobDB 在图数据库中的理论收益

写入侧收益(确定的):

读取侧收益(部分的):

读取侧限制(需注意的):

图数据库 COUNT 场景的优化路径

虽然 BlobDB 当前不支持 key-only iteration,但可以通过以下方式间接优化 COUNT 查询:

方案一:利用 SST 变小的间接收益

即使 Iterator 仍然读取 Blob Value,由于 SST 文件变小:

方案二:自定义 Column Family 分离

将需要频繁 COUNT 的数据放入独立的 Column Family,该 CF 只存储 Key(Value 为空或极小),而属性数据放入启用 BlobDB 的另一个 CF。

CF_edge_index:  Key = SrcID+EdgeType+Rank+DstID, Value = "" (空)
CF_edge_props:  Key = SrcID+EdgeType+Rank+DstID, Value = 属性数据 (BlobDB)

COUNT 查询只扫描 CF_edge_index,完全不涉及 Blob 文件。

方案三:关注社区进展

RocksDB 社区一直在讨论 key-only iteration 的支持。一旦实现,BlobDB 的 Iterator 在只访问 key() 时将自动跳过 Blob 文件的读取,这将是图数据库 COUNT 场景的最佳方案。


成熟度评估与社区使用情况

整体成熟度

维度评估说明
API 稳定性⭐⭐⭐⭐使用标准 rocksdb::DB API,已稳定多年
功能完备性⭐⭐⭐⭐支持 Put/Get/Delete/Iterator/Merge/事务/Checkpoint/Backup 等
GC 有效性⭐⭐⭐基础功能可靠,但极端场景下空间放大可能较高
生产验证⭐⭐⭐Meta 内部使用,部分社区用户生产使用
文档与工具⭐⭐⭐Wiki 文档较完善,但实际调优经验分享较少
缺陷修复⭐⭐⭐⭐RocksDB 团队持续维护,响应较快

已知问题与风险

空间放大问题

社区多次报告空间放大偏高的问题。核心原因是 Blob GC 与 LSM-Tree Compaction 的解耦:一个 Blob 只有在其对应的 BlobIndex 被 Compaction 移除后才被标记为垃圾。如果某些 SST 文件长期不参与 Compaction,其引用的 Blob 文件中的无效数据就无法回收。

在某些用户的生产环境中,空间放大达到了 2 倍以上,这对磁盘成本敏感的场景是不可接受的。

缓解措施:

文件句柄泄漏

在 Java 绑定(RocksJNI)中,有用户报告了 Blob 文件删除后文件句柄未正确释放的问题(RocksDB 9.4.0 引入的回归 Bug)。这个问题在 9.5.x 和 9.6.x 中存在。如果使用 Java 绑定,需要关注此问题的修复状态。

空间放大统计不准确

BlobDB 虽然精确追踪了 Blob 文件级别的垃圾量,但报告的”空间放大”只反映了 Blob 文件内部的物理垃圾比例,不包含 LSM-Tree 中尚未 Compaction 的过期 BlobIndex 所间接占用的空间。因此,实际的整体空间放大可能显著高于统计值。

Range Scan 性能下降

Key-Value 分离的固有代价是范围查询需要额外的随机 I/O 来读取 Blob 文件。根据 PingCAP 的测试,这种性能下降在 40% 到数倍之间。对于范围查询密集型的工作负载(如图遍历),这是一个需要认真评估的 trade-off。

使用案例

TiKV / TiDB(PingCAP)

TiKV 使用了基于类似思想的 Titan 引擎(BlobDB 的外部插件版本)。从 TiDB v7.6.0 开始,Titan 已默认为新创建的集群启用。PingCAP 建议在平均 Value 大于 1KB 时使用 Titan。不过 TiKV 的 Titan 文档中仍然提到”Titan 尚未达到最终成熟度”,使用前需充分评估。

Titan 在写入场景的性能比 RocksDB 高约 70%,更新场景高约 180%,但范围查询性能会下降。空间消耗可能是 RocksDB 的两倍。

Meta(Facebook)

BlobDB 最初由 Meta 开发和使用,Integrated BlobDB 的重新架构也是在 Meta 内部完成的。Mark Callaghan(smalldatum 博客)等 RocksDB 专家在持续测试和优化 BlobDB,并将其集成到 benchmark 工具中。

其他

社区中有不少用户在测试和使用 BlobDB,但大规模生产使用的公开案例较少。多数反馈集中在空间放大和 GC 调优方面。


BlobDB vs Titan vs BadgerDB:KV 分离方案对比

特性Integrated BlobDBTitan (TiKV)BadgerDB (Go)
宿主引擎RocksDB 内置RocksDB 插件独立引擎
API 兼容性100% RocksDB API100% RocksDB API独立 API
GC 策略Compaction 集成Compaction 集成 + 独立 GC独立 GC
Level Merge不支持支持(最后两层)不适用
Blob 排序按 Key 排序按 Key 排序无序
成熟度较成熟较成熟(TiDB 默认启用)成熟
适用语言C++ / 各语言绑定Rust (TiKV)Go
生产验证Meta 内部PingCAP 大规模使用Dgraph 使用
想念 C++,讨厌 Rust
RocksDB性能优化手册