kv数据库-leveldb (14) 比较器 (Comparator)

在上一章 缓存 (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): 这是最重要的核心方法。它接收两个键 ab,并返回:
    • < 0 如果 a 小于 b
    • == 0 如果 a 等于 b
    • > 0 如果 a 大于 b 在我们的例子中,我们用 atoll 将字符串转换成长整型数字,然后比较它们的大小。
  • FindShortestSeparatorFindShortSuccessor: 这两个是用于空间优化的高级方法,它们帮助 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 的比较逻辑如下:

  1. 首先,使用你提供的 user_comparator_ 来比较两个内部键的用户键部分。
  2. 如果用户键不相同,比较就结束了,直接返回用户键的比较结果。
  3. 如果用户键相同 ,则需要比较它们的序列号 。为了保证新写入的数据(拥有更大的序列号)排在前面,InternalKeyComparator 会按序列号降序排列。序列号大的反而"小"。
graph TD subgraph "InternalKeyComparator::Compare(key_A, key_B)" A{比较 user_key(A) 和 user_key(B)
使用你的 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 呢?

相关推荐
vortex52 小时前
在 Kali Linux 上配置 MySQL 服务器并实现 Windows 远程连接
linux·数据库·mysql
夜晚中的人海3 小时前
C++11(2)
android·数据库·c++
StarRocks_labs3 小时前
StarRocks:Connect Data Analytics with the World
数据库·starrocks·iceberg·存算分离·lakehouse 架构
没有bug.的程序员3 小时前
ShardingSphere 与分库分表:分布式数据库中间件实战指南
java·数据库·分布式·中间件·分布式数据库·shardingsphere·分库分表
Elastic 中国社区官方博客4 小时前
理解 Elasticsearch 中的分块策略
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
Czi.4 小时前
DataGrip+postgresql+postgis
数据库·postgresql
GreatSQL社区5 小时前
GreatSQL 优化技巧:最值子查询与窗口函数相互转换
java·服务器·数据库
全栈工程师修炼指南5 小时前
DBA | MySQL 数据库基础数据操作学习实践笔记
数据库·笔记·学习·mysql·dba
牛奶咖啡135 小时前
通过keepalived搭建MySQL双主模式的MySQL集群
数据库·mysql·mysql双主互备模式架构·mysql双主互备模式搭建·mysql主主互备模式·mysql双主互备实现高可用·keepalived高可用