第26章 数据库持久化

26.1 问题从哪来#

第25章给数据库加了索引。记录区 rows 保存完整学生记录,索引区 index 按 id 排序,索引项里保存 id -> row_pos。查找时先在索引里二分查找,再跳到记录区。

但这两块数据都在内存里。程序退出后,rowsindex 占用的内存都会被操作系统回收。下一次启动,数据库又是空的。

第 9 章已经做过文件读写:把学生记录写成文本行,再从文本行读回结构体。这个办法仍然能用,只是现在要处理一个新问题:

  • 记录区要保存。
  • 索引区要不要保存?

答案是:只保存记录区,加载时重建索引。

索引是根据记录的 id 和它在记录区中的位置建立出来的辅助结构。只要记录还在,索引就能重新生成。把索引也写进文件,反而会多一个一致性问题:文件里的记录被改了,索引可能就过期了。


26.2 先看一个例子#

内存里的数据库有三条记录:

rows[0] = {5, "Alice", 92}
rows[1] = {3, "Bob",   78}
rows[2] = {8, "Carol", 85}

索引区按 id 排序:

index[0] = {id=3, row_pos=1}
index[1] = {id=5, row_pos=0}
index[2] = {id=8, row_pos=2}

保存时,只把记录区写进文件:

5 Alice 92
3 Bob 78
8 Carol 85

内存 DB 的记录区写入文件,每行对应一条记录

加载时,程序逐行读取文件。格式正确、id 又没有重复的记录会交给 db_insertdb_insert 会写入记录区,也会把索引项插入到正确位置。

加载文件时逐行读取,恢复记录区

每条记录插入数据库时,索引自动重建

这两个方向各有一个常见名字:

术语含义
序列化把内存里的结构体变成文本或字节,写进文件
反序列化把文件里的文本或字节变回内存里的结构体

26.3 最小接口#

这一章只给数据库增加两个函数:

int db_save(const struct DB *db, const char *filename);
int db_load(struct DB *db, const char *filename);

返回值约定:

函数返回值
db_save成功返回写入的记录数,打开或写入失败返回 -1
db_load成功返回加载的记录数,文件不存在或打不开返回 0

db_load 要求调用前数据库已经 db_init。如果要把文件内容作为新的数据库状态,先 db_freedb_init,然后再加载。这个最小接口不区分“文件不存在”和“文件为空”,两种情况都会返回 0。


26.4 写出保存和加载#

第25章已经有 struct DBdb_insertdb_find。这一章只在它们旁边增加文件读写,不重新铺开整套数据库。

先写保存。保存只关心记录区:

int db_save(const struct DB *db, const char *filename)
{
    FILE *fp = fopen(filename, "w");  // 以写入模式打开文件
    if (fp == NULL) {
        return -1;                    // 打开失败返回错误
    }

    for (int i = 0; i < db->count; i++) {  // 遍历所有记录
        struct Student s = db->rows[i];     // 取出当前记录
        if (fprintf(fp, "%d %s %d\n", s.id, s.name, s.score) < 0) {  // 写入一行文本
            fclose(fp);
            return -1;                      // 写入失败,关闭文件并返回错误
        }
    }

    if (fclose(fp) != 0) {
        return -1;                          // 关闭文件失败
    }
    return db->count;                       // 返回保存的记录数
}

关键部分是循环遍历 rows,把每条记录写成同一种文本格式:

5 Alice 92

再写加载。加载不直接手动改 rowsindex,而是先把一行文本解析成 struct Student,再调用 db_insert

int db_load(struct DB *db, const char *filename)
{
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        return 0;
    }

    int loaded = 0;
    char line[128];
    struct Student s;
    struct Student old;
    char extra[2];

    while (fgets(line, sizeof line, fp) != NULL) {
        if (sscanf(line, "%d %31s %d %1s",
                   &s.id, s.name, &s.score, extra) != 3) {
            continue;   // 这一行格式不对,跳过
        }
        if (db_find(db, s.id, &old)) {
            continue;   // 已有相同 id,跳过
        }
        if (db_insert(db, s)) {
            loaded++;
        }
    }

    fclose(fp);
    return loaded;
}

这里调用 db_insert,是为了复用已经写好的插入逻辑。插入函数已经知道怎样写入记录区、怎样维护索引区,加载函数不需要重复这套逻辑。

可以用下面的 main 验证:

int main(void)
{
    struct DB db;
    db_init(&db);                                         // 初始化数据库

    printf("Loaded %d records\n", db_load(&db, "students.db"));  // 从文件加载已有记录

    struct Student a = {5, "Alice", 92};                  // 准备三条新记录
    struct Student b = {3, "Bob", 78};
    struct Student c = {8, "Carol", 85};

    struct Student old;
    if (!db_find(&db, a.id, &old)) {                      // id 不存在才插入,避免重复
        db_insert(&db, a);
    }
    if (!db_find(&db, b.id, &old)) {
        db_insert(&db, b);
    }
    if (!db_find(&db, c.id, &old)) {
        db_insert(&db, c);
    }

    printf("Saved %d records\n", db_save(&db, "students.db"));  // 保存到文件

    db_free(&db);                                         // 释放数据库资源
    return 0;
}

把两个函数补齐后编译运行:

$ gcc db_persist.c -o db_persist
$ ./db_persist
Loaded 0 records
Saved 3 records

第一次运行时,如果没有 students.db,会从空数据库开始。运行结束后,目录下会出现一个文本文件:

5 Alice 92
3 Bob 78
8 Carol 85

第二次运行时,程序先加载这三条记录,再尝试插入相同 id 的记录。上面的 main 在插入前用 db_find 查了一次;db_load 读文件时也会跳过已有 id,所以文件不会无限重复增长。

这里的重复检查放在 db_loadmain 里。这样写不要求你马上改 db_insert。如果以后要让同一条插入操作执行多次也不改变最终状态,更稳的做法是把重复 id 检查收进 db_insert 本身。


26.5 文件和内存怎样对应#

保存时,数据从记录区流向文件。每个 struct Student 写成一行文本;索引区不写入文件。

保存时只写记录区,索引区留在内存里

加载时,格式正确的一行先变回一个 struct Student。这条记录交给 db_insert 后,记录区和索引区会一起恢复。

加载文件行后复用 db_insert,同时恢复 rows 和 index

加载完成后,记录区按文件顺序排列,索引区按 id 排序。文件只保存原始记录,索引由加载过程重新建立。


26.6 为什么不保存索引#

索引是派生数据。它不是用户输入的原始数据,而是为了加快查找建立的目录。

只保存记录区有三个好处:

好处说明
文件更简单每行就是 id name score
不怕索引过期加载时重新根据记录生成
便于人工检查打开文件就能看懂

代价是加载时要重建索引。对于这个小数据库,重建索引就是逐条 db_insert,成本可以接受。


26.7 常见坑#

坑 1:保存和加载格式不一致。

保存时写的是:

fprintf(fp, "%d %s %d\n", id, name, score);

加载时也必须按同样的顺序读:

sscanf(line, "%d %31s %d %1s", &s.id, s.name, &s.score, extra);

字段顺序一变,读回来的数据就会错。

文件格式是程序和文件之间的约定。只改保存、不改加载,或者只改加载、不改保存,都会让旧数据读不回来。给格式加新字段时,可以先写一个小样例文件,再确认 db_load 读到的字段数和值都对。

坑 2:%s 没有限制宽度。

sscanf(line, "%d %s %d", &s.id, s.name, &s.score);        // 错
sscanf(line, "%d %31s %d %1s", &s.id, s.name, &s.score, extra);  // 对

name 只有 32 个字节,最多读 31 个可见字符,最后 1 个位置留给 \0。最后的 %1s 用来检查这一行后面是不是还多了字段;如果有多余内容,sscanf 的返回值就不是 3,这一行应该跳过。

坑 3:加载前没有初始化数据库。

struct DB db;
db_load(&db, "students.db");    // 错:db 里的指针和计数器还没初始化

正确顺序:

struct DB db;
db_init(&db);
db_load(&db, "students.db");

坑 4:重复加载同一个文件。

如果对同一个 DB 连续调用两次 db_load,第二次会再次读入文件。上面的 db_load 在插入前用 db_find 检查 id,已有记录会跳过;如果省掉这一步,就可能出现重复记录。

坑 5:名字里有空格。

%31s 遇到空格就停。读到 Alice 后,下一步本来要读分数,却遇到 Wang,这一行就不能按当前格式正确解析。这个最小格式只支持不带空格的名字。要支持空格,需要换成 CSV,或者给名字加引号后再写更完整的解析逻辑。


26.8 自己试试看#

Q1:打印索引。 加一个 db_print_index 函数,加载文件后打印索引数组,验证它仍然按 id 排序。

Q2:故意打乱文件顺序。 手动把 students.db 的行顺序改成 8, 5, 3,再运行程序,观察索引是否仍然有序。

Q3:处理坏行。 在文件中加一行 bad data,观察 db_load 会不会跳过坏行,继续读取后面的记录。

Q4:导出 CSV。 写一个 db_export_csv,第一行输出 id,name,score,后面每行一条记录。

Q5:替换式加载。 写一个 db_reload,先 db_free,再 db_init,最后 db_load,让文件内容完全替换当前内存状态。


下一章的问题#

db_save"w" 模式打开文件。文件一打开,旧内容就被清空,然后程序一行一行写入新内容。

如果写到一半程序崩溃,文件就只剩前半部分。旧数据已经没了,新数据也不完整。数据库启动时只能读到残缺文件。

有没有办法在修改数据库之前,先留下"我要做什么"的记录,让程序崩溃后还能把操作补回来?