☰
【C++三方组件】RocksDB:写多读少的嵌入式 KV
2026/10/12 4:55:40 网站建设 项目流程

【C++三方组件】RocksDB:写多读少的嵌入式 KV

【摘要】:RocksDB 是 Facebook 从 LevelDB 演化出的嵌入式持久 KV,用 LSM-tree 把写路径变成顺序追加:先写内存 memtable 与 WAL,再批量落成不可变 SST,后台 compaction 分层归并。本篇讲 Put / Get / Status 的日常、WriteBatch 千条成批的实测吞吐差、迭代器范围扫描、快照一致性读、Column Family 的命名空间与「重开必须带全」的坑,以及 write_buffer_size 一档入门调参与 GetProperty 观测;末尾对照 SQLite 与 LMDB,给出写多读少场景的选型边界。
【关键词】:RocksDB、LSM-tree、嵌入式 KV、WriteBatch、Column Family、compaction
【版本基准】:RocksDB 11.8.1(双许可:GPLv2 / Apache-2.0 二选一)|示例 C++20——11.x 起要求 C++20(GCC ≥ 11 / Clang ≥ 10 / MSVC v143 及以上)|文中输出与吞吐均为本机实测(MSVC v145/VS2026,Windows;数字随机器与磁盘而变)

1. What:为写而生的 KV 引擎

RocksDB 是一个嵌入式持久化键值库:键值皆字节串(rocksdb::Slice),按字典序组织,单个进程直接打开本地目录使用——没有服务进程,数据库就是一个文件夹。它 2012 年从 Google 的 LevelDB fork 而来,Facebook(Meta)为服务端负载持续改造十年:多线程 compaction、Column Family、速率限制、备份、事务……今天的用户名单里有 TiKV、MyRocks、Flink 的 state backend——**「写多读少、数据量大、要持久」**是它们的共同点。

与第 33 篇 SQLite 的根本分野在写路径的物理形态。SQLite 是 B-tree 页式存储:一条 UPDATE 意味着在文件中间某页就地改写——随机写。RocksDB 是 LSM-tree(Log-Structured Merge-tree):写入只追加到内存 memtable 和磁盘 WAL,memtable 写满后一次性顺序刷成不可变的 SST 文件,后台 compaction 再把多层 SST 归并压缩。磁盘最擅长的顺序写被放到主路,随机写被赶到后台——这就是「写多读少」的物理来源。

代价也长在同一处:读要跨 memtable + 多层 SST 查找(读放大),删除只是写墓碑、空间要等 compaction 才回收(空间放大),compaction 占用后台 CPU 与 IO(写放大与延迟抖动)。三个放大是 LSM 的三角形约束,调参(第 8 节)本质是在三角里挪位置。

API 面很窄,全是rocksdb::Status通道:

std::unique_ptr<rocksdb::DB>db;rocksdb::DB::Open(Options,path,&db);// 打开(11.x 出参是 unique_ptr)db->Put(wo,key,value);// 写db->Get(ro,key,&value);// 读(NotFound 是分支不是错误)db->Delete(wo,key);// 删(写墓碑)db->Write(wo,&batch);// 批量原子写db->NewIterator(ro);// 有序扫描db->GetSnapshot()/ReleaseSnapshot();// 快照db->CreateColumnFamily(...)// 列族

从旧教程迁移的读者会撞到的第一个变化:11.x 的DB::Open出参是std::unique_ptr<DB>*(10.3 弃用、11.0 移除了DB**版本)——所有权从此由智能指针表达,delete db的手工纪律退场;Column Family 句柄仍是裸指针,用db->DestroyColumnFamilyHandle(h)归还。

注意它不是SQL 数据库:没有表、没有查询计划,"值"是一块不透明的字节——需要结构化查询回到第 33 篇,需要把 JSON/protobuf 自己塞进 value。

2. Why:自研持久 KV 的深水区

「写个 map 存硬盘」的朴素版本两天就能跑起来,然后深水区一个接一个:崩溃安全要求每次修改先写日志再改内存结构(WAL),fsync 时机错一步就丢数据或写坏文件;memtable 满了要刷盘,刷盘期间新的写去哪(不可变 memtable 轮换);磁盘文件不可变,更新与删除只能靠后台归并(compaction)回收空间,而 compaction 的层级策略、触发时机、限速、与前台 IO 的隔离,每一项都是论文级的调优对象;范围扫描要跨 memtable 与多层 SST 归并出一致视图;并发要处理多线程读写同一 memtable 的正确性。

RocksDB 把这些全部做好了,而且是在 Meta 的生产规模上锤出来的。选它的账本很简单:一颗经过十年生产验证的存储引擎树,换来你完全不用发明 compaction。成本同样明确:编译时间(本篇示例在 Windows 上从源码全量编译一次,MSVC + Ninja -j6 实测约 6~8 分钟)、二进制代价(静态库本体数百 MB,好在链接器只抽取用到的符号——示例程序链接后仅 5.5 MB)、以及不得不学的调参词汇表。

3. How:接入与运行

  1. vcpkg:vcpkg install rocksdb,CMake 走find_package(RocksDB CONFIG REQUIRED),目标是rocksdb::rocksdb;
  2. 源码:GitHub release 下载解压,add_subdirectory编静态库。示例工程的CMakeLists.txt预设了一组「最小可用」开关——WITH_SNAPPY / WITH_ZLIB / WITH_LZ4 / WITH_ZSTD / WITH_BZ2全关(压缩可后补,不影响 API)、WITH_TESTS / WITH_TOOLS全关、ROCKSDB_BUILD_SHARED OFF只编静态库。

⚠️ 接入前先看语言门槛:RocksDB 11.x 要求 C++20(头文件里已经用上默认比较运算符等特性,源码默认CMAKE_CXX_STANDARD 20,官方要求 GCC ≥ 11 / Clang ≥ 10)。项目还锁在 C++17 时有两个选择:降级用 RocksDB 8.x / 9.x(C++17 时代版本,API 兼容本篇示例),或把它隔离在单独的静态库里、边界只暴露 C 接口。本系列其它示例保持 C++17,本篇示例单独用 C++20——配套 CMakeLists 里两个目标各钉各的标准。

完整示例 rocksdb_demo.cpp 一个 main 串七节:基础读写、WriteBatch、迭代器、快照、吞吐实测、Column Family、运行时观测。构建与运行入口见 配套说明。

4. 基础读写:Status 是唯一通道

rocksdb::Status s=db->Put(wo,"name","rocksdb");if(!s.ok()){/* s.ToString() 看详情 */}s=db->Get(ro,"name",&value);// 命中s=db->Get(ro,"missing",&value);// s.IsNotFound() == trues=db->Delete(wo,"temp");

RocksDB 没有异常、没有 errno——一切成败都装在返回的Status里,ok() / IsNotFound() / IsCorruption() / IsIOError()分类分支。两个习惯要养成:Get查无此键不是错误,IsNotFound()是与ok()平级的正常分支;不检查 Status 的调用等于把 IO 错误当成功(示例的check()小包装即为此)。

WriteOptions最要紧的一个开关是sync(默认 false):false 时写只进 WAL 缓冲不强制落盘——进程崩溃不丢,机器断电可能丢最近的写;true 则每笔写都 fsync,持久但慢。这对应 SQLite 的synchronous语义,默认档位同样是「快而够用」。

5. WriteBatch:一批写,原子生效

rocksdb::WriteBatch batch;for(inti=0;i<1000;++i)batch.Put(key_of(i),value);batch.Delete("old");db->Write(wo,&batch);// 一批一次 WAL 追加

Batch 把 N 次独立的 WAL 追加与锁竞争合成一次,还附带原子性:要么全部可见,要么全不可见(崩溃恢复时按整条 WAL 记录重放)。实测吞吐差(10 万条,key 12 B / value 100 B,本机 NVMe):

逐条 Put : 788.3 ms(12.7 万条/秒) 千条成批 : 33.8 ms(296.1 万条/秒)

约 23 倍,但差距的来源与第 33 篇那次不同:RocksDB 逐条Put默认不 fsync(WAL 只进缓冲),batch 省掉的是每条一次的锁获取、序列化与 WAL 记录封装。把两组实测放一起看更有意思——同为「逐条写」:本篇 12.7 万条/秒,第 33 篇 SQLite autocommit 约 108 条/秒,差三个数量级,因为 SQLite 每条 COMMIT 都在等 fsync,而 LSM 把落盘推迟给了后台。数量级结论:成批写入值得;「逐条 Put」在 RocksDB 上不是灾难,在 SQLite autocommit 上才是。

6. 迭代器与快照:读侧的两件工具

迭代器是有序扫描的正门——Seek到第一个大于等于目标的位置,Next一路向后:

rocksdb::Iterator*it=db->NewIterator(ro);it->Seek("batch:2");for(;it->Valid();it->Next())// it->key() / it->value() 是 Slice,用 ToString()deleteit;// 迭代器要手动归还

两个纪律:迭代器用完必须 delete(否则挡住底层文件回收);遍历结束查一下it->status().ok()——中途 IO 错误会让Valid()提前变 false,不查就把错误当「扫完了」。

快照给「一边写一边读」的场景一个固定时刻的一致性视图:

constrocksdb::Snapshot*snap=db->GetSnapshot();db->Put(wo,"counter","200");// 快照之后的写rocksdb::ReadOptions ro_snap;ro_snap.snapshot=snap;db->Get(ro_snap,"counter",&v);// 仍是 "100"db->ReleaseSnapshot(snap);

实测输出:快照读 = 100,当前读 = 200。⚠️ 快照用完必须 Release——它钉住了那一刻的全部数据版本,不释放会挡住 compaction 删除旧文件,是磁盘空间泄漏的常见来源。这根 「用完归还」的弦,与迭代器、Column Family 句柄(第 7 节)是同一根。

7. Column Family:一个库里的多张「逻辑表」

Column Family(CF)是 RocksDB 的「命名空间」:所有 KV 默认住在default,新建 CF 得到独立的 memtable、compaction 策略与参数——可以给热数据配大内存、给索引数据开布隆过滤器、给日志数据配 TTL,而它们共享一个 WAL 与一份打开的文件句柄。

db->CreateColumnFamily(cf_opts,"users",&users);db->Put(wo,users,"alice","1");// 写进 users 列族db->Get(ro,users,"alice",&v);db->DestroyColumnFamilyHandle(users);// 句柄要手动归还db.reset();// unique_ptr 关库

实测输出:

cf[users].alice = 1;cf[orders].o-1001 = alice 重开前列出 CF:default users orders 重开后 cf[users].alice = 1(数据仍在)

本篇最大的坑在重开:打开数据库时必须先ListColumnFamilies列全、把每个 CF 的描述传给DB::Open——漏列一个,Open 直接失败(“You have to open all column families”)。这是新库最常见的一类「升级后打不开」事故:加了一个 CF,忘了把重开路径上的列表从硬编码换成动态列举。关闭顺序同样有讲究:先归还全部 CF 句柄,再关 DB。

8. 调参入门与运行时观测

Options有上百个旋钮,入门只需要盯住写路径这一组:

参数默认作用
write_buffer_size64 MB单个 memtable 上限;越大 flush 越稀、SST 越大
max_write_buffer_number2memtable 槽位;写快于 flush 时要加
max_background_jobs2flush / compaction 后台线程数
compressionSnappy*各层压缩;空间与 CPU 的交换

*压缩默认 Snappy,但编译时未启用 snappy 时静默退化为不压缩(示例工程即如此)——空间敏感的项目要显式接上压缩依赖再确认Options::compression生效。

直觉模型:写太快 → flush 跟不上 →write_buffer_size与槽数加倍;compaction 跟不上 → 后台任务数加倍。示例第 7 节演示了这些设置。调参不是玄学开工,而是先观测再动:

db->GetIntProperty("rocksdb.estimate-num-keys",&keys);// 活键估计db->GetProperty("rocksdb.stats",&stats);// 各层文件/读写放大

实测rocksdb.stats摘录(写入 20 万条后):

** Compaction Stats [default] ** Level Files Size Score Read(GB) Rn(GB) Rnp1(GB) Write(GB) ... --------------------------------------------------------------------

同时estimate-num-keys实测为 200005(default 列族 20 万条吞吐数据加几个演示键)。注意它是估计值——墓碑与覆盖未即时扣除,拿它做监控趋势可以,做精确计数不行。

9. 使用边界与选型

不适合 RocksDB 的信号:读远多于写、要求稳定点查延迟(LSM 层级查找与 compaction 抖动天然不利于此);全库顺序扫描是主负载(每次都要归并全部 SST);不想引入调参与运维心智(三个放大需要长期盯着);数据库必须放在网络文件系统上(NFS / SMB 不支持,与 SQLite 的 WAL 同一物理原因)。以及一条工程判断:数据量长不大(< 1 GB)、又是结构化查询需求,SQLite 一把梭更省——不必为 10 万条配置文件上 compaction。

嵌入式存储三路线对照(本篇为其中一极):

SQLite(33)RocksDB(本篇)LMDB(35)
模型SQL / B-treeKV / LSM-treeKV / B-tree + mmap
写吞吐中(单写者)高低(写者串行)
点查延迟稳定有层级开销极稳
空间回收即时等 compaction即时
调参负担小大极小
需求信号要 SQL / 关系写多读少、量大读多写少、极简

一句话选型:要 SQL 选 SQLite,要写吞吐选 RocksDB,要极简稳定读选 LMDB——第 35 篇从 mmap 路线再会。

〔关联〕LSM-tree 的分层归并、RocksDB 的读写路径剖析属设计内幕,后续在cpp-source-reading专栏展开;LSM vs B-tree 的复杂度分析是面试高频,见cpp-interview-faq。

10. 参考资料

  • 官方文档 github.com/facebook/rocksdb/wiki:Basic Operations、Column Families、RocksDB Tuning Guide 三篇与本篇对应;
  • HISTORY.md:从 LevelDB fork 以来的演进时间线;
  • 完整构建与运行入口:examples/33-34/README;
  • 上一篇(第 33 篇):SQLite——同一「嵌入式存储」赛道上 SQL / B-tree 路线的对照极。

参考:facebook/rocksdb v11.8.1(双许可 GPLv2 / Apache-2.0)。本篇全部输出与吞吐(逐条 Put 与 WriteBatch 对比、CF 重开、快照读、stats 摘录)均为本机实测(MSVC v145/VS2026,Windows,NVMe SSD;数字随机器与磁盘而变)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询