「我 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. 自测
- 检查异常强制的是「处理」还是「声明」?差别在哪?
- 为什么
RuntimeException在工程上胜出,它的代价由什么兜底? new RuntimeException(e.getMessage())丢了哪三样东西?- 事务回滚的判定发生在哪个时刻?为什么在边界内吞掉异常等于声明成功?
- 日志为什么只该在边界记一次完整堆栈?
- 测异常时断言类型与错误码、不断言完整文案,理由是什么?