第27章 日志和恢复

27.1 问题从哪来#

第26章把数据库保存到文件。保存时,程序打开 students.db,把记录区一行一行写进去。

这解决了"程序退出后数据消失"的问题,但没有解决"写到一半崩溃"的问题。

假设 students.db 原来有三条记录:5 Alice 923 Bob 788 Carol 85。程序要保存新状态。它用 "w" 打开文件,旧内容立刻被清空。写完前两条时程序崩溃,文件只剩前两行。

用 w 重写数据库文件时,崩溃会留下不完整文件

Carol 丢了。旧文件已经被清空,新文件又没写完整。

要让数据库更稳,修改数据之前需要先留下操作记录:

  1. 我要插入谁?
  2. 我要删除谁?

这份操作记录就是日志。


27.2 先看一个例子#

一次插入操作不再直接改内存,而是分两步:

  1. 先把 INSERT 8 Carol 85 追加到日志文件。
  2. 再把 Carol 插入内存数据库。

先写日志,再改内存表

如果崩溃发生在不同位置:

崩溃位置日志内存恢复时怎么处理
写日志前没有操作没改什么都不用做
写日志后、改内存前有操作没改回放日志,把操作补上
改内存后、保存前有操作改了但没进快照回放日志,得到同样结果

崩溃发生在不同位置,恢复结果都正确

启动恢复时做两件事:

  1. students.db 加载上次保存好的快照。
  2. 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_insertdb_deletedb_savedb_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;
}

这一章可以按四步验证:

步骤做什么看什么
1db_insert_logged 插入三条记录students.log 出现三行 INSERT
2退出前不 checkpointstudents.db 还没有新数据,students.log 有操作
3再启动,先 db_loadreplay_log内存数据库恢复出三条记录
4checkpointstudents.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 数据/内存/流程里发生了什么#

一次带日志的插入:

  1. 调用 db_insert_logged(&db, "students.log", {8, "Carol", 85})
  2. append_insert_log 写入 "INSERT 8 Carol 85"
  3. db_insert 写入 rows
  4. index_insert 更新有序索引。

一次启动恢复:

  1. 调用 db_init
  2. db_load("students.db") 读取快照文件。
  3. replay_log("students.log") 回放日志文件。

快照文件只保存记录区。日志文件只保存操作。恢复时,回放 INSERTDELETE 会同时更新记录区和索引区,因为它们走的是同一组数据库函数。


27.6 重复回放为什么没有破坏数据#

日志回放可能遇到这种情况:

  1. 快照里已经有 id=8
  2. 日志里又有 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 自己试试看#

  1. 模拟崩溃。checkpoint 前加 return 0;,运行一次后再运行第二次,观察日志回放。
  2. 手动写日志。students.log 里写 INSERT 10 Dave 88,再运行程序,观察 Dave 是否恢复进数据库。
  3. 测试重复 INSERT。 快照里已有 id=5,再在日志里写 INSERT 5 Alice 92,观察是否会重复。
  4. 统计日志行数。 写一个函数,在恢复前打印日志文件有多少行。
  5. 加 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

程序要读入这行字符串,拆出命令名和参数,再调用数据库函数。这样数据库才从函数库变成一个可以交互使用的小工具。