在上一章 缓存 (Cache) 中,我们学习了 LevelDB 如何利用内存缓存来为频繁访问的数据块加速,从而显著提升读取性能。我们已经探索了 LevelDB 在数据组织、存储和加速方面的许多精妙设计。
现在,让我们回到一个更根本的问题上。LevelDB 的核心是基于"排序"的,无论是内存中的 内存表 (MemTable) 还是磁盘上的 排序字符串表 (SSTable),它们内部的数据都严格按照键(key)的顺序排列。但是,"顺序"究竟是如何定义的呢?
默认情况下,LevelDB 按照字节顺序(也就是字典序)来比较键。这对于大多数字符串键来说工作得很好。但如果我们的键有特殊的含义,比如它们是数字,或者需要按特定的规则排序,那该怎么办呢?
这就是本章的主角------比较器(Comparator)------要解决的问题。
什么是比较器?
Comparator
定义了键(key)的排序规则。你可以把它想象成图书馆管理员用来给书籍上架的规则手册。这本手册决定了书籍是应该按照作者姓名的字母顺序、出版年份的先后,还是书名的字典序来排列。
LevelDB 的默认行为,就像一本只按书名字母顺序排列的规则手册。但它允许你提供一本自定义的规则手册 。这本手册就是你实现的 Comparator
对象,它是整个数据库有序性的基础。
为什么需要自定义比较器?
让我们来看一个常见的场景。假设我们用用户的ID作为键来存储用户信息,但这些ID是数字。为了存入 LevelDB,我们把它们转换成了字符串:
- 键: "1", 值: "用户信息A"
- 键: "10", 值: "用户信息B"
- 键: "2", 值: "用户信息C"
如果我们使用 LevelDB 默认的字节序比较,排序结果会是怎样的呢? "1"
< "10"
< "2"
因为在字典序中,字符 '1' 比 '2' 小。这显然不是我们想要的数字顺序!我们希望的顺序是 "1"
< "2"
< "10"
。
为了实现这种按数值大小排序的需求,我们就需要提供一个自定义的 Comparator
。
如何使用自定义比较器
Comparator
是一个抽象基类,要创建自己的比较规则,你需要继承它并实现其中的几个关键方法。
1. 实现一个自定义比较器
让我们来创建一个 NumericComparator
,专门用于比较字符串形式的整数。
cpp
#include "leveldb/comparator.h"
#include <string>
#include <cstdlib> // 为了使用 atoll
class NumericComparator : public leveldb::Comparator {
public:
// 比较器的唯一名称。
const char* Name() const override { return "NumericComparator"; }
// 核心比较逻辑。
int Compare(const leveldb::Slice& a, const leveldb::Slice& b) const override {
long long num_a = atoll(a.ToString().c_str());
long long num_b = atoll(b.ToString().c_str());
if (num_a < num_b) return -1;
if (num_a > num_b) return 1;
return 0;
}
// 以下是高级优化方法,对于简单的比较器,保持默认实现即可。
void FindShortestSeparator(std::string* start,
const leveldb::Slice& limit) const override {}
void FindShortSuccessor(std::string* key) const override {}
};
解释:
Name()
: 必须返回一个唯一的、不会改变的名称。LevelDB 在打开数据库时会检查这个名字,如果与创建数据库时使用的名字不符,就会报错。这可以防止你用错误的排序规则打开一个数据库,从而导致数据混乱。Compare(a, b)
: 这是最重要的核心方法。它接收两个键a
和b
,并返回:< 0
如果a
小于b
== 0
如果a
等于b
> 0
如果a
大于b
在我们的例子中,我们用atoll
将字符串转换成长整型数字,然后比较它们的大小。
FindShortestSeparator
和FindShortSuccessor
: 这两个是用于空间优化的高级方法,它们帮助 LevelDB 在 排序字符串表 (SSTable) 的索引块中生成更短的分割键。对于初学者来说,直接留空实现是完全可以的。
2. 在打开数据库时应用比较器
比较器必须在数据库第一次创建时就指定,并且之后每次打开都必须使用同一个。我们通过 选项 (Options) 对象来设置它。
cpp
#include "leveldb/db.h"
#include "leveldb/options.h"
// 假设 NumericComparator 已经定义好了
int main() {
NumericComparator numeric_comparator; // 创建一个实例
leveldb::Options options;
options.create_if_missing = true;
// 将我们的自定义比较器设置给 options
options.comparator = &numeric_comparator;
leveldb::DB* db;
// 用这个配置好的 options 打开数据库
leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb_numeric", &db);
if (!status.ok()) {
// 处理错误
}
// ... 之后所有的操作都会按照数字大小来排序键 ...
delete db;
return 0;
}
黄金法则 :一个数据库一旦使用某个比较器创建,就必须永远使用这个比较器(或者行为完全相同的另一个实例)来打开。否则,数据库内部的有序结构将被破坏,导致数据不可读或程序崩溃。
比较器内部是如何工作的?
你提供的 Comparator
并不仅仅是用于你自己的查询,它渗透到了 LevelDB 的每一个角落,是所有内部数据结构有序性的基石。
但 LevelDB 内部比较的键,并不完全是你提供的"用户键"。为了实现版本控制(快照),LevelDB 使用了一种内部键 (Internal Key) 。一个内部键由三部分组成:用户键 (user_key) 、序列号 (sequence_number) 和 类型 (type)(例如,值或删除标记)。
因此,LevelDB 内部需要一个能比较这种复杂内部键的比较器。这个比较器就是 InternalKeyComparator
。它并不取代你的比较器,而是包装了你的比较器。
InternalKeyComparator
的比较逻辑如下:
- 首先,使用你提供的
user_comparator_
来比较两个内部键的用户键部分。 - 如果用户键不相同,比较就结束了,直接返回用户键的比较结果。
- 如果用户键相同 ,则需要比较它们的序列号 。为了保证新写入的数据(拥有更大的序列号)排在前面,
InternalKeyComparator
会按序列号降序排列。序列号大的反而"小"。
使用你的 Comparator} --> B{user_key 是否相等?}; B -- "否" --> C[返回 user_key 的比较结果]; B -- "是" --> D{比较 sequence(A) 和 sequence(B)
(降序比较)}; D --> E[返回 sequence 的比较结果]; end
这个设计确保了对于同一个用户键,最新的版本总能被最先找到,而旧版本和删除标记则排在后面,这对于 合并 (Compaction) 和读取操作至关重要。
深入代码实现
让我们看看相关的源码来印证我们的理解。
Comparator
接口 (include/leveldb/comparator.h
)
这是 Comparator
的抽象基类定义,清晰地列出了需要子类实现的纯虚函数。
cpp
// 来自 include/leveldb/comparator.h
class LEVELDB_EXPORT Comparator {
public:
virtual ~Comparator();
// 三路比较
virtual int Compare(const Slice& a, const Slice& b) const = 0;
// 比较器的名字
virtual const char* Name() const = 0;
// 高级优化函数
virtual void FindShortestSeparator(std::string* start,
const Slice& limit) const = 0;
virtual void FindShortSuccessor(std::string* key) const = 0;
};
默认的 BytewiseComparator
(util/comparator.cc
)
这是 LevelDB 提供的默认实现。它的 Compare
方法非常直接,就是调用了 Slice
自身的字节比较方法。
cpp
// 来自 util/comparator.cc
class BytewiseComparatorImpl : public Comparator {
public:
// ...
int Compare(const Slice& a, const Slice& b) const override {
return a.compare(b);
}
// ...
};
InternalKeyComparator
(db/dbformat.h
)
这个类是内部排序的关键。它持有一个指向用户提供的 Comparator
的指针。
cpp
// 来自 db/dbformat.h
class InternalKeyComparator : public Comparator {
private:
const Comparator* user_comparator_;
public:
explicit InternalKeyComparator(const Comparator* c) : user_comparator_(c) {}
// ...
int Compare(const Slice& a, const Slice& b) const override;
// ...
};
它的 Compare
方法实现(位于 db/dbformat.cc
)精确地执行了我们之前描述的逻辑。
cpp
// 来自 db/dbformat.cc (简化逻辑)
int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {
// 1. 使用用户的比较器比较 user_key
int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));
if (r == 0) {
// 2. 如果 user_key 相等,则按 sequence number 降序比较
const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);
const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);
if (anum > bnum) {
r = -1; // a 的序列号更大,所以 a 更"小"
} else if (anum < bnum) {
r = +1;
}
}
return r;
}
总结
在本章中,我们学习了定义 LevelDB 内部秩序的"规则手册"------Comparator
。
Comparator
定义了数据库中所有键的排序规则。- LevelDB 的默认行为是按字节顺序 进行比较,由
BytewiseComparator
实现。 - 我们可以通过继承
Comparator
类并实现Name()
和Compare()
方法,来创建自定义的排序逻辑,例如按数字大小排序。 - 自定义的比较器必须在数据库创建时通过
Options
指定,并且在数据库的整个生命周期中不能更改。 - LevelDB 内部使用
InternalKeyComparator
来包装用户的比较器,以处理带有序列号和类型的内部键。
我们现在知道如何为数据排序,也知道如何通过缓存加速读取。但是,当我们需要查找一个键时,LevelDB 必须深入到 SSTable
的索引块和数据块中。如果一个 SSTable
文件很大,但我们想查的键根本就不存在于这个文件中,我们能否有一种方法,可以在访问文件之前就快速判断出这一点,从而避免无谓的磁盘 I/O 呢?