异常日志记录の优化实践:从 `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));if (e instanceof BizException) {throw (BizException) e;}throw BizException.build(BossKgConstant.ERROR_6000, "身份证图片上传异常:" + ExceptionUtils.getMessage(e));
}
这一修改虽然降低了日志体积,但直接导致了堆栈跟踪信息的丢失,在后续排查问题时,只能看到异常消息,无法定位具体代码位置、调用链路和嵌套异常,显著增加了问题排查的难度。
五、正确的做法:区分异常类型,差异化处理
开发者“一刀切”地移除所有异常堆栈,这是一种典型的教条化思维,它将“降低存储成本”这一原则绝对化,而忽略了日志的根本目的:快速定位与解决问题。
关键在于区分异常类型:
| 异常类型 | 特点 | 日志策略建议 |
|---|---|---|
| 自定义业务异常 | 系统自定义,预期内,表示明确的业务规则违反(如参数校验失败、状态不正确)。 | 堆栈信息通常价值有限,可直接抛出,一般无需记录 ERROR 级别日志(可考虑 WARN 或 DEBUG)。 |
| 系统/运行时异常 | 如 NPE、IOException、数据库异常、第三方调用异常等。 |
堆栈至关重要,包含触发位置、调用链路、嵌套原因,是定位线上问题的核心抓手。 |
推荐实现如下:
try {// 业务逻辑// ...
} catch (Exception e) {if (e instanceof BizException) {// 业务异常:直接抛出,通常无需记录 ERROR 日志// 若需记录,可使用 WARN 级别并仅记录消息// log.warn("业务异常: {}", e.getMessage());throw (BizException) e;}// 系统/运行时异常:必须记录 ERROR 级别日志及完整堆栈log.error("*****根据接口编号路由上传图片 异常", e);// 将系统异常转换为对上游友好的业务异常throw BizException.build(BossKgConstant.ERROR_6000, "身份证图片上传异常");
}
关于“教条主义”的思考:
这个案例反映了在工程实践中,僵化执行单一指标(如“降低日志量”)可能带来更大的隐性成本——问题排查时间呈指数级增加。工程师应具备的核心能力之一是权衡(Trade-off),在存储成本与可观测性、开发效率与运行性能之间做出情境化的合理选择。
六、异常日志记录最佳实践总结
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. 平衡存储成本与排查效率
- 对高频且稳定的业务异常,可仅记录消息,或采用采样日志。
- 对系统异常、底层中间件异常、网络超时等,必须保留完整堆栈。
- 可借助日志框架的异步追加器(Async Appender)和合理的滚动策略,平衡 I/O 性能与存储。
4. 提供有意义的上下文信息
在记录异常时,附带关键的业务参数或请求标识,能极大提升日志的排查价值。
// 好的做法:附带关键业务参数
log.error("身份证上传失败 - 用户ID:{} 文件名:{} 大小:{}KB", userId, fileName, fileSize / 1024, e);// 更好的做法:使用 MDC(Mapped Diagnostic Context)设置请求级标识
MDC.put("traceId", traceId);
try {// ...
} catch (Exception e) {log.error("处理请求异常", e); // 日志自动附带 traceId
} finally {MDC.remove("traceId");
}
七、总结
一次看似简单的 catch 块日志记录,背后涉及了日志级别、格式规范、存储成本、排查效率、异常分类处理等多个工程权衡点。通过这次代码评审我们认识到:
- 避免机械优化:理解每处修改的真实影响,而非机械执行某个最佳实践。
- 坚持区分原则:业务异常与系统异常应采取不同的日志策略。
- 建立全局成本观:日志存储成本远低于因信息缺失导致的问题排查时间成本和系统稳定性风险。
良好的异常日志实践,是构建可观测、易维护系统的基石。在每次编写 try-catch 时,多花几秒钟思考如何记录异常,就是在为未来的自己和团队节省大量的排查时间。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/20191812
