之前几章我们看到了写入一条数据后,LevelDB 目录下的一些文件:CURRENT、MANIFEST-000002、000003.log,还有一把 LOCK。然后我们的进程就推出了,如果我们再另起一个进程来读取这些文件,可这些文件是否可信,我们要想一个办法进行判断。
这一篇文章中,我们就可以回答:一个数据库被打开时,这些文件按什么顺序被读出来?如果上次没关干净,又怎么判断该信谁?
这一篇文章我们会了解 Open 的完整流程,然后把 01 篇留下的两个小谜团解掉:
- 为什么新库的第一个 Manifest 是
MANIFEST-000002,而日志偏偏叫000003.log? LOG里那句Delete type=3 #1又是什么意思?
Open 一个数据库时发生了什么?一堆没有入口的文件,怎么恢复出可用的状态?
第一步:先获取锁,再决定是否建库
所有事情从 DB::Open 开始(db_impl.cc:1483)。它构造出 DBImpl、锁住内部互斥量,然后调用 DBImpl::Recover(db_impl.cc:292)。Recover 这个名字容易让人以为"恢复是重启时才做的事",其实它囊括了每次打开都要走的全部流程,包括第一次建库。
Recover 做的第一件事是拿锁:
cpp
env_->CreateDir(dbname_);
Status s = env_->LockFile(LockFileName(dbname_), &db_lock_);
LockFileName 就是数据库目录下的 LOCK 文件(filename.cc:55)。POSIX 环境里这个锁是 fcntl(F_SETLK) 的文件写锁(env_posix.cc:628、439-448),最直白的效果是同一个库不能被两个进程同时打开 。如果锁拿不到,Open 会直接返回 IOError。
拿到锁之后才轮到判断库是否存在:CURRENT 不存在就走 create_if_missing 分支(db_impl.cc:306),存在而又要求 error_if_exists 就报错(db_impl.cc:318)。第一次建库时,NewDB(db_impl.cc:181)会写一个只有一条记录的 Manifest------记录 comparator、NextFile = 2、LogNumber = 0、序列号为 0------然后通过 SetCurrentFile 生成 CURRENT。
这里就顺带解释了 01 篇的第一个疑问。为什么建库写完的 MANIFEST-000001 后来不见了?因为 NewDB 只是写了一张"出生证明",真正的运行时 Manifest 是在后面的 LogAndApply 里生成的:文件号计数器已经走到 2,于是创建 MANIFEST-000002 并通过 SetCurrentFile 把 CURRENT 指向它(version_set.cc:808、837)。旧的 MANIFEST-000001 随即被当成过时文件删除,LOG 里那句 Delete type=3 #1(type=3 正是 kDescriptorFile)就是它。
顺带看一下 SetCurrentFile(filename.cc:123):它先往临时文件里写一行形如 MANIFEST-000002\n 的内容,再 Rename 成 CURRENT。重命名是原子的 ,所以任何时刻 CURRENT 要么指向旧的 Manifest,要么指向新的,不存在"指到一半"的中间态。这个原子替换是整套元数据安全的地基。
从 CURRENT 到 MANIFEST:拼出"当前版本"
Recover 接下来的重头戏是 versions_->Recover(save_manifest)(db_impl.cc:324),实现落在 db/version_set.cc 的 VersionSet::Recover(version_set.cc:862)。代码看着很长,但实际流程剥离出来可以分为:
- 读
CURRENT,拿到当前 Manifest 的文件名。注意一个细节:文件内容必须以换行结尾,否则直接判损坏(version_set.cc:876);
cpp
if (current.empty() || current[current.size() - 1] != '\n') {
return Status::Corruption("CURRENT file does not end with newline");
}
- 打开这个 Manifest,从头到尾读
VersionEdit记录; - 每读一条就
builder.Apply(&edit),把变更应用到正在重建的版本上(version_set.cc:924)。
Manifest 本质上是一本变更流水:最新写入的日志号、下一个可用文件号、全局最大序列号,以及每一层新增/删除了哪些文件,都记录在这里面。把整本流水从头到尾过一遍,就还原出了数据库的"当前状态"。我们先对此有个概念,后面会专门讲它的格式与轮转。
翻流水时有一道校验不能省:Manifest 里记录的 comparator 名字必须和当前使用的 comparator 一致,否则直接返回 InvalidArgument(version_set.cc:915)。
cpp
if (edit.has_comparator_ &&
edit.comparator_ != icmp_.user_comparator()->Name()) {
s = Status::InvalidArgument(
edit.comparator_ + " does not match existing comparator ",
icmp_.user_comparator()->Name());
}
数据的有序性是靠 comparator 定义的,用另一把尺子去读同一批数据,结果没有意义------这种问题越早暴露越好。
重放 WAL:把还没落盘的数据找回来
Manifest 给了我们"磁盘上有哪些文件",但最新的一批写很可能还没变成 SSTable ,它们只在 WAL 里。所以 Recover 还有第二步(db_impl.cc:337-376):
- 找出所有编号不小于
LogNumber的.log文件(以及历史遗留的PrevLogNumber); - 按编号排序------恢复必须按日志产生的顺序重放,否则序列号会乱;
- 逐个交给
RecoverLogFile(db_impl.cc:385)。
cpp
// Recover from all newer log files than the ones named in the
// descriptor (new log files may have been added by the previous
// incarnation without registering them in the descriptor).
//
// Note that PrevLogNumber() is no longer used, but we pay
// attention to it in case we are recovering a database
// produced by an older version of leveldb.
const uint64_t min_log = versions_->LogNumber();
const uint64_t prev_log = versions_->PrevLogNumber();
std::vector<std::string> filenames;
s = env_->GetChildren(dbname_, &filenames);
if (!s.ok()) {
return s;
}
std::set<uint64_t> expected;
versions_->AddLiveFiles(&expected);
uint64_t number;
FileType type;
std::vector<uint64_t> logs;
for (size_t i = 0; i < filenames.size(); i++) {
if (ParseFileName(filenames[i], &number, &type)) {
expected.erase(number);
if (type == kLogFile && ((number >= min_log) || (number == prev_log)))
logs.push_back(number);
}
}
if (!expected.empty()) {
char buf[50];
std::snprintf(buf, sizeof(buf), "%d missing files; e.g.",
static_cast<int>(expected.size()));
return Status::Corruption(buf, TableFileName(dbname_, *(expected.begin())));
}
// Recover in the order in which the logs were generated
std::sort(logs.begin(), logs.end());
for (size_t i = 0; i < logs.size(); i++) {
s = RecoverLogFile(logs[i], (i == logs.size() - 1), save_manifest, edit,
&max_sequence);
if (!s.ok()) {
return s;
}
// The previous incarnation may not have written any MANIFEST
// records after allocating this log number. So we manually
// update the file number allocation counter in VersionSet.
versions_->MarkFileNumberUsed(logs[i]);
}
RecoverLogFile 打开日志文件,挂上一个 log::Reader,然后一条条读出记录。每条记录是一个 WriteBatch,直接 InsertInto 到一个全新的 MemTable(db_impl.cc:444)------恢复和正常写入走的是同一条插入代码,这也是它可靠的原因之一:没有第二条容易被忽略的"恢复专用路径"。
值得一提的是,这里的 reader 强制打开校验(db_impl.cc:422):哪怕把 paranoid_checks 关掉,校验和也照算不误。源码注释给的理由是,宁可让一整条损坏的提交被跳过,也不能把错误信息(比如一个异常大的序列号)带进内存。
cpp
// We intentionally make log::Reader do checksumming even if
// paranoid_checks==false so that corruptions cause entire commits
// to be skipped instead of propagating bad information (like overly
// large sequence numbers).
log::Reader reader(file, &reporter, true /*checksum*/, 0 /*initial_offset*/);
Log(options_.info_log, "Recovering log #%llu",
(unsigned long long)log_number);
重放全部结束后,如果 MemTable 没有被复用,它会被整表写成一张 L0 表(db_impl.cc:493-500)。
cpp
if (mem != nullptr) {
// mem did not get reused; compact it.
if (status.ok()) {
*save_manifest = true;
status = WriteLevel0Table(mem, edit, nullptr);
}
mem->Unref();
}
重放途中若 MemTable 提前涨过了 write_buffer_size,也会先落一张表(db_impl.cc:455-466)。
cpp
if (mem->ApproximateMemoryUsage() > options_.write_buffer_size) {
compactions++;
*save_manifest = true;
status = WriteLevel0Table(mem, edit, nullptr);
mem->Unref();
mem = nullptr;
if (!status.ok()) {
// Reflect errors immediately so that conditions like full
// file-systems cause the DB::Open() to fail.
break;
}
}
两处调用传的 base 都是 nullptr,拿不到 PickLevelForMemTableOutput 的直推机会,所以恢复产出的表固定落在 L0(常规 Flush 可能直推 L1/L2,区别留待后面文章详细讨论)。实验里的日志只有 64 字节,走的是前一条路径------重启之后,原本只在日志里的数据变成了 .ldb 文件。
重放结束还有一个默认行为值得点明。源码里有一段"复用旧日志"的逻辑(db_impl.cc:472),但它由 reuse_logs 控制,而这个选项默认是 false(options.h:137)。所以默认路径下:恢复出的 MemTable 会被写成一个 SSTable,旧的 .log 作废;DB::Open 随后再分配一个全新的日志文件号、开一个空 MemTable(db_impl.cc:1492-1505)。每次重启,日志文件号都会往前走,目录里会多出一个 .ldb------这正是本节实验要验证的事。
cpp
if (s.ok() && impl->mem_ == nullptr) {
// Create new log and a corresponding memtable.
uint64_t new_log_number = impl->versions_->NewFileNumber();
WritableFile* lfile;
s = options.env->NewWritableFile(LogFileName(dbname, new_log_number),
&lfile);
if (s.ok()) {
edit.SetLogNumber(new_log_number);
impl->logfile_ = lfile;
impl->logfile_number_ = new_log_number;
impl->log_ = new log::Writer(lfile);
impl->mem_ = new MemTable(impl->internal_comparator_);
impl->mem_->Ref();
}
}
收尾时,如果本次恢复产生了新版本,就把新的 LogNumber 等信息通过 LogAndApply 写进 Manifest(db_impl.cc:1507-1511),再调 RemoveObsoleteFiles 清掉旧日志和旧 Manifest(db_impl.cc:1513),最后 MaybeScheduleCompaction 安排后台工作(db_impl.cc:1514)。到这一步,数据、元数据、后台任务三者都对齐了,Open 返回一个可用的 DB*。
cpp
if (s.ok() && save_manifest) {
edit.SetPrevLogNumber(0); // No older logs needed after recovery.
edit.SetLogNumber(impl->logfile_number_);
s = impl->versions_->LogAndApply(&edit, &impl->mutex_);
}
if (s.ok()) {
impl->RemoveObsoleteFiles();
impl->MaybeScheduleCompaction();
}
动手实践:重启短链库,看文件号怎么变化
为了不破坏 01、02 篇的实验数据,这次用一个全新的库 .output/recovery-demo:
bash
./.output/demo_v0 .output/recovery-demo
写入后目录是这样的:
plain
000003.log 64 字节 还在日志里,没成表
CURRENT 16 字节 内容是 "MANIFEST-000002\n"
LOCK 0 字节
LOG 151 字节 信息日志
MANIFEST-000002 50 字节 只有两条 VersionEdit
现在重启一次,只读那条短链:
bash
./.output/reopen .output/recovery-demo c:Ab3xK
reopen 的源码我会放到文章末尾,不影响连续阅读的体验。
运行之后,我们就能看到输出:
rust
c:Ab3xK -> https://example.com/a-very-long-url
正是我们之前写入的数据,说明数据可以正确读取。更有意思的是运行之后,目录变成了:
plain
000005.ldb 154 字节 ← 从 WAL 恢复出来的 L0 表
000006.log 0 字节 ← 新开的空日志
CURRENT 16 字节 现在指向 MANIFEST-000004
LOG.old 151 字节 上一轮的 LOG
LOG 304 字节
MANIFEST-000004 87 字节 比原来长了
LOG 把这次恢复的每一步都记了下来:
plain
2026/09/19-13:58:31.034155 0x10ff59600 Recovering log #3
2026/09/19-13:58:31.035426 0x10ff59600 Level-0 table #5: started
2026/09/19-13:58:31.041049 0x10ff59600 Level-0 table #5: 154 bytes OK
2026/09/19-13:58:31.069607 0x10ff59600 Delete type=0 #3
2026/09/19-13:58:31.069647 0x10ff59600 Delete type=3 #2
回忆我们刚刚阅读过的源码流程,可以发现 LOG 中记录的和我们预想的完全一致:读日志 3、把重放出的 MemTable 写成 5 号表、然后删掉旧日志(type=0)和旧 Manifest(type=3)。而建库那一刻的 Delete type=3 #1,就留在这个目录的 LOG.old 里------01 篇里那句一模一样的记录,也是这么来的。
bash
2026/09/19-13:58:15.456095 0x110f0a600 Creating DB .output/recovery-demo since it was missing.
2026/09/19-13:58:15.526857 0x110f0a600 Delete type=3 #1
再看新 Manifest,它多了一条 AddFile:
plain
VersionEdit {
LogNumber: 6
PrevLogNumber: 0
NextFile: 7
LastSeq: 1
AddFile: 0 5 154 'c:Ab3xK' @ 1 : 1 .. 'c:Ab3xK' @ 1 : 1
}
意思是:0 层新增了 5 号文件,154 字节,key 范围是 c:Ab3xK(序列号 1)。到这一步,c:Ab3xK 才算真正从"日志里的字节"变成了"磁盘上的表"。
动手实践:文件丢了,Open 会拒绝启动
既然 Manifest 记录了每个文件,那文件真丢了会怎样?把刚才那张表删掉再打开:
bash
rm .output/recovery-demo/000005.ldb
./.output/reopen .output/recovery-demo c:Ab3xK
得到的是:
plain
open error: Corruption: 1 missing files; e.g.: .output/recovery-demo/000005.ldb
这来自 Recover 里的一段"点名"逻辑(db_impl.cc:344-361):先把 Manifest 里所有存活文件放进一个期望集合,再拿目录里实际存在的文件去抵消,最后如果集合还有剩的,就报 Corruption 并带上一个缺失文件名。
cpp
std::set<uint64_t> expected;
versions_->AddLiveFiles(&expected);
uint64_t number;
FileType type;
std::vector<uint64_t> logs;
for (size_t i = 0; i < filenames.size(); i++) {
if (ParseFileName(filenames[i], &number, &type)) {
expected.erase(number);
if (type == kLogFile && ((number >= min_log) || (number == prev_log)))
logs.push_back(number);
}
}
if (!expected.empty()) {
char buf[50];
std::snprintf(buf, sizeof(buf), "%d missing files; e.g.",
static_cast<int>(expected.size()));
return Status::Corruption(buf, TableFileName(dbname_, *(expected.begin())));
}
我们完全可以理解 LevelDB 选择拒绝启动的原因。数据损坏对于存储引擎而言是致命的,就算不影响之后的写入读取流程,某些 key 凭空消失也是不能为用户所接受的,所以"尽早报错"才是正确的选择。
LevelDB 另给了 RepairDB(修复思路是扫描所有可用的表文件、重建元数据),但这属于坏盘之后的应急手段,不是正常路径。
动手实践:kill -9 之后,数据靠什么回来
最后一个实验正面检验 WAL 的作用。写一个程序,连续写入三条短链,然后不给任何清理机会,直接把自己杀掉:
bash
./.output/crash_write .output/crash-demo # puts c:Crash1/2/3 后 SIGKILL,退出码 137
crash_write 的源码同样放在文章末尾。
此刻目录里没有 .ldb,三条记录全在 000003.log 里:
plain
--- offset 0; sequence 1
put 'c:Crash1' 'https://example.com/Crash1'
--- offset 56; sequence 2
put 'c:Crash2' 'https://example.com/Crash2'
--- offset 112; sequence 3
put 'c:Crash3' 'https://example.com/Crash3'
重新打开:
bash
./.output/reopen .output/crash-demo c:Crash1 c:Crash2 c:Crash3
三条数据都能正确读取。原因很简单:Put 在修改 MemTable 之前先追加了 WAL,进程被杀不影响已经写进日志文件的内容,重启时按上文的流程重放一遍即可。
不过有一个边界必须说清楚:"进程被杀不丢"不等于"机器掉电不丢" 。默认 sync = false(options.h:182)时,Put 只把数据交给操作系统页缓存就返回,日志还没真正落盘;进程崩溃时内核还在,页缓存还在,数据安全;整机断电时页缓存一起丢,最后几条写就可能消失。要跨过这条线,得让 Put 带上 sync = true,用一次磁盘同步换取确定的持久性。这条边界的物理细节留给后面的文章。
对比参照:三种恢复,三种目标
同样是"重启后恢复",三个系统的目标并不一样。
InnoDB 恢复到一个事务一致的状态。 它靠 redo 把已提交的改动补齐,再靠 undo 把崩溃时没提交的事务回滚掉,中间还有 doublewrite 处理页撕裂。目标是:所有已提交事务在,所有未提交事务不在。
Redis 的 AOF 从头重放整个命令流。 它没有"部分恢复"一说------把文件里记录的所有命令重新执行一遍,在内存里重建出数据集;RDB 则是直接加载一个快照。目标更接近"把内存状态重新构建出来"。
LevelDB 只重放到最后一个完整提交。 它没有事务语义,一个 WriteBatch 就是一个提交单位:完整就应用,损坏或写了一半就丢弃(后面会讲怎么判断)。没有回滚,也不需要回滚------因为不存在"跨多个提交的整体一致性"这个承诺。
恢复目标的差异,根本在于有没有事务语义。要保证事务,就必须额外记录能回滚、能判断未提交的信息;只要保证"单次写要么整体在要么整体不在",日志结构本身就已经足够了。
工程启示
用好 LevelDB:把 Open 当成一次性成本
恢复时长基本等于 WAL 的重放时长。日志越大、越多,启动越慢:
write_buffer_size越大,同样数据量下日志切换越少,但每次重启要重放的日志也越大 ,内存占用(mem_+imm_两份)同步上升;- 频繁重启的场景要把这笔账算进去:与其反复开关数据库,不如让它常驻;
- 默认
reuse_logs = false,只要日志里还有未落盘的数据,重启就会把它重放成新表、再开新日志,目录里的.ldb会因重启而增加------这是设计行为,不是泄漏; - 怀疑数据损坏时,先在
paranoid_checks = true下跑一次,让损坏尽早暴露;确认需要抢救再用RepairDB。
设计借鉴:分层恢复与幂等重放
把 Open 的流程抽象出来,是一套通用的恢复模式:
- 先元数据、后数据 :先用
CURRENT → MANIFEST确定"磁盘上应该有哪些文件",再用 WAL 补齐"还差哪些内容"。顺序颠倒,就得在数据里大海捞针; - 自描述 + 校验:每条记录自带长度和校验和,读得懂才用,读不懂就丢,不必依赖外部索引;
- 幂等重放 :数据的落盘和元数据的登记是分开的两步。文件先落盘,
CURRENT的原子替换才算"安装";安装之前产生的任何中间产物,下次启动都会被当成孤儿清理掉。于是"恢复做到一半又崩了"不会留下半吊子状态,重来一次即可。
一句话:元数据给确定性,日志给完整性,原子替换给安全性。 这套组合拳不只适用于存储引擎,也适合任何需要"重启后对齐状态"的系统。
数据库已经打开、数据恢复到位。接下来是 02 篇数据流图上留下的那个问题:多个线程同时往一个库写,LevelDB 为什么能让它们排队、还顺便把请求合并成一批?下一篇,从 Write 的队列说起。
reopen.cc 代码。
cpp
// reopen.cc:打开一个已存在的库,读回指定 key(验证数据从 WAL 恢复)。
// 构建 leveldb(仓库根目录执行,产物统一放 .output/ 下):
// cmake -S leveldb-1.23 -B .output/build -DLEVELDB_BUILD_TESTS=OFF \
// -DLEVELDB_BUILD_BENCHMARKS=OFF -DCMAKE_BUILD_TYPE=Release
// cmake --build .output/build -j8
// 编译(仓库根目录,macOS 用 clang++,Linux 用 g++):
// clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/reopen.cc \
// .output/build/libleveldb.a -o .output/reopen
// 运行(默认库 .output/shorturl-demo,可传库路径,后接要读的 key):
// ./.output/reopen .output/recovery-demo c:Ab3xK
#include <cstdio>
#include <string>
#include <vector>
#include "leveldb/db.h"
int main(int argc, char* argv[]) {
const std::string dbpath = argc > 1 ? argv[1] : ".output/shorturl-demo";
std::vector<std::string> keys;
for (int i = 2; i < argc; ++i) keys.push_back(argv[i]);
if (keys.empty()) keys.push_back("c:Ab3xK");
leveldb::Options options;
options.create_if_missing = false; // 只打开已存在的库
leveldb::DB* db = nullptr;
leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
if (!s.ok()) {
std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
return 1;
}
for (const std::string& key : keys) {
std::string value;
s = db->Get(leveldb::ReadOptions(), key, &value);
if (s.ok()) {
std::printf("%s -> %s\n", key.c_str(), value.c_str());
} else if (s.IsNotFound()) {
std::printf("%s -> (not found)\n", key.c_str());
} else {
std::printf("%s -> error: %s\n", key.c_str(), s.ToString().c_str());
}
}
delete db;
return 0;
}
crash_write.cc 代码。
cpp
// crash_write.cc:写入若干 key 后直接 SIGKILL 自己,模拟 kill -9。
// 用来观察"进程没机会干净退出时,数据靠 WAL 恢复"。
// 编译(仓库根目录):
// clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/crash_write.cc \
// .output/build/libleveldb.a -o .output/crash_write
// 运行:
// ./.output/crash_write .output/crash-demo
#include <csignal>
#include <cstdio>
#include <string>
#include <unistd.h>
#include "leveldb/db.h"
int main(int argc, char* argv[]) {
const std::string dbpath = argc > 1 ? argv[1] : ".output/crash-demo";
leveldb::Options options;
options.create_if_missing = true;
leveldb::DB* db = nullptr;
leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
if (!s.ok()) {
std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
return 1;
}
const char* codes[] = {"Crash1", "Crash2", "Crash3"};
for (const char* code : codes) {
std::string key = std::string("c:") + code;
std::string value = std::string("https://example.com/") + code;
s = db->Put(leveldb::WriteOptions(), key, value);
if (!s.ok()) {
std::fprintf(stderr, "put %s error: %s\n", key.c_str(),
s.ToString().c_str());
return 1;
}
std::printf("put %s ok\n", key.c_str());
}
std::printf("killing myself with SIGKILL (simulating kill -9)\n");
std::fflush(stdout);
::kill(::getpid(), SIGKILL); // 不给 delete db / 析构任何机会
return 0; // 不会执行到这里
}