在本文中,我们将讨论GreptimeDB中Rust错误处理为主题的实践,并在最后分享了可能的未来工作。 主题包括:
(1)如何构建一个更便宜但更准确的错误堆栈来替换系统回溯;
(2) 如何组织大型项目中的错误;
(3) 如何将不同方案中的错误打印到日志和最终用户。
在GreptimeDB中可能的错误如下:
0: Foo error, at src/common/catalog/src/error.rs:80:10
1: Bar error, at src/common/function/src/error.rs:90:10
2: Root cause, invalid table name, at src/common/catalog/src/error.rs:100:10
介绍
首先要我们要知道Rust的错误处理是以Result枚举为核心的,其中E通常(也可以不)扩展自std::Error::Error
pub enum Result<T, E> {
/// Contains the success value
Ok(T),
/// Contains the error value
Err(E),
}
本文分享了我们在像GreptimeDB这样的复杂系统中组织各种类型错误的经验,从如何定义错误到如何记录错误或将其呈现给最终用户。这样的系统由多个组件组成,每个组件都有自己的错误定义。
Rust错误处理的现状
Rust中的一些标准库提供了实现std::Error::Error的Error结构,如std::io::Error或std::fmt::Error。 但开发者通常会为他们的项目定义自定义错误,因为他们想表达应用程序特定的错误信息,或者需要在枚举中对多个错误进行分组。由于std::error::error特性并不复杂,因此很容易手动实现一种自定义错误类型。 然而,您通常不想这样做。因为随着错误变体的增加,使用洪泛模板代码将非常困难。
如今,有一些广泛使用的开箱即用的实用程序可以帮助处理定制的错误类型。 例如,this error和anyhow是由著名的Rust专家 @dtolnay开发的,区别在于this error主要用于库,anyhow用于二进制文件。这条规则适用于大多数情况。但对于像GreptimeDB这样的项目,我们将整个工作空间划分为几个单独的子项目,我们需要为每个项目定义一种错误类型,同时保持精简的组合。thiserror和anyhow都不容易实现。
因此,我们选择了另一个库snafu应用到我们的错误系统。它在使用上类似是thiserror和anyhow的组合。this error提供了一个方便的宏来定义自定义错误类型,包括显示、源和一些上下文字段。anyhow提供了一个上下文特性,可以很容易地从一个底层错误转换为另一个具有新上下文的错误。
this error主要为你的错误类型实现std::convert::From trait,这样你就可以简单地使用?传播你收到的错误。因此,这也意味着你不能从同一源类型定义两个错误变量。考虑到你正在执行一些I/O操作,你将不知道是在写路径还是读路径中生成了错误。这也是我们不使用this error的一个重要原因:上下文的类型模糊。
堆栈错误处理
设计目标
在现实世界中,仅仅知道错误的根本原因是不够的。假设我们正在GreptimeDB中构建一个协议组件。它从网络中读取消息,对其进行解码,执行一些操作,然后发送它们。我们可能会从几个方面遇到错误:
enum Error{
ReadSocket(hyper::Error),
DecodeMessage(serde_json::Error),
Operation(GreptimeError),
EncodeMessage(serde_json::Error),
WriteSocket(hyper::Error),
}
我们可能得到的一个错误消息是:DecodeMessage(serde_json: invalid character at 1)。然而,在一个特定的代码片段中,解码消息的位置可能超过10个(从而抛出此错误)!我们如何判断在哪个步骤中看到了无效字符?因此,尽管错误本身说明了发生了什么,但如果我们想知道这个错误发生在哪里,如果我们应该注意它,我们需要错误携带更多信息。为了进行比较,这里有一个你可能会从GreptimeDB看到的错误日志示例。
Failed to handle protocol
0: Failed to handle incoming content, query: blabla, at src/protocol/handler.rs:89:22
1: Failed to reading next message at queue 5 of 10, at src/protocol/loop.rs:254:14
2: Failed to decode `01010001001010001` to ProtocolHeader, at src/protocol/codec.rs:90:14
3: serde_json(invalid character at position 1)
一份好的错误报告不仅是关于它是如何构建的,更重要的是,告诉人们从它的原因和痕迹中可以理解什么。我们称之为堆栈错误。它应该是直观的,你一定在其他地方见过类似的格式,比如链路追踪。从这个日志中,很容易了解整个事情的完整背景,从面向用户的行为到根本原因。加上每个错误传播的确切行号和列号。您将知道此错误是from the query "blabla", the fifth package's header is corrupted。这可能是无效的用户输入,我们可能不需要从服务器端处理它。
此示例显示了错误应包含的关键信息:
• The Root Cause告诉我们发生异常的根错误原因。
• The full context stack 用于调试或找出错误发生位置的完整上下文堆栈。
• What happens from the user's perspective从用户的角度来看会发生什么。决定是否需要向用户公开错误。
在许多情况下,根错误原因通常很明显,就像上面的DecodeMessage示例一样,只要我们使用的库或函数正确实现了它们的错误类型。但仅仅找到根错误原因是不够的。
在接下来的部分中,我们将重点介绍上下文堆栈和显示错误的方法。并展示了我们实现它的方法。所以希望你能重现与GreptimeDB中相同的实践。
系统异常堆栈回溯
因此,现在你已经找到了根本原因(DecodeMessage(serde_json: invalid character at 1))。但目前尚不清楚此错误发生在哪一步:是在解码标头时,还是在解码正文时?一个直观的想法是捕捉堆栈异常进行回溯。unwrap()是第一选择,当错误发生时,回溯将显示出(当然这是一种糟糕的做法)。它会给你一个完整的调用堆栈和行号。
这样的调用堆栈包含完整的跟踪,包括许多不相关的系统堆栈、运行时堆栈和std堆栈。如果你想在应用程序代码中找到调用,你必须逐个检查源代码堆栈,跳过所有不相关的代码。如今,许多库还提供了在构造错误时捕获回溯的能力。无论系统回溯是否能提供我们真正想要的东西,它在CPU(#1261)和内存(#1273)上都非常昂贵。
捕获回溯将大大减慢程序的速度,因为它需要遍历调用堆栈并转换指针。然后,为了能够翻译堆栈指针,我们需要在二进制文件中包含一个大的debuginfo。在GreptimeDB中,这意味着二进制大小增加了>700MB(与没有debuginfo的170MB相比增加了4倍)。在捕获的系统回溯中会有很多噪音,因为系统无法区分代码是来自标准库、第三方异步运行时还是应用程序代码。
系统回溯和提出的堆栈异常之间还有另一个区别。系统回溯告诉我们如何到达发生错误且无法控制错误的位置,而堆栈异常则显示错误是如何传播的。
以下面的代码片段为例,检查系统回溯和虚拟堆栈之间的区别:
async fn handle_request(req: Request) -> Result<Output> {
let msg = decode_msg(&req.msg).context(DecodeMessage)?; // propagate error with new stack and context
verify_msg(&msg)?; // pass error to the caller directly
process_msg(msg).await? // pass error to the caller directly
}
async fn decode_msg(msg: &RawMessage) -> Result<Message> {
serde_json::from_slice(&msg).context(SerdeJson) // propagate error with new stack and context
}
系统回溯将打印整个调用堆栈,如下所示:
1: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::call
at /rustc/3f28fe133475ec5faf3413b556bf3cfb0d51336c/library/alloc/src/boxed.rs:2029:9
std::panicking::rust_panic_with_hook
at /rustc/3f28fe133475ec5faf3413b556bf3cfb0d51336c/library/std/src/panicking.rs:783:13
... many lines for std's internal traces
22: tokio::runtime::task::raw::RawTask::poll
at /home/wayne/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.35.1/src/runtime/task/raw.rs:201:18
... many lines for tokio's internal traces
32: std::thread::Builder::spawn_unchecked_::{{closure}}::{{closure}}
at /rustc/3f28fe133475ec5faf3413b556bf3cfb0d51336c/library/std/src/thread/mod.rs:529:17
... many lines for std's internal traces
如你所见,它包含了许多你不感兴趣的内部堆栈。
对于其他复杂的逻辑,如批处理,错误可能不会立即传播,而是会保持一段时间,虚拟堆栈也有助于使其易于理解。当生成子错误时,系统回溯会被捕获到适当的位置,就像在映射减少逻辑的中间步骤中一样。但是,使用虚拟堆栈,您可以将时间推迟到reduce步骤或之后,在那里你可以获得有关整个任务的更多信息。
虚拟用户堆栈
现在让我们介绍一下虚拟用户堆栈。“虚拟”一词是指系统堆栈的对比度。意味着它完全基于用户代码进行定义和构造。仔细看看前面的例子:
# 堆代码 duidaima.com
0: Failed to handle incoming content, query: blabla, at src/protocol/handler.rs:89:22
1: Failed to reading next message at queue 5 of 10, at src/protocol/loop.rs:254:14
2: Failed to decode `01010001001010001` to ProtocolHeader, at src/protocol/codec.rs:90:14
3: serde_json(invalid character at position 1)
堆栈层由3个部分组成:[stack_NUM]:[MSG],at [FILE_LOCATION]
• Stack num是此堆栈的编号。较小的数字表示外部错误层。当然是从0开始。
• MSG是与一个层相关的消息。这是从该错误的std::fmt::Display实现中抓取的。开发人员可以在此处附加有用的上下文,如查询字符串或循环计数器。
• FILE_LOCATION是生成一个错误(并传播到中间错误层)的位置。Rust提供file,line!和column!宏来帮助获取这些信息。我们还考虑了显示它的方式,大多数编辑器可以直接跳转到该位置。
在实践中,我们使用snafu::Location来收集代码位置。因此,每个位置都指向误差的构造位置。通过这个链,我们知道这个错误是如何产生并传播到最上层的。
以下是从代码方面来看的所有内容:
#[derive(Snafu)]
pub enum Error {
#[snafu(display("General catalog error: "))] // <-- the `Display` impl derive
Catalog {
location: Location, // <-- the `location`
source: catalog::error::Error, // <-- inner cause
}
}
此外,我们实现了一个proc宏stack_trace_debug,从Error的定义中抓取必要的信息,并生成相关trait StackError的实现,该trait提供了访问和打印错误的有用方法:
pub trait StackError: std::error::Error {
fn debug_fmt(&self, layer: usize, buf: &mut Vec<String>);
fn next(&self) -> Option<&dyn StackError>;
fn last(&self) -> &dyn StackError where Self: Sized { ... }
}
这个proc宏主要做两件事:
• 实现StackError作为脚手架
• 基于Debug_fmt()实现std::fmt::Debug
顺便说一句,我们已经将Location和display添加到GreptimeDB中的所有错误中。这就是方法论背后的辛勤工作。
宏详细信息
错误是一个单链表,就像洋葱从外到内。因此,我们可以在最外层捕获错误并遍历它。 我们在这里做的一件棘手的事情是如何区分内部和外部错误。内部错误都实现了相同的特征ErrorExt,可以用作标记。但根据这一点,每次都需要downcast。我们通过简单地给它们一个不同的名称并在宏中检测来避免这种额外的downcast调用。
如下所示,我们将所有外部错误命名为error,将所有内部错误命名为source。然后,如果我们发现错误,在实现StackError::next方法时返回None,如果我们读取源代码,则返回Some(source)。
#[derive(Snafu)]
#[stack_trace_debug]
pub enum Error {
#[snafu(display("Failed to deserialize value"))]
ValueDeserialize {
#[snafu(source)]
error: serde_json::error::Error, // <-- external source
location: Location,
},
#[snafu(display("Table engine not found: {}", engine_name))]
TableEngineNotFound {
engine_name: String,
location: Location,
source: table::error::Error, // <-- internal source
}
}
StackError::debug_fmt方法用于呈现错误堆栈。它将在生成的代码中递归调用。每一层错误都会向可变buf写入自己的调试消息。内容将包含从#[snafu(display)]属性捕获的错误描述、TableEngineNotFound等不同错误类型以及在枚举中的位置。鉴于我们已经以这种方式定义了错误类型,采用堆栈错误不需要太多的工作,只需在每个错误类型中添加属性宏#[stack_trace_debug]就足够了。
向终端用户呈现错误
到目前为止,我们已经涵盖了大部分方面。现在,让我们深入研究最后一部分,即如何向用户显示错误。与系统开发人员不同,用户可能不关心行号甚至堆栈。那么,哪些信息对最终用户真正有益呢? 这个话题非常主观。仍然以上述错误为例,让我们考虑用户会或应该关心哪些部分:
Failed to handle protocol
0: Failed to handle incoming content, query: blabla, at src/protocol/handler.rs:89:22
1: Failed to reading next message at queue 5 of 10, at src/protocol/loop.rs:254:14
2: Failed to decode `01010001001010001` to ProtocolHeader, at src/protocol/codec.rs:90:14
3: serde_json(invalid character at position 1)
第一行简要描述了这个错误,即用户从顶层实际看到了什么。我们也应该保留它。第2行和第3行是关于内部细节的,这些细节太冗长了,无法包含在内。第4行是叶子内部错误,或从内部代码到外部依赖的边界。它有时可能包含有用的信息,所以我们把它算进去。但是,我们只包括错误描述,因为堆栈号和代码位置对用户没有用。最后一行是外部错误,这通常是根本原因,我们也会将其包括在内。
让我们把刚挑选的碎片组装起来。向用户显示的最终错误消息如下:
Failed to handle protocol - Failed to decode `01010001001010001` to ProtocolHeader (serde_json(invalid character at position 1))
这可以通过之前的StackError::next和StackError::last轻松实现。或者,您可以使用这些方法自定义所需的格式。我们的经验是,叶子(或最内层)错误的信息可能很有用,因为它更接近真正的问题所在。消息可以进一步分为两部分:内部和外部,其中内部错误是我们代码库中定义的错误,外部错误来自依赖关系,如前一个示例中的serde_json。根(或最外层)错误的类别更准确,因为它来自向用户抛出错误的地方。
简而言之,我们提出的错误消息方案是:
KIND - REASON ([EXTERNAL CAUSE])
成本?
到目前为止,虚拟堆栈是相当棒的,与系统堆栈相比,它被证明更具成本效益和准确性。那么,成本是多少?至于运行时开销,它只需要针对每个级别的原因和位置使用一些字符串格式。二进制大小成本表现更好。在GreptimeDB的二进制文件中,调试符号占用了约700MB。相比之下,缩减后的二进制大小约为170MB,.rodata部分大小为22.6M,.text部分大小为106.8M。
删除所有Location会将.rodata大小减小到约为22.6M,更改非常小,整体二进制大小减小到170MB,而删除所有#[snafu(display)]会将.rodota大小减小至约22.5M,整体二进二进制大小减小至170MB。因此,堆栈错误机制对二进制大小的开销非常低(~100K)。
结论和未来工作
在这篇文章中,我们将介绍如何实现一个proc宏stack_trace_debug,并使用它来组装一个低开销但仍然强大的堆栈错误消息。它还提供了一种方便的方式来遍历错误链,以帮助在不同的模式中为不同的目的呈现错误。此宏目前仅在GreptimeDB中采用,我们正试图使其更适用于不同的项目。广泛采用这种模式还可以通过桥接第三方堆栈和详细原因使其更加强大。
此外,std::error::error现在提供了一个不稳定的API提供,它允许在结构中获取字段。我们可以考虑在重构堆栈跟踪实用程序时使用它。