RocksDB 本身只提供字节序有序的键值接口,并没有原生所谓“嵌套键”或者“表结构”的概念。但在实际业务中,我们常常需要表达类似 user:1001:profile、order:2023:item:5 这样的层级关系,并且希望既能按前缀批量扫描,也能精确点查。实现嵌套键的本质,就是把多层逻辑字段编码进一个连续的字节串,同时利用 RocksDB 的字典序特性来保留层级顺序。
一、嵌套键的编码方式
最常见的实践是使用分隔符拼接各层字段。例如用冒号作为分隔符,把用户 ID 和子资源名拼成一个 key。这种方案直观、易调试,而且人眼可读。只要分隔符不会出现在业务字段中,就能稳定工作。
但冒号这类可见字符有个隐患:如果某层字段本身可能包含冒号,或者不同层级长度不一,前缀扫描时可能出现边界误判。更稳妥的做法是使用不可见分隔符,比如 0x01,或者采用固定长度编码(如前 8 字节为用户 ID 的二进制,后 4 字节为资源类型)。下面给出一个使用 0x01 分隔的写入示例:
#include <string>
#include <rocksdb/db.h>
rocksdb::DB* db;
rocksdb::Options options;
options.create_if_missing = true;
rocksdb::Status s = rocksdb::DB::Open(options, "/tmp/testdb", &db);
// 构造嵌套键 userx011001x01profile
std::string key = "user" + std::string(1, 'x01') + "1001" + std::string(1, 'x01') + "profile";
std::string value = "{"name":"Tom"}";
s = db->Put(rocksdb::WriteOptions(), key, value);
使用不可见字符后,业务字段即使包含冒号也不会破坏结构。如果追求极致性能,还可以把数字 ID 转为定长大端二进制,这样比较时无需解析字符串,且字节序天然正确。
无论选哪种编码,都要保证同一张列族内的所有 key 遵循同一种规则,否则前缀提取和扫描会混乱。建议在代码里用统一的 KeyBuilder 类封装,避免散落各处的字符串拼接。
二、基于前缀的扫描与 SliceTransform
嵌套键最大的价值在于可以按前缀做范围查询,比如列出某个用户的所有子资源。RocksDB 提供 SliceTransform 抽象,用来从完整 key 中提取“前缀部分”,配合 PrefixExtractor 和 ReadOptions::prefix_same_as_start 能高效定位。
假设我们的前缀是第一个 0x01 之前的内容(即 user),可以自定义一个 transform。这样迭代器在 seek 时会利用前缀 bloom 过滤,减少文件读取。下面示例展示如何设置及扫描:
class UserPrefixTransform : public rocksdb::SliceTransform {
public:
const char* Name() const override { return "UserPrefix"; }
rocksdb::Slice Transform(const rocksdb::Slice& src) const override {
const char* p = (const char*)memchr(src.data(), 'x01', src.size());
if (p == nullptr) return src;
return rocksdb::Slice(src.data(), p - src.data());
}
bool InDomain(const rocksdb::Slice& src) const override {
return memchr(src.data(), 'x01', src.size()) != nullptr;
}
};
// 打开 DB 时设置
options.prefix_extractor.reset(new UserPrefixTransform());
options.memtable_prefix_bloom_size_ratio = 0.1;
// 扫描 userx011001 下所有键
rocksdb::ReadOptions ro;
ro.prefix_same_as_start = true;
std::string prefix = "user" + std::string(1, 'x01') + "1001";
ro.iter = db->NewIterator(ro);
for (it->Seek(prefix); it->Valid() && it->key().starts_with(prefix); it->Next()) {
// 处理 it->key(), it->value()
}
上面的代码里,Transform 只取 user 这一段作为前缀,因此所有 user:xxx 的键共享一个前缀域,适合做粗粒度过滤。如果你需要精确到 user:1001 这一层,就把 transform 改成截到第二个 0x01 之前。
注意,前缀提取必须保持稳定:同一个 key 每次提取出的前缀要一致,否则 compaction 后索引会错位。另外,开启 prefix bloom 会占用额外内存,写入量极小的场景可以关掉以省资源。
三、写入放大与批量提交优化
嵌套键常用来存 JSON 文档或稀疏字段,若每次更新其中一个子键都重写整个文档,会造成严重的写放大。RocksDB 的 WriteBatch 允许把多个子键的更新原子提交,而 Merge 算子能把“增量”直接记在 LSM 树里,读取时再合并。
例如用户画像有 age、city 两个子键,我们分别用 userx011001x01age 和 userx011001x01city 存储。要同时更新两者,用 WriteBatch 一次提交:
rocksdb::WriteBatch batch;
batch.Put("userx011001x01age", "30");
batch.Put("userx011001x01city", "Beijing");
db->Write(rocksdb::WriteOptions(), &batch);
如果业务只需要追加而不是覆盖,比如计数加一,可以定义 Merge 操作,避免先 Get 再 Put 的读改写。虽然 Merge 会让读取时做更多合并计算,但写路径被极大简化,尤其适合高频埋点。
对于读多写少、且常需一次取出用户全部属性的场景,可以用 MultiGet 把多个子键批量取出,减少多次迭代的调用开销。如下:
std::vector<rocksdb::Slice> keys = {
"userx011001x01age",
"userx011001x01city"
};
std::vector<std::string> values(keys.size());
std::vector<rocksdb::Status> stats = db->MultiGet(rocksdb::ReadOptions(), keys, &values);
四、版本化与删除处理
有时我们需要保留嵌套键的历史版本,比如 profile 每次变更都写一个新时间戳子键:userx011001x01profilex011700000000。这样自然形成时间有序,旧版本不会被覆盖,用前缀扫描就能拿到变更流水。
删除子键直接调用 Delete 即可,Rocksdb 会写一条 tombstone 记录,后续 compaction 时物理清理。但要注意,如果前缀扫描依赖存活键的数量做统计,tombstone 在合并前仍会被迭代器看到,应用层需过滤。
// 删除某个具体嵌套键
db->Delete(rocksdb::WriteOptions(), "userx011001x01profilex011699999999");
// 扫描带时间戳的 profile 版本
std::string pfx = "userx011001x01profilex01";
for (it->Seek(pfx); it->Valid() && it->key().starts_with(pfx); it->Next()) {
// 每个版本的值
}
采用时间戳作为最内层,既满足了嵌套结构,又获得了天然的版本序。如果担心 key 过长影响内存,可对时间戳做二进制编码,并用 SliceTransform 在前缀阶段忽略该层。
整体来看,RocksDB 的嵌套键并不是引擎特性,而是一套“编码约定加迭代器技巧”的组合拳。只要守住编码一致、前缀清晰、批量写入三条原则,就能在单实例里支撑复杂的层级数据模型。
RocksDBnested_keyskey_encoding修改时间:2026-08-06 13:51:26