minikv 重构项目 · 任务 2 / 8

m02:专用方法换成通用核心

对应第 6 章(通用的模块更深),设计步骤用第 11 章(设计两次)

背景与对应章节

如果明天要加一个 DECR 命令,你得改几个类?存储层为什么要知道"整数溢出"这回事?

minikv 的命令是一个一个加上去的:先有 SET/GET/DEL,后来产品要计数器,于是 Store 里多了 incr;又要拼接日志行,于是多了 append;再后来要取子串,多了 getRange。每次都是"给新命令在存储层开一个新方法",最快、最顺手。

这个任务练的是第 6 章的判断:接口应该反映"这个模块能做什么",而不是"今天有哪些调用者"。做之前请读完 第 6 章 · 通用的模块更深(尤其是 6.2~6.6 节)和 第 11 章 · 设计两次。本任务的特别之处是:写代码之前,先在本页写下两个截然不同的接口方案并比较。

你要找的问题

先只读代码,回答下面的问题:

文件读的时候问自己
src/store.h公有方法里,哪些只为某一个命令服务?用书中 6.5 节的三个问题逐个问一遍。
src/store.cpp 的 set、del、incr、append、handle把这五个函数并排放,哪几行几乎一模一样?如果要改垃圾字节的统计方式,要改几处?漏改一处会怎样?
Store::incr、Store::getRange"值必须是 64 位整数""end 是闭区间"是谁的规则:存储引擎的,还是命令语言的?
src/server.cpp 的 handleIncr、handleGetRange存储层抛出的 std::invalid_argument、std::out_of_range、KeyNotFound 在这里被翻译成错误回复。这条"抛出—翻译"的路径经过了几个类?
src/database.h每多一个专用方法,这里就多一个转发。加一个 DECR 命令一共要改几个文件?

确认基线全绿,然后数一数存储层有多少地方在"组记录、写日志":

cd project/minikv
make test
grep -n "writer_.append\|kRecordHeaderSize" src/store.cpp

先设计两次

动手之前,为存储层的写接口画出两个差别很大的方案。只写最重要的几个方法签名和一句话说明就够了,不用写实现(原书第 11 章说,一个类的方案比较一两个小时就够)。两个方案要真的不一样,而不是同一个想法的两种写法。如果想不出第二个,可以从这个方向出发:一个方案提供几个"基本操作",另一个方案只提供一个"读出旧值、算出新值、写回去"的操作。

方案 A

方案 B

然后按下面几条比较,写下你的选择和理由:

任务与约束

  1. 按你选定的方案,把 Store 的写接口改成与具体命令无关的通用操作。
  2. "把一条记录应用到内存状态(索引、日志字节数、垃圾字节数)"只写一次,写操作和启动重放共用它。
  3. INCR、APPEND、GETRANGE 的语义(整数解析、溢出、拼接、截取)挪到命令层,存储层不再知道它们。
  4. Database 跟着改:它的接口也不再有一个命令一个方法。

约束:

自查清单

提示

提示 1:存储层真正要提供什么

把每个命令拆成"读了什么、写了什么":INCR 读一个值、写一个值;APPEND 读一个值、写一个值;GETRANGE 只读;DEL 只去掉一个键。拆完你会发现,存储层只要支持三件事就能拼出所有命令。剩下的都是"拿到值以后怎么算",那是命令语言的事。

提示 2:重复的簿记代码收到哪里

启动重放时调用的 handle() 已经在做"把一条记录应用到内存"这件事了。如果写操作先把记录写进日志,再交给同一个函数处理,重放和正常写就不可能算得不一样。可以让它返回记录大小,顺便给统计用。

提示 3:DEL 的错误怎么保留

"去掉一个不存在的键"对存储层来说不必是错误,告诉调用者"原来有没有"就够了(返回 bool)。要不要把它当错误回复给用户,是命令层的决定。行为保持不变即可,至于是否该把这个错误彻底定义掉,留给 m06。

参考答案与讨论

先自己做完、测试全绿,再打开下面的内容。尤其不要跳过"设计两次"那一步:参考答案里对两个方案的比较,只有在你自己先比较过之后才有意义。

打开参考答案

两个方案的比较

std::pair<bool, std::string> get(const std::string& key) const;
void put(const std::string& key, const std::string& value, const WriteOptions& wo);
bool erase(const std::string& key, const WriteOptions& wo);  // true if key existed

// INCR in the command layer:
auto old = db_.get(key);
// ... parse old.second, check overflow ...
db_.put(key, std::to_string(result), writeOptions_);
// fn receives the current value (nullopt if absent) and returns the new
// one; returning nullopt removes the key.
using Updater = std::function<
    std::optional<std::string>(const std::optional<std::string>&)>;
void update(const std::string& key, const Updater& fn, const WriteOptions& wo);

// INCR in the command layer:
db_.update(key, [&](const std::optional<std::string>& old) {
    // ... parse *old, check overflow (how to report an error from here?) ...
    return std::optional<std::string>(std::to_string(result));
}, writeOptions_);
比较A:get / put / eraseB:update(key, fn)
上层好不好写直白,三个方法一看就懂每个命令一个 lambda;错误要从 lambda 里抛出来
接口要解释的东西每个方法一句话nullopt 进、nullopt 出各代表什么;fn 抛异常时日志里写没写
查找次数读-改-写查两次一次
原子性get 和 put 之间别人可能插进来内部可以加锁,天然原子

参考答案选 A:minikv 是单线程的,get 和 put 之间不会有人插进来;A 的每个方法一句话就能说清,B 光是把 nullopt 的两种含义和异常语义讲明白就要一段注释。多查一次哈希表的代价可以忽略(m08 会测量 GET 的时间到底花在哪里)。

一旦 minikv 支持多个客户端并发,用 A 实现的 INCR 会丢更新:两个客户端同时读到 5,各自写回 6。那时需要一个在存储层内部原子完成的读-改-写操作,也就是 B。好在参考答案里所有修改都经过一个私有函数,以后补一个 update 并不难。这也是第 11 章的意思:比较过的方案不会白费,它告诉你什么条件下该换。

改了什么

void set(const std::string& key, const std::string& value, const WriteOptions& wo);
void del(const std::string& key, const WriteOptions& wo);
int64_t incr(const std::string& key, int64_t delta, const WriteOptions& wo);
size_t append(const std::string& key, const std::string& suffix, const WriteOptions& wo);
std::string getRange(const std::string& key, int64_t start, int64_t end) const;

// in each of set / incr / append (and similar in del, handle):
writer_.append(rec, wo);
uint64_t size = kRecordHeaderSize + key.size() + value.size();
if (old) dead_ += old->recordSize;
total_ += size;
index_.put(key, value, size);
stats_.add("bytes_written", size);

同一段簿记代码五份;命令规则(整数、溢出、闭区间)在存储层,以异常的形式穿过三层再翻译成回复。

// Makes `value` the value of `key`, replacing any previous value.
void put(const std::string& key, const std::string& value, const WriteOptions& wo);

// Makes sure `key` has no value. Returns true if it had one (and a
// delete was logged), false if there was nothing to remove.
bool erase(const std::string& key, const WriteOptions& wo);

void Store::put(const std::string& key, const std::string& value, const WriteOptions& wo) {
    Record rec;
    rec.type = RecordType::Put;
    rec.key = key;
    rec.value = value;
    writer_.append(rec, wo);
    stats_.add("bytes_written", handle(rec));   // same code path as replay
}

写操作和重放共用 handle();INCR、APPEND、GETRANGE 在 Server 的处理函数里用 get + put 实现,错误回复直接在那里产生。

数字:store.cpp 205 → 127 行,database.cpp 78 → 63 行,server.cpp 232 → 246 行(命令语义本来就属于这一层)。Store 和 Database 的公有接口一共少了 6 个方法。现在加一个 DECR 或 STRLEN,只改 server.cpp。

代价与取舍

基线的 incr/append/getRange 直接查索引,不计入 keyspace_hits/misses;现在它们通过 get 读值,会被计入。命令回复完全不变,测试也不检查这两个计数。我们认为这样更一致(这三个命令确实读了键),但这是一个外部可见的变化,提交时应该写清楚。

更"通用"的做法是让 Store 只暴露 write(const Record&),上层自己组记录。那会把日志记录这个实现细节泄漏到 Server,上层反而更难写。书中 6.5 节提醒过:太通用的接口(比如只能一次删一个字符)同样会让今天的使用变难。put/erase 是对上层最顺手的那一级通用。

命令语义集中到了 Server 的处理函数里,handleIncr 变长了。如果命令继续增多,可以把它们挪进单独的 commands.cpp,但不该再沉回存储层。处理函数之间大量重复的参数检查是 m05 的题目。

审稿人仍会指出的问题

  • get 返回 std::pair<bool, std::string>,读的人得猜 .first 是什么:m08。
  • Database 的读方法仍然是纯转发,WriteOptions 仍然一路透传:m03。
  • DEL 一个不存在的键仍然报错。存储层已经不把它当错误了,协议层要不要也把它定义掉:m06。

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

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