rocksdb 学习笔记

LevelDB 缺陷

RocksDB 官方 FAQ 中有写

“We benchmarked LevelDB and found that it was unsuitable for our server workloads. The benchmark results look awesome at first sight, but we quickly realized that those results were for a database whose size was smaller than the size of RAM on the test machine.” — RocksDB FAQ

LevelDB的设计假设是数据集能放进 OS page cache. 当数据量超过内存 5 倍以上时,性能急剧恶化. Facebook 发现了以下具体问题(来源:RocksDB FAQ + Mark Callaghan 的 benchmark 文章):

Mark Callaghan 在其博客 Small Datum 中进一步量化了这个差距:

RocksDB 配置了 128MB 的 write buffer,L1 限制为 1GB;LevelDB 使用 2MB write buffer,L1 限制为 10MB. RocksDB 使用 7 个线程做 flush,16 个线程做 compaction. 仅一个 compaction 线程对于 LevelDB 的测试结果来说已经太少了. Small Datum: Comparing LevelDB and RocksDB, take 2

RocksDB 的改进

写入路径优化

Group Commit 改进

LevelDB 的写入路径有一个全局 mutex. 所有写线程排队,由 leader 线程批量写 WAL + memtable. 这在高并发下是一个严重瓶颈.

Leveldb 的写入路径其实已经有了一些优化, rocksdb继承了这个优化, 就是 Group Commit, 在 db/db_impl/db_impl_write.cc 中,WriteThread 类实现了写入分组. leader 线程收集一批 WriteBatch,统一写 WAL 和 memtable. 主要优化在于减少了 DB mutex 的持有时间. 这里逻辑看起来非常复杂, 它使用 SuperVersion,合并引用计数,LevelDB 的问题是:每次读操作都要获取 mutex,然后逐一增加每个 memtable 和每个 SST 文件的引用计数, Rocksdb 的 SuperVersion 持有所有 memtable 和 SST table 的引用计数. 这样读者只需要对这一个数据结构增加引用计数,而不是逐个增加. 每个线程第一次 Get() 时付出一次 mutex 代价获取 SuperVersion 引用,然后缓存在线程本地存储中. 一个原子变量追踪全局 SuperVersion 版本号. 后续的读只需要比较本地版本号和全局版本号——如果相同,直接用缓存的引用,零开销. 只有 Version 变更时才需要重新获取 mutex,而这个代价被上百万次读摊薄到可以忽略; rocksdb 还有一套 lockless sweep 机制:当 flush/compaction 产生新 Version 时,后台线程用 CAS 操作扫描所有线程的 local storage 并释放旧的 SuperVersion,不需要任何锁.

Concurrent Memtable Insert

Rocksdb 增加了 Concurrent Memtable Insert, 就是在 db/memtable.cc 中,RocksDB 实现了基于 lock-free SkipList 的并发插入. 需要开启配置项:

allow_concurrent_memtable_write = true  (默认开启)
enable_write_thread_adaptive_yield = true

Mark Callaghan 的测试显示:开启并发 memtable 写入后,在 sync 关闭的情况下,插入速率提升了约 3 倍;sync 开启时提升约 2 倍. Small Datum: Concurrent inserts and the RocksDB memtable

Pipelined Write

RocksDB 5.5 中引入了 Pipelined Write. WAL 写入和 memtable 写入可以流水线化——当前一个 writer 完成 WAL 写入后,下一个 writer 可以开始写 WAL,而前一个 writer 同时进行 memtable 写入. RocksDB Wiki: Pipelined Write

“With pipelined write we got roughly 30% improvement on write throughput.”

Rockdb 写入的代码入口是 DBImpl::WriteImpl()db/db_impl/db_impl_write.cc),它根据配置选择不同的写入策略. 策略看起来也很复杂:

TiKV: Multi-Batch Write

TiKV 发现 Pipelined Write 仍然存在”Pipeline Bubble”问题——WAL 写入和 memtable 写入的负载不均导致流水线停顿. 他们实现了 Multi-Batch Write 来进一步解耦这两个阶段. 值得学习看看 How We Optimize RocksDB in TiKV — Write Batch Optimization

Write Stall 的精细化控制

LevelDB 在 L0 文件过多时直接阻塞写入. RocksDB 实现了更精细的控制:

db/column_family.cc 中,RecalculateWriteStallConditions() 函数根据以下条件决定是减速还是停止写入:

Compaction 策略优化

RocksDB 提供了三种 Compaction 策略

Leveled Compaction

代码:db/compaction/compaction_picker_level.cc 中的 LevelCompactionBuilder; 每层文件 key range 不重叠(L0 除外),读放大最低,但写放大最高(约 10-30x). MyRocks 论文中明确提到了 RUM Conjecture 的权衡:

“We picked LSM-tree over B-Tree to save space at the expense of read performance. For read intensive databases where all data resides in memory, MyRocks was hardly better than InnoDB.”MyRocks VLDB 2020 论文

Universal Compaction

代码:db/compaction/compaction_picker_universal.cc

特点:类似 Size-Tiered,写放大显著降低(据 GetStream 的分析,比 LevelDB 低约 7 倍),但读放大和空间放大更大. GetStream: The Fundamentals of RocksDB

FIFO Compaction

代码:db/compaction/compaction_picker_fifo.cc

最简单的策略——超过 TTL 或大小限制的最老文件直接删除. 适用于时间序列数据. 几乎零写放大.

Subcompaction

除了多线程 compaction(多个 compaction 任务并行),RocksDB 还实现了 subcompaction(单个 compaction 任务内部的并行化). 这在代码中由 CompactionJob::GenSubcompactionBoundaries() 实现. RocksDB Wiki: Subcompaction

统一内存管理

WriteBufferManager

include/rocksdb/write_buffer_manager.h 中,WriteBufferManager 允许多个 Column Family 甚至多个 DB 实例共享一个内存池. 当总 memtable 内存超过阈值时,触发最大 memtable 的 flush.

Block Cache 统一管理

RocksDB 最近的改进方向是将所有内存消耗都纳入 Block Cache 的管控:

“The goal of recent memory tracking work in RocksDB is to enable users to cap the total memory usage of RocksDB instances under a single, configurable limit — the block cache capacity. This is achieved by tracking and charging all major memory consumers (memtables, table readers, file metadata, compression buffers, filter construction) to the block cache.”

其他

分层压缩优化

RocksDB 支持每层使用不同的压缩算法:

L0-L2: 不压缩或 LZ4(快速,减少 CPU 开销)
L3-L(n-1): LZ4
Lmax: ZSTD(高压缩比,因为底层数据量最大且最少被读取)

这来自代码中 ColumnFamilyOptions::compression_per_level 配置项.

Rate Limiter

SSD 有一个著名的”写悬崖”(write cliff)现象:当写入速率超过 SSD 内部 GC 的处理能力时,延迟会突然从微秒级跳到毫秒级. RocksDB 的 Rate Limiter 通过控制 compaction 和 flush 的 IO 速率来避免这种情况.

Direct I/O

LevelDB 依赖 OS page cache. RocksDB 支持 Direct I/O,绕过 OS page cache,使用自己的 Block Cache(可选 LRU 或 HyperClockCache). 这避免了双重缓存和 OS page cache 的不可控回收. 在 TiKV 中,45% 的系统内存分配给了 BlockCache,用于缓存热 data block、index block 和 filter block. TiDB Docs: RocksDB Overview

Bloom Filter 优化

RocksDB 支持两种 Bloom Filter 策略:

  1. Full Bloom Filter:覆盖整个 SST 文件,用于点查
  2. Prefix Bloom Filter:只对 key 的前缀建 filter,用于 prefix scan

TiKV 利用 Prefix Bloom Filter 来加速 MVCC 读取——查询某个 user_key 的最新版本时,可以通过 prefix filter 快速跳过不包含该 key 的 SST 文件. TiKV Deep Dive: RocksDB

Prefix Key Encoding

RocksDB 的 BlockBasedTable 在数据 block 内对相邻 key 做 prefix 压缩. 这在 TiKV 的场景中尤其有效——MVCC key 的格式是 user_key + commit_ts,相同 user_key 的不同版本共享很长的前缀.

SuperVersion

VersionEdit → MANIFEST → VersionSet → Version → SuperVersion

关键更新流程

  1. Flush/compaction 完成,生成 VersionEdit
  2. 调用 VersionSet::LogAndApply()
  3. VersionEdit 追加写入 MANIFEST 文件
  4. 基于当前 Version + VersionEdit 构建新 Version
  5. 原子替换当前 Version
  6. 旧 Version 在所有引用(iterator、snapshot)释放后才回收

SuperVersion 的美妙之处在于:读操作不需要任何锁——只需获取当前 SuperVersion 的引用计数,就能获得一致的快照视图.


RocksDB 独有的一些特性

Column Family

这是 RocksDB 相对于 LevelDB 最重要的架构级改进之一.

db/column_family.h 中,ColumnFamilyData 类封装了每个 Column Family 的完整状态:

// 每个 CF 拥有独立的:
// - MemTable / Immutable MemTable list
// - SST files (在各自的 level)
// - Compaction 配置和状态
// - Block cache 配置
// 共享的:
// - WAL (Write-Ahead Log)
// - DB mutex

每个 CF 可以有独立的 compaction 策略、compression 配置和 block cache 大小. lock CF 可以配置更激进的 compaction(因为数据生命周期短),而 default CF 可以配置更高的压缩比(因为是大数据量存储).

CompactionFilter

允许在 compaction 过程中对每个 key-value 做决策——保留、删除、或修改 value. 核心接口是 FilterV2(),compaction 遍历每个 key 时调用,返回 kKeepkRemovekChangeValue 等 Decision. 需要注意 CompactionFilter 不保证 snapshot 一致性,被 filter 删除的 key 可能从已有 snapshot 中消失. 主要用来实现 TTL 过期数据的自动清理——在 value 中编码写入时间戳,compaction 时判断过期就返回 kRemove,数据在后台被自然淘汰,不需要业务层额外发起删除.

MergeOperator

RocksDB 最有特色的 API 之一. DB::Merge(key, operand) 只记录一个增量操作(operand),不会立即读取当前值. operand 累积在 memtable 和 SST 中,直到 Get() 或 compaction 时才通过 FullMergeV2() 合并成最终结果. 这避免了经典的 read-modify-write 模式. 对于简单的满足结合律的操作(累加、拼接),可以用 AssociativeMergeOperator;复杂场景则实现 MergeOperator,额外提供 PartialMerge() 让 compaction 时能预合并多个 operand 来减少后续开销. 主要用来实现高频写入的计数器、限流器等场景——传统方式每次更新都要 Get + Put 两次 IO,用 Merge 只需一次写入.

SST File Ingestion

DB::IngestExternalFile() 可以直接将外部构建好的 SST 文件导入 DB,绕过 WAL 和 memtable. 先用 SstFileWriter 在外部按 key 有序地构建 SST 文件,然后调用 ingest,RocksDB 会为文件分配全局 sequence number,找到 key 范围没有重叠的最低层级放入,原子更新 MANIFEST. 主要用于批量数据加载场景,比如 Hadoop/Spark 直接生成 SST 文件后 ingest 进来,比逐条 Put 快几个数量级. IngestExternalFiles() 还支持多个 Column Family 原子导入.

EventListener

通过 Options::listeners 注册事件监听器,可以在 flush、compaction、SST 文件创建/删除、write stall 状态变化、后台错误等关键时刻收到回调. 所有回调都在 RocksDB 内部线程上执行,必须快速返回,否则会阻塞 flush/compaction. 主要用来做生产环境的监控和告警——比如监听 OnStallConditionsChanged 在 write stall 发生时立即报警,或者监听 OnCompactionCompleted 来追踪 compaction 的 IO 开销和耗时.

Snapshot isolation

存储层的快照一致性是构建分布式事务的基础. DB::GetSnapshot() 捕获当前的全局 sequence number,之后基于这个 snapshot 的读操作只能看到 seqno 小于等于它的数据. 内部实现上,SnapshotImpl 持有 sequence number,所有活跃 snapshot 组成一个双向链表. Compaction 时只有没有任何 snapshot 引用的旧版本才能被安全删除,所以长期持有 snapshot 会阻止旧版本回收,导致空间放大. TiKV 利用这个机制实现 MVCC 读——分布式事务的 start_ts 映射为 RocksDB 的 snapshot sequence number,确保事务内所有读取看到一致的数据视图.

Range deletion(DeleteRange

DB::DeleteRange(begin_key, end_key) 删除 [begin_key, end_key) 范围内的所有 key. 内部通过写入一条 range tombstone(类型为 kTypeRangeDeletion)来实现,而不是为每个 key 单独写一条 delete tombstone.

db->DeleteRange(WriteOptions(), cf, "user:1000", "user:2000");
// 一条写入就删除了 [user:1000, user:2000) 范围内的所有 key

主呀用来实现 关系数据库中类似 TRUNCATE 表的操作

TablePropertiesCollector

在 SST 文件构建过程中收集自定义统计信息并嵌入到文件的 property block 中. 每个 key 写入 SST 时会调用 AddUserKey(),文件构建完成时调用 Finish() 将收集到的属性写入. 还有一个 NeedCompact() 方法,可以根据收集到的统计来建议 RocksDB 触发 compaction. 主要用来实现基于数据特征的自适应 compaction——比如统计每个 SST 文件的 tombstone 占比,当删除比例过高时自动触发 compaction 来回收空间,TiKV 的 GC 就用了类似的机制.

事务

LevelDB 只有 WriteBatch 级别的原子性. RocksDB 实现了完整的事务支持:

三种写策略:

  1. WriteCommitted(默认):commit 时才写 memtable,最简单但 commit 阶段重
  2. WritePrepared:prepare 阶段写 memtable,commit 只写一条 marker,大幅降低 2PC commit 延迟
  3. WriteUnprepared:write 阶段就写 memtable,支持超大事务 RocksDB Blog: WritePrepared Transactions

WritePrepared 事务的核心数据结构是 CommitCache——一个内存中的 prepare_seq → commit_seq 映射,用于在读取时判断某个 key 的版本是否已提交.

观测性

这是 LevelDB 最大的短板. RocksDB 在代码中内置了极其丰富的监控基础设施:

Mark Callaghan 在其 benchmark 文章中特别强调了这一点:

“Statistics reporting was enabled for RocksDB. This data has been invaluable for explaining good and bad performance. That feature isn’t in LevelDB.”Small Datum: Comparing LevelDB and RocksDB, take 2

[NeoKV] 基于Braft分布式KV,支持Redis协议
我的姐姐