minikv 重构项目 · 任务 3 / 8

m03:拆掉透传层

对应第 7 章(不同的层,不同的抽象)

背景与对应章节

一条 GET 命令要穿过几个类才碰到哈希表?每一层各自提供了什么别的层没有的抽象?

minikv 的分层看上去很"规范":Server 处理文本协议,Database 是库的入口,Store 管存储,Index 管内存索引。写的人大概觉得层越多越清楚。可顺着一条命令往下跟,你会发现好几层几乎什么都没做,只是把参数原样交给下一层;还有一个参数被一路带下去,只为在最底下读一次。

这个任务练的是第 7 章的判断:每一层都应该提供和上下层不同的抽象,否则这一层就是负担。做之前请读完 第 7 章 · 不同的层,不同的抽象,尤其是 7.1(透传方法和三种处理方式)、7.4(接口与实现)、7.5(透传变量与上下文对象)。

你要找的问题

先别改,拿一条命令顺着调用链读下去:

从哪里开始读的时候问自己
Server::handleGet → src/database.cpp → src/store.cpp → src/index.cpp画出 GET 的调用链。每一层的方法签名长什么样?哪一层真正做了事?
src/database.h逐个方法标出:纯转发、转发加一点点、真正干活。它真正干的那几件事,用到的信息在谁手里?它是怎么拿到的?
src/index.h把它的公有方法和 std::unordered_map 的成员函数对照一下,多出来的有几个?
WriteOptions在 src/ 里搜它:从哪里构造、穿过哪些方法、最后在哪里被读?它的值在一次运行中会变吗?
Store::compact()这里也用到了 WriteOptions,而且传的值和别处不一样。这说明了什么?

确认基线全绿,然后看看 WriteOptions 走了多远:

cd project/minikv
make test
grep -n "WriteOptions\|writeOptions_\|, wo)" src/*.h src/*.cpp

任务与约束

  1. 去掉透传层:让 GET 从协议层到哈希表之间,只经过真正提供不同抽象的层。
  2. Database 里真正有内容的工作(压缩时机、STATS、Options 校验)要找到合适的新家,不能丢,也不能变成上层要记着调用的步骤。
  3. 去掉透传变量:WriteOptions 只在真正需要它的地方出现。

约束:

自查清单

提示

提示 1:三种处理方式各试一下

书中 7.1 节给了三条路:让调用者直接用下层、在两层之间重新分配功能、把两层合并。对 Database 分别想一想:如果只是让 Server 直接用 Store,"写完检查压缩"这件事谁来做?如果把它挪进 Store,Database 还剩什么?

提示 2:Index 值不值得单独存在

一个类的接口和它内部的数据结构一一对应时(7.4 节),它替使用者藏住的东西很少。Store 直接持有一个 std::unordered_map,再加一个私有的查找辅助函数,会不会更简单?

提示 3:变量该在哪一层固定下来

问一句:这个值由谁决定、什么时候决定、会不会变?如果它在启动时决定、一次运行里不变,它就是某个对象的属性,可以在构造时交给真正用它的那一层。但留意 compact():在最底下那一层,不同调用确实传了不同的值。

参考答案与讨论

先自己做完、测试全绿,再打开下面的内容。透传问题往往有不止一种合理解法;关键是能说清楚每一层现在提供的抽象是什么。

打开参考答案

参考解法删掉了 Database 和 Index,调用链变成 Server(文本协议)→ Store(持久化的内存键值表)→ LogWriter(记录编码与写文件)。

// server.cpp
if (!db_.exists(key)) return "(nil)";
auto r = db_.get(key);

// database.cpp
std::pair<bool, std::string> Database::get(const std::string& key) const {
    return store_.get(key);
}
void Database::set(const std::string& key, const std::string& value, const WriteOptions& wo) {
    store_.set(key, value, wo);
    maybeCompact();   // asks store_ for dead() and total()
}

// store.cpp
std::optional<std::string> v = index_.get(key);

// index.cpp
auto it = map_.find(key);
// server.cpp
if (!store_.exists(key)) return "(nil)";
auto r = store_.get(key);

// store.h
class Store {
public:
    // `options` are fixed for the lifetime of the Store; throws
    // std::invalid_argument if they are invalid.
    Store(const std::string& path, const Options& options);
    void set(const std::string& key, const std::string& value);
    ...
    std::string stats() const;   // the STATS line
private:
    const Entry* lookup(const std::string& key) const;
    void maybeCompact();         // called at the end of every write
    WriteOptions writeOptions_;  // derived from options_.sync_writes
    std::unordered_map<std::string, Entry> index_;
    ...
};

三种处理方式,各用在哪里

  • 让调用者直接用下层:对 Database 不合适。只删 Database、让 Server 直接用 Store,"写完检查压缩"就得由 Server 记着做,等于把一条调用顺序约束推给上层。
  • 重新分配功能:Database 真正做的三件事(压缩时机、STATS 那一行、Options 校验)用到的信息全在 Store 手里,挪进 Store 后,count()/total()/dead() 这几个只为 Database 存在的 getter 也一起消失。
  • 合并:挪完以后 Database 只剩转发,删除。Index 的接口照搬 unordered_map,并入 Store,前缀查找写进 Store::keys()。

WriteOptions:同一个参数,两种身份

// Server: built once, passed on every write
db_.set(key, rawValue_, writeOptions_);
// Database::set / del / incr / append (..., const WriteOptions& wo)
store_.set(key, value, wo);
// Store::set / del / incr / append (..., const WriteOptions& wo)
writer_.append(rec, wo);
// LogWriter::append: the only reader
if (options.sync && !file_.sync()) ...
Store::Store(const std::string& path, const Options& options)
    : options_(checked(options)), path_(path) {
    writeOptions_.sync = options_.sync_writes;
    ...
}
// normal writes
writer_.append(rec, writeOptions_);
// compaction: many records unsynced, one fsync at the end
WriteOptions noSync;
out.append(rec, noSync);

相关的出现次数从 31 次降到 7 次,全在 Store 和 LogWriter 之间。WriteOptions 留在了 LogWriter::append 上,因为在那一层它确实会变:普通写按配置决定,压缩写一律不 fsync、最后统一 fsync。判断透传变量的标准不是"它传了几层",而是"中间这几层用不用它、它的值在调用之间变不变"。

数字:Database+Index+Store+Server 共 770 行,合并后 Store+Server 600 行;src/ 总计 1418 → 1248 行。

边界与取舍

LevelDB、RocksDB 的写接口也带一个 WriteOptions,在那里它不是透传变量:调用者确实会给不同的写做不同的选择(账务记录要落盘,缓存数据不必),它是接口的一部分,每一层都必须如实传下去。minikv 的 sync 由启动参数决定、一次运行里不变,所以它是 Store 的属性。如果哪天要支持 SET key value SYNC 这样的逐条选择,就该把它重新做成写方法的参数,而且从命令层到 LogWriter 每一层都有意义。

书中 7.5 节对透传变量的常用解法是上下文对象:把配置和共享状态放进一个对象,主要对象在构造时拿到它的引用。用在 minikv 上大概是这样(草图,参考答案没有采用):

// All process-wide settings and shared counters of one minikv instance.
struct Context {
    const Options options;  // fixed at startup
    Stats stats;            // counters that several layers update
};
class LogWriter {
public:
    explicit LogWriter(Context& ctx) : ctx_(ctx) {}
    uint64_t append(const Record& rec);  // reads ctx_.options.sync_writes
private:
    Context& ctx_;
};

它比全局变量好:一个进程里能开多个实例(我们的测试同时开很多个 Server),测试也能通过它改配置。但作者自己也承认它的毛病:和全局变量一样看不出某个字段为什么存在、谁在用;容易变成杂物袋,制造不明显的依赖;多线程时字段最好不可变。minikv 现在只有一个变量、一个使用者,构造参数就够了。当配置、统计、时钟、日志器都要到达好几个类时,再考虑它。

用 make bench 量一下:基线和 m03 的 GET 耗时差别在两次运行的波动范围内。-O2 下这些转发大多被内联了。透传层是设计问题,不一定是性能问题;GET 的时间花在哪里,是 m08 要先测量再回答的。

store.cpp 205 → 247 行。它现在负责内存表、日志和压缩时机,这些都围绕同一份信息(键、记录大小、垃圾量),放在一起是合理的。如果将来压缩策略变复杂(分段日志、后台压缩),可以把策略拆成一个有实际深度的类,前提是它的接口比原来 Database 那堆转发窄得多。

审稿人仍会指出的问题

  • Store 的 set/incr/append/del 仍各自重复写日志和簿记,命令语义仍在存储层:m02。
  • 日志格式仍然分散在三个类里:m01。
  • 压缩阈值、检查间隔、索引预留仍然推给用户去调:m04。

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

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