3.1 被吞掉的异常与 try 坑 本节摘要:空 catch、只打日志继续跑、finally 里 return 吞异常、用异常做流程控制——四类坏味道让故障"静默化"。本节从一次支付回调丢失事故讲起,覆盖异常层次、try-with-resources 的资源语义、异常信息的可排错性标准,以及 checked 异常的工程取舍。 事故:丢了三天的支付回调 支付网关的回调处理上线后运行"正常",日志干净,无 ERROR。直到对账才发现:每天约千分之二的回调没有落库。排查从队列开始、到网络、再到幂等控制,最后在代码里发现了这个: 一次紧急发布里为了"先恢复服务"把异常吞了,注释里的"后续修复"随着版本翻篇再没人提起。千分之二的失败被无声抹平,日志一行不留——这不是处理了异常,是把异常的证据销毁了。
本节摘要:空 catch、只打日志继续跑、finally 里 return 吞异常、用异常做流程控制——四类坏味道让故障"静默化"。本节从一次支付回调丢失事故讲起,覆盖异常层次、try-with-resources 的资源语义、异常信息的可排错性标准,以及 checked 异常的工程取舍。
支付网关的回调处理上线后运行"正常",日志干净,无 ERROR。直到对账才发现:每天约千分之二的回调没有落库。排查从队列开始、到网络、再到幂等控制,最后在代码里发现了这个:
try { callbackService.handle(payload); } catch (Exception e) { // 临时绕过 后续修复 -- 某次紧急上线的注释 }
一次紧急发布里为了"先恢复服务"把异常吞了,注释里的"后续修复"随着版本翻篇再没人提起。千分之二的失败被无声抹平,日志一行不留——这不是处理了异常,是把异常的证据销毁了。
更糟的连锁:因为失败被掩盖,上游认为全部成功,不重试;等对账发现时,数据已经缺了三天,只能人工补录。故障损失 = 技术修复成本 × 静默时长,吞异常把静默时长拉到最大。
} catch (Exception e) { log.warn("failed", e); // 打了日志 然后呢? }
打日志只解决"可发现",不解决"可处理"。调用方拿着一个半成品结果继续走,错误状态沿调用链扩散,最后在离根因很远的地方以数据错乱的形式显形。正确姿势二选一:要么能恢复就真恢复(重试、降级、默认值,并且要写清楚凭什么这样恢复),要么不能恢复就往上抛,把终止权交给有上下文的人。
try { return computeValue(); // 抛异常时该向上传播 } finally { return fallback; // 但 finally 的 return 会把它吃掉 }
finally 里的 return(或 throw)会丢弃 try 块里未处理的异常,编译器对此仅给一个过时的警告。finally 的合法职责只有清理资源,任何带业务语义的 return 都不该出现在里面。
宽 catch 看似稳健,实际把 InterruptedException、OutOfMemoryError 邻区的严重失败和普通业务失败混在一个桶里。尤其 InterruptedException 被抓后不恢复中断位,线程池的优雅停机会莫名失效(第 5 章会再遇到它)。抓取粒度的纪律:catch 离 throw 越近越好,能具体就具体。
try { User u = userDao.find(id); // 查不到就抛 EntityNotFoundException ... } catch (EntityNotFoundException e) { return GuestProfile.instance(); }
用异常表达"查无此记录"这种预期内的分支,代价有两个:异常构造要抓取整个调用栈填充堆栈(这是异常最贵的部分,比 throw 本身贵一个数量级),高并发下 CPU 火焰图里 fillInStackTrace 一条粗柱;语义上读代码的人无法从签名预知控制流。预期内的分支用返回值(null / Optional / 结果对象),异常留给意外。
老代码里 close 散落在 finally 里,遇到嵌套资源就是一场缩进灾难,还会出现"close 时抛的异常覆盖主异常"。JDK 7 起的标准写法把清理交给编译器:
try (Connection conn = pool.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, id); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { /* map row */ } } } // 反向顺序自动 close 主异常被保留 close 的异常变成 suppressed
要求资源实现 AutoCloseable。被抑制的异常可以通过 getSuppressed() 拿到——排错时主因副因都在。
一条合格的异常信息要能回答三个问题:哪个业务对象、什么输入、为什么不行。对比:
throw new IllegalArgumentException("invalid arg"); // 排错时等于没说 throw new IllegalArgumentException( "order status must be PAID to refund, got " + status + ", orderId=" + orderId); // 看日志即定位
自定义异常要有层次但别太深:BizException 下按域分 OrderException/PayException,够边界层统一处理即可。别为每种错误码建一个类——那是把枚举误当成了类型。

checked 异常(IOException 等)强迫调用方处理,初衷是好的,但实际工程里它催生了大量"签名抛 Exception"和"catch 后包一层 RuntimeException 重抛"的应付写法。JDK 自己也在纠偏:NIO.2 的 Files 保留了 checked,而 Lambda/Stream 生态干脆绕开了它(Function 接口不声明 checked 异常)。我的实践建议:领域内部用 unchecked( RuntimeException 派生),系统边界(RPC、消息消费入口)统一接住转成响应码——边界集中处理,内部保持调用链干净。
⚠️ 常见坑:重抛异常时
throw new BizException(e.getMessage())——丢掉了原异常对象,堆栈断裂,根因消失。要么传 cause,要么别包。
💡 关键直觉:异常是控制流之外的信号通道,吞异常等于把火警器拆了。写 catch 时默念一句:读这段日志的人是三个月后的你自己。
InterruptedException 要么上抛要么恢复中断位下一节看泛型——编译期的好帮手,运行时的隐形人。