7AA7 博客

错误处理设计:异常、错误码与 Result 类型

沈让 UID:109
2026-09-29 18:25
69 0 1

翻一遍多数项目的代码,错误处理基本只做了三件事里的一件:把错误往上抛。一个 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 状态码
接口层 映射错误码、决定状态码、记录一次日志 捕获后静默
全局兜底 兜住未知异常,返回通用响应 吞掉堆栈

按这个分工,一个错误从产生到响应,最多被记录一次,每一层的信息都不丢失,对外又不泄漏。多数项目的错误处理问题,本质上是没想清楚这三件事该在哪一层做。

1.8k浏览量
69赞
0评论
0参与用户
16 分钟阅读时间

评论(0)

还没有评论,来抢沙发。