kv数据库-leveldb (4) 数据库实例 (DB)

82 阅读8分钟

在前一章 选项 (Options) 中,我们学习了如何使用 Options 这个“设置菜单”来配置数据库的行为和性能。我们已经准备好了蓝图,现在是时候根据这张蓝图,建造并使用我们的数据库大楼了。

本章,我们将正式与 LevelDB 的核心进行互动。所有的数据存取操作,都必须通过一个统一的入口来完成。这个入口,就是 DB 对象,即数据库实例。

什么是数据库实例 (DB)?

你可以把 DB 对象想象成一个图书馆的总管理员。无论你是想借书(读取数据)、还书(写入数据)还是注销一本书(删除数据),你都得跟这位管理员打交道。他不会让你直接去书架上乱翻,而是会根据你的请求,遵循一套高效的内部流程来帮你完成操作。

DB 类正是 LevelDB 中这样一个角色。它是与数据库交互的唯一入口,负责协调内部的所有组件,如 预写日志 (Log / WAL)、内存表 (MemTable) 和磁盘上的 排序字符串表 (SSTable),来响应你的每一个请求。它保证了数据操作的正确性、一致性和高性能。

如何使用 DB 实例

DB 实例的交互主要围绕着四个基本操作:打开(Open)、写入(Put)、读取(Get)和删除(Delete)。让我们通过一个完整的例子来看看如何使用它。

1. 打开一个数据库

在进行任何操作之前,我们首先需要“打开”一个数据库。这相当于聘请那位图书馆管理员,并告诉他图书馆的位置。这个操作由静态方法 DB::Open 完成。

#include <iostream>
#include "leveldb/db.h"
#include "leveldb/options.h"

int main() {
  leveldb::DB* db;
  leveldb::Options options;
  // 如果数据库目录不存在,则创建它
  options.create_if_missing = true;

  // 打开数据库。"/tmp/testdb" 是一个目录路径
  leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);

  if (!status.ok()) {
    std::cerr << "打开数据库失败: " << status.ToString() << std::endl;
    return -1;
  }

  std::cout << "数据库已成功打开!" << std::endl;

  // ... 在这里进行数据库操作 ...
  
  // 操作完成后,记得关闭数据库
  delete db;
  return 0;
}

解释:

  • DB::Open 接收三个参数:一个配置好的 Options 对象、一个目录路径(LevelDB 会在这个目录下管理所有文件)以及一个指向 DB* 指针的指针,用于接收创建好的数据库实例。
  • LevelDB 中几乎所有的操作都会返回一个 leveldb::Status 对象。在执行操作后,务必检查 status.ok() 来确认操作是否成功。

2. 写入数据 (Put)

数据库打开后,我们就可以向里面存放数据了。Put 方法用于插入或更新一个键值对。

// 假设 db 和 status 已经像上面那样准备好了

std::string key = "name";
std::string value = "LevelDB";

// 将 "name" -> "LevelDB" 这个键值对写入数据库
status = db->Put(leveldb::WriteOptions(), key, value);

if (status.ok()) {
  std::cout << "写入数据成功!" << std::endl;
}

解释:

  • Put 方法接收一个 WriteOptions(这里我们使用默认的)、一个作为键的 数据切片(Slice) 和一个作为值的 Slice
  • C++ 的 std::string 可以被隐式转换为 Slice,所以我们可以直接传递。
  • 如果键 name 已经存在,Put 操作会用新值覆盖旧值。

3. 读取数据 (Get)

数据存进去之后,我们自然需要把它读出来。Get 方法通过键来查找对应的值。

std::string value_read;

// 根据键 "name" 读取值
status = db->Get(leveldb::ReadOptions(), "name", &value_read);

if (status.ok()) {
  std::cout << "读取成功, 值是: " << value_read << std::endl;
} else if (status.IsNotFound()) {
  std::cout << "键 'name' 不存在" << std::endl;
} else {
  std::cerr << "读取失败: " << status.ToString() << std::endl;
}

输出:

读取成功, 值是: LevelDB

解释:

  • Get 方法接收一个 ReadOptions、要查找的键和一个用于存放结果的 std::string 指针。
  • 如果找到了键,status.ok() 会返回 true,并且 value_read 会被填充上对应的值。
  • 如果键不存在,status.ok() 会返回 false,但 status.IsNotFound() 会返回 true。这是一个正常情况,而不是一个错误。

4. 删除数据 (Delete)

如果我们不再需要某个键值对,可以使用 Delete 方法将其删除。

// 删除键 "name" 对应的数据
status = db->Delete(leveldb::WriteOptions(), "name");

if (status.ok()) {
  std::cout << "删除数据成功!" << std::endl;
}

// 再次尝试读取
status = db->Get(leveldb::ReadOptions(), "name", &value_read);
if (status.IsNotFound()) {
  std::cout << "再次读取:键 'name' 确实已被删除。" << std::endl;
}

解释:

  • Delete 方法只需要一个 WriteOptions 和要删除的键。
  • 删除一个不存在的键并不会报错,操作同样会返回成功的状态。

5. 关闭数据库

最后,当你完成了所有操作,你需要关闭数据库以释放所有资源。在 C++ 中,这通过 delete 数据库指针来完成。

// main 函数的结尾
delete db;

DB 对象的析构函数会负责所有清理工作,比如确保所有内存中的数据都已写入磁盘,并释放文件锁。

DB 内部是如何工作的?

DB 接口看起来很简单,但它的背后隐藏着一套精心设计的复杂机制。理解其工作原理有助于我们更好地使用 LevelDB。

DB 类本身(定义在 include/leveldb/db.h)是一个抽象基类,它只定义了接口,没有具体的实现。真正的实现位于 DBImpl 类中(定义在 db/db_impl.h)。当我们调用 DB::Open 时,它实际上是创建并返回了一个 DBImpl 对象。

让我们看看一次写入(Put)和一次读取(Get)操作在内部大致的流程。

sequenceDiagram
    participant App as "你的应用程序"
    participant DB as "DB 实例 (DBImpl)"
    participant WAL as "预写日志 (文件)"
    participant Mem as "MemTable (内存)"
    participant SST as "SSTables (磁盘)"
    
    App->>+DB: Put("name", "LevelDB")
    Note right of DB: 管理员收到写入请求
    DB->>+WAL: 1. 写入日志
    Note right of WAL: 保证数据不丢失
    WAL-->>-DB: 写入成功
    DB->>+Mem: 2. 写入内存表
    Note right of Mem: 极快的内存操作
    Mem-->>-DB: 写入成功
    DB-->>-App: 返回成功

    App->>+DB: Get("name")
    Note right of DB: 管理员收到读取请求
    DB->>Mem: 1. 先查内存表

    alt 在 MemTable 中找到
        Mem-->>DB: 返回 "LevelDB"
        DB-->>-App: 返回 "LevelDB"
    else 未在 MemTable 中找到
        Mem-->>DB: 未找到
        DB->>SST: 2. 再查磁盘文件
        SST-->>DB: 找到并返回 "LevelDB"
        DB-->>App: 返回 "LevelDB"
    end

写入流程 (Put):

  1. 记录到日志DB 实例首先会将这个写操作追加到一个叫 预写日志 (Log / WAL) 的文件中。这就像管理员先把你的“还书”请求记在一个流水账本上。即使此时程序或机器崩溃,这个日志也能在下次启动时用于恢复数据,保证了数据的持久性。
  2. 写入内存表:然后,DB 实例会将这个键值对插入到一个位于内存的数据结构——内存表 (MemTable) 中。因为是在内存里操作,所以这个过程非常快。

读取流程 (Get):

  1. 查询内存表DB 实例首先会去查询当前活跃的 内存表 (MemTable)。因为最新的数据总是在这里,所以大概率能直接找到。
  2. 查询不可变内存表:如果没找到,它会接着查询一个“被冻结”的、正准备写入磁盘的内存表(如果有的话)。
  3. 查询磁盘文件:如果内存里全都没找到,DB 实例才会去查询磁盘上的一系列 排序字符串表 (SSTable) 文件。LevelDB 使用了多层次的文件结构和索引来加速这个过程。

通过这种“优先写内存”和“优先读内存”的策略,LevelDB 极大地提高了写入性能和常见读取的响应速度。

深入代码实现

让我们快速看一下相关代码,以加深理解。

DB 类的定义展示了其纯虚函数接口:

// 来自 include/leveldb/db.h
class LEVELDB_EXPORT DB {
 public:
  // ...
  static Status Open(const Options& options, const std::string& name,
                     DB** dbptr);
  
  virtual ~DB();

  virtual Status Put(const WriteOptions& options, const Slice& key,
                     const Slice& value) = 0;

  virtual Status Get(const ReadOptions& options, const Slice& key,
                     std::string* value) = 0;
  // ... 其他纯虚函数 ...
};

这里的 = 0 表明这些函数必须由子类来实现。DBImpl 就是这个子类。

DB::Open 的实现逻辑在 db/db_impl.cc 中,它创建了 DBImpl 实例并执行恢复流程。

// 来自 db/db_impl.cc (简化后)
Status DB::Open(const Options& options, const std::string& dbname, DB** dbptr) {
  *dbptr = nullptr;
  DBImpl* impl = new DBImpl(options, dbname);
  
  // ... 加锁 ...
  // 核心步骤:恢复数据库状态
  Status s = impl->Recover(&edit, &save_manifest);
  // ... 其他初始化和日志应用 ...
  
  if (s.ok()) {
    *dbptr = impl; // 成功后,将实现类的指针赋给用户
  } else {
    delete impl;   // 失败则清理
  }
  return s;
}

这个 Recover 函数是 Open 的核心,它会检查数据库目录下的文件,重放日志,把数据库恢复到一个一致的状态。

总结

在本章中,我们认识了 LevelDB 的“总管理员”—— DB 实例。

  • DB 是与 LevelDB 交互的核心入口,所有数据操作都通过它进行。
  • 我们学习了四个基本操作:DB::Open 用于打开数据库,Put 用于写入数据,Get 用于读取数据,Delete 用于删除数据。
  • 我们了解到在每次操作后检查 Status 是非常重要的编程习惯。
  • 我们还初步探索了 DB 实例的内部工作原理:写操作会先进入 预写日志 (Log / WAL)和内存表 (MemTable) 以保证速度和安全,而读操作会优先从内存中查找。

现在你已经掌握了如何对数据库进行单条的读写。但是,如果你需要一次性写入多条数据(比如,更新用户信息时同时修改姓名和年龄),每次都调用一次 Put 效率高吗?有没有办法把多次修改“打包”成一个原子操作呢?