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任务与约束
- 对每个参数做第 8.2 节的检验:使用者能不能选出一个比模块自己算的更好的值?不能的,删掉,让模块自己决定。
- 能的,保留,并把它的注释写到使用者一看就知道"打开它换来什么、付出什么"。
- 把压缩时机改成一条说得出理由的内部规则,理由写在代码注释里。如果规则里还有常数,也要说明这个常数为什么合理。
- 删掉因参数消失而失去意义的代码:校验、命令行解析、帮助文本、README 段落。
约束:
make test全绿,不许修改tests/。注意test_persistence.cpp里的log_compacts_itself_under_overwrites:它用默认构造的Server覆盖写 3000 次约 1 KB 的值,要求日志最后小于 1.5 MB。你的规则必须让它通过。Server(path)和Server(path, options)两种构造方式都要还能用。- 只做本任务的事:
WriteOptions一路往下传(m03)、Database的透传方法(m03)、含糊的dead()/total()(m07)先别碰。
自查清单
- 每个被删掉的参数,你都能用一句话说出"模块凭什么比使用者更懂"。
- 每个留下的参数,你都能说出一个具体的使用者,以及这个人知道而 minikv 不可能知道的事。
- 压缩规则的注释说明了它保证什么(例如日志最多多大、额外写入最多多少),而不只是复述
if条件。 ./minikv --help之类的输出和 README 都只讲留下的参数。make test全绿,编译零警告;make bench的 SET 耗时没有变坏。
提示
提示 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