minikv 重构项目 · 任务 4 / 8

m04:把配置参数收回去

对应第 8 章(把复杂性往下拉)

背景与对应章节

minikv 有 5 个可调参数。哪几个是使用者真的比 minikv 更懂的?其余的,minikv 能不能自己决定?

minikv 的日志会不断积累垃圾(被覆盖、被删除的旧记录),所以需要定期压缩。什么时候压缩?写代码的同事一时拿不准,于是做成了参数;顺手又加了"每隔几次写检查一次"和"索引预留多少个键"。README 里为此专门写了一节"调参",main.cpp 解析 5 个命令行参数,Options::validate() 检查它们的组合是否合法。

看起来很"灵活",但第 8 章会让你换个角度看:每个配置参数都是一个交给使用者去做的决定。模块的开发者只有一个,使用者有很多;如果开发者都拿不准,使用者更拿不准。做之前请读 第 8 章 · 把复杂性往下拉(重点是原书 8.2 节"配置参数"和 8.3 节"做过头"),再回头看一眼 第 5 章 的 5.9 节。

你要找的问题

先只读代码和文档,不改。需要看的地方:

位置读的时候问自己
README.md 的"调参"一节假如你是第一次部署 minikv 的运维,读完这一节,你知道 --compact-ratio 该填多少吗?那句"写多读少可以调高"有什么依据?
src/options.h、src/options.cpp每个字段:它在权衡什么和什么?权衡所需的信息,是使用者手里有,还是程序运行时就有?
Database::maybeCompact()把默认的 compaction_ratio = 0.5 换成一句话描述的规则,它等价于什么?如果有人把 ratio 和 min_bytes 都设得很小,会发生什么?
compaction_check_interval它要省掉的那个"检查",到底有多贵?
index_reserve不设它,std::unordered_map 会怎样?设错了又会怎样?
src/main.cpp有多少行代码只是因为参数存在而存在(解析、报错、帮助文本、校验)?

为每个参数写一句判断:"使用者能 / 不能比模块选出更好的值,因为……"。

确认基线测试全绿,再数一数"参数"一共牵涉了多少地方:

cd project/minikv
make test
grep -n "compaction_\|index_reserve\|sync_writes\|validate" src/*.cpp src/*.h README.md

任务与约束

  1. 对每个参数做第 8.2 节的检验:使用者能不能选出一个比模块自己算的更好的值?不能的,删掉,让模块自己决定。
  2. 能的,保留,并把它的注释写到使用者一看就知道"打开它换来什么、付出什么"。
  3. 把压缩时机改成一条说得出理由的内部规则,理由写在代码注释里。如果规则里还有常数,也要说明这个常数为什么合理。
  4. 删掉因参数消失而失去意义的代码:校验、命令行解析、帮助文本、README 段落。

约束:

自查清单

提示

提示 1:哪些信息程序运行时本来就有

决定"现在压不压缩"需要两个量:日志里有多少垃圾、有多少有效数据。Store 时刻都知道这两个数(dead() 和 total() - dead())。使用者在启动 minikv 时只能猜,猜完还会过时。凡是"运行时测得到的量",通常比"启动时填进去的数"更可靠——书中用网络重试间隔说明过同样的道理。

提示 2:给 ratio 找一个理由

压缩只重写有效数据,代价约等于 live 字节的写入,换回 garbage 字节。问自己:垃圾和有效数据是什么关系时,压缩"划算"?算出来以后,再和基线默认的 0.5 比较一下。

提示 3:哪个参数该留

某个参数决定的是"数据丢不丢",而不是"快一点还是慢一点"。键值存储无从知道里面存的是缓存还是账本。这种业务层面的取舍,是第 8 章承认的、使用者确实更懂的情形。

参考答案与讨论

先自己做完、测试全绿,再打开下面的内容。你的规则和参考答案不同没关系,关键是能把规则背后的理由讲清楚。

打开参考答案

逐个参数的判断

参数使用者更懂吗?处理
compaction_ratio不。权衡的是磁盘空间和额外写入,两个量模块运行时精确知道删,改为"垃圾 ≥ 有效数据"
compaction_min_bytes不。防止小文件反复压缩是实现层面的成本删,改为内部常量 1 MiB
compaction_check_interval不。被"省掉"的检查只是两次整数比较删,每次写都检查
index_reserve不。哈希表会自己扩容删
sync_writes是。数据能不能在掉电时丢,是业务决定保留,注释写清代价
struct Options {
    double compaction_ratio = 0.5;
    uint64_t compaction_min_bytes = 1 << 20;
    uint32_t compaction_check_interval = 100;
    size_t index_reserve = 1024;
    bool sync_writes = false;
    std::string validate() const;
};

void Database::maybeCompact() {
    if (++writesSinceCheck_ < options_.compaction_check_interval) return;
    writesSinceCheck_ = 0;
    uint64_t garbage = store_.dead();
    if (garbage < options_.compaction_min_bytes) return;
    if (static_cast<double>(garbage) <
        options_.compaction_ratio * static_cast<double>(store_.total())) return;
    store_.compact();
}
struct Options {
    // Call fsync(2) after every write command, so that an acknowledged write
    // survives a power failure. Without it, a write survives a crash of
    // minikv but may be lost if the machine goes down. ...
    bool sync_writes = false;
};

// Compaction rewrites only the live data, so it costs about `live` bytes of
// I/O and reclaims `garbage` bytes. Waiting until garbage >= live means every
// byte compaction writes pays for at least one byte of garbage removed: total
// disk writes stay within 2x of what the commands wrote, and the log stays
// within about 2x the size of the live data. ...
void Database::maybeCompact() {
    uint64_t garbage = store_.dead();
    uint64_t live = store_.total() - garbage;
    if (garbage >= kMinGarbageToCompact && garbage >= live) store_.compact();
}

注意一个有意思的事实:"垃圾占日志一半"和"垃圾 ≥ 有效数据"完全等价,所以基线的默认值本来就是对的。问题不在默认值,而在把它做成了参数:谁都可以把它改坏,改坏的后果没有任何地方提醒,而为了防止改坏又写了 validate()。

改动规模:options.cpp 整个删除,options.h 39 → 20 行,main.cpp 94 → 66 行,src/ 总计 1418 → 1346 行(diff +48 / −121)。测试一行未改。

1 MiB 这个常数

内部常数仍然是一个决定,区别在于谁为它负责:现在由模块作者负责,理由写在常数旁边——低于 1 MiB 的垃圾,回收它省下的空间无人在意,却要付创建文件、fsync、rename 的固定成本。将来想改它,只需要改一个地方、说服一个人,而不需要每个部署都重新理解一遍。

代价与取舍

新规则让日志最多约为有效数据的 2 倍。磁盘非常紧张的使用者可能接受不了。如果真有这样的使用者,对方知道的是"我最多能给 X 字节",那么该暴露的是一个磁盘预算,而不是把 ratio 放回来:参数应该用使用者懂的语言表达。这对应书中 5.9 节的提醒,确实需要被外界掌握的东西不能为了"简洁"而藏起来。

触发压缩的那次写会一直卡到有效数据全部重写完。在乎尾延迟的使用者可能想控制"什么时候压缩"。更好的回应是把压缩挪到后台或提供"暂缓压缩"的开关,而不是让每个人都去调 ratio。本任务没做,因为 minikv 现在没有这样的使用者。

sync_writes 看上去也能"自动决定",比如写得频繁就不 sync。但那等于替使用者做一个关于数据安全的业务决定,猜错就是丢数据。往下拉的复杂性要和模块本来的职责紧密相关;"这份数据值不值得等磁盘"不是键值存储能知道的事。

审稿人仍会指出的问题

  • Server 仍然接收整个 Options,只为了取出一个 bool 转成 WriteOptions 再一层层往下传:m03。
  • maybeCompact() 用到的 dead()、total() 名字含糊:m07。

完整讨论见 solutions/m04/DISCUSSION.md。对照自己的改动:

cd project            # 仓库里 minikv/ 和 solutions/ 所在的目录
make -C minikv clean && make -C solutions/m04 clean
diff -ru minikv solutions/m04
make -C solutions/m04 test