第27章 日志和恢复
27.1 问题从哪来#
第26章把数据库保存到文件。保存时,程序打开 students.db,把记录区一行一行写进去。
这解决了"程序退出后数据消失"的问题,但没有解决"写到一半崩溃"的问题。
假设 students.db 原来有三条记录:5 Alice 92、3 Bob 78、8 Carol 85。程序要保存新状态。它用 "w" 打开文件,旧内容立刻被清空。写完前两条时程序崩溃,文件只剩前两行。
Carol 丢了。旧文件已经被清空,新文件又没写完整。
要让数据库更稳,修改数据之前需要先留下操作记录:
- 我要插入谁?
- 我要删除谁?
这份操作记录就是日志。
27.2 先看一个例子#
一次插入操作不再直接改内存,而是分两步:
- 先把
INSERT 8 Carol 85追加到日志文件。 - 再把 Carol 插入内存数据库。
如果崩溃发生在不同位置:
| 崩溃位置 | 日志 | 内存 | 恢复时怎么处理 |
|---|---|---|---|
| 写日志前 | 没有操作 | 没改 | 什么都不用做 |
| 写日志后、改内存前 | 有操作 | 没改 | 回放日志,把操作补上 |
| 改内存后、保存前 | 有操作 | 改了但没进快照 | 回放日志,得到同样结果 |
启动恢复时做两件事:
- 从
students.db加载上次保存好的快照。 - 从
students.log逐行回放快照之后发生的操作。
快照文件保存某一刻的完整状态,日志文件保存那一刻之后的变化。
27.3 日志格式#
日志文件是普通文本文件,每行一条操作:
INSERT 5 Alice 92
DELETE 3
INSERT 8 Carol 85格式要简单,方便 fscanf 解析:
| 操作 | 日志行 |
|---|---|
| 插入 | INSERT id name score |
| 删除 | DELETE id |
日志只追加,不修改已有内容。追加比重写整个数据库文件简单,也更不容易破坏旧数据。
27.4 给数据库操作套一层日志#
到第26章为止,小数据库已经有 db_insert、db_delete、db_save 和 db_load。这一章新增的不是另一套数据库,而是在修改之前先写一行日志。
先写两个只负责追加日志的函数:
int append_insert_log(const char *logfile, struct Student s);
int append_delete_log(const char *logfile, int id);它们只做文件操作。插入日志写成:
INSERT 8 Carol 85删除日志写成:
DELETE 3再写两个带日志的包装函数:
int db_insert_logged(struct DB *db, const char *logfile, struct Student s)
{
if (!append_insert_log(logfile, s)) {
return 0; // 写日志失败则放弃本次插入
}
return db_insert(db, s); // 日志写成功后再插入内存
}
int db_delete_logged(struct DB *db, const char *logfile, int id)
{
if (!append_delete_log(logfile, id)) {
return 0; // 写日志失败则放弃本次删除
}
db_delete(db, id); // 找不到 id 也没关系,恢复时再删一次仍然安全
return 1; // 删除操作已写进日志
}顺序很重要:先写日志,再改内存。这样崩溃发生在两步之间时,恢复程序仍然能从日志里看到这次操作。db_delete_logged 返回 1 表示"删除操作已经写进日志"。如果 id 本来就不存在,db_delete 不会改动数据库,这条 DELETE 以后再回放也不会破坏数据。
恢复时需要回放日志:
int replay_log(struct DB *db, const char *logfile)
{
FILE *fp = fopen(logfile, "r");
if (fp == NULL) {
return 0; // 没有日志文件,就没有需要回放的操作
}
int count = 0;
char line[128];
while (fgets(line, sizeof line, fp) != NULL) {
char op[16];
if (sscanf(line, "%15s", op) != 1) {
continue; // 空行不表示一条操作
}
if (strcmp(op, "INSERT") == 0) {
struct Student s;
char extra[2];
if (sscanf(line, "%15s %d %31s %d %1s",
op, &s.id, s.name, &s.score, extra) != 4) {
break; // 这一行不完整或多了字段,不能使用 s
}
db_insert(db, s);
count = count + 1;
} else if (strcmp(op, "DELETE") == 0) {
int id;
char extra[2];
if (sscanf(line, "%15s %d %1s", op, &id, extra) != 2) {
break; // DELETE 后面没有完整的 id,或多了字段
}
db_delete(db, id);
count = count + 1;
} else {
break; // 遇到不认识的操作,停止回放
}
}
fclose(fp);
return count;
}这段代码用到了 strcmp,文件开头要有 #include <string.h>。它按行读取日志,再用 sscanf 检查字段数。字段不完整,或者一行里多出字段,都不回放这一行。
本章仍然沿用前面几章的简化规则:姓名里不带空格,所以 %31s 可以读出一个名字。
最后写 checkpoint:
int checkpoint(const struct DB *db, const char *dbfile, const char *logfile)
{
if (db_save(db, dbfile) < 0) {
return 0;
}
/* 快照已经保存成功,清空日志文件 */
FILE *fp = fopen(logfile, "w");
if (fp == NULL) {
return 0;
}
fclose(fp);
return 1;
}这一章可以按四步验证:
| 步骤 | 做什么 | 看什么 |
|---|---|---|
| 1 | 调 db_insert_logged 插入三条记录 | students.log 出现三行 INSERT |
| 2 | 退出前不 checkpoint | students.db 还没有新数据,students.log 有操作 |
| 3 | 再启动,先 db_load 再 replay_log | 内存数据库恢复出三条记录 |
| 4 | 调 checkpoint | students.db 有完整记录,students.log 被清空 |
把这些函数补齐后编译运行:
$ gcc wal_demo.c -o wal_demo
$ ./wal_demo
$ ls students.*
students.db students.log
运行后会出现两个文件:
| 文件 | 内容 |
|---|---|
students.db | 最近一次保存后的完整记录 |
students.log | 快照之后发生但还没有进入快照的操作 |
示例程序最后调用 checkpoint,所以正常结束时日志会被清空。要观察恢复效果,可以在调用 checkpoint 前提前 return 0;,模拟程序在保存快照之前崩溃。
27.5 数据/内存/流程里发生了什么#
一次带日志的插入:
- 调用
db_insert_logged(&db, "students.log", {8, "Carol", 85})。 append_insert_log写入"INSERT 8 Carol 85"。db_insert写入rows。index_insert更新有序索引。
一次启动恢复:
- 调用
db_init。 db_load("students.db")读取快照文件。replay_log("students.log")回放日志文件。
快照文件只保存记录区。日志文件只保存操作。恢复时,回放 INSERT 和 DELETE 会同时更新记录区和索引区,因为它们走的是同一组数据库函数。
27.6 重复回放为什么没有破坏数据#
日志回放可能遇到这种情况:
- 快照里已经有
id=8。 - 日志里又有
INSERT 8 Carol 85。
这在程序保存快照成功、但还没来得及清空日志时可能发生。示例里的 db_insert 会检查 id 是否已经存在。存在就不重复插入,所以回放这条 INSERT 不会产生两条 id=8。
DELETE 也类似。如果要删除的 id 已经不在数据库里,db_delete 返回 0,不做修改。这让 DELETE 多回放一次也不会破坏结果。
这种性质叫幂等:同一条操作执行一次和执行多次,最终数据库状态一样。最小数据库用去重和"找不到就不删"实现了这个效果。注意,幂等说的是最终状态;函数返回值可能不同,比如第一次插入返回成功,第二次因为 id 已经存在而返回失败。
27.7 常见坑#
坑 1:先改内存,再写日志。
db_insert(db, s);
append_insert_log(logfile, s); // 顺序错
如果改完内存后崩溃,日志里没有这条操作,下一次启动就补不回来。顺序必须是先写日志,再改内存。
坑 2:日志用 "w" 打开。
FILE *fp = fopen(logfile, "w"); // 错:会清空旧日志
日志要追加,使用 "a" 模式。
写入函数返回成功,不等于数据已经离开了 C 标准库的缓冲区。标准库可能先把内容放在缓冲区里,等缓冲区满了或文件关闭时再交给系统。示例程序用 fclose 结束写入;如果长期运行的程序写完日志后还要继续执行,可以在关键位置调用 fflush(fp)。更严格的断电保护还要考虑操作系统和磁盘自己的缓存,这已经超出这个小程序的范围。
坑 3:保存快照后忘记清空日志。
快照已经包含日志里的操作,如果日志不清空,下次启动会再次回放。示例用 id 去重降低了风险,但日志会越来越长,启动也会越来越慢。保存快照后要清空日志。
坑 4:回放日志时不检查字段数。
fscanf(fp, "%d %31s %d", &s.id, s.name, &s.score);
db_insert(db, s); // 错:读失败时 s 可能是旧值
只有 fscanf 返回 3,才说明读到一条完整的 INSERT。
坑 5:日志无限增长。
日志每次操作都追加。如果长期不做 checkpoint,启动时要回放很多操作。定期保存快照并清空日志,可以控制恢复时间。
27.8 自己试试看#
- 模拟崩溃。 在
checkpoint前加return 0;,运行一次后再运行第二次,观察日志回放。 - 手动写日志。 在
students.log里写INSERT 10 Dave 88,再运行程序,观察 Dave 是否恢复进数据库。 - 测试重复 INSERT。 快照里已有 id=5,再在日志里写
INSERT 5 Alice 92,观察是否会重复。 - 统计日志行数。 写一个函数,在恢复前打印日志文件有多少行。
- 加 UPDATE 操作。 设计一条
UPDATE id score日志,让数据库可以修改学生分数。
下一章的问题#
现在这个小数据库有了快照和日志。只要日志已经写入,程序在保存快照前崩溃,也有机会恢复。但使用它仍然需要写 C 代码:
db_insert_logged(&db, logfile, s);
db_delete_logged(&db, logfile, 3);普通用户不会改 C 代码来操作数据库。他们需要在终端里输入一行命令,比如:
$ INSERT 8 Carol 85
$ SELECT 8
$ DELETE 3
程序要读入这行字符串,拆出命令名和参数,再调用数据库函数。这样数据库才从函数库变成一个可以交互使用的小工具。