Hotdry.
systems-engineering

停止转发错误,开始设计错误:Rust错误处理的工程化实践

深入分析Rust错误处理从'转发'到'设计'的范式转变,提供面向机器与人类的双重错误设计模式与可落地参数。

凌晨三点,生产环境宕机。你盯着日志行:

Error: serialization error: expected ',' or '}' at line 3, column 7

你知道 JSON 解析失败了。但你完全不知道为什么在哪里导致的。是配置加载器?用户 API?Webhook 消费者?

这个错误已经成功地在你的 20 层技术栈中冒泡,完美地保留了原始消息,却在过程中丢失了所有意义。

我们称之为 "错误处理"。但实际上,这只是错误转发。我们把错误当作烫手山芋 —— 捕获它们,包装它们(也许),然后尽快向上抛出。

你添加一个println!,重启服务,等待 bug 重现。这将是一个漫长的夜晚。

当前实践的缺陷:从转发到设计的范式缺失

std::error::Error:高尚但有缺陷的抽象

Rust 的std::error::Error trait 假设错误形成链 —— 每个错误都有一个可选的source()指向底层原因。这在大多数情况下有效;绝大多数错误没有来源或只有一个来源。

但作为标准库抽象,它过于武断。它明确排除了来源形成树的情况:具有多个字段失败的验证错误、带有部分结果的超时。这些场景确实存在,而标准 trait 无法表示它们。

回溯:昂贵药物治错了病

Rust 的std::backtrace::Backtrace本意是提高错误可观测性。它们比没有好,但有严重限制:

在异步代码中,它们几乎无用。你的回溯将包含49 个栈帧,其中 12 个是对GenFuture::poll()的调用异步工作组指出,挂起的任务对传统栈跟踪不可见。

它们只显示起源,而非路径。回溯告诉你错误创建在哪里,而不是它通过应用程序的逻辑路径。它不会告诉你 "这是用户 X 的请求处理器,调用服务 Y,参数 Z"。

捕获回溯是昂贵的。标准库文档承认:"捕获回溯可能是一个相当昂贵的运行时操作。"

thiserror:按起源而非行动分类

thiserror使得定义错误枚举变得容易:

#[derive(Debug, thiserror::Error)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    Connection(#[from] ConnectionError),
    #[error("query failed: {0}")]
    Query(#[from] QueryError),
    #[error("serialization failed: {0}")]
    Serde(#[from] serde_json::Error),
}

这看起来合理。但请注意这种常见实践如何分类错误:按起源,而非按调用者可以采取的行动

当你收到DatabaseError::Query时,你应该做什么?重试?报告给用户?记录并继续?错误没有告诉你。它只告诉你哪个依赖失败了。

正如一位博主恰当地指出:"这种错误类型没有告诉调用者你在解决什么问题,而是告诉调用者你如何解决它。"

anyhow:太方便以至于忘记添加上下文

anyhow采取相反的方法:类型擦除。到处使用anyhow::Result<T>并通过?传播。不再有枚举变体,不再有#[from]注解。

问题?它方便了。

fn process_request(req: Request) -> anyhow::Result<Response> {
    let user = db.get_user(req.user_id)?;
    let data = fetch_external_api(user.api_key)?;
    let result = compute(data)?;
    Ok(result)
}

每个?都是添加上下文的机会。用户 ID 是什么?我们在调用哪个 API?哪个计算失败了?错误不知道这些。

anyhow文档鼓励使用.context()添加信息。但.context()是可选的 —— 类型系统不要求它。"我稍后会添加上下文" 是最容易对自己说的谎。稍后意味着永远不会 —— 直到凌晨三点生产环境着火。

双重设计模式:为机器与人类分别优化

错误有两个受众,各自有不同的需求:

受众 目标 需求
机器 自动恢复 扁平结构、清晰的错误种类、可预测的代码
人类 调试 丰富的上下文、调用路径、业务级信息

当重试中间件收到错误时,它不关心你美丽的嵌套错误链。它只需要知道:这可以重试吗? 一个简单的布尔值或枚举变体就足够了。

当你在凌晨三点调试时,你不需要知道栈深处有一个io::Error。你需要知道:哪个文件、哪个用户、哪个请求、我们在尝试做什么?

大多数错误处理设计没有为任何一方优化。它们为编译器优化。

为机器设计:扁平、可操作、基于种类

当错误需要以编程方式处理时,复杂性是敌人。你的重试逻辑不想遍历嵌套错误链检查特定变体。它想问:is_retryable()?

这是从Apache OpenDAL 的错误设计中提取的有效模式:

pub struct Error {
    kind: ErrorKind,
    message: String,
    status: ErrorStatus,
    operation: &'static str,
    context: Vec<(&'static str, String)>,
    source: Option<anyhow::Error>,
}

pub enum ErrorKind {
    NotFound,
    PermissionDenied,
    RateLimited,
    // ... 按调用者可以做什么分类
}

pub enum ErrorStatus {
    Permanent,   // 不要重试
    Temporary,   // 安全重试
    Persistent,  // 已重试,仍然失败
}

这种设计支持清晰的决策:

// 调用者可以做出明智决策
match result {
    Err(e) if e.kind() == ErrorKind::RateLimited && e.is_temporary() => {
        sleep(Duration::from_secs(1)).await;
        retry().await
    }
    Err(e) if e.kind() == ErrorKind::NotFound => {
        create_default().await
    }
    Err(e) => return Err(e),
    Ok(v) => v,
}

注意关键设计决策:

ErrorKind 按响应而非起源分类NotFound意味着 "东西不存在,不要重试"。RateLimited意味着 "放慢速度再试一次"。调用者不需要知道是 S3 404 还是文件系统 ENOENT—— 他们需要知道如何处理它。

ErrorStatus 是显式的。不是从错误类型猜测可重试性,而是一个首类字段。服务可以在知道重试可能有帮助时将错误标记为临时性。

每个库一个错误类型。不是在模块间散布错误枚举,单个扁平结构保持简单。context字段提供所有需要的特异性,无需类型扩散。

不再遍历错误链,不再从错误类型猜测。直接询问错误。

为人类设计:低摩擦上下文捕获

良好错误上下文的最大敌人不是能力 —— 而是摩擦。如果添加上下文很烦人,开发者就不会做。

exn库(294 行 Rust 代码,零依赖)展示了一种方法:错误形成帧的,每个帧通过#[track_caller]自动捕获其源位置。与线性错误链不同,树可以表示多个原因 —— 当并行操作失败或验证产生多个错误时很有用。

我们需要的是:

自动位置捕获。不是昂贵的回溯,而是使用#[track_caller]零成本捕获文件 / 行 / 列。每个错误帧应该知道它在哪里创建。

符合人体工程学的上下文添加。添加上下文的 API 应该如此自然,以至于添加它感觉不对:

fetch_user(user_id)
    .or_raise(|| AppError(format!("failed to fetch user {user_id}")))?;

thiserror比较,添加相同上下文需要定义新变体和手动包装:

#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("failed to fetch user {user_id}: {source}")]
    FetchUser {
        user_id: String,
        #[source]
        source: DbError,
    },
    // ... 每个需要上下文的调用站点一个变体
}

fn fetch_user(user_id: &str) -> Result<User, AppError> {
    db.query(user_id).map_err(|e| AppError::FetchUser {
        user_id: user_id.to_string(),
        source: e,
    })?
}

在模块边界强制执行上下文。这是 exn 与anyhow的关键区别。使用anyhow,每个错误都被擦除为anyhow::Error,所以你总是可以使用?并继续 —— 类型系统不会阻止你。上下文方法存在,但没有任何东西阻止你忽略它们。

exn 采取不同方法:Exn<E>保留最外层的错误类型。如果你的函数返回Result<T, Exn<ServiceError>>,你不能直接?一个Result<U, Exn<DatabaseError>>—— 类型不匹配。编译器强制你调用or_raise()并提供ServiceError,这正是你应该添加上下文关于你的模块在尝试做什么的时刻。

// 这不会编译——类型不匹配强制你添加上下文
pub fn fetch_user(user_id: &str) -> Result<User, Exn<ServiceError>> {
    let user = db.query(user_id)?;  // 错误:期望Exn<ServiceError>,找到Exn<DbError>
    Ok(user)
}

// 你必须在边界提供上下文
pub fn fetch_user(user_id: &str) -> Result<User, Exn<ServiceError>> {
    let user = db.query(user_id)
        .or_raise(|| ServiceError(format!("failed to fetch user {user_id}")))?;  // 现在编译
    Ok(user)
}

类型系统成为你的盟友:它不会让你在模块边界偷懒。

在实践中是这样的:

pub async fn execute(&self, task: Task) -> Result<Output, ExecutorError> {
    let make_error = || ExecutorError(format!("failed to execute task {}", task.id));
    
    let user = self.fetch_user(task.user_id).await.or_raise(make_error)?;
    let result = self.process(user).or_raise(make_error)?;
    
    Ok(result)
}

每个?都有上下文。当这在凌晨三点失败时,而不是神秘的serialization error,你看到:

failed to execute task 7829, at src/executor.rs:45:12
||-> failed to fetch user "John Doe", at src/executor.rs:52:10
||-> connection refused, at src/client.rs:89:24

现在你知道:是任务 7829,我们在获取用户数据,连接被拒绝。你可以在请求日志中 grep 该任务 ID 并找到所需的一切。

工程化实践:可落地参数与监控要点

错误设计检查清单

  1. 机器可读性参数

    • 错误种类枚举不超过 15 个变体
    • 每个错误种类有明确的is_retryable()语义
    • HTTP 状态码映射表完整(覆盖率≥95%)
    • 错误序列化大小≤2KB
  2. 人类可读性参数

    • 每个错误帧自动包含file:line:column
    • 上下文字段支持至少 10 个键值对
    • 错误树深度限制为 20 层(防止无限递归)
    • 格式化输出包含业务标识符(用户 ID、请求 ID、任务 ID)
  3. 性能参数

    • 错误创建开销≤100 纳秒(无回溯)
    • 内存占用≤512 字节(基础结构)
    • 序列化 / 反序列化时间≤1 毫秒
    • 异步兼容性:支持#[track_caller]而非回溯

监控与告警配置

  1. 错误分类仪表板

    error_kinds:
      - NotFound:  # 永久性,无需告警
        threshold: 1000/小时
        action: log_only
      - RateLimited:  # 临时性,监控趋势
        threshold: 500/小时  
        action: warning
      - PermissionDenied:  # 安全相关,立即告警
        threshold: 10/小时
        action: alert_pagerduty
    
  2. 上下文完整性检查

    • 业务标识符缺失率 < 5%
    • 调用路径深度≥3 层(覆盖率)
    • 时间戳精度:毫秒级
  3. 重试策略参数

    const RETRY_CONFIG: RetryConfig = RetryConfig {
        max_attempts: 3,
        initial_delay: Duration::from_millis(100),
        max_delay: Duration::from_secs(5),
        jitter: 0.1,  // ±10%随机抖动
        only_on_temporary: true,
    };
    

部署与回滚策略

  1. 渐进式部署

    • 阶段 1:新错误类型与旧类型共存
    • 阶段 2:逐步迁移关键路径(API 网关→业务逻辑→数据层)
    • 阶段 3:监控错误分类准确率(目标≥98%)
    • 阶段 4:移除旧错误类型支持
  2. 回滚触发器

    • 错误解析失败率 > 1%
    • 监控仪表板数据缺失 > 30 分钟
    • 性能回归:P99 延迟增加 > 50 毫秒
    • 内存使用增加 > 10%
  3. 测试覆盖率要求

    • 单元测试:错误创建路径 100%
    • 集成测试:错误传播路径≥90%
    • 负载测试:高错误率场景(1000 错误 / 秒)
    • 兼容性测试:新旧错误类型互操作

实施路线图:从理论到生产

第 1 周:基础架构搭建

  • 定义核心错误类型(AppError + ErrorKind + ErrorStatus
  • 实现#[track_caller]自动位置捕获
  • 建立基本测试框架(错误创建、序列化、反序列化)

第 2 周:关键路径迁移

  • 迁移 API 网关层错误处理
  • 实现错误监控仪表板原型
  • 配置基础告警规则(错误率、分类准确性)

第 3 周:业务逻辑集成

  • 迁移核心业务服务(用户、订单、支付)
  • 完善上下文捕获(用户 ID、请求 ID、业务操作)
  • 性能基准测试与优化

第 4 周:生产验证

  • 金丝雀部署(5% 流量)
  • 监控错误分类准确率、性能指标
  • 收集开发团队反馈,调整 API 设计

第 5 周:全面推广

  • 全量部署
  • 文档完善与培训
  • 建立错误处理代码审查清单

结论:从转发者到设计师的转变

下次你写函数时,看看Result返回类型。

不要把它看作 "我可能会失败"。把它看作 "我可能需要解释自己"。

如果你的错误类型不能回答 "我应该重试吗?"—— 你让机器失望了。如果你的错误日志不能回答 "是哪个用户?"—— 你让人类失望了。

错误不仅仅是需要传播的故障模式。它们是通信。它们是系统出错时发送的消息。像任何通信一样,它们值得被设计。

停止转发错误。开始设计它们。

参考资料

  1. FastLabs 博客:Stop Forwarding Errors, Start Designing Them
  2. Apache OpenDAL 错误设计 RFC
  3. exn:Rust 的上下文感知错误库
  4. 大型 Rust 项目中的错误处理(GreptimeDB)
  5. Rust 错误处理深度指南
  6. Provider API 跟踪问题
  7. 异步栈跟踪工作组
查看归档