凌晨三点,生产环境宕机。你盯着日志行:
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 并找到所需的一切。
工程化实践:可落地参数与监控要点
错误设计检查清单
-
机器可读性参数:
- 错误种类枚举不超过 15 个变体
- 每个错误种类有明确的
is_retryable()语义 - HTTP 状态码映射表完整(覆盖率≥95%)
- 错误序列化大小≤2KB
-
人类可读性参数:
- 每个错误帧自动包含
file:line:column - 上下文字段支持至少 10 个键值对
- 错误树深度限制为 20 层(防止无限递归)
- 格式化输出包含业务标识符(用户 ID、请求 ID、任务 ID)
- 每个错误帧自动包含
-
性能参数:
- 错误创建开销≤100 纳秒(无回溯)
- 内存占用≤512 字节(基础结构)
- 序列化 / 反序列化时间≤1 毫秒
- 异步兼容性:支持
#[track_caller]而非回溯
监控与告警配置
-
错误分类仪表板:
error_kinds: - NotFound: # 永久性,无需告警 threshold: 1000/小时 action: log_only - RateLimited: # 临时性,监控趋势 threshold: 500/小时 action: warning - PermissionDenied: # 安全相关,立即告警 threshold: 10/小时 action: alert_pagerduty -
上下文完整性检查:
- 业务标识符缺失率 < 5%
- 调用路径深度≥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:新错误类型与旧类型共存
- 阶段 2:逐步迁移关键路径(API 网关→业务逻辑→数据层)
- 阶段 3:监控错误分类准确率(目标≥98%)
- 阶段 4:移除旧错误类型支持
-
回滚触发器:
- 错误解析失败率 > 1%
- 监控仪表板数据缺失 > 30 分钟
- 性能回归:P99 延迟增加 > 50 毫秒
- 内存使用增加 > 10%
-
测试覆盖率要求:
- 单元测试:错误创建路径 100%
- 集成测试:错误传播路径≥90%
- 负载测试:高错误率场景(1000 错误 / 秒)
- 兼容性测试:新旧错误类型互操作
实施路线图:从理论到生产
第 1 周:基础架构搭建
- 定义核心错误类型(
AppError+ErrorKind+ErrorStatus) - 实现
#[track_caller]自动位置捕获 - 建立基本测试框架(错误创建、序列化、反序列化)
第 2 周:关键路径迁移
- 迁移 API 网关层错误处理
- 实现错误监控仪表板原型
- 配置基础告警规则(错误率、分类准确性)
第 3 周:业务逻辑集成
- 迁移核心业务服务(用户、订单、支付)
- 完善上下文捕获(用户 ID、请求 ID、业务操作)
- 性能基准测试与优化
第 4 周:生产验证
- 金丝雀部署(5% 流量)
- 监控错误分类准确率、性能指标
- 收集开发团队反馈,调整 API 设计
第 5 周:全面推广
- 全量部署
- 文档完善与培训
- 建立错误处理代码审查清单
结论:从转发者到设计师的转变
下次你写函数时,看看Result返回类型。
不要把它看作 "我可能会失败"。把它看作 "我可能需要解释自己"。
如果你的错误类型不能回答 "我应该重试吗?"—— 你让机器失望了。如果你的错误日志不能回答 "是哪个用户?"—— 你让人类失望了。
错误不仅仅是需要传播的故障模式。它们是通信。它们是系统出错时发送的消息。像任何通信一样,它们值得被设计。
停止转发错误。开始设计它们。