BlobDB 是 RocksDB 针对大 Value 场景的优化方案。通过将大 Value 分离存储到独立的 Blob 文件中,可以显著减少写放大和空间放大。本文将深入分析 BlobDB 的实现原理、配置调优、成熟度评估,并探讨其在图数据库 key-only 查询(如 COUNT)场景下的优化潜力。
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。
BlobDB 的设计思想源自 2016 年发表的 WiscKey 论文。核心理念非常简洁:将大 Value 从 LSM-Tree 中分离出来,单独存储在 Blob 文件中,而 LSM-Tree 中只保留 Key 和指向 Blob 文件的小型指针(BlobIndex)。
这样做的好处是:Compaction 过程中不再需要反复拷贝大 Value,只需要处理小型的 Key + BlobIndex,写放大因此大幅降低。
RocksDB 中存在两代 BlobDB 实现:
| 特性 | Legacy BlobDB | Integrated 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 文件元数据) │
└──────────────────────────────────────────────────────────┘
min_blob_size,Value 被写入 Blob 文件,SST 中记录 <Key, BlobIndex>min_blob_size,Value 直接内联存储在 SST 中(与传统 RocksDB 一致)关键设计:Blob 文件的构建被下放到了 RocksDB 的后台线程(Flush/Compaction),而不是前台应用线程。这带来了几个重要优势:
Point Lookup (Get/MultiGet):
Iterator (Seek/Next):
新版 BlobDB 将 RocksDB 的 Version 概念扩展为同时包含活跃的 SST 文件和 Blob 文件,读取路径利用线程本地存储(Thread-Local Storage),基本实现了无锁读取。
Key-Value 分离带来了一个新问题:当一个 Key 被覆盖或删除时,其对应的 Blob 变成了无效的垃圾数据。BlobDB 的 GC 机制集成在 Compaction 过程中:
这种方式比 WiscKey 原始的 GC 更高效——WiscKey 需要对每个 Blob 执行 Get 检查是否仍被引用,然后执行 Put 更新引用,这会与应用写入产生竞争和冲突。
GC 关键配置参数:
enable_blob_garbage_collection:是否启用 GC(默认 false)blob_garbage_collection_age_cutoff:只有”年龄”在此阈值之前的 Blob 文件才会被 GC 选中(默认 0.25,即最老的 25%)blob_garbage_collection_force_threshold:当 Blob 文件中垃圾比例超过此阈值时,强制触发 GC(默认 1.0,即禁用)// 启用 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
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_size | blob_file_size | 压缩 | GC |
|---|---|---|---|---|
| 大 Value(>10KB) | 4KB | 256MB | LZ4 | 开启,cutoff=0.25 |
| 中等 Value(1-10KB) | 1KB | 128MB | LZ4 | 开启,cutoff=0.5 |
| 混合负载 | 512B | 256MB | LZ4 | 开启,force_threshold=0.5 |
| 图数据库(key-heavy) | 256B~1KB | 64MB | LZ4/Snappy | 开启,cutoff=0.5 |
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 才能决定
}
};
RocksDB 官方 FAQ 中明确提到了一个重要信息:
“在 BlobDB 中,Key 和大 Value 分开存储,因此仅遍历 Key 可能是有益的,但目前尚未支持。未来可能会增加这一功能。”
这是一个关键限制:当前 BlobDB 的 Iterator 在遍历时,即使你只访问 key() 而不访问 value(),底层仍然会从 Blob 文件中读取 Value。 这意味着对于 COUNT、key-exists 等只需要 Key 的查询,BlobDB 当前版本无法自动跳过 Value 的读取。
不过,由于 Key-Value 分离,SST 文件的体积显著减小(只包含 Key 和 BlobIndex 指针),这使得:
基于 RocksDB 的图数据库(如 NebulaGraph、JanusGraph 等)通常具有以下存储特征:
在统计查询场景中(如 COUNT(*)、COUNT(edge)、MATCH (v) RETURN count(v)),查询引擎通常只需要遍历 Key 来计数,而不需要读取完整的属性 Value。
写入侧收益(确定的):
读取侧收益(部分的):
读取侧限制(需注意的):
虽然 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 倍以上,这对磁盘成本敏感的场景是不可接受的。
缓解措施:
blob_garbage_collection_age_cutoff(如 0.5),让更多旧 Blob 文件参与 GCblob_garbage_collection_force_threshold(如 0.5),强制回收垃圾率高的文件periodic_compaction_seconds 确保所有 SST 文件定期参与 Compactionblob_garbage_collection_space_amp_limit 参数可以直接设定空间放大目标文件句柄泄漏
在 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 调优方面。
| 特性 | Integrated BlobDB | Titan (TiKV) | BadgerDB (Go) |
|---|---|---|---|
| 宿主引擎 | RocksDB 内置 | RocksDB 插件 | 独立引擎 |
| API 兼容性 | 100% RocksDB API | 100% RocksDB API | 独立 API |
| GC 策略 | Compaction 集成 | Compaction 集成 + 独立 GC | 独立 GC |
| Level Merge | 不支持 | 支持(最后两层) | 不适用 |
| Blob 排序 | 按 Key 排序 | 按 Key 排序 | 无序 |
| 成熟度 | 较成熟 | 较成熟(TiDB 默认启用) | 成熟 |
| 适用语言 | C++ / 各语言绑定 | Rust (TiKV) | Go |
| 生产验证 | Meta 内部 | PingCAP 大规模使用 | Dgraph 使用 |