翻一遍多数项目的代码,错误处理基本只做了三件事里的一件:把错误往上抛。一个 try-catch 包住,打印一句日志,然后 throw new RuntimeException(e),链路上每一层都这么来一遍。最后用户看到”系统繁忙”,开发看到二十行一模一样的堆栈。
完整的错误处理包含三个环节:分类、传播、处理。多数代码只实现了传播,分类和处理都缺失。
三件事缺一不可
分类发生在错误产生的那一刻。这个错误是可重试的还是永久失败的?是用户操作问题还是系统故障?是要展示给用户还是只需要开发排查?分类决定了后续所有环节的行为。
传播是错误从产生点流动到处理点的过程。传播中要保留原始信息,同时补充上下文——但不能重复记录。
处理是真正做决定的地方:重试、降级、返回特定错误码、告警、或者吞掉。处理点应该尽可能少,理想情况下每个边界层只有一个。
问题就出在”处理”这一环。因为传播太容易(一行 throw),而处理需要思考,于是处理被无限推迟,最后推到了最顶层的全局异常处理器。全局处理器面对的是所有类型的错误,它只能给出”系统繁忙”这种无信息量的响应。
三种机制的语义差异
异常、错误码、Result 类型不是语法糖的差别,它们表达的是不同的预期。
| 机制 | 语义 | 编译期强制 | 性能特征 | 适用场景 |
|---|---|---|---|---|
| 异常 | “这不应当发生” | 否(受检异常除外) | 抛出时开销大 | 编程错误、不可恢复故障 |
| 错误码 | 返回值的一部分 | 否 | 无额外开销 | 跨语言边界、C 风格 API |
| Result 类型 | “这是可能的结果之一” | 是 | 无额外开销 | 可预期的业务失败 |
异常适合”不应发生”的情况。 空指针、数组越界、断言失败、配置缺失——这些是程序 bug,正确的处理是让它崩溃并留下完整现场,而不是捕获后继续运行。捕获一个 NPE 然后返回一个 null,只是把崩溃点挪到了更难排查的地方。
Result 适合”可预期”的失败。 用户余额不足、优惠券已过期、库存不够——这些都是正常业务流程的一部分,调用方理应处理。用 Result 强制调用方面对这件事:
fn deduct_stock(sku: &str, qty: u32) -> Result<Remaining, DeductError> {
let current = query_stock(sku).map_err(DeductError::Storage)?;
if current < qty {
return Err(DeductError::Insufficient { requested: qty, available: current });
}
write_stock(sku, current - qty).map_err(DeductError::Storage)?;
Ok(Remaining(current - qty))
}
// 调用方必须显式处理每一种情况
match deduct_stock("SKU-1", 2) {
Ok(r) => respond_ok(r),
Err(DeductError::Insufficient { requested, available }) => {
respond_bad_request(format!("库存不足,需要 {},仅有 {}", requested, available))
}
Err(DeductError::Storage(e)) => {
log::error!("库存服务故障: {:?}", e);
respond_service_unavailable()
}
}
错误码的主要价值在跨进程、跨语言边界。进程内的错误处理不宜用错误码——它最大的问题是可忽略性:调用方拿到 -1 可以不检查,代码照样编译通过。跨进程时则反过来,异常无法穿过 HTTP 或 RPC 边界,只剩错误码加错误描述这一条路,所以对外接口的错误码设计必须当作一份契约来维护。
一个务实的折中是:语言支持 Result 类型就用 Result 处理可预期失败,不支持(如 Java)就用受检异常或者自己封装一个轻量的 Result<T, E>;把非受检异常留给真正的程序错误。
异常与控制流混用的代价
用异常表达正常的业务分支,代价具体体现在三个地方:
一、控制流不可见。 读 createOrder() 的代码,看不到它可能在哪里跳转出去。要弄清楚完整的控制流,得把整条调用链上所有 throw 的位置都找出来。
二、性能。 异常的抛出成本远高于普通函数调用,因为它要填充完整的栈轨迹(fillInStackTrace)。在一个高频调用的接口里用异常表示”参数校验失败”,会把这个成本乘以调用次数。对延迟敏感的服务,这个差别是可观测的。
三、丢失类型信息。 catch (Exception e) 什么都接,然后就没法针对不同类型的错误做不同的处理了。链路越往上,信息量越少。
// 反例:用异常表达业务分支
public Order createOrder(Cmd cmd) {
try {
validate(cmd); // 校验失败抛 ValidationException
deductStock(cmd); // 库存不足抛 StockException
return save(cmd);
} catch (ValidationException e) {
throw new BizException("参数错误"); // 详细信息丢了
} catch (StockException e) {
throw new BizException("下单失败"); // 类型信息丢了
}
}
// 正例:业务分支用返回值
public Result<Order, OrderError> createOrder(Cmd cmd) {
Validation v = validate(cmd);
if (v.failed()) return Result.err(OrderError.invalid(v.details()));
StockResult s = deductStock(cmd);
if (s.insufficient()) return Result.err(OrderError.outOfStock(s.available()));
return Result.ok(save(cmd));
}
错误分类的维度
至少要在三个维度上给错误打标签:
按可恢复性: 可重试(网络超时、限流、锁冲突、503)vs 不可重试(参数错误、权限不足、资源不存在)。这个维度直接决定重试策略。
按归属: 业务错误(余额不足、状态不对)vs 系统错误(数据库连不上、序列化失败)。业务错误一般不告警,系统错误要告警。
按受众: 面向用户(可以展示文案)vs 面向开发(只能进日志)。
type ErrorCode struct {
Code string // 对外错误码,如 "ORDER.STOCK_INSUFFICIENT"
HTTPStatus int
Retryable bool
UserFacing bool // 是否可以把 message 直接展示给用户
LogLevel Level
}
var (
ErrStockInsufficient = ErrorCode{
Code: "ORDER.STOCK_INSUFFICIENT", HTTPStatus: 409,
Retryable: false, UserFacing: true, LogLevel: Info,
}
ErrInventoryTimeout = ErrorCode{
Code: "ORDER.INVENTORY_TIMEOUT", HTTPStatus: 503,
Retryable: true, UserFacing: false, LogLevel: Error,
}
)
把分类固化成结构体而不是散落在字符串里,好处是可以在测试里断言”每个错误码都有明确的 retryable 标记”,避免遗漏。
上下文包装与重复记录
错误的上下文应该逐层累加,每一层补充它自己知道的信息。
// 数据层:只知道 SQL 失败
return fmt.Errorf("query order by id: %w", err)
// 服务层:补充业务上下文
if err := repo.Get(ctx, id); err != nil {
return fmt.Errorf("create shipment for order %s: %w", orderID, err)
}
// 最终日志
// create shipment for order SO-991: query order by id: dial tcp 10.0.0.7:3306: i/o timeout
规则是:每一层要么包装(wrap)后向上抛,要么记录(log)后返回业务错误,不要既 log 又 throw。
// 反例:既记录又抛出,链路上有五层就这样,日志里会有五条一模一样的错误
if err != nil {
log.Errorf("failed to get order: %v", err)
return err
}
// 正例:只在边界记录一次
if err != nil {
return fmt.Errorf("get order %s: %w", id, err) // 只包装
}
用 %w(Go)、raise ... from(Python)、throw new XxxException(msg, e)(Java)保留原始错误,这样最上层的日志能拿到完整的因果链,同时中间层不用重复打印。
吞掉异常的四种反模式
一、空 catch。
try { cache.invalidate(key); }
catch (Exception e) { /* 忽略 */ }
缓存清不掉可能导致长期数据不一致。至少记一条 warn。
二、只打印 e.getMessage()。
catch (Exception e) {
log.error("下单失败: " + e.getMessage());
}
丢掉了类型和堆栈。NullPointerException 的 message 常常是 null,日志里就只剩”下单失败: null”。
三、捕获后转成无信息的通用错误。
catch (SqlException e) {
throw new RuntimeException("数据库错误");
}
原始异常被丢弃,排查时无从下手。
四、finally 里 return。
try { return doSomething(); }
finally { return null; } // doSomething 抛出的异常被静默吞掉
这是最隐蔽的一种,finally 中的 return 会覆盖 try 块中的异常,代码看起来正常,错误凭空消失。
判断标准很简单:如果吞掉错误后,系统的行为在出错和没出错时完全一样,那这个 catch 就是多余的;如果不一样,那就不该吞。
对外错误码与内部错误的映射
边界层要做一次翻译:内部的技术细节不能出去,用户的可行动信息要进来。
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handle(Exception ex) {
if (ex instanceof BizException biz) {
return ResponseEntity.status(biz.code().HTTPStatus)
.body(new ApiError(biz.code().Code, safeMessage(biz)));
}
// 未知异常:对外统一,对内留痕
String traceId = MDC.get("traceId");
log.error("unhandled exception, traceId={}", traceId, ex);
return ResponseEntity.status(500)
.body(new ApiError("INTERNAL_ERROR", "服务暂时不可用,请稍后重试,追踪码: " + traceId));
}
private String safeMessage(BizException biz) {
// 只有 UserFacing 的错误才返回业务文案
return biz.code().UserFacing ? biz.getMessage() : biz.code().Code;
}
三条边界规则:
- 不泄漏实现细节。 数据库表名、字段名、内部 IP、堆栈、SQL 语句都不应该出现在响应里。这既是体验问题,也是安全问题。
- 给可行动信息。 余额不足要告诉用户差多少、库存不足要告诉用户还剩多少,而不是一句”操作失败”。
- 留追踪码。 用户报障时给出的 traceId,是连接外部反馈和内部日志的唯一桥梁。
分层的处理职责
| 层级 | 职责 | 不该做什么 |
|---|---|---|
| 数据访问层 | 包装原始错误,保留 cause | 记录业务日志、翻译成用户文案 |
| 领域/服务层 | 判断业务语义,产生分类错误 | 打印日志、决定 HTTP 状态码 |
| 接口层 | 映射错误码、决定状态码、记录一次日志 | 捕获后静默 |
| 全局兜底 | 兜住未知异常,返回通用响应 | 吞掉堆栈 |
按这个分工,一个错误从产生到响应,最多被记录一次,每一层的信息都不丢失,对外又不泄漏。多数项目的错误处理问题,本质上是没想清楚这三件事该在哪一层做。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.