Rust 错误处理在 GreptimeDB 的实践
本文讨论了在 GreptimeDB 中 Rust 错误处理的实践,包括:(1)如何构建一个高效且精确的错误堆栈来取代系统 Backtrace 的堆栈;(2)如何在大型项目中组织错误定义;以及(3)如何在不同的方案中打印错误日志,并向最终用户报告错误。文章末尾分享了后续的工作计划。
以下是 GreptimeDB 中的一个错误示例:
错误处理简介
Rust 语言如何定义错误
在 Rust 中,可能失败的函数通常返回一个特殊的枚举 Result<T, E>
,其中 E
通常实现了 std::error::Error trait
(但不必须)。这是错误处理的基础。
本篇博客分享了我们在像 GreptimeDB 这样相对复杂的系统中组织复杂错误类型的经验,从定义错误类型,到如何将错误呈现给最终用户。这样的系统由多个组件组成,每个组件都有自己的错误定义。
Rust 错误处理的现状
Rust 标准库的不少错误类型都实现了 std::error::Error trait
,例如 std::io::Error
和 std::fmt::Error
等。但是,复杂的 Rust 项目通常会自己定义若干个错误类型。这是因为项目需要表达自己的错误信息,或者需要将多个来源的错误聚合成为同一个错误枚举类型。
由于 std::error::Error
trait 并不复杂,手动实现自定义错误类型就相对简单。然而,随着错误枚举变种的增加,处理大量模板代码将变得非常困难。
目前,有一些广泛使用的工具箱可以帮助处理自定义错误类型。例如,著名 Rust 魔法师 dtolnay 开发的 thiserror
和 anyhow
就是 Rust 错误处理生态当中的早期实践。其中 thiserror
主要用于库,而 anyhow
主要用于终端应用。这一规则也适用于大多数情况。
但对于像 GreptimeDB 这样的项目,我们将整个工作空间划分为几个单独的子包,并且需要为每个包定义一个错误类型,又希望在组合操作不同错误类型时有简洁的开发者体验。thiserror
和 anyhow
都不容易实现这一点。
因此,我们选择了另一个包 snafu
来构建我们的错误系统。它类似于 thiserror
和 anyhow
的组合。thiserror
提供了一个方便的宏来定义自定义错误类型,包括显示、源和一些上下文字段。anyhow
提供了一个 Context
trait,可以轻松地从一个底层错误转换为另一个带有新上下文的错误。snafu
既提供了自定义错误类型的过程宏工具,也提供了一系列 context
辅助方法来构造带有上下文的错误实例。
thiserror
主要实现了错误类型的 std::convert::From
trait,这样你就可以轻松地使用 ?
来传递你接收到的错误。这也意味着你不能定义两个来自同一来源的错误变种。假设程序正在进行一些 I/O 操作,使用 thiserror
定义的错误无法区分错误是在写入还是读取时生成的。这也是我们不使用 thiserror
的一个重要原因:类型中的上下文模糊不清。
错误堆栈改良
设计目标
在现实世界中,仅知道错误的 Root Cause 是不够的。假设我们在 GreptimeDB 中实现一个通信协议组件,它从网络字节流中读取消息、解码、执行对应操作,然后发送消息的回复。我们可能会在多个节点上遇到错误:
当发生错误时,一个可能得错误信息是:DecodeMessage(serde_json: invalid character at 1)
。但是,在某个具体组件的逻辑里,可能有超过 10 个地方进行消息解码,每次解码都可能抛出解码错误!我们如何确定在哪一步发现了无效内容呢?很明显,仅仅依靠 Root Cause 是不足以断定的。
因此,尽管 Root Cause 告诉了我们发生了什么错误,但是如果我们想了解这个错误发生的具体位置,以及应该如何处理这个错误,我们就需要错误信息暴露更多的细节上下文。
比如,以下是一个 GreptimeDB 错误日志的示例:
一个好的错误报告不仅能说明错误的根因,更重要的是,处理错误的人能够从错误中获取到什么信息。上面这个错误报告的模式,我们称之为错误堆栈。错误堆栈报告了错误发生的关键轨迹。这非常直观,而且你大概在其他地方,例如 Backtrace
的使用中,看到过类似的形式。
上面的日志准确全面地报告了错误发生的情况,包括用户最终看到的错误到失败的根本原因。此外,还有每个错误堆栈中错误实例确切的行号和列号。可以看到,这个错误是“来自查询 'blabla',第五个包的头部已损坏”。这很可能是无效的用户输入,并且我们可能不需要在服务器端处理它。
这个示例显示了一个错误需要包含的关键信息:
根本原因:告诉我们发生了什么;
完整的上下文堆栈:用于调试或确定错误发生的位置;
从用户的角度看发生了什么:决定了我们是否需要向用户公开展示错误。
很多时候,只要我们使用的库或函数使用了正确实现的错误类型,如同前文展示的 DecodeMessage
示例,根本原因是清晰的。但是,只有 Root Cause 很多时候不足以让用户处理接收到的错误信息。
这里是一个来自 Databricks 所作的 Delta Lake 的示例,佐证了我们关于错误堆栈必要性的观点。
在接下来的部分中,我们将关注上下文堆栈和展示错误的方式,并介绍我们是如何实现这些功能的。
标准库 Backtrace
回到 DecodeMessage
的例子。现在我们知道了错误发生的 Root Cause 是 DecodeMessage(serde_json: invalid character at 1)
,但是,我们不清楚这个错误发生在哪一步:是解码 Header 时发生的?还是解码 Body 时发生的?
一个自然的想法是获取错误发生时的 Backtrace。.unwrap()
是第一个能想到的选择:当错误发生时,它将打印调用的 Backtrace(当然,这是一个坏的实践)。.unwrap()
使用的标准库 Backtrace 提供了完整的调用堆栈以及行号。然后,你会逐层检查源代码,跳过许多无关的系统调用堆栈、运行时堆栈和标准库堆栈,最终将看到错误相关的应用层代码。
如今的 Rust 生态中,许多库也提供在错误构建时捕获 Backtrace 的能力。然而,即使标准库 Backtrace 可以解决功能问题,它在实际运行中也会大量消耗 CPU(#1261)和内存(#1273)资源,从而推高 Backtrace 的使用成本。
捕获 Backtrace 会大大减慢程序的运行速度,因为它需要遍历调用堆栈并翻译指针。同时,为了能够翻译堆栈指针,程序需要在二进制文件中包含大量的 debuginfo
,导致编译产物显著体积增加。在 GreptimeDB 中,使用这一策略将导致最终二进制文件大小增加 700MB 以上(比没有调试信息时的 170MB 增加了 4 倍)。
而且,捕获的标准库 Backtrace 中包含许多噪音,因为系统无法区分代码是来自标准库、第三方异步运行时还是我们的代码库。
标准库 Backtrace 跟我们最终实现的错误堆栈方案之间还有一个区别。标准库 Backtrace 告诉我们如何回到错误发生的位置,但是这个位置是由标准库提供的,应用没有办法更好的展示错误传播的逻辑轨迹。而错误堆栈则展示了错误是如何传播的。
以下面这段代码为例,我们可以看到标准库 Backtrace 和错误堆栈之间的区别:
如果使用标准库 Backtrace,那么上面 ?
传递的错误在 .unwrap
等操作打印这个 Backtrace 时将完全打印整个调用堆栈:
可以看到,这包括了大量你不关心的代码路径,例如标准库、tokio 运行时和系统调用等堆栈。
我们实现的错误堆栈方案,只在显式调用 .context
添加上下文时包含参数提供的上一层错误的堆栈。
对于其他复杂处理逻辑,如批处理,如果其中某一步产生了错误,这个错误可能不会立即传播,而是暂时保留。错误堆栈方案中的虚拟堆栈可以合并这些错误上下文,而标准库 Backtrace 在错误生成时立即捕获的。这导致后者的信息可能残缺不全。例如,标准库 Backtrace 将捕获 map-reduce 逻辑的中间步骤。但虚拟堆栈可以支持我们将捕获时间推迟到 reduce 之后。这样,你就有了更多关于整个任务的信息。
虚拟用户堆栈
现在我们介绍一下虚拟用户堆栈。“虚拟”一词意味着与系统堆栈形成对比。这意味着它完全由用户代码定义和构建。仔细看前面的示例:
堆栈层由 3 部分组成:[STACK_NUM]: [MSG], at [FILE_LOCATION]
Stack num 代表堆栈的编号。更小的数字意味着更外层的错误,这个数字从 0 开始计数;
Message 对应与某一层相关的消息。这是调用错误的
std::fmt::Display
实现获得的。开发者可以在这里附加有用的上下文,如查询字符串或循环计数器;File location 是错误生成(以及传播,对于中间错误层而言)的位置。Rust 提供了
file!
、line!
和column!
宏来帮助获取这些信息。我们展示它的方式也是经过考虑的,大多数编辑器可以直接跳转到对应的位置。
在实践中,我们使用 snafu::Location
来收集代码位置。因此,每个位置都能精准指向错误构建的地方。通过这一链条,我们知道这个错误是如何生成并传播到最上层的。
GreptimeDB 当中的一个错误枚举定义大致如下:
此外,我们自定义了一个 stack_trace_debug
过程宏,来从错误定义中抓取必要信息,并生成相关 trait StackError
的实现。这提供了访问和打印错误的有用方法:
stack_trace_debug
过程宏主要做两件事:
生成实现
StackError
的代码;基于
debug_fmt()
实现std::fmt::Debug
。
顺带一提,我们已经在 GreptimeDB 的所有错误中添加了 Location 和 display。这是实践错误堆栈方法论所需的必要劳动时间。
过程宏的实现要点
错误是一个单向链表,像洋葱一样从外层到内层。因此,我们可以在最外层捕获一个错误并逐层深入。
我们在这里需要处理的一个棘手问题是如何区分系统内部错误和外部错误,因为外部错误并不总是带有堆栈信息,实际上将成为错误堆栈的最内层。
内部错误都实现了同一个特性 ErrorExt
,可以用作标记。但是依赖实现 ErrorExt
trait 进行判断,需要重复调用 downcast
测试,这会明显提高错误处理花费的时间。所以,我们选择简单地为通过命名来区分内外部错误。
如下所示,我们将所有外部错误命名为 error
,所有内部错误命名为 source
。如果发现外部错误,则在实现 StackError::next
方法时返回 None;如果读取到 source
,则返回 Some(source)
。
StackError::debug_fmt
方法用于渲染错误堆栈。它会在生成的代码中递归调用。每层错误都会向可变缓冲区写入自己的调试消息。内容将包含从 #[snafu(display)]
属性捕获的错误描述、像 TableEngineNotFound
这样的变体类型和来自枚举的位置。
鉴于我们已经以这种方式定义了我们的错误类型,采用堆栈错误不需要太多工作,只需向每种错误类型添加属性宏 #[stack_trace_debug]
即可。
向终端用户展示错误
到目前为止,我们已经完成了大部分工作。这是关于如何向用户展示错误的最后一部分。
不同于系统开发者,用户可能不关心行号甚至是堆栈。那么,什么信息对终端用户有帮助呢?
这个话题非常主观。仍以上述错误为例,让我们考虑一下用户会或应该关心哪些部分:
第 1 行简要描述了这个错误,即用户从顶层实际看到的内容,我们也应该保留它。第 2 行和第 3 行是关于内部细节的,包含信息过多。第 4 行是 leaf 内部错误,或者是内部代码到外部依赖的边界。它有时可能包含有用的信息,所以我们将其计入。但只包括错误描述,堆栈号和代码位置对用户无用。最后一行外部错误通常是根本原因,我们也应该包括它。
让我们组合一下刚才挑选的部分。最终呈现给用户的错误消息是:
这可以通过之前的 StackError::next
和 StackError::last
轻松实现。或者你可以用这些方法自定义你想要的格式。
我们的经验是,最内层的错误的消息可能很有用,因为它更接近真正出错的地方。最外层错误的类别通常更准确,因为它来自于错误展示给用户的地方。简而言之,我们提出的错误消息方案是:
错误处理的成本
综上所述,错误堆栈或说虚拟用户堆栈可以非常好的解决复杂 Rust 项目中错误处理的问题。相比于标准库的 Backtrace,错误堆栈更精确,成本更低。
那么,错误堆栈实际的成本到底有多少呢?
错误堆栈方案的运行时开销,只需要格式化打印每层错误的原因和位置。
编译产物体积方案,错误堆栈方案更体现出优势。GreptimeDB 的二进制里,调试符号占用约 700MB。作为比较,剥离后的二进制文件大小约为 170MB,.rodata
部分大小为 016a2225
(~22.6M),.text
部分占用 06ad7511
(~106.8M)。
移除所有错误堆栈需要的 Location
信息,可以将 .rodata
大小减少到 0169b225
(仍约 22.6M,变化非常小)大小仍是约 170MB。而移除所有 #[snafu(display)]
可以将 .rodata
大小减少到 01690225
(~22.5M)并将整体二进制文件大小减少了 0.1MB。
这样,我们可以计算出错误堆栈方案对二进制大小的开销非常低,大约占用 100K 的空间。
结论及未来工作
在这篇文章中,我们展示了如何实现 stack_trace_debug
过程宏。通过使用 stack_trace_debug
,我们可以组装出一个低开销但功能强大的错误堆栈。此外,我们还可以方便地遍历错误链,从而使用不同的方案为不同的目的呈现错误。
这个宏目前只在 GreptimeDB 中采用,我们正在尝试将它重构得更通用,以适用于不同项目的用例。如果生态能够广泛采用这个模式,那么不同库之间可以级联的打印虚拟用户堆栈,整个错误处理的体验会更好。
此外,最新版本的标准库为 std::error::Error
定义了一个暂不稳定的 API provide 来获取结构中的字段。我们或许可以在重构堆栈跟踪工具利用这个 API 来简化编码。
关于 Greptime
Greptime 格睿科技专注于为可观测、物联网及车联网等领域提供实时、高效的数据存储和分析服务,帮助客户挖掘数据的深层价值。目前基于云原生的时序数据库 GreptimeDB 已经衍生出多款适合不同用户的解决方案,更多信息或 demo 展示请联系下方小助手(微信号:greptime)。
欢迎对开源感兴趣的朋友们参与贡献和讨论,从带有 good first issue 标签的 issue 开始你的开源之旅吧~期待在开源社群里遇见你!添加小助手微信即可加入“技术交流群”与志同道合的朋友们面对面交流哦~
Star us on GitHub Now: https://github.com/GreptimeTeam/greptimedb
Twitter: https://twitter.com/Greptime
Slack: https://greptime.com/slack
评论