minikv 重构项目 · 任务 6 / 8

m06:少一些需要处理的错误

对应第 10 章(把错误定义为不存在)

背景与对应章节

minikv 里每一种错误,是真的需要有人处理,还是只是因为接口这样定义了,才变成了错误?

minikv 的错误处理是"能报的都报"风格写成的:请求稍有不合常规就回 ERR,每个命令处理函数都包着一层 try/catch,日志文件末尾少了几个字节就拒绝启动。看上去很严谨,实际上把大量处理工作推给了调用者和运维。

这个任务对应 第 10 章 · 把错误定义为不存在。书里给了四种减少异常处理点的办法:把错误定义掉、在低层屏蔽、在高层聚合、直接崩溃。minikv 里四种都用得上,而且每一种都有"用错地方"的风险。做之前请读完原书第 10 章,特别是 10.3、10.5~10.8 节,以及 10.9 节"做过头"。

你要找的问题

先只读代码、做实验,把找到的每一个错误分到下面四类之一。需要看的地方:

位置读的时候问自己
Store::del、Store::getRange
(src/store.cpp)
调用者发 DEL k 时,想达成的目标是什么?k 本来就不存在时,这个目标达成了吗?想取"前 100 个字符"的调用者,现在要写多少额外代码?
src/server.cpp 的各个 handleXxx一共有多少个 catch?它们做的事有什么不同?写日志失败(磁盘满)时,服务器回什么、之后还会接受写入吗?那时日志文件末尾可能是什么样子?
Store::load()、LogReader::next()进程在 append 写到一半时被杀,下次启动会发生什么?用户该怎么办?这种情况在追加写日志里有多常见?
同上如果坏的是日志中间的某条记录呢?程序的反应和上一种有区别吗?给用户的信息够不够用来做决定?

先亲手制造一次"写到一半就崩溃",看看基线怎么反应:

cd project/minikv
make
printf 'SET a apple\nSET b banana\nDEL nothing\nGETRANGE b 0 100\n' | ./minikv /tmp/m06.log
truncate -s -3 /tmp/m06.log      # 模拟最后一次写只写了一部分
echo 'GET a' | ./minikv /tmp/m06.log
grep -c catch src/server.cpp

任务与约束

  1. 定义掉:重新定义 DEL 和 GETRANGE 的语义,使它们对任何合法格式的请求都不会失败。GETRANGE 建议采用和 Python 切片、Redis 一致的约定:负数从末尾数、越界截断、空范围返回空串(注意 minikv 的 end 是包含的)。
  2. 聚合:去掉各处理函数里重复的 try/catch,让"一个请求失败 → 回 ERR"只在一个地方发生;同时把"请求做不了"和"minikv 自己出了故障"区分开,后者不该被当成普通请求错误吞掉。
  3. 屏蔽:最后一条记录不完整时,丢掉它、在 stderr 留一行警告、正常启动。
  4. 崩溃:日志中间的记录损坏时,打印清楚的诊断信息(哪个文件、哪个偏移、为什么判定为损坏、用户可以怎么做)后终止进程。

约束:

自查清单

提示

提示 1:先问"调用者想达成什么"

把 DEL 的语义从"删除这个 key"改成"确保这个 key 不存在",错误就没了。GETRANGE 同理:"返回值中落在 [start, end] 里的那些字符(如果有的话)"。语义改了,返回值仍然可以保留有用的信息:DEL 返回原来在不在,少数关心的人照样能知道。

提示 2:哪些错误该回 ERR,哪些不该

INCR 一个非数字值:只影响这一个请求,回 ERR 后服务可以照常继续。写日志失败:日志末尾可能已经有半条记录,继续接受写入会把它压到文件中间,下次启动就从"可以屏蔽"变成"必须崩溃"。给请求级错误一个专门的异常类型,Server::handle 只接这一种;其余的让它们传出去。

提示 3:怎么判断"写到一半"

基线的 LogWriter::append 用一次 write(2) 写出整条记录。所以崩溃留下的只能是最后一条记录的前缀:读到文件末尾时记录还没读完,就是写到一半。还有一种情况值得想一想:掉电后文件长度已经更新、数据却没落盘,最后一条记录长度完整但 checksum 不对。其余任何位置出错,都不可能是写到一半。

要让重放代码区分"读到一半到了文件尾"和其他读错误,可以让 LogReader 抛一个专门的异常类型。

提示 4:在子进程里测试崩溃

子进程里先用 setrlimit(RLIMIT_CORE, …) 关掉 core 文件,把 stderr dup2 到一个管道,再构造 Server;如果构造成功就 _exit(0)。父进程读管道、waitpid,检查 WIFSIGNALED 和 WTERMSIG == SIGABRT。

参考答案与讨论

先自己做完、测试全绿,再打开下面的内容。尤其是"什么算写到一半",值得自己先想清楚再对照。

打开参考答案

行为变化一览

改之前改之后
DEL 不存在的 keyERR no such key(integer) 0,不写日志
GETRANGE 越界、反向、负数ERR index out of range截断;负数从末尾数;空范围返回 ""
GETRANGE 不存在的 keyERR no such key""
请求级错误(INCR 非整数)各 handler 自己 catch抛 RequestError,Server::handle 统一转成 ERR
写日志失败回 ERR,继续服务传出 Server::handle,main 打印后退出
最后一条记录残缺拒绝启动截掉它,警告,正常启动
中间记录损坏抛异常,信息含糊打印位置、原因、处理办法后 abort()

参考答案因此替换了 tests/test_errors.cpp 和 tests/test_recovery.cpp,其余测试一字未改。

定义掉

void Store::del(const std::string& key, const WriteOptions& wo) {
    const IndexEntry* old = index_.find(key);
    if (!old) throw KeyNotFound(key);
    ...
std::string Store::getRange(const std::string& key, int64_t start, int64_t end) const {
    const IndexEntry* e = index_.find(key);
    if (!e) throw KeyNotFound(key);
    int64_t len = static_cast<int64_t>(e->value.size());
    if (start < 0 || end < start || end >= len) {
        throw std::out_of_range("index out of range");
    }
    ...
// server.cpp
} catch (const KeyNotFound&) {
    return "ERR no such key";
} catch (const std::out_of_range&) {
    return "ERR index out of range";
}
// Makes sure `key` is absent. Returns true if it was present.
bool Store::del(const std::string& key, const WriteOptions& wo) {
    const IndexEntry* old = index_.find(key);
    if (!old) return false;  // already absent: nothing to do, nothing to log
    ...
// Characters start..end of the value, both inclusive. Negative positions
// count from the end (-1 is the last character). Positions outside the
// value are clamped to it, so this never fails: an empty range or a
// missing key gives "".
std::string Store::getRange(...) const {
    const IndexEntry* e = index_.find(key);
    if (!e) return "";
    int64_t len = static_cast<int64_t>(e->value.size());
    if (start < 0) start += len;
    if (end < 0) end += len;
    start = std::max<int64_t>(start, 0);
    end = std::min<int64_t>(end, len - 1);
    if (start > end) return "";
    ...

KeyNotFound 整个类消失了。

聚合:一个 catch,只接请求级错误

std::string Server::handleIncr() {
    ...
    try {
        int64_t v = db_.incr(key, delta, writeOptions_);
        return "(integer) " + std::to_string(v);
    } catch (const std::invalid_argument& e) {
        return std::string("ERR ") + e.what();
    } catch (const std::overflow_error& e) {
        return std::string("ERR ") + e.what();
    } catch (const std::exception& e) {
        return std::string("ERR ") + e.what();   // 连"磁盘写失败"也吞掉
    }
}
// request_error.h
// Thrown when one request cannot be carried out because of what it asks
// for. ... Every other exception means minikv itself is in trouble; those
// are deliberately not caught per request.
class RequestError : public std::runtime_error { ... };

// server.cpp
std::string Server::handle(const std::string& line) {
    ...
    try {
        return (this->*(it->second))();
    } catch (const RequestError& e) {
        return std::string("ERR ") + e.what();
    }
}

std::string Server::handleIncr() {
    ...
    int64_t v = db_.incr(key, delta, writeOptions_);
    return "(integer) " + std::to_string(v);
}

server.cpp 的 catch 从 16 个降到 3 个(剩下 2 个是把 std::stoll 的异常就地翻译成错误回复,属于 m05 要消除的重复),文件从 232 行变成 194 行。写日志失败现在会一路传到 main、进程退出:这是书里说的错误提升,把一个难以就地处理的错误,变成一个已经有恢复路径的大错误——重启时的残缺尾部处理。

屏蔽与崩溃:都在重放循环里

// Store::load()
while (true) {
    uint64_t start = reader.offset();
    bool more = false;
    try {
        more = reader.next(raw);
    } catch (const TruncatedRecord&) {     // file ends inside a record
        dropTornTail(start, fileSize);       // warn + truncate(2)
        break;
    } catch (const std::exception& e) {
        damaged(e.what());                   // prints where and what, then abort()
    }
    if (!more) break;
    Record r;
    try {
        r = RecordParser::parse(raw);
    } catch (const std::exception& e) {
        if (reader.offset() == fileSize) {   // complete-looking last record, bad checksum
            dropTornTail(start, fileSize);
            break;
        }
        damaged(e.what());
    }
    handle(r);
    end = reader.offset();
}

判断依据:append 用一次 write(2) 写一条记录,所以写到一半只可能留下最后一条记录的前缀。崩溃时打印的信息类似:

minikv: fatal: data.log: record at offset 0 is damaged (checksum mismatch) and more data
follows it, so it is not an interrupted write. Refusing to start. Restore the file from a
backup, or truncate it to 0 bytes to drop this record and everything after it.

代价与边界

对单进程的命令行工具,中间损坏时停下来是最安全的:继续运行就是带着一个大小未知的数据缺口对外服务。但如果 minikv 被做成库、嵌在别人的进程里,abort() 会把宿主一起带走,那时应该改为抛出明确的"日志损坏"异常,让宿主决定。如果它是有副本的存储服务,坏一份数据可以从副本恢复,崩溃反而把可恢复的故障变成停机。写日志失败就退出也是同样的道理:对单机工具是最简单的正确做法,对高可用服务可能要改成只读模式。书中 10.8 节同样强调这取决于应用。

屏蔽只适用于上层不需要知道的信息。丢掉残缺尾部,损失最多是崩溃前的最后一次写,等价于"崩溃早发生了一点"。但现在的格式没有单独校验头部:如果中间某条记录的长度字段被磁盘翻了位,它看起来会"越过文件末尾",被误当成残缺尾部,后面的好数据会一起被截掉。不改格式就分辨不了,真正的修复是给头部加独立 checksum。在那之前,警告里打出被丢弃的字节数是一道保险:写到一半最多丢一条记录,如果警告说丢了几 MB,就该警觉了。

有人担心 DEL 不报错会让"删错了 key"的 bug 更难发现。书中 10.5 节对这类质疑的回答是:带错误的接口会制造另一类 bug(多出来的检查代码、被忽略的错误)。而且信息并没有丢:DEL 返回 0 还是 1,真正关心的少数调用者照样能知道。

审稿人仍会指出的问题

  • 整数参数解析仍在 INCR 和 GETRANGE 里各写一遍,严格程度还不一致(INCR 拒绝 12abc,GETRANGE 接受):m05。
  • 参数个数不对、key 太长等错误仍在每个 handler 里各写一遍 return "ERR …";也可以统一改成抛 RequestError,但那是 m05 的"消除重复"。
  • 残缺尾部的判断要同时看 LogReader 和 RecordParser 的结果,正是因为格式知识散落在多个类里(m01);在 m01 的 LogFile 里做这件事会更自然。

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

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