在Java应用里,日志和异常是排查问题的两大支柱。很多系统上线后出现问题,开发同学翻日志却只看到一句“null”或者“操作失败”,根本找不到异常发生在哪一行,这就是异常信息没有被正确记录导致的。本文围绕Java生态中主流的日志框架,说明怎样把异常和日志结合起来,既保留完整堆栈,又方便后续分析。

为什么不能直接拼接异常到日志消息
初学者最常写的代码是把异常对象用加号拼到字符串里,例如 logger.info("处理订单出错:" + e)。这种写法在编译期会把 e 转成调用其 toString 方法的结果,通常只输出异常类名和 message,而最重要的堆栈轨迹(stack trace)完全不会被打印。当系统出现偶发故障时,没有堆栈就意味着无法定位是哪一个方法、哪一行代码抛出的问题。
从日志框架设计角度看,日志事件本身支持携带一个独立的 Throwable 参数。框架在输出时会专门格式化这个参数,递归打印 cause 链和每一层的方法调用。如果把它提前拼进消息字符串,框架就只把它当作普通文本,丧失了结构化的处理能力。因此,任何会导致异常对象“降级”为字符串的写法都应避免。
// 错误示例:异常信息被提前转成字符串
try {
orderService.create(req);
} catch (Exception e) {
logger.error("创建订单失败:" + e);
}
// 正确示例:把异常作为独立参数
try {
orderService.create(req);
} catch (Exception e) {
logger.error("创建订单失败", e);
}
使用SLF4J和Log4j2记录异常的标准方式
SLF4J作为门面,定义了带 Throwable 参数的重载方法。以 Log4j2 为底层实现时,这些方法会把异常对象交给 PatternLayout 中的 %throwable 或 %xThrowable 指令处理。默认情况下,即使你在格式串里没写异常占位符,Log4j2 也会在消息后面追加完整堆栈,这是它与早期 Log4j 1.x 行为不同的地方。
如果你希望控制堆栈的打印深度,比如只输出前 ten 行以减少日志体积,可以在 Log4j2 的配置中使用 %throwable{10}。对于嵌套层次很深的异常,例如远程调用失败引发的包装异常,使用 %xThrowable 可以省略重复的公共框架栈帧,让日志更聚焦业务代码。下面是一段典型的 Log4j2 配置片段和对应 Java 代码。
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5level %logger - %msg%n%throwable{20}"/>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PaymentJob {
private static final Logger logger = LoggerFactory.getLogger(PaymentJob.class);
public void run() {
try {
gatewayClient.call();
} catch (GatewayException e) {
// 第一个参数是业务描述,第二个是异常对象
logger.error("支付网关调用异常,订单号={}", orderNo, e);
}
}
}
异步日志与异常对象的生命周期
为了提升性能,很多项目会开启异步日志(如 Log4j2 的 AsyncAppender 或 Disruptor 模式)。此时日志事件会被放进队列,由后台线程消费。如果异常对象里包含了不能序列化的字段,或者你在 catch 块里对异常做了某些修改,就可能遇到堆栈错乱或序列化失败。
正确的做法是在捕获异常后,立即将其作为不可变对象传入日志接口,不要在异步落盘前对异常做任何业务加工。另外,异步模式下如果使用了对象池复用日志事件,要确认框架版本不会把 Throwable 提前清空。下面的代码展示了在异步环境中安全地记录异常,以及避免把异常消息里的大对象引用住。
// 假设使用 Log4j2 异步 logger
public void syncTask(Data data) {
try {
processor.handle(data);
} catch (BusinessException ex) {
// 不要先把 ex.getMessage() 提取出来拼字符串
// 直接传 ex,由异步线程统一格式化
logger.error("数据处理失败, id={}", data.getId(), ex);
}
}
日志级别与异常类型的匹配
并不是所有异常都该用 error 级别。如果是用户可以重试的校验异常,比如参数非法、余额不足,用 warn 甚至 info 更合适,避免 error 日志被监控误报警。而对于未预期的空指针、数据库连不上等,才应该记 error 并带完整堆栈。
另外,在微服务架构中,链路追踪 ID 一般放在 MDC 里。记录异常时要确保 MDC 在异步线程池中被正确传递,否则查日志时无法把一次请求的所有异常串起来。可以通过自定义线程池包装器把 MDC 上下文拷贝到子线程,再配合日志里的 %X{traceId} 输出,就能实现异常日志的全链路关联。
| 异常场景 | 推荐级别 | 是否带堆栈 |
|---|---|---|
| 参数校验不通过 | warn | 否 |
| 下游依赖超时 | error | 是 |
| 已知业务拒绝 | info | 否 |
| 未捕获运行时异常 | error | 是 |
常见误区与排查清单
误区之一是使用 logger.error(e.getMessage(), e),虽然能打印堆栈,但消息部分丢失了业务语境;更好的写法是把业务动作写清楚,如“下单失败,用户ID=123”。误区之二是捕获异常后又抛出新异常却不把原异常设为 cause,导致链条断裂,应在自定义异常构造时传入原始 e。
排查时可以对照以下清单:是否调用了带 Throwable 的重载方法;异步配置是否支持 Throwable 传递;日志格式是否包含 %throwable;异常里是否持有大对象导致内存压力。把这些点理顺,Java 日志里的异常记录就能既完整又高效,为故障定位提供坚实依据。
记住:异常对象永远作为独立参数交给日志框架,而不是提前变成字符串。