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任务与约束
- 定义掉:重新定义
DEL和GETRANGE的语义,使它们对任何合法格式的请求都不会失败。GETRANGE 建议采用和 Python 切片、Redis 一致的约定:负数从末尾数、越界截断、空范围返回空串(注意 minikv 的 end 是包含的)。 - 聚合:去掉各处理函数里重复的 try/catch,让"一个请求失败 → 回 ERR"只在一个地方发生;同时把"请求做不了"和"minikv 自己出了故障"区分开,后者不该被当成普通请求错误吞掉。
- 屏蔽:最后一条记录不完整时,丢掉它、在 stderr 留一行警告、正常启动。
- 崩溃:日志中间的记录损坏时,打印清楚的诊断信息(哪个文件、哪个偏移、为什么判定为损坏、用户可以怎么做)后终止进程。
约束:
- 这个任务会改变外部行为。
tests/test_commands.cpp和tests/test_persistence.cpp不许改,必须保持全绿;tests/test_errors.cpp和tests/test_recovery.cpp描述的正是要改的行为,你需要按新语义重写它们。 - "会崩溃"的测试不能把测试进程本身带走:在
fork()出来的子进程里打开日志,父进程用waitpid检查它是否因SIGABRT终止,并检查 stderr 里的信息。 - 日志文件格式不变。整数参数的重复解析(m05)、日志格式散落各处(m01)都先别管。
- 更新
README.md中 DEL、GETRANGE 的说明,并写明日志损坏时的行为。
自查清单
DEL不存在的 key 回(integer) 0,而且不往日志里写任何东西。GETRANGE s -3 -1、GETRANGE s 0 100、GETRANGE s 5 2、GETRANGE nosuchkey 0 1都有确定的、不报错的结果。- 原来专为这些错误存在的异常类型,现在还有人用吗?没人用就删掉。
server.cpp里"把异常变成 ERR"的代码只有一处;你能一句话说清哪些异常会被它接住、哪些不会、为什么。- 截断尾部的逻辑只出现在重放代码里,
Server、Database、main都不知道它的存在。 - 你的程序能区分"写到一半"和"中间损坏",并且你能说出依据(提示:
append是怎么写一条记录的?)。 - 崩溃时的信息足够让一个没读过源码的运维决定下一步。
- 测试运行后没有留下 core 文件。
提示
提示 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 不存在的 key | ERR no such key | (integer) 0,不写日志 |
GETRANGE 越界、反向、负数 | ERR index out of range | 截断;负数从末尾数;空范围返回 "" |
GETRANGE 不存在的 key | ERR 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