☰
leveldb源码学习(二)
2026/10/17 22:48:19 网站建设 项目流程

对于leveldb源码学习(一),我们主要是去了解了接口有哪些,接口的功能是什么,也大致明白了他的架构是什么样子,这篇主要是去探究那些接口是怎么写的,是如何实现的,由于每一个接口代码量太多,我只会写出比较关键的部分。

我们第一个要探究的就是put是怎么实现的,

1.db_impl.cc

一句话:

db_impl.cc实现了 LevelDB 的真正数据库对象DBImpl,负责打开数据库、恢复日志、读写 MemTable、生成 SSTable、调度 Compaction、清理旧文件、管理 Snapshot 和 Iterator。

你可以把 LevelDB 分成两层:

对外接口层: DB、Options、ReadOptions、WriteOptions、WriteBatch 内部实现层: DBImpl、MemTable、Log、VersionSet、TableCache、Compaction

其中DBImpl是内部实现层的总控制器。

前面db.h里有:

class DB { public: virtual Status Put(...) = 0; virtual Status Get(...) = 0; virtual Status Write(...) = 0; };

而DBImpl就是真正继承DB并实现这些函数的类。

所以:

DB 是接口。 DBImpl 是实现。

2.DBImpl::Writer结构体

struct DBImpl::Writer {
explicit Writer(port::Mutex* mu)
: batch(nullptr), sync(false), done(false), cv(mu) {}

Status status;
WriteBatch* batch;
bool sync;
bool done;
port::CondVar cv;
};

首先是结构体,他的目的是表示一个正在等待写入数据库的写请求,其中每个参数的意思是

status:这次写入最后的结果
batch:这次要写入的一批操作
sync:这次写入是否要求刷盘
done:这次写入是否完成
cv:条件变量,用来睡眠和唤醒等待线程

这个设计非常重要。

LevelDB 不是每个线程来了都自己抢着写 WAL 和 MemTable。

它会把写请求排成队列:

writers_ 队列: Writer1 -> Writer2 -> Writer3 -> ...

然后队头线程负责真正写入。

这和很多数据库里的“写入串行化”思想类似。

3.为什么写入要排队?

因为 LevelDB 写入时要维护一些全局顺序:

SequenceNumber WAL log 顺序 MemTable 插入顺序

数据库不能让多个线程乱序写。

比如两个线程同时写:

线程 A:Put key v1 线程 B:Put key v2

LevelDB 必须给它们一个明确顺序:

先 A 后 B,最终 key = v2

或者:

先 B 后 A,最终 key = v1

不能乱。

所以Writer队列就是为了保证写入顺序。

这和 MySQL 里 redo log、事务提交顺序也有相似思想。

4.SanitizeOptions:允许用户去修改配置,但是修改的配置会按照要求在一定的范围之类

5.DBImpl构造函数:把数据库内部组件组装起来

你看到:

DBImpl::DBImpl(const Options& raw_options, const std::string& dbname) : env_(raw_options.env), internal_comparator_(raw_options.comparator), internal_filter_policy_(raw_options.filter_policy), options_(SanitizeOptions(...)), table_cache_(new TableCache(...)), mem_(nullptr), imm_(nullptr), logfile_(nullptr), log_(nullptr), tmp_batch_(new WriteBatch), versions_(new VersionSet(...)) {}

这就是在初始化数据库内部组件。

几个重点成员:

env_: 操作系统环境,负责文件读写、锁、后台线程等。 internal_comparator_: 内部 key 比较器。 options_: 修正后的配置。 table_cache_: SSTable 文件缓存。 mem_: 当前正在写入的 MemTable。 imm_: 已经写满、等待刷盘的 Immutable MemTable。 logfile_ / log_: 当前 WAL 日志文件。 tmp_batch_: 临时 WriteBatch,用于合并多个写请求。 versions_: 管理所有 SSTable 文件版本信息。

你可以这样理解:

DBImpl 构造函数 = 把 LevelDB 运行需要的核心零件装起来。

6. 析构函数:关闭数据库前等后台任务结束

代码里:

DBImpl::~DBImpl() { mutex_.Lock(); shutting_down_.store(true, std::memory_order_release); while (background_compaction_scheduled_) { background_work_finished_signal_.Wait(); } mutex_.Unlock(); ... }

意思是:

数据库对象销毁时,要先通知后台任务停止,并等待正在运行的后台 Compaction 完成。

为什么?

因为如果数据库正在后台压缩文件,你直接销毁对象,就可能出现:

后台线程还在用 mem_ 后台线程还在写 SSTable 后台线程还在访问 versions_ 主线程已经 delete 掉了这些对象

这就是典型的并发资源悬空问题。

所以析构时必须:

先停止后台任务 再释放资源

这和 MySQL/Redis 关闭服务时要先停止后台线程、刷盘、释放文件句柄是同一种思想。

7.NewDB():创建一个全新的数据库

代码里:

Status DBImpl::NewDB() { VersionEdit new_db; new_db.SetComparatorName(user_comparator()->Name()); new_db.SetLogNumber(0); new_db.SetNextFile(2); new_db.SetLastSequence(0); const std::string manifest = DescriptorFileName(dbname_, 1); ... }

这个函数是在数据库不存在时创建新库。

它主要做几件事:

1. 创建 VersionEdit,记录新数据库的元信息 2. 创建 MANIFEST 文件 3. 把数据库初始状态写入 MANIFEST 4. 创建 CURRENT 文件,让 CURRENT 指向当前 MANIFEST

这里的几个概念很重要:

MANIFEST: 记录数据库当前有哪些 SSTable、每个文件在哪一层、key 范围是什么。 CURRENT: 一个很小的文件,里面写着当前应该使用哪个 MANIFEST。

你可以类比 MySQL:

SSTable 文件 = 真正的数据文件 MANIFEST = 数据文件元信息目录 CURRENT = 指向当前元信息文件的指针

8.Recover():数据库打开时恢复状态

这是非常核心的函数:

Status DBImpl::Recover(VersionEdit* edit, bool* save_manifest)

它在DB::Open里面被调用。

作用是:

打开数据库时,根据 CURRENT、MANIFEST、WAL 日志,把数据库恢复到正确状态。

它大概做这些事:

1. 创建数据库目录 2. 加数据库锁,防止多个进程同时打开 3. 如果数据库不存在,根据 create_if_missing 决定是否创建 4. 如果数据库存在,根据 error_if_exists 决定是否报错 5. 从 MANIFEST 恢复已有 SSTable 版本信息 6. 找出需要恢复的 log 文件 7. 按日志编号顺序重放 WAL 8. 更新 LastSequence

这就是数据库的 crash recovery。

和 MySQL 的崩溃恢复非常像:

LevelDB:读取 WAL log,恢复 MemTable / SSTable 状态 MySQL:读取 redo log,恢复 Buffer Pool / 数据页状态 Redis:读取 AOF,重放命令恢复内存状态

共同思想是:

磁盘上的数据文件可能不是最新状态。 所以打开数据库时,要根据日志把最近操作补回来。

9.RecoverLogFile():重放 WAL 日志

这个函数更具体:

Status DBImpl::RecoverLogFile(...)

它做的事情是:

打开某个 log 文件 一条一条读取 log record 把 record 解析成 WriteBatch 把 WriteBatch 插入 MemTable 如果 MemTable 太大,就写成 Level-0 SSTable

代码里有:

WriteBatchInternal::SetContents(&batch, record); status = WriteBatchInternal::InsertInto(&batch, mem);

意思是:

log record 里面保存的是 WriteBatch 的编码内容。 恢复时把它还原成 batch,再插入 MemTable。

这也说明了你前面学的WriteBatch为什么重要:

LevelDB 的 WAL 日志里记录的,本质上就是 WriteBatch。

写入时:

WriteBatch -> WAL WriteBatch -> MemTable

恢复时:

WAL -> WriteBatch -> MemTable

这个设计很统一。

10.WriteLevel0Table():把 MemTable 写成 SSTable

代码里:

Status DBImpl::WriteLevel0Table(MemTable* mem, VersionEdit* edit, Version* base)

这个函数的作用是:

把一个 MemTable 里的内容写成磁盘上的 SSTable 文件。

流程大概是:

1. 给新 SSTable 分配 file number 2. 从 MemTable 创建 Iterator 3. 调用 BuildTable 生成 SSTable 文件 4. 记录这个文件的 smallest key 和 largest key 5. 把新文件加入 VersionEdit 6. 记录 compaction 统计信息

这就是 LevelDB 从内存到磁盘的关键步骤。

写入路径可以这样理解:

Put/Delete | WAL | MemTable | MemTable 满 | Immutable MemTable | WriteLevel0Table | Level-0 SSTable

也就是说,WriteLevel0Table是:

内存数据变成磁盘有序文件的地方。

11.CompactMemTable():压缩 immutable MemTable

代码里:

void DBImpl::CompactMemTable()

作用是:

把已经写满的 immutable MemTable 刷成 SSTable,并更新 MANIFEST。

重点流程:

1. 调用 WriteLevel0Table,把 imm_ 写成 Level-0 SSTable 2. 创建 VersionEdit 3. 写入 MANIFEST,记录新文件 4. 释放 imm_ 5. 删除过期文件

这里要注意:

写 SSTable 成功还不够。 还必须更新 MANIFEST。

否则数据库下次打开时不知道这个新 SSTable 是有效数据文件。

所以 LevelDB 里很多操作都不是只写数据文件,还要写元数据。

这和 MySQL 里数据页、redo、元信息保持一致是一个思想。

12、MaybeScheduleCompaction()

1. 函数作用:MaybeScheduleCompaction()

直译:或许可以调度一次压缩它不是直接执行合并压缩,只做一件事:判断现在要不要启动后台压缩任务。

它会依次判断一堆条件,有任意一条不满足就不触发后台压缩:

  1. 已有后台压缩正在跑:不能重复开,避免磁盘 IO 打满;
  2. 数据库正在关闭:停止一切后台任务;
  3. 之前后台任务出过致命错误:不再调度;
  4. 存在 immutable memtable(内存表满了,必须刷盘合并);
  5. 用户手动触发了压缩;
  6. 当前版本 Version 检测到 SST 文件层数超限、文件过多,需要合并。

只要满足「需要压缩」且所有前置放行条件,就执行:

cpp

运行

env_->Schedule(&DBImpl::BGWork, this);

2.env_->Schedule(任务函数, 对象)是什么?

  • Env是 LevelDB 封装的操作系统环境层,里面自带线程池 / 后台工作线程;
  • Schedule= 往后台线程池丢一个异步任务;
  • DBImpl::BGWork是后台真正执行 compaction、刷 imm 表的函数;
  • this是当前 DB 实例,后台函数拿到数据库指针去做合并。

关键点:前台用户写请求线程,不会阻塞在这里做繁重压缩。 Schedule 只是把任务丢进队列,立刻返回,写操作马上响应客户端。

3. 类比 MySQL、Redis 后台机制,统一设计思想

MySQL 例子

前台执行insert/update只是写内存缓冲池(buffer pool),不会同步刷磁盘脏页; 刷脏页、undo/purge 清理、索引合并全部丢后台线程异步做。

Redis 例子

前台set只写内存; AOF 重写、RDB 持久化、过期 key 淘汰,全部后台异步执行。

LevelDB 同理

前台:db->Put()/WriteBatch只写内存 memtable,快速返回; 重活:memtable 落地 L0、SST 分层合并、文件删除、空间回收,全部交给后台线程执行。

核心设计思想一句话解释

前台线程只处理用户即时请求,保证低延迟;磁盘 IO、文件合并这类耗 CPU、耗磁盘的重量级整理工作,全部丢到后台线程异步执行,不阻塞用户读写。

拆开两层好处

  1. 提升前台读写速度,降低延迟Compaction 需要大量磁盘读写、key 归并排序,非常慢。 如果 Put 同步等待压缩完成,每一次写入都会卡顿,吞吐量暴跌。 丢后台后,用户写完立刻返回,感知不到耗时。

  2. 统一管控后台负载,避免 IO 风暴MaybeScheduleCompaction会检查是否已有后台任务在跑,同一时间最多只有一次 compaction。 防止无限创建压缩线程,导致磁盘被占满、业务卡死。

  3. 故障隔离 后台压缩报错(磁盘满、IO 错误)不会直接崩溃前台写入,只会标记后台错误,后续调度时拦截。

完整流程串联(结合你之前学的 imm 刷 L0)

  1. 前台大量 Put,memtable 写满 → 切换成 imm_不可变内存表;
  2. 用户写完返回,前台线程调用MaybeScheduleCompaction();
  3. 函数判断:有 imm_、无后台任务、数据库正常 → 调度 BGWork 后台任务;
  4. 后台线程异步执行:
    • WriteLevel0Table 把 imm_生成 L0 SST
    • 构建 VersionEdit、写入 MANIFEST 更新版本
    • 释放 imm 内存、清理过期 SST 文件
  5. 前台全程不等待上面磁盘操作,继续接收新的写入请求。

通俗大白话总结

就像餐厅:

  • 前台服务员(用户读写线程)只负责接单、记菜单(写内存 memtable),快速接待客人;
  • 后厨(后台 BGWork 线程)慢慢洗菜、做菜、整理库存(compaction 文件合并、刷盘);MaybeScheduleCompaction相当于领班判断:后厨空着就安排备菜,后厨忙就先不安排,不让客人等。

13.BackgroundCompaction():后台真正执行压缩

代码里:

void DBImpl::BackgroundCompaction()

这是 Compaction 的调度中心。

它大概做这些事:

1. 如果 imm_ 不为空,优先 compact MemTable 2. 如果是手动 compact,就按用户指定范围选 compaction 3. 如果是自动 compact,就让 versions_->PickCompaction() 4. 如果只是简单移动文件,就直接从一层移动到下一层 5. 否则调用 DoCompactionWork 真正合并文件 6. 完成后清理旧文件

这里说明 LevelDB 的 Compaction 分两类:

轻量情况: 文件可以直接移动到下一层,不需要重写。 重量情况: 多个 SSTable 需要合并、过滤旧版本、生成新 SSTable。

14.DoCompactionWork():真正合并 SSTable

这是整个文件里最复杂、也最核心的函数之一:

Status DBImpl::DoCompactionWork(CompactionState* compact)

它做的是:

读取参与 compaction 的多个 SSTable,把它们合并成新的 SSTable,并丢弃过期数据。

这里最关键的是它会判断某条记录要不要 drop。

代码里有两类丢弃逻辑:

1. 如果同一个 key 已经有更新版本,旧版本可以丢 2. 如果删除标记已经没有必要保留,也可以丢

比如:

key -> v1, seq=10 key -> v2, seq=20

如果没有快照需要看到 seq=10,那么 seq=10 的旧版本就可以丢掉。

再比如:

Delete key, seq=30

如果更低层已经没有这个 key 的旧数据了,那么这个删除标记也可以丢掉。

这就是 Compaction 真正的价值:

清理旧版本 清理删除标记 合并文件 降低读放大 降低空间浪费

你之前问的 MySQL/Redis 类比,这里也很像:

LevelDB Compaction: 清理旧版本和 tombstone。 MySQL purge: 清理 MVCC undo 旧版本。 Redis AOF rewrite: 清理历史命令,只保留最终状态。

15.Get():LevelDB 的读取路径

代码里:

Status DBImpl::Get(const ReadOptions& options, const Slice& key, std::string* value)

这个函数就是用户调用:

db->Get(read_options, key, &value);

最终进入的地方。

读取顺序非常重要:

1. 确定 snapshot sequence 2. 先查 mem_ 3. 再查 imm_ 4. 最后查当前 Version 里的 SSTable

代码里也体现了:

if (mem->Get(lkey, value, &s)) { // Done } else if (imm != nullptr && imm->Get(lkey, value, &s)) { // Done } else { s = current->Get(options, lkey, value, &stats); }

这就是 LevelDB 的读路径:

读最新内存表 | 读等待刷盘的内存表 | 读磁盘 SSTable

这个顺序很好理解:

最新写入的数据一定先在 MemTable。 如果 MemTable 没有,可能在 Immutable MemTable。 再没有,才去磁盘文件里找。

这和 Redis + MySQL 类比也很自然:

先查快的地方。 找不到再查慢的地方。

只不过 LevelDB 这里是在存储引擎内部完成。

分步逐条解释

1. 确定 snapshot sequence(快照序列号)

LevelDB 每一次写入 (Put/Delete) 都会分配一个全局递增序列号SequenceNumber,用来实现快照、隔离、MVCC。

  • 如果用户传入了自定义快照ReadOptions.snapshot,就用快照对应的序列号;
  • 没指定快照,就用数据库当前最新全局序列号。 作用:查询时只读取 ≤ 当前序列号的数据,屏蔽这条快照之后新增的数据,保证快照一致性。

2. 先查 mem_(活跃内存表)

mem_:当前正在接收写入的可变 MemTable,最新的数据都存在这里。

  • 遍历 memtable 跳表查找 key;
  • 如果找到,判断这条记录的序列号 ≤ 快照序列号:
    • 如果是 Put:直接返回 value;
    • 如果是 Delete(墓碑):代表 key 已被删除,返回 “不存在”;
  • 找到直接 return,不会去查 imm 和 SST,减少磁盘 IO。

3. 再查 imm_(不可变内存表)

当 mem_ 写满后,会切换为imm_,新数据写到新 mem_,imm_ 等待后台线程刷成 L0 SST。 imm_ 里的数据比 mem_ 旧,但比磁盘 SST 新。

  • mem_ 没找到,才会去查 imm_;
  • 同样匹配 key + 校验序列号,命中直接返回。

4. 最后查当前 Version 里的 SSTable(磁盘文件)

内存两张表都没找到,才走磁盘,也就是Version管理的所有层级 SST 文件:

  1. 优先查 L0:L0 文件 key 区间重叠,需要逐个遍历匹配;
  2. L1~Lmax:每层有序不重叠,通过二分快速定位最多一个 SST;
  3. 逐层从上到下查找(上层更新,下层更旧);
  4. 一旦在某个 SST 中找到 key,校验序列号,区分 Put/Delete,返回结果;
  5. 全部 SST 遍历完都没有 → key 不存在。

16.NewIterator():创建统一视图的迭代器

代码里:

Iterator* DBImpl::NewIterator(const ReadOptions& options)

它会先调用:

NewInternalIterator(...)

内部会收集多个迭代器:

mem_ iterator imm_ iterator SSTable iterators

然后通过:

NewMergingIterator(...)

把它们合并成一个有序迭代器。

这很关键。

因为 LevelDB 的数据分散在:

MemTable Immutable MemTable Level-0 SSTable Level-1 SSTable Level-2 SSTable ...

但用户使用 Iterator 时,不想关心这些。

用户只想:

it->SeekToFirst(); it->Next();

所以 LevelDB 内部要把多个来源合并成一个逻辑有序流。

这和 MySQL 执行计划里把多个数据来源组合成一个结果集,也有一点设计思想上的相似。

17.GetSnapshot()/ReleaseSnapshot():快照管理

代码里:

const Snapshot* DBImpl::GetSnapshot() { MutexLock l(&mutex_); return snapshots_.New(versions_->LastSequence()); }

意思是:

创建一个快照,记录当前最新 sequence number。

释放时:

void DBImpl::ReleaseSnapshot(const Snapshot* snapshot) { MutexLock l(&mutex_); snapshots_.Delete(static_cast<const SnapshotImpl*>(snapshot)); }

Snapshot 的本质不是复制一份完整数据库。

它只是记录一个 sequence number。

比如:

当前 LastSequence = 100 GetSnapshot() 得到 snapshot(seq=100)

之后即使数据库继续写到 seq=120,这个 snapshot 读的时候仍然只看:

sequence <= 100 的数据

这就是 LevelDB 的 MVCC 味道。

和 MySQL 的 ReadView 很像:

MySQL ReadView: 决定当前事务能看到哪些版本。 LevelDB Snapshot: 决定当前读取能看到哪些 sequence number。

在这个里面有一个思考点就是为什么要上锁,而前面的get则不用。

首先MutexLock 是什么?

MutexLock l(&mutex_);

RAII 自动锁:

  • 构造函数:上锁mutex_.Lock()
  • 函数结束、对象销毁时自动解锁,不会漏解锁。

整个GetSnapshot()函数内所有共享变量访问都被同一把mutex_保护,保证原子性:

读取最新序列号 + 创建快照并插入链表,这两步作为一个不可分割的原子操作。

其实答案就是get是只是去读,但是GetSnapshot()在读取全局序列号时,会对其进行修改共享的快照链表。

如果更精确一点的话,就是

GetSnapshot()不修改全局序列号,只是读取正在被并发修改的全局序列号,同时修改共享快照链表,这两步合起来必须加锁; 而普通Get()只做纯查询,不碰这两个临界资源,所以不用这把锁。

普通db->Get()读取路径只是只读遍历 mem/imm/SST,不会修改快照链表、不会读写全局序列号; 但GetSnapshot()要读取可变全局序列号 + 修改共享快照链表,属于「读 + 修改临界区」,必须加锁。

不加锁会出现什么 bug?

  1. 读到损坏的序列号,快照查询数据不一致;
  2. 快照链表并发插入导致链表结构损坏,程序 crash;
  3. Compaction 遍历快照链表时访问野指针崩溃;
  4. 重复创建重复序列号快照,版本回收逻辑错乱。

18.Write():最核心的写入路径

这是整个文件最应该认真看的函数:

Status DBImpl::Write(const WriteOptions& options, WriteBatch* updates)

它是用户调用:

db->Put(...) db->Delete(...) db->Write(...)

最终都会走到的地方。

整体流程是:

1. 创建 Writer,把本次写请求放入 writers_ 队列 2. 等自己成为队头 3. 调用 MakeRoomForWrite,确保 MemTable 有空间 4. 调用 BuildBatchGroup,把多个写请求合并成一个大 batch 5. 给 batch 设置 sequence number 6. 写入 WAL log 7. 如果 sync=true,就 Sync 日志文件 8. 把 batch 插入 MemTable 9. 更新 LastSequence 10. 唤醒队列里已经一起完成的 writer

在这个里面,为什么需要等到队头才去执行是一个可以去思考的问题。

「等自己成为队头」完整含义

首先需要直到writers_ 是什么,他是 DBImpl 内部一个等待写入的线程队列(条件变量 + 链表)。 多个业务线程同时调用db->Put()/db->Write()时,每个线程都会封装成一个Writer对象,塞进writers_排队。

LevelDB 写入采用批量合并写(group commit): 不让每个写请求单独落盘、单独写 memtable,而是队列头部一次性捞一批请求合并执行,减少 WAL IO、锁竞争,提升吞吐。

1. 流程还原

线程进来第一步:封装自己为 Writer,push 进 writers_ 队列。 然后执行循环等待逻辑:

while (自己不是队列第一个元素) { 等待条件变量 cond_; }

也就是:只要当前线程的 Writer 不在队列头部,就阻塞休眠,不往下执行写逻辑。

2. 为什么要等自己变成队头?

批量合并写的规则:只有队头 Writer 才有资格真正执行写入流程。

  • 排在后面的所有 Writer 全部阻塞等待;
  • 队头线程会一次性把自己 + 后面连续一批等待的 Writer 全部打包合并成一个大 Batch;
  • 统一写 WAL、统一插入 MemTable、统一分配序列号,一次性完成一批请求;
  • 写完后批量唤醒这批等待的线程,全部返回成功。

举个场景: 线程 1、2、3、4 同时并发写,依次进入 writers_队列: 队列顺序:[W1, W2, W3, W4]

  • W1 是队头,不用等,继续执行 MakeRoomForWrite、合并 Batch;
  • W2、W3、W4 发现自己不是队头,全部阻塞休眠; W1 执行 BuildBatchGroup,把 W1+W2+W3+W4 全部合并成一个大 batch,一次性写入; 写完后唤醒 W2/W3/W4,四个线程同时返回上层业务。

3. 如果不等待、直接执行会怎么样?

每个线程都尝试去写 WAL、修改 memtable、更新全局序列号,必然出现:

  1. 大量锁争抢,频繁上下文切换;
  2. 每个请求单独刷 WAL,磁盘 IO 爆炸,性能暴跌;
  3. 序列号分配并发冲突,必须加超大粒度锁,并发直接退化串行。

其次需要思考的是第六步与第七步有什么区别

写入 WAL log

只是把日志数据写入操作系统页缓存(page cache)

  • 数据现在在内存缓冲区,还没真正落到磁盘硬件上;
  • 写入速度极快,只是内存拷贝;
  • 此时如果机器突然断电、内核崩溃,这份新写的 WAL 数据会直接丢失。

sync=true 执行 Sync(fsync/fdatasync)

---------------------------------------------------------------------------------------------------------------------------------

强制把内核缓存里的 WAL 数据刷到磁盘持久化存储

  • 触发磁盘硬件 IO,把缓存数据写入磁盘盘片;
  • 完成 sync 之后,就算断电,数据也不会丢;
  • sync 很慢,涉及磁盘物理写入,开销大。

这就是 LevelDB 写入的主干。

可以画成:

用户 Put/Delete/Write | v Writer 入队 | v 成为队头 | v MakeRoomForWrite | v BuildBatchGroup 合并多个写 | v 写 WAL | v 写 MemTable | v 返回 Status

这段代码非常体现数据库系统设计。

19.BuildBatchGroup():合并多个写请求

代码里:

WriteBatch* DBImpl::BuildBatchGroup(Writer** last_writer)

这个函数做的是:

把写队列里连续的多个 Writer 合并成一个更大的 WriteBatch。

为什么要合并?

因为每个写请求单独写 WAL 成本比较高。

如果多个写请求排在队列里,可以合并:

Writer1: Put A Writer2: Put B Writer3: Delete C

变成:

一个大 batch: Put A Put B Delete C

然后一次写 WAL,一次插入 MemTable。

这和 Redis pipeline、MySQL group commit 思想非常像:

多个小请求合并处理,提高吞吐。

但代码里也有两个限制:

1. batch 不能无限大 2. sync 写不能被非 sync 写带着一起处理

因为如果 batch 太大,小请求会被拖慢。

所以它用了:

size_t max_size = 1 << 20; if (size <= (128 << 10)) { max_size = size + (128 << 10); }

意思大概是:

最多合并到 1MB。 如果第一个请求很小,就不要合并太多,避免小请求延迟太高。

这就是吞吐和延迟之间的权衡。

20.MakeRoomForWrite():写入前确保 MemTable 有空间

代码里:

Status DBImpl::MakeRoomForWrite(bool force)

这个函数负责:

写入前检查当前 MemTable 是否还能写,如果不能,就切换新的 MemTable,并安排旧 MemTable 刷盘。

它里面几个判断非常重要:

1. 如果有后台错误,停止写入 2. 如果 Level-0 文件太多,稍微延迟写入 3. 如果 MemTable 还有空间,直接写 4. 如果 imm_ 还没 compact 完,就等待 5. 如果 Level-0 文件多到危险程度,就等待 6. 否则创建新 log,把当前 mem_ 变成 imm_,再创建新的 mem_

这就是 LevelDB 的写入背压机制。

什么叫背压?

就是当后台整理跟不上前台写入时,前台写入不能无限冲。

否则会出现:

Level-0 文件越来越多 读性能越来越差 Compaction 压力越来越大 磁盘越来越乱

所以 LevelDB 会让写入慢下来,甚至等待 compaction。

这和 MySQL、Redis 都类似:

MySQL 脏页太多时,前台写入可能变慢。 Redis AOF rewrite 或内存压力大时,也可能影响请求延迟。

数据库不是只追求“写得快”,还要防止系统被写爆。

21.DB::Put和DB::Delete的默认实现

代码最后有:

Status DB::Put(const WriteOptions& opt, const Slice& key, const Slice& value) { WriteBatch batch; batch.Put(key, value); return Write(opt, &batch); } Status DB::Delete(const WriteOptions& opt, const Slice& key) { WriteBatch batch; batch.Delete(key); return Write(opt, &batch); }

这非常重要。

它证明了我们前面说的:

单条 Put/Delete,本质上也是一个只有一条操作的 WriteBatch。

也就是说:

Put(key, value)

等价于:

创建 WriteBatch batch.Put(key, value) db->Write(batch)

所以 LevelDB 的写入核心其实只有一个:

DBImpl::Write(...)

Put和Delete都只是便利函数。

22.DB::Open():打开数据库入口

代码里:

Status DB::Open(const Options& options, const std::string& dbname, DB** dbptr)

这是用户真正调用的入口:

leveldb::DB* db; Status s = leveldb::DB::Open(options, "./testdb", &db);

内部做这些事:

1. 创建 DBImpl 2. 加锁 3. 调用 Recover 恢复数据库 4. 如果恢复后还没有 mem_,创建新的 log 和 MemTable 5. 如果需要,保存新的 MANIFEST 6. 删除过期文件 7. 安排后台 compaction 8. 成功则把 impl 赋给 *dbptr 9. 失败则 delete impl

这里你能看到DB和DBImpl的关系:

用户拿到的是 DB* 实际对象是 DBImpl*

这就是典型的 C++ 多态:

DB* db = new DBImpl(...);

用户只看到接口,内部实现隐藏起来。


23.DestroyDB():删除整个数据库

最后还有:

Status DestroyDB(const std::string& dbname, const Options& options)

这个函数负责删除数据库目录下的 LevelDB 文件。

它会:

1. 列出数据库目录下文件 2. 加数据库锁 3. 删除除 LOCK 外的数据库文件 4. 解锁 5. 删除 LOCK 文件 6. 尝试删除目录

这个函数很危险。

它不是删除一个 key,而是删除整个数据库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询