2829 字
约 9 分钟
20
S04-A06 · Java 错误模型:检查异常与非检查异常的工程结论

「我 catch 了,为什么还是崩了」。答案通常很朴素:catch 的是 Exception,抛的是 Error;或者 catch 之后又包了一层,把原因扔了;再或者异常在另一个线程里,根本没经过这个 catch。

异常设计的争论持续了二十年,工程上的结论其实很清楚:检查异常在签名层面强制了「可能失败」,但没强制「正确处理」。它的收益与成本,取决于代码被谁调用。


1. 检查异常的设计初衷与三个失效

设计意图 实际发生 根因
编译器提醒调用方处理 方法签名层层 throws 中间层没有处理能力,只能转发
强制不忽略失败 catch (Exception e) { } 空吞 编译器只查「有没有 catch」
让失败成为 API 的一部分 包装后 cause 丢失 包装写法随手,无约束
// 签名污染的典型结果:每一层都在复制 throws
public Order load(long id) throws IOException, SQLException, ParseException { ... }
public Order service(long id) throws IOException, SQLException, ParseException { ... }
public Order controller(long id) throws IOException, SQLException, ParseException { ... }

三层的 throws 没有增加任何信息,只是把「可能失败」从底层搬到了顶层。签名是给人看的契约,重复三次之后,没人再读它。


2. 为什么 RuntimeException 成为主流

不是因为它「更优雅」,而是因为它不污染签名。

阻力 检查异常 非检查异常
接口演化 加一个 throws 就是破坏性变更 不改变签名
Lambda / Stream 函数式接口不声明受检异常 可直接抛
框架集成 反射、代理、AOP 调用链要额外解包 无额外处理
跨层传递 每层签名跟着改 一路向上

代价也很具体:编译器不再提醒,失败路径靠人自觉。缓解手段不是回到检查异常,而是用全局处理器兜底、用测试覆盖失败分支、用日志把未处理异常暴露出来。


3. 分类的正确维度

按「检查/非检查」分类没有工程意义。有意义的维度有两个:能不能恢复、该由谁处理。

类型 例子 处理者 策略
调用方可恢复 参数非法、库存不足、余额不够 调用方 返回可读错误,引导重试或修正
系统可重试 超时、连接重置、下游 503 框架/重试层 有限重试 + 退避
系统不可恢复 配置缺失、磁盘满、数据结构损坏 运维/开发者 快速失败,告警
编程错误 NPE、越界、状态断言失败 开发者 不捕获,暴露出来

NullPointerException 永远不该被业务代码 catch:它表示代码有 bug,捕获它等于把 bug 变成静默的数据错误。


4. 异常链与 cause 的保留规则

// 丢栈:只剩一句消息,原始堆栈与类型全部消失
throw new RuntimeException(e.getMessage());

// 丢原因:新异常没有 cause,排查时断链
throw new BizException("下单失败");

// 正确:保留 cause,消息补上下文,不重复拼接
throw new BizException("下单失败, orderNo=" + orderNo, e);
写法 保留类型 保留堆栈 保留原因 结论
new RuntimeException(msg) 否 否 否 不可用
new RuntimeException(e) 否 是 是 可接受
new BizException(msg, e) 是 是 是 推荐

printStackTrace() 是另一种掩盖:它把栈打到标准错误,绕过了日志框架的级别、格式与采集,生产环境里通常哪都找不到。


5. 自定义异常体系的分层

层 基类 携带信息 典型子类
业务 BizException 业务码、用户可读消息 库存不足、重复提交
系统 SysException 组件名、是否可重试 下游超时、配置缺失
校验 ValidationException 字段名、约束 参数越界
public abstract class AppException extends RuntimeException {
    private final String code;
    protected AppException(String code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
    }
    public String code() { return code; }
}

分层的判据是处理方式是否不同,不是「看起来整齐」。业务异常映射成 4xx 且消息可外露;系统异常映射成 5xx 且消息要脱敏;两类混在一个基类里,全局处理器就只能靠字符串猜。


6. 全局处理的落点与顺序

@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)   // 兜底,放最后
public class GlobalHandler {
    @ExceptionHandler(BizException.class)
    public ResponseEntity<ApiError> biz(BizException e) { ... }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> unknown(Exception e) { ... }
}
顺序 处理器 作用
1 具体业务异常 转 4xx,消息可外露
2 校验异常 汇总字段错误
3 框架异常 转标准状态码
4 Exception 兜底 记日志、脱敏、500

顺序由 @Order 与异常的具体程度共同决定:具体类型优先,兜底最后。兜底处理器必须记录完整堆栈,其余处理器只记必要上下文,否则同一异常会被记两遍。


7. 异常与事务回滚

抛出类型 默认回滚 备注
RuntimeException 是 默认规则
Error 是 默认规则
检查异常 否 需要 rollbackFor
被 catch 后未再抛 否 事务正常提交
@Transactional(rollbackFor = Exception.class)   // 显式覆盖检查异常
public void placeOrder(Command cmd) throws IOException { ... }

最容易出事的是「catch 了业务异常、转成返回值、事务照常提交」:库存扣了、消息发了、订单没建。回滚判定发生在异常穿出事务边界的那一刻,在边界内把异常吃掉,等于声明「本次事务成功」。


8. Result 对象与异常的选择

维度 抛异常 返回 Result
局部代码可读性 主流程不被错误分支打断 每步都要判 isSuccess
强制处理 非检查异常不强制 类型上提醒,但可忽略
跨进程 需序列化成错误响应 天然可序列化
框架集成 事务、重试、AOP 都能感知 框架看不到失败
调用方体验 要读异常类型 要读码表

跨进程时两者的差别消失:无论本地抛什么,最终都变成 HTTP 状态码与 body。跨进程的契约用错误码表达,进程内用异常表达,是多数团队的实际落点。


9. 日志只在一处记

// 差:每层都记,同一异常出现五行,根因被淹没
catch (Exception e) { log.error("service failed", e); throw e; }

// 好:中间层只补上下文,边界统一记一次
catch (Exception e) { throw new BizException("cancel failed, id=" + id, e); }
// 边界(全局处理器)里:log.error("request failed, path={}", path, e);
位置 记什么 不记什么
中间层 上下文(ID、参数摘要) 完整堆栈
边界 完整堆栈 + 请求标识 业务可预期分支
重试层 尝试次数与结果 每次的完整堆栈

日志量与异常量成正比是正常的,日志量与异常嵌套层数成正比就是设计问题。


10. 异常的成本

异常对象的开销主要来自填充堆栈,构造时遍历调用栈。低频路径上可以忽略,高频路径上不能。

// 高频路径:用预分配的单例异常,关闭栈填充
public class FastFailure extends RuntimeException {
    public static final FastFailure INSTANCE = new FastFailure();
    private FastFailure() { super("rejected", null, false, false); }
}

流控、限流、参数校验失败这类「每秒可能上万次」的路径,适合用返回值或单例异常;业务失败这类「低频但需要现场」的路径,保留完整堆栈。用异常做流程控制是另一个坑:它把可读的循环变成靠 catch 跳出的状态机。


11. 错误码与异常共存,以及怎么测

关注点 做法
码表 错误码集中定义,禁止散落字符串
映射 异常类型 → HTTP 类别 → 业务码,一层映射
可重试性 写进码表,调用方不必猜
测试 断言类型 + 关键消息,不断言完整文案
@Test
void rejectsWhenStockShort() {
    BizException e = assertThrows(BizException.class, () -> service.place(cmd));
    assertEquals("STOCK_SHORTAGE", e.code());
}

只断言「抛了异常」的测试几乎没有价值:抛错类型、错误码、是否保留 cause,才是调用方真正依赖的部分。断言完整消息则会让每次文案微调都红一片,断言码,不断言文案。


12. 排错:五种「捕获了却没生效」

现象 原因 处理
catch 不到 异常在别的线程/线程池里抛出 加 UncaughtExceptionHandler
日志里没有根因 包装时丢了 cause 构造器传 e
事务没回滚 检查异常或异常被吞 rollbackFor 或重新抛出
全局处理器没触发 异常被更具体的处理器先接走 查 @Order 与类型层级
堆栈指向框架 异常在框架层被重新包装 沿 getCause() 找到根因

13. 边界:不必建立完整体系的情况

场景 结论 理由
一次性脚本 直接抛,让进程失败 没人消费错误码
单进程小工具 少量自定义异常即可 分层收益低于维护成本
面向最终用户的提示层 只做文案映射 内部结构不该外露
已有框架统一模型 跟随框架 自建会与框架冲突
纯计算库 用标准异常 调用方按 JDK 约定处理

异常体系的复杂度应该与调用方数量、故障代价、排障频率匹配。一个只有自己的服务,最重要的不是异常分层,而是别把 cause 丢掉。


14. 自测

  1. 检查异常强制的是「处理」还是「声明」?差别在哪?
  2. 为什么 RuntimeException 在工程上胜出,它的代价由什么兜底?
  3. new RuntimeException(e.getMessage()) 丢了哪三样东西?
  4. 事务回滚的判定发生在哪个时刻?为什么在边界内吞掉异常等于声明成功?
  5. 日志为什么只该在边界记一次完整堆栈?
  6. 测异常时断言类型与错误码、不断言完整文案,理由是什么?
S04-A06 · Java 错误模型:检查异常与非检查异常的工程结论
https://www.clxhxhhr.top/posts/6179/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。