3.1 被吞掉的异常与try坑


文档摘要

3.1 被吞掉的异常与 try 坑 本节摘要:空 catch、只打日志继续跑、finally 里 return 吞异常、用异常做流程控制——四类坏味道让故障"静默化"。本节从一次支付回调丢失事故讲起,覆盖异常层次、try-with-resources 的资源语义、异常信息的可排错性标准,以及 checked 异常的工程取舍。 事故:丢了三天的支付回调 支付网关的回调处理上线后运行"正常",日志干净,无 ERROR。直到对账才发现:每天约千分之二的回调没有落库。排查从队列开始、到网络、再到幂等控制,最后在代码里发现了这个: 一次紧急发布里为了"先恢复服务"把异常吞了,注释里的"后续修复"随着版本翻篇再没人提起。千分之二的失败被无声抹平,日志一行不留——这不是处理了异常,是把异常的证据销毁了。

3.1 被吞掉的异常与 try 坑

本节摘要:空 catch、只打日志继续跑、finally 里 return 吞异常、用异常做流程控制——四类坏味道让故障"静默化"。本节从一次支付回调丢失事故讲起,覆盖异常层次、try-with-resources 的资源语义、异常信息的可排错性标准,以及 checked 异常的工程取舍。

事故:丢了三天的支付回调

支付网关的回调处理上线后运行"正常",日志干净,无 ERROR。直到对账才发现:每天约千分之二的回调没有落库。排查从队列开始、到网络、再到幂等控制,最后在代码里发现了这个:

try { callbackService.handle(payload); } catch (Exception e) { // 临时绕过 后续修复 -- 某次紧急上线的注释 }

一次紧急发布里为了"先恢复服务"把异常吞了,注释里的"后续修复"随着版本翻篇再没人提起。千分之二的失败被无声抹平,日志一行不留——这不是处理了异常,是把异常的证据销毁了

更糟的连锁:因为失败被掩盖,上游认为全部成功,不重试;等对账发现时,数据已经缺了三天,只能人工补录。故障损失 = 技术修复成本 × 静默时长,吞异常把静默时长拉到最大。

四类坏味道逐个看

坏味道一:空 catch / 打日志继续

} catch (Exception e) { log.warn("failed", e); // 打了日志 然后呢? }

打日志只解决"可发现",不解决"可处理"。调用方拿着一个半成品结果继续走,错误状态沿调用链扩散,最后在离根因很远的地方以数据错乱的形式显形。正确姿势二选一:要么能恢复就真恢复(重试、降级、默认值,并且要写清楚凭什么这样恢复),要么不能恢复就往上抛,把终止权交给有上下文的人。

坏味道二:finally 里 return

try { return computeValue(); // 抛异常时该向上传播 } finally { return fallback; // 但 finally 的 return 会把它吃掉 }

finally 里的 return(或 throw)会丢弃 try 块里未处理的异常,编译器对此仅给一个过时的警告。finally 的合法职责只有清理资源,任何带业务语义的 return 都不该出现在里面。

坏味道三:catch (Exception) 一把抓

宽 catch 看似稳健,实际把 InterruptedExceptionOutOfMemoryError 邻区的严重失败和普通业务失败混在一个桶里。尤其 InterruptedException 被抓后不恢复中断位,线程池的优雅停机会莫名失效(第 5 章会再遇到它)。抓取粒度的纪律:catch 离 throw 越近越好,能具体就具体

坏味道四:异常做流程控制

try { User u = userDao.find(id); // 查不到就抛 EntityNotFoundException ... } catch (EntityNotFoundException e) { return GuestProfile.instance(); }

用异常表达"查无此记录"这种预期内的分支,代价有两个:异常构造要抓取整个调用栈填充堆栈(这是异常最贵的部分,比 throw 本身贵一个数量级),高并发下 CPU 火焰图里 fillInStackTrace 一条粗柱;语义上读代码的人无法从签名预知控制流。预期内的分支用返回值(null / Optional / 结果对象),异常留给意外

资源管理的正解:try-with-resources

老代码里 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 异常的取舍

checked 异常(IOException 等)强迫调用方处理,初衷是好的,但实际工程里它催生了大量"签名抛 Exception"和"catch 后包一层 RuntimeException 重抛"的应付写法。JDK 自己也在纠偏:NIO.2 的 Files 保留了 checked,而 Lambda/Stream 生态干脆绕开了它(Function 接口不声明 checked 异常)。我的实践建议:领域内部用 unchecked( RuntimeException 派生),系统边界(RPC、消息消费入口)统一接住转成响应码——边界集中处理,内部保持调用链干净。

⚠️ 常见坑:重抛异常时 throw new BizException(e.getMessage())——丢掉了原异常对象,堆栈断裂,根因消失。要么传 cause,要么别包。

💡 关键直觉:异常是控制流之外的信号通道,吞异常等于把火警器拆了。写 catch 时默念一句:读这段日志的人是三个月后的你自己。

防坑清单

  • 空 catch 和"打日志继续"一律打回;恢复要写依据,不能恢复就上抛
  • finally 只做清理,绝不 return/throw
  • catch 粒度尽量具体,InterruptedException 要么上抛要么恢复中断位
  • 预期内分支不用异常;异常信息带业务对象、输入、原因三要素
  • 资源一律 try-with-resources;重抛必须携带 cause

本节要点回顾

  • 事故机理:吞异常把故障静默化,损失随静默时长放大
  • 四类坏味道:空 catch、finally return、宽 catch、异常流程控制
  • 性能维度:异常构造贵在堆栈抓取,预期分支用返回值
  • 资源语义:try-with-resources 自动反向关闭,suppressed 保住副因
  • 分层策略:域内 unchecked,边界统一转换

下一节看泛型——编译期的好帮手,运行时的隐形人。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U