推敲见文章:从 `try..catch` 看异常日志打印的正确姿势
一、一个常见的异常处理场景
在 Java 开发中,异常处理和日志记录是基础但容易出错的环节。最近在一次代码评审中,发现了下面这段典型的异常处理代码(其中的BizException是一个自定义异常):
try { // 业务逻辑:上传身份证图片 // ... } catch (Exception e) { log.info("*****上传图片异常 reqId:{},", reqId, e); if (e instanceof BizException) { throw (BizException) e; } throw BizException.build(BossKgConstant.ERROR_6000, "身份证图片上传异常:" + ExceptionUtils.getMessage(e)); }这段代码存在几个可优化点。下面我们通过完整的代码评审过程,探讨异常日志记录的正确做法。
二、第一次优化:修正日志级别和冗余信息
代码评审指出了两个问题:
- 日志级别不当:在 catch 块中使用
INFO级别记录异常是不恰当的。异常通常表示错误或非预期情况,应使用ERROR或WARN级别。 - 冗余上下文:异常堆栈通常已包含调用链信息,额外打印
reqId可能非必需,尤其在高频接口中会增加日志体积。
开发者修改后的版本如下:
log.error("*****上传图片异常:{},", e);三、第二次优化:修正日志格式问题
修改后的代码在格式上仍不够清晰:
log.error("*****上传图片异常:{},", e);这里的格式字符串包含占位符{},但实际传入了异常对象e。在主流日志框架(如 Logback、Log4j2)中,这样写虽然能正常输出堆栈,但格式上易产生混淆。更清晰的写法是:
log.error("*****上传图片异常:", e);四、第三次“优化”:平衡存储成本与排查效率
接下来,评审人从日常工作中强调的成本意识的角度提出:异常堆栈通常较长,全量打印会增加日志存储开销。
开发者修改后的版本如下:
} catch (Exception e) { log.error("*****上传图片异常:{}", ExceptionUtils.getMessage(e)); ... }评审人:这一修改虽然降低了日志体积,但直接导致了堆栈跟踪信息的丢失,在后续排查问题时,只能看到异常消息,无法定位具体代码位置、调用链路和嵌套异常,显著增加了问题排查的难度。
五、哭笑不得:开发者回退到上一版本
开发者意识到“仅打印消息”会导致堆栈信息丢失后,没了主意。面对“要存储成本”和“要排查信息”这两个看似矛盾的要求,他采取了最简单的做法:直接回退到上一个“正确”的版本。
log.error("*****上传图片异常:", e);这个“回退”动作本身,恰恰暴露了问题:
- 思考中断:开发者没有继续深入分析评审人提出的“成本”关切背后的合理成分(即:是否所有异常都需要完整堆栈?),而是选择退回到一个“安全”但未经深思的旧方案。
- 方案摇摆:代码在“完整堆栈”和“仅消息”两个极端之间摇摆,说明开发者尚未建立起处理这类权衡问题的稳定思路和决策框架。
- 问题依旧:“成本”问题被搁置了,但并未被解决。如果未来日志量真的成为瓶颈,这个“回退”只是将问题推迟,而非化解。
六、正确的做法:区分异常类型,差异化处理
简单地回退或“一刀切”都是不行的。关键在于建立清晰的决策逻辑:区分异常类型,差异化处理。这能将“降低存储成本”和“保留排查线索”这两个目标统一起来。
| 异常类型 | 特点 | 日志策略建议 | 解决的核心矛盾 |
|---|---|---|---|
| 自定义业务异常 | 系统内定义,预期内,表示明确的业务规则违反(如“参数无效”、“余额不足”)。 | 堆栈价值低。通常直接抛出,无需记ERROR日志(可记WARN)。 | 显著降低成本:这类异常往往高频,不打印其堆栈能大幅减少日志量。 |
| 系统/运行时异常 | 如NPE、IO异常、DB连接异常、RPC超时等。 | 堆栈至关重要,必须记ERROR级别日志及完整堆栈。 | 保障可排查性:这类异常是线上问题的主要来源,堆栈是定位根因的生命线。 |
最终的实现方案如下:
try { // 业务逻辑 // ... } catch (Exception e) { if (e instanceof BizException) { // 1. 业务异常:低成本处理 // 直接抛出,通常无需记录ERROR日志。若需跟踪,可记WARN且仅记消息。 // log.warn("业务异常[code:{}]: {}", ((BizException)e).getCode(), e.getMessage()); throw (BizException) e; } // 2. 系统/运行时异常:高价值信息保留 // 必须记录ERROR和完整堆栈,这是付出的必要“成本”。 log.error("*****上传图片异常", e); // 3. 统一对外暴露 // 将系统异常转换为对上游友好的业务异常。 throw BizException.build(BossKgConstant.ERROR_6000, "身份证图片上传异常"); }工程思维的体现:
这个方案的成功之处在于,它没有在“成本”和“信息”之间二选一,而是通过分类找到了平衡点:
- 对高频、低信息价值的业务异常,做减法,实现成本优化。
- 对低频、高信息价值的系统异常,做加法,保障运维能力。
这才是对“成本意识”的完整理解——成本不仅仅是存储开销,更包括潜在的故障排查时间成本。后者往往比前者高得多。
七、异常日志记录最佳实践总结
1. 合理选择日志级别
- ERROR:用于系统异常、不可恢复错误、第三方服务调用失败。
- WARN:用于可恢复异常、业务预期内但不希望发生的情况(如重试、降级)。
- INFO/DEBUG:用于业务流程记录、调试信息,一般不用于记录异常本身。
2. 区分异常类型,差异化处理
try { // ... } catch (BusinessException e) { // 业务异常:可记录 WARN,通常直接抛出 log.warn("业务处理失败, code:{}, msg:{}", e.getCode(), e.getMessage()); throw e; } catch (IOException | TimeoutException e) { // 特定的系统异常:记录 ERROR 和堆栈,可附加上下文 log.error("IO操作失败 - 目标资源:{}", resourceId, e); throw new BusinessException("SYS_ERROR", "系统繁忙,请重试", e); } catch (Exception e) { // 未知异常:必须记录 ERROR 和完整堆栈 log.error("未捕获的异常", e); throw new BusinessException("SYS_ERROR", "系统内部错误"); }3. 平衡存储成本与排查效率
- 对高频且稳定的业务异常,可仅记录消息,或采用采样日志。
- 对系统异常、底层中间件异常、网络超时等,必须保留完整堆栈。
八、总结
一次看似简单的catch块日志记录,背后涉及了日志级别、格式规范、存储成本、排查效率、异常分类处理等多个工程权衡点。通过这次代码评审我们认识到:
- 避免机械执行与简单回退:理解每处修改的真实影响和背后原因,建立自己的决策框架,而不是在几个选项间盲目摇摆。
- 坚持分类处理原则:通过区分业务异常与系统异常,能从根本上统一“降低成本”和“保留信息”这两个目标。
- 建立全局成本观:日志存储成本远低于因信息缺失导致的问题排查时间成本和系统稳定性风险。好的设计正是在多重约束中寻找最优解。
【碎碎念一番】良好的异常日志实践,是构建可观测、易维护的系统的基石。在每次编写try-catch时,多花几秒钟思考如何记录异常,就是在为未来的自己和团队节省大量的排查时间。
