RocksDB中Cuckoo Table技术内幕解析
Cuckoo-Table作为RocksDB里的一种SST文件格式,具有高吞吐量的随机点查找 (point lookups)能力,本文详细介绍了Cuckoo-Table的实现细节。
一、 Cuckoo Table 概述:一种为点查找优化的 SST 文件格式
在深入代码细节之前,我们首先需要理解 Cuckoo Table 在 RocksDB 生态系统中的位置和它的设计目标。
1.1 RocksDB 的可插拔表格式 (Pluggable Table Format) 架构
RocksDB 的一个强大特性是其高度模块化和可插拔的架构。SST (Sorted String Table) 文件的物理布局并非一成不变,而是由一个称为 TableFactory
的抽象工厂来定义的。这意味着开发者可以根据不同的工作负载,选择甚至开发不同的表格式实现,以达到最优的性能。
RocksDB 允许在数据库级别,甚至是列族 (Column Family) 级别指定不同的 TableFactory
。这为精细化的性能调优提供了极大的灵活性。Cuckoo Table 正是 RocksDB 提供的、一种高度特化的表格式实现。
1.2 Cuckoo Table 的定位与适用场景
Cuckoo Table 的设计目标非常明确:为高吞吐量的随机点查找 (point lookups) 提供极致的性能。它基于布谷鸟哈希算法,能够以近乎 O(1) 的时间复杂度完成键查找。
为了达到这个目标,它建立在一些严格的假设和约束之上:
- **键和值必须是定长的 (Fixed Length)**:这是其数据布局的基础。
- 不支持 Merge 操作:它的结构不适合处理需要合并的写操作。
- **不支持前缀布隆过滤器 (Prefix Bloom Filter)**:它的哈希基于完整的用户键 (
user_key
)。 - 范围扫描性能较差:由于键是按哈希值散乱分布的,范围扫描的效率远低于默认的
BlockBasedTable
。
因此,Cuckoo Table 的理想使用场景是:当你的业务主要是通过 Get()
API 进行大量、快速的随机键查询,且很少或几乎没有范围扫描 (Iterator
) 需求时,例如用作某些索引或元数据的存储。
1.3 Cuckoo Table 与默认 Block-Based Table 的核心区别
为了更清晰地理解其定位,我们可以将它与 RocksDB 默认的 BlockBasedTable
格式进行对比。
特性 | Cuckoo Table | Block-Based Table (默认) |
---|---|---|
核心数据结构 | 哈希表 (Hash Table) | B-Tree (通过索引块和数据块模拟) |
点查找机制 | 直接哈希计算内存地址,近 O(1) | 查找索引块 (1-2次 I/O),再读取数据块 (1次 I/O) |
范围扫描机制 | 昂贵 (首次需全量读取并排序) | 高效 (按序迭代数据块) |
键/值长度 | 必须是定长 | 可变长 |
适用场景 | 专用于高速点查找 | 通用场景,点查找和范围扫描性能均衡 |
二、 CuckooTableFactory:配置中心与构造工厂
CuckooTableFactory
是使用 Cuckoo Table 的入口。它遵循工厂设计模式,作为连接 RocksDB 内核与 Cuckoo Table 具体实现的桥梁。
2.1 Factory 的核心职责与设计模式
CuckooTableFactory
继承自 rocksdb::TableFactory
,其核心职责有二:
- 持有配置:保存用户为 Cuckoo Table 指定的所有配置项 (
CuckooTableOptions
)。 - 创建实例:当 RocksDB 需要创建或读取 SST 文件时,调用工厂的
NewTableBuilder
或NewTableReader
方法,来生产出配置正确的具体实例。
2.2 CuckooTableOptions:定制你的 Cuckoo Table
用户通过 CuckooTableOptions
结构体来定制 Cuckoo Table 的行为。
代码 (rocksdb/table.h
):
struct CuckooTableOptions {
// Determines the space utilization of the hash table.
// Smaller values result in more space being used but fewer collisions.
double hash_table_ratio = 0.9;
// Maximum number of Cuckoo hash functions.
uint32_t max_search_depth = 100;
// In case of collision, Cuckoo hash will attempt to kick out an element
// to other locations. This defines the block size that will be searched
// within Cuckoo hash.
uint32_t cuckoo_block_size = 5;
// If this option is enabled, user key is treated as uint64_t and its value
// is used as hash value. This option is valid only when user key is 8 bytes.
bool identity_as_first_hash = false;
// If this option is set to true, module hashing will be used to calculate
// hash values. Otherwise, bit-masking will be used.
bool use_module_hash = true;
};
-
hash_table_ratio
: 哈希表空间利用率。值越小,哈希表越大,冲突越少,但空间浪费越多。 -
max_search_depth
: 解决哈希冲突时,”踢出”路径的最大搜索深度,防止无限循环。 -
cuckoo_block_size
: Cuckoo 块大小。这是一个优化,当哈希到一个位置时,会线性探测一个大小为cuckoo_block_size
的区域,可以有效减少冲突。 -
identity_as_first_hash
: 一个性能优化。当键本身就是 8 字节的整数时,可以直接将其值作为第一个哈希值,避免计算开销。
2.3 核心算法:CuckooHash 函数深度解析
CuckooHash
函数是整个 Cuckoo Table 功能的数学基础,它负责为同一个键生成多个不同的哈希位置。
代码 (table/cuckoo/cuckoo_table_factory.h
):
static inline uint64_t CuckooHash(
const Slice& user_key, uint32_t hash_cnt, bool use_module_hash,
uint64_t table_size_, bool identity_as_first_hash,
uint64_t (*get_slice_hash)(const Slice&, uint32_t, uint64_t)) {
#if !defined NDEBUG || defined OS_WIN
if (get_slice_hash != nullptr) {
return get_slice_hash(user_key, hash_cnt, table_size_);
}
#else
(void)get_slice_hash;
#endif
uint64_t value = 0;
if (hash_cnt == 0 && identity_as_first_hash) {
value = (*reinterpret_cast<const int64_t*>(user_key.data()));
} else {
value = MurmurHash(user_key.data(), static_cast<int>(user_key.size()),
kCuckooMurmurSeedMultiplier * hash_cnt);
}
if (use_module_hash) {
return value % table_size_;
} else {
return value & (table_size_ - 1);
}
}
2.3.1 多哈希函数与种子
布谷鸟哈希需要为同一个键计算多个哈希值。这里的实现非常巧妙:它使用同一个 MurmurHash
算法,但通过传入不同的种子 (seed) 来达到目的。种子是由 kCuckooMurmurSeedMultiplier * hash_cnt
计算得来的。hash_cnt
从 0 开始递增,从而为同一个 user_key
生成一个哈希值序列。
2.3.2 Identity-as-first-hash 优化
if (hash_cnt == 0 && ...)
这段代码是一个性能捷径。当此选项开启,且键的长度为 8 字节时,第一个哈希函数 (hash_cnt == 0
) 不会执行 MurmurHash
,而是直接将键的 8 个字节内容重新解释 (reinterpret_cast) 为一个 int64_t
作为哈希值。对于本身就是数字类型的键,这极大地提升了哈希计算的速度。
2.3.3 范围映射:位运算与取模
if (use_module_hash)
分支决定了如何将 64 位的哈希值映射到 [0, table_size_ - 1]
的桶索引范围。
-
value % table_size_
: 取模运算,通用但性能稍慢。 -
value & (table_size_ - 1)
: 按位与运算。当table_size_
是 2 的整数次幂时,这个操作与取模等价,但速度快得多。CuckooTableBuilder
在use_module_hash
为false
时会确保table_size_
满足此条件。
2.4 创建 Builder 和 Reader 实例
这两个方法是 TableFactory
接口的核心实现,负责“生产”具体的读写工具。
代码 (table/cuckoo/cuckoo_table_factory.cc
):
TableBuilder* CuckooTableFactory::NewTableBuilder(
const TableBuilderOptions& table_builder_options,
WritableFileWriter* file) const {
return new CuckooTableBuilder(
file, table_options_.hash_table_ratio, 64,
table_options_.max_search_depth,
table_builder_options.internal_comparator.user_comparator(),
table_options_.cuckoo_block_size, table_options_.use_module_hash,
table_options_.identity_as_first_hash, nullptr /* get_slice_hash */,
table_builder_options.column_family_id,
table_builder_options.column_family_name, table_builder_options.db_id,
table_builder_options.db_session_id, table_builder_options.cur_file_num);
}
Status CuckooTableFactory::NewTableReader(
const ReadOptions& /*ro*/, const TableReaderOptions& table_reader_options,
std::unique_ptr<RandomAccessFileReader>&& file, uint64_t file_size,
std::unique_ptr<TableReader>* table,
bool /*prefetch_index_and_filter_in_cache*/) const {
std::unique_ptr<CuckooTableReader> new_reader(new CuckooTableReader(
table_reader_options.ioptions, std::move(file), file_size,
table_reader_options.internal_comparator.user_comparator(), nullptr));
Status s = new_reader->status();
if (s.ok()) {
*table = std::move(new_reader);
}
return s;
}
-
NewTableBuilder
: 当 RocksDB 需要创建 Cuckoo Table 格式的 SST 文件时调用此方法。它会实例化一个CuckooTableBuilder
,并将工厂自身存储的table_options_
配置以及 RocksDB 核心传入的通用参数(如文件句柄、比较器等)一并传给Builder
的构造函数。 -
NewTableReader
: 当 RocksDB 需要读取一个 Cuckoo Table 格式的 SST 文件时调用此方法。它会实例化一个CuckooTableReader
,并将文件句柄、文件大小等信息传递给Reader
的构造函数,以便Reader
进行初始化。
三、 CuckooTableBuilder:从零到一构建哈希表
CuckooTableBuilder
负责将无序的键值对集合转换成一个结构紧凑、查询高效的 Cuckoo Table SST 文件。其过程可以概括为“收集 -> 构建 -> 写入”。
3.1 Builder 的核心流程:收集 -> 构建 -> 写入
- 收集 (Collect): 在
Add()
方法被调用时,Builder
并不立即构建哈希表。它首先进行约束验证(如键值是否定长),然后将所有键值对线性地追加到内存中的kvs_
和deleted_keys_
两个字符串缓冲区内。 - 构建 (Build): 当
Finish()
方法被调用时,真正的构建工作开始。Builder
会在内存中创建一个std::vector<CuckooBucket>
来代表哈希表。它会遍历所有收集到的键,通过MakeHashTable()
方法将它们逐一安置到CuckooBucket
向量中,并在此过程中解决所有哈希冲突。 - 写入 (Write): 内存中的哈希表布局一旦确定,
Finish()
方法就会遍历CuckooBucket
向量,将每个桶的内容(真实的键值对或空桶占位符)按顺序写入文件。最后,附上包含所有元数据的属性块和文件尾部,完成 SST 文件的创建。
3.2 关键数据结构:CuckooBucket 的巧妙设计
CuckooBucket
是构建过程中哈希桶在内存中的临时表达,其设计的核心是效率。
代码 (table/cuckoo/cuckoo_table_builder.h
):
private:
struct CuckooBucket {
CuckooBucket() : vector_idx(kMaxVectorIdx), make_space_for_key_call_id(0) {}
uint32_t vector_idx;
uint32_t make_space_for_key_call_id;
};
static const uint32_t kMaxVectorIdx = std::numeric_limits<int32_t>::max();
3.2.1 vector_idx:轻量级数据指针
该字段是一个 uint32_t
索引,指向键值对在 kvs_
或 deleted_keys_
字符串缓冲区中的位置。这种“指针”而非“实体”的设计极大地节省了内存。一个特殊值 kMaxVectorIdx
被用来标记这是一个空桶。
3.2.2 make_space_for_key_call_id:无重置的“已访问”标记
在 MakeSpaceForKey
的 BFS 搜索中,需要标记已访问的节点以防循环。常规的 bool visited
标志需要在每次搜索前重置整个哈希表,代价高昂。此字段通过一个不断递增的“调用ID”来解决这个问题。每次搜索都有一个唯一的ID,一个桶只有其 make_space_for_key_call_id
与当前搜索的ID相同时,才被认为是“已访问”。这巧妙地避免了重置操作,是重要的性能优化。具体的:
CuckooTableBuilder
内部维护一个从 0 开始的计数器make_space_for_key_call_id
。每次调用 MakeSpaceForKey 时, 这个计数器会 ++, 得到一个本次调用独一无二的 ID.
// in cuckoo_table_builder.cc // 在 MakeHashTable 的循环中 while (!bucket_found && !MakeSpaceForKey(hash_vals, ++make_space_for_key_call_id, buckets, &bucket_id)) { // ... }
在
MakeSpaceForKey
的 BFS 搜索中, 当访问一个桶时, 它会把这个桶的make_space_for_key_call_id
字段设置为当前调用的ID.// in cuckoo_table_builder.cc: // 在 MakeSpaceForKey 的 BFS 探索中 (*buckets)[static_cast<size_t>(child_bucket_id)] .make_space_for_key_call_id = make_space_for_key_call_id;
在检查一个桶是否”已访问”时, 它不再检查 bool 值, 而是比较桶的 make_space_for_key_call_id 是否等于当前调用的ID.
// in cuckoo_table_builder.cc`): if ((*buckets)[static_cast<size_t>(child_bucket_id)] .make_space_for_key_call_id == make_space_for_key_call_id) { // 已在本次搜索中访问过, 跳过 continue; }
3.3 Add() 方法:数据收集与约束验证
Add()
方法是数据流入 Builder
的入口,负责验证和暂存。
代码 (table/cuckoo/cuckoo_table_builder.cc
):
void CuckooTableBuilder::Add(const Slice& key, const Slice& value) {
// ...
if (!has_seen_first_key_) {
is_last_level_file_ = ikey.sequence == 0;
has_seen_first_key_ = true;
// ...
key_size_ = is_last_level_file_ ? ikey.user_key.size() : key.size();
}
if (key_size_ != (is_last_level_file_ ? ikey.user_key.size() : key.size())) {
status_ = Status::NotSupported("all keys have to be the same size");
return;
}
// ... (similar check for value_size_)
if (ikey.type == kTypeValue) {
kvs_.append(is_last_level_file_ ? ikey.user_key.data() : key.data(),
is_last_level_file_ ? ikey.user_key.size() : key.size());
kvs_.append(value.data(), value.size());
// ...
}
// ...
}
3.3.1 键值定长:Cuckoo Table 的硬性要求
代码中的 if (!has_seen_first_key_)
块会在添加第一个键时执行,捕获其键长和值长。后续所有 Add()
调用都会检查新的键值是否与第一次的长度严格相等。这是因为最终的 SST 文件是一个由定长桶组成的连续数组,Reader
需要依赖这个定长来计算偏移量。
3.3.2 LSM-Tree Level 判断与序列号剥离优化
is_last_level_file_ = ikey.sequence == 0;
这一行代码是一个关键优化。在 RocksDB 中,只有当一个键在整个数据库中是最新版本时(即所有旧版本已被合并,且上层没有新版本),其序列号才可能在Compaction到最底层时被“剥离”为0。因此,sequence == 0
是一个强烈的信号,表明这个文件将被放在 LSM-Tree 的最底层。在这种情况下,user_key
在该层是唯一的,Builder
便可以只存储 user_key
而非完整的 InternalKey
,为每个键节省8字节。
为什么
sequence == 0
能表明这个文件在LSM-Tree的最底层?为什么user_key
在该层是唯一的
基础: Sequence Number (序列号) 的作用
首先, 我们要理解 Sequence Number (后文简称 seq) 是什么.
在 RocksDB 中, 每一次写入操作 (Put, Delete, Merge) 都会被分配一个全局唯一、单调递增的 seq. 它就像这次操作的”时间戳”或”版本号”. seq 的核心作用是实现多版本并发控制 (MVCC),也就是让不同时间的读请求能看到不同版本的数据快照.例如:
Put(“mykey”, “v1”) -> 分配 seq=100
Put(“mykey”, “v2”) -> 分配 seq=101
Delete(“mykey”) -> 分配 seq=102
在 RocksDB 内部, 这三个操作对应三个不同的 InternalKey: mykey@100, mykey@101, mykey@102. 即使它们的 user_key (“mykey”) 相同, 但因为 seq 不同, 它们是三个独立的条目.
解答: 为什么最底层的 user_key 是唯一的?
这归功于 Compaction (合并) 机制.
LSM-Tree 的数据是从高层 (Level 0, L0) 向底层 (Level 1, L2, …, Lmax) 不断合并的. Compaction 的一个核心任务就是清理冗余数据.
在高层 (如 L0, L1): 一个 user_key 可能存在多个版本. 比如 L0 中可能同时存在 mykey@100 和 mykey@101. 这是因为 L0 的文件是 MemTable 直接 Flush 下来的,可能包含重叠的键范围和多个版本.
合并过程中的版本选择: 当 RocksDB 将 L_N 层的文件和 L_{N+1} 层的文件进行合并时, 对于任何一个相同的 user_key, 它只会保留
seq
最高 (也就是最新) 的那一个版本,而所有旧版本都会被丢弃.到达最底层 (Last Level): “最底层” (我们称之为 Lmax) 是数据的最终归宿. 当数据经过一层又一层的 Compaction 到达 Lmax 时, 所有关于同一个 user_key的历史版本都已经被处理掉了. 因此, 在 Lmax 这一层的所有 SST 文件中, 任意一个
user_key
最多只会存在一个条目. 如果这个 user_key 有一个更新的版本,那么这个新版本必然存在于比 Lmax 更高的层级 (L0 到 L_{max-1}).结论: Compaction 机制保证了在最底层的数据集合中,
user_key
是唯一的.
为什么 ikey.sequence == 0 能假定在最底层?
这是基于上述结论的一个非常重要的优化.
既然我们知道了在最底层, user_key 是唯一的, 那么版本号 (seq) 对于区分 user_key 的多个版本来说, 就失去了意义. 它的存在只是为了和上层可能存在的新版本进行比较.
RocksDB 在执行到最底层的 Compaction 时, 会进行一个特殊的检查:
对于一个即将被写入最底层 Lmax 的键 user_key (假设其版本为 seq=S), RocksDB 会检查所有上层 (L0 到 L_{max-1}) 是否存在任何版本比
S
更新的user_key
.
情况A: 存在更新的版本. 如果上层有一个 mykey@S_newer (其中 S_newer > S), 那么写入 Lmax 的这个键必须保留它原始的 seq=S, 以便未来的读请求能正确地选择上层的最新版本.
情况B: 不存在任何更新的版本. 如果检查发现所有上层都没有 mykey 的踪影, 这意味着当前要写入 Lmax 的这个版本就是整个数据库中最新的、最权威的版本. 在这种情况下, seq
对于版本控制来说是多余的. 为了节省空间 (每个 seq 占用 7 个字节), RocksDB 会执行一个”序列号剥离” (Sequence Number Stripping) 的优化: 直接将seq
设置为 0.所以, 逻辑链是这样的:
只有在 Compaction 到最底层时, 才有可能触发 “序列号剥离” 优化.
只有当一个键在整个数据库中没有更老的版本(已被清理)也没有更新的版本(不存在于上层)时, 它的 seq 才会被置为 0.
因此, 当 CuckooTableBuilder 在 Add() 方法中看到一个键的 ikey.sequence == 0 时, 它可以做出一个非常可靠的推断: 这个键是经过了上述优化处理的.产生这种键的唯一途径就是一次到最底层的 Compaction (或者用户通过特殊接口直接Ingest文件到最底层).
既然文件中的一个键来自最底层, 那么可以合理地假定整个 SST 文件都是为最底层构建的.
这就是为什么一行简单的 is_last_level_file_ = ikey.sequence == 0; 背后蕴含了对整个 LSM-Tree Compaction 和版本管理机制的深刻理解. 这个判断使得 Cuckoo Table可以为最底层文件进行特殊优化, 即直接存储 user_key (因为唯一), 而不是存储完整的 InternalKey, 从而节省了 8 字节/每条记录 的空间.
3.4 Finish() 方法:将内存布局写入文件
Finish()
是将内存中的理想布局物化为物理文件的过程。
代码 (table/cuckoo/cuckoo_table_builder.cc
):
Status CuckooTableBuilder::Finish() {
// ...
status_ = MakeHashTable(&buckets);
// ...
// Determine unused_user_key to fill empty buckets.
// ...
// Write the table.
for (auto& bucket : buckets) {
if (bucket.vector_idx == kMaxVectorIdx) {
io_status_ = file_->Append(opts, Slice(unused_bucket));
} else {
io_status_ = file_->Append(opts, GetKey(bucket.vector_idx));
if (io_status_.ok() && value_size_ > 0) {
io_status_ = file_->Append(opts, GetValue(bucket.vector_idx));
}
}
// ...
}
// Write meta blocks.
// ...
return status_;
}
该方法首先调用 MakeHashTable()
完成内存布局。然后,它会生成一个保证不存在的 unused_bucket
作为空位占位符。接着,它遍历 buckets
向量,如果桶是空的 (vector_idx == kMaxVectorIdx
),就写入占位符;否则,就通过 GetKey()
和 GetValue()
从缓冲区取出真实的键值对写入文件。最后,写入包含所有布局参数的属性块和文件尾部。
3.5 MakeHashTable():哈希放置调度
此函数是放置所有键的调度中心,它为 Add()
收集的每个键寻找一个“家”。
代码 (table/cuckoo/cuckoo_table_builder.cc
):
Status CuckooTableBuilder::MakeHashTable(std::vector<CuckooBucket>* buckets) {
// ...
for (uint32_t vector_idx = 0; vector_idx < num_entries_; vector_idx++) {
// ...
// 1. 尝试直接放置
for (uint32_t hash_cnt = 0; hash_cnt < num_hash_func_ && !bucket_found; ++hash_cnt) {
// ... 遍历 Cuckoo Block 寻找空位
}
// 2. 处理冲突
while (!bucket_found &&
!MakeSpaceForKey(hash_vals, ++make_space_for_key_call_id, buckets,
&bucket_id)) {
// 3. 增加哈希函数作为最后手段
// ...
}
// 4. 最终放置
(*buckets)[static_cast<size_t>(bucket_id)].vector_idx = vector_idx;
}
return Status::OK();
}
它的策略是:对每个键,首先尝试最廉价的操作——直接在它的所有哈希位置(包括Cuckoo Block)中寻找空位。如果失败,则进入 while
循环,调用昂贵的 MakeSpaceForKey()
尝试“踢出”其他键。如果连“踢出”都失败了,它会采取最后的手段:为当前键增加一个新的哈希函数,赋予它一个全新的位置选择,然后再次尝试。一旦找到位置,就将键的索引 vector_idx
记录到目标桶中。
3.6 MakeSpaceForKey():布谷鸟“踢出”算法的精髓
这是 Cuckoo Hash 最核心、最有趣的算法,用于在没有空位时,通过移动现有元素来创造空间。
代码 (table/cuckoo/cuckoo_table_builder.cc
):
bool CuckooTableBuilder::MakeSpaceForKey(
/* ... */) {
struct CuckooNode { /* ... */ };
std::vector<CuckooNode> tree;
// ... BFS 搜索逻辑 ...
while (!null_found && curr_pos < tree.size()) {
// ... 扩展节点,寻找空位 ...
}
if (null_found) {
// 路径找到,反向遍历 tree 进行“踢出”
uint32_t bucket_to_replace_pos = static_cast<uint32_t>(tree.size()) - 1;
while (bucket_to_replace_pos >= num_hash_func_) {
CuckooNode& curr_node = tree[bucket_to_replace_pos];
(*buckets)[static_cast<size_t>(curr_node.bucket_id)] =
(*buckets)[static_cast<size_t>(tree[curr_node.parent_pos].bucket_id)];
bucket_to_replace_pos = curr_node.parent_pos;
}
*bucket_id = tree[bucket_to_replace_pos].bucket_id;
}
return null_found;
}
3.6.1 BFS 搜索树的构建与可视化
该函数通过广度优先搜索(BFS)寻找一条从某个被占用的桶到某个空桶的路径。它使用一个 std::vector<CuckooNode> tree
来模拟搜索树。树的根节点是当前要插入的键的所有候选位置。然后,它逐层扩展:对于树中的一个节点(代表一个被占用的桶),它会查找桶中那个键的备用位置,并将这些备用位置作为子节点加入树中,直到找到一个空桶为止。
3.6.2 “踢出”路径的查找与替换逻辑
一旦 BFS 找到一个空桶,if (null_found)
块内的逻辑就会被触发。这里的 while
循环是实现“踢出”操作的关键。它从找到的空桶节点开始,沿着 parent_pos
指针反向回溯到根节点。在回溯的每一步,它都执行 (*buckets)[child_bucket] = (*buckets)[parent_bucket];
操作,即将父节点桶中的内容(键)移动到子节点桶的位置。这个连锁反应最终会使路径的起点(即最初要插入的键的某个候选位置)变为空,从而为新键腾出空间。
假设我们有一个大小为 8 的哈希表 (hash_table_size_ = 8), 并且已经存放了一些键. 我们用 K1, K2 等来代表不同的键.
哈希表当前状态:
Bucket Index: 0 1 2 3 4 5 6 7 Content: K1 K2 K3 键的哈希位置: 假设我们有2个哈希函数 h1 和 h2.
- h1(K1)=1, h2(K1)=5 (K1 占用了它的主位置 1)
- h1(K2)=2, h2(K2)=1 (K2 占用了它的主位置 2, 它的备用位置 1 已被 K1 占用)
- h1(K3)=5, h2(K3)=7 (K3 占用了它的主位置 5, 它的备用位置 7 是空的)
当前任务: 我们现在要插入一个新键 K_new, 不幸的是, 它的哈希位置是 h1(K_new)=2 和 h2(K_new)=5. 这两个位置都已经被占用了.
此时, MakeHashTable 发现无法直接放置 K_new, 于是调用 MakeSpaceForKey.
BFS 的 tree 是什么样的?
tree 是一个用 std::vector 实现的扁平化的树, 节点之间通过 parent_pos (父节点在 vector 中的索引) 关联.
CuckooNode
结构:
struct CuckooNode { uint64_t bucket_id; // 桶的 ID, 即在哈希表中的索引 uint32_t depth; // 在 BFS 树中的深度 uint32_t parent_pos;// 父节点在 tree 向量中的索引 // ... };
MakeSpaceForKey
开始执行:Level 0 (根节点):
MakeSpaceForKey 首先将 K_new 的所有候选位置 (2 和 5) 作为根节点放入 tree.
// tree 向量的内容: [ // index 0 { bucket_id: 2, depth: 0, parent_pos: 0 }, // index 1 { bucket_id: 5, depth: 0, parent_pos: 0 } ]
Level 1 (扩展子节点):
BFS 从 tree 的头部 (index 0) 开始处理.
- 处理
tree[0]
(bucket 2):
桶 2 中存放的是 K2.
K2 的备用位置是 h2(K2)=1.
将备用位置 1 作为一个新的 CuckooNode 加入 tree, 它的父节点是 tree[0].
// tree 向量的内容: [ { bucket_id: 2, depth: 0, parent_pos: 0 }, // index 0 { bucket_id: 5, depth: 0, parent_pos: 0 }, // index 1 // 新增节点, 父节点索引为 0 { bucket_id: 1, depth: 1, parent_pos: 0 } // index 2 ]
- 处理
tree[1]
(bucket 5):
桶 5 中存放的是 K3.
K3 的备用位置是 h2(K3)=7.
将备用位置 7 作为一个新的 CuckooNode 加入 tree, 它的父节点是 tree[1].
// tree 向量的内容: [ { bucket_id: 2, depth: 0, parent_pos: 0 }, // index 0 { bucket_id: 5, depth: 0, parent_pos: 0 }, // index 1 { bucket_id: 1, depth: 1, parent_pos: 0 }, // index 2 // 新增节点, 父节点索引为 1 { bucket_id: 7, depth: 1, parent_pos: 1 } // index 3 ]
- 发现空位! 当把 bucket_id: 7 这个节点加入 tree 时, 代码会检查哈希表中 buckets[7] 的状态. 它发现 buckets[7] 是空的 (vector_idx == kMaxVectorIdx). BFS 搜索成功!
BFS
tree
的最终形态:
这个 tree 向量在逻辑上代表了这样一棵搜索树:
(K_new) / \ (Level 0) Bucket 2 (K2) Bucket 5 (K3) | | (Level 1) Bucket 1 (K1) Bucket 7 (EMPTY) <-- 成功找到路径!
我们找到了一条可行的”踢出路径”: 将
K3
从桶 5 移动到桶 7, 就能空出桶 5 给K_new
.
四、 CuckooTableReader:快如闪电的点查找
与 Builder
的复杂性形成鲜明对比,Reader
的设计极其简洁高效。所有复杂的布局工作都已在写入时完成,Reader
的任务就是以最快的方式利用这个预设好的布局。
4.1 Reader 的核心思想:mmap 与直接地址计算
CuckooTableReader
的高性能秘诀主要有两点:
- 内存映射 (mmap):
Reader
通过mmap
系统调用将整个 SST 文件映射到进程的虚拟地址空间。这避免了传统的read()
系统调用带来的内核态/用户态切换和数据拷贝开销。后续的所有文件访问都变成了直接的内存访问,速度极快(仅在首次访问时会触发缺页中断)。 - 直接地址计算: 它不依赖于任何索引块。对于一个给定的键,它能通过哈希函数直接计算出键在文件(即内存)中的几个潜在位置。查找过程就是几次哈希计算和内存访问,时间复杂度接近 O(1)。
4.2 构造函数:加载元数据与内存映射
Reader
的构造函数负责读取 Builder
留下的“地图”,并完成内存映射。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
CuckooTableReader::CuckooTableReader(
const ImmutableOptions& ioptions,
std::unique_ptr<RandomAccessFileReader>&& file, uint64_t file_size,
/* ... */) {
if (!ioptions.allow_mmap_reads) {
status_ = Status::InvalidArgument("File is not mmaped");
return;
}
// 读取并解析 Table Properties
status_ =
ReadTableProperties(file_.get(), file_size, kCuckooTableMagicNumber,
ioptions, read_options, &props);
// ...
// 从 Properties 中恢复所有布局参数
// ... find and assign num_hash_func_, unused_key_, key_length_, etc. ...
bucket_length_ = key_length_ + value_length_;
// 将整个文件映射到内存
status_ = file_->Read(IOOptions(), 0, static_cast<size_t>(file_size),
&file_data_, nullptr, nullptr);
}
构造函数首先检查 mmap
是否被允许。然后,它通过 ReadTableProperties
从文件尾部读取元数据,恢复 Builder
保存的所有布局参数,如哈希函数数量、键值长度、空桶占位符 unused_key_
等。最后,它调用 file_->Read()
(在 mmap
模式下)将整个文件内容映射到 file_data_
这个 Slice
中,为后续的查找做好了准备。
4.3 Get() 方法:从哈希到内存地址的直接转换
Get()
方法是 Reader
的精髓所在,完美体现了其设计思想。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
Status CuckooTableReader::Get(const ReadOptions&, const Slice& key, GetContext* get_context, ... ) {
Slice user_key = ExtractUserKey(key);
for (uint32_t hash_cnt = 0; hash_cnt < num_hash_func_; ++hash_cnt) {
uint64_t offset =
bucket_length_ * CuckooHash(user_key, hash_cnt, ...);
const char* bucket = &file_data_.data()[offset];
for (uint32_t block_idx = 0; block_idx < cuckoo_block_size_; ++block_idx, bucket += bucket_length_) {
if (ucomp_->Equal(Slice(unused_key_.data(), user_key.size()),
Slice(bucket, user_key.size()))) {
return Status::OK();
}
if (ucomp_->Equal(user_key, Slice(bucket, user_key.size()))) {
Slice value(bucket + key_length_, value_length_);
// ... 保存 value 到 get_context ...
return Status::OK();
}
}
}
return Status::OK();
}
查找过程非常直接:
- 对给定的
user_key
,遍历所有哈希函数。 - 对于每个哈希函数,通过
CuckooHash(...)
计算出哈希值,再乘以bucket_length_
,得到在file_data_
中的**精确字节偏移量offset
**。 - 从该
offset
开始,在线性探测cuckoo_block_size_
个桶。 - 在每个桶中,首先与
unused_key_
比较,如果是空桶,则说明键不在此处。然后与user_key
比较,如果匹配,则从bucket + key_length_
处提取值并返回。 - 如果所有哈希函数对应的所有 Cuckoo Block 都检查完毕仍未找到,则键不存在。
4.4 Prepare() 方法:利用 CPU 缓存预取
这是一个可选的性能优化,用于在 Get()
之前“预热”CPU缓存。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
void CuckooTableReader::Prepare(const Slice& key) {
// ...
uint64_t addr = /* ... calculate address of first hash location ... */;
uint64_t end_addr = addr + cuckoo_block_bytes_minus_one_;
for (addr &= CACHE_LINE_MASK; addr < end_addr; addr += CACHE_LINE_SIZE) {
PREFETCH(reinterpret_cast<const char*>(addr), 0, 3);
}
}
该方法会计算出键的第一个哈希位置对应的内存地址,然后通过 PREFETCH
宏(通常是编译器的 __builtin_prefetch
)指示 CPU 提前将这块内存区域加载到高速缓存中,从而降低后续 Get()
时的内存访问延迟。
五、 CuckooTableIterator:范围扫描的代价
Cuckoo Table 为了极致的点查找性能,牺牲了范围扫描的能力。其迭代器的实现方式清晰地反映了这一点。
5.1 迭代器的核心机制:一次性全量排序
由于 SST 文件中的键是按哈希值散乱分布的,为了提供 Iterator
所要求的有序遍历功能,CuckooTableIterator
不得不采取一种直接但代价高昂的策略:在首次使用时,读取所有键并进行一次完整的排序。
5.2 InitIfNeeded():昂贵的初始化
迭代器的所有魔法都发生在 InitIfNeeded()
中,该方法在 Seek
或 SeekToFirst
等导航方法首次被调用时执行。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
void CuckooTableIterator::InitIfNeeded() {
if (initialized_) {
return;
}
// ...
const char* bucket = reader_->file_data_.data();
// 1. 遍历所有桶, 收集所有非空桶的 ID
for (uint32_t bucket_id = 0; bucket_id < num_buckets; ++bucket_id) {
if (Slice(bucket, reader_->key_length_) != Slice(reader_->unused_key_)) {
sorted_bucket_ids_.push_back(bucket_id);
}
bucket += reader_->bucket_length_;
}
// 2. 对所有非空桶的 ID, 按照其对应的 Key 进行排序
std::sort(sorted_bucket_ids_.begin(), sorted_bucket_ids_.end(),
bucket_comparator_);
// ...
}
- 收集: 代码首先遍历整个
mmap
的文件内容,检查每一个桶。如果桶的内容不是unused_key_
,就将其bucket_id
添加到一个std::vector<uint32_t> sorted_bucket_ids_
中。 - 排序: 这是最昂贵的操作。它调用
std::sort
,并传入一个自定义的BucketComparator
,对sorted_bucket_ids_
向量进行排序。排序的依据不是桶ID的数值,而是ID对应桶中的键。
5.3 BucketComparator:如何比较“桶ID”
这个自定义比较器是实现排序的关键。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
struct BucketComparator {
// ...
bool operator()(const uint32_t first, const uint32_t second) const {
const char* first_bucket = /* ... get key address from first id ... */;
const char* second_bucket = /* ... get key address from second id ... */;
return ucomp_->Compare(Slice(first_bucket, user_key_len_),
Slice(second_bucket, user_key_len_)) < 0;
}
// ...
};
它的 operator()
接收两个桶ID,然后根据ID从 file_data_
中找到对应的键,最后使用用户提供的 Comparator
(ucomp_
) 来比较这两个键,从而决定桶ID的顺序。
5.4 Seek/Next/Prev:基于已排序索引的快速导航
一旦 InitIfNeeded()
完成了昂贵的排序,迭代器的所有导航操作就变得非常轻快。
代码 (table/cuckoo/cuckoo_table_reader.cc
):
void CuckooTableIterator::Seek(const Slice& target) {
InitIfNeeded();
// ...
auto seek_it =
std::lower_bound(sorted_bucket_ids_.begin(), sorted_bucket_ids_.end(),
kInvalidIndex, seek_comparator);
// ...
}
void CuckooTableIterator::Next() { ++curr_key_idx_; /* ... */ }
void CuckooTableIterator::Prev() { --curr_key_idx_; /* ... */ }
-
Next()
和Prev()
只是简单地移动sorted_bucket_ids_
向量的下标curr_key_idx_
。 -
Seek()
则是在这个已排序的ID向量上执行高效的二分查找 (std::lower_bound
)。 - 这些操作都只涉及整数向量的操作,因此一旦初始化完成,迭代本身的速度非常快。
六、 总结:Cuckoo Table 的设计哲学与权衡
通过对 Factory
、Builder
和 Reader
的深入分析,我们可以清晰地看到 Cuckoo Table 的设计哲学:通过在写入时承担更多的复杂性,并施加更严格的约束,来换取读取时极致的性能。
优点:
- 无与伦比的点查找性能,这得益于其
mmap
友好的扁平文件结构和 O(1) 复杂度的直接哈希地址计算。
- 无与伦比的点查找性能,这得益于其
代价与权衡:
- 严格的约束: 必须使用定长的键和值。
- 复杂的写入路径:
Builder
需要处理复杂的哈希冲突和“踢出”逻辑,构建过程可能比BlockBasedTable
更慢,CPU消耗也更高。 - 昂贵的范围扫描:
Iterator
的首次使用需要进行全量扫描和排序,对于范围查询的场景是完全不适合的。
总而言之,Cuckoo Table 是 RocksDB 工具箱中一件锋利的“专科武器”,而非“万金油”。它完美地诠释了在高性能存储系统设计中,“没有最好的,只有最合适的”这一黄金法则。当你的业务场景与它的设计目标高度契合时,它能带来惊人的性能提升。