人人都会AI编程

22.3 异常处理与日志最佳实践

更新时间:2026-07-10

异常处理和日志系统看似是应用的“配角”,实际上直接决定了线上问题的排查效率和用户体验。一个成熟的系统,异常既不能直白地抛给前端让用户看到一堆堆栈,也不能被悄无声息地吞掉让故障无从追溯。本节围绕 Spring Boot 应用,整理出一套经过实战检验的异常处理与日志记录方案。

22.3.1 全局异常处理:用 @ControllerAdvice 建立统一防线

在没有任何全局处理机制时,Controller 方法中散落着大量 try-catch,或者干脆让框架把异常直接抛给用户,返回一屏对普通人毫无意义的堆栈信息。Spring 提供的 @ControllerAdvice 结合 @ExceptionHandler 可以集中拦截所有控制器抛出的异常,转换为统一格式的响应。

1. 定义统一的响应体

首先约定一个标准响应结构,让客户端能够稳定地解析错误信息:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
    private int code;
    private String message;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "success", data);
    }

    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}

2. 创建全局异常处理器

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 处理自定义业务异常
    @ExceptionHandler(BusinessException.class)
    public ApiResponse<Void> handleBusinessException(BusinessException ex) {
        log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
        return ApiResponse.error(ex.getCode(), ex.getMessage());
    }

    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
        String msg = ex.getBindingResult().getFieldErrors()
                .stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .collect(Collectors.joining("; "));
        log.warn("参数校验失败: {}", msg);
        return ApiResponse.error(400, "参数错误: " + msg);
    }

    // 兜底处理未知异常
    @ExceptionHandler(Exception.class)
    public ApiResponse<Void> handleUnknown(Exception ex) {
        log.error("系统未知异常", ex);
        return ApiResponse.error(500, "系统内部错误,请稍后重试");
    }
}

关键点:

  • 通过 @RestControllerAdvice 实现自动扫描并返回 JSON 响应。
  • 业务异常 BusinessException 携带错误码和人类可读的消息,前端可以直接展示 message 字段。
  • 参数校验异常 MethodArgumentNotValidException 提取字段级错误详情,帮助调用方快速定位。
  • 兜底的 Exception 处理要避免将堆栈泄露给客户端,统一返回模糊的“系统内部错误”,同时交由日志记录完整堆栈。

3. 自定义业务异常设计

public class BusinessException extends RuntimeException {
    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() { return code; }
}

业务开发时,遇到不符合预期的业务规则就直接抛 BusinessException,例如:

if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
    throw new BusinessException(40001, "订单金额必须大于零");
}

这样全局处理器就能自动将其转化为与前端约定的格式,无需在 Controller 中编写任何 try-catch。

22.3.2 异常分层处理:区分业务异常与系统异常

仅仅统一捕获还不够,必须为异常赋予明确的层级,才能让监控和排查有的放矢。

  • 业务异常(可预期):由不满足业务规则引起,属于正常流程的一部分,不需要开发人员立即介入。日志级别通常为 WARN,且无需记录完整堆栈。
  • 系统异常(不可预期):如数据库连接超时、空指针、调用第三方服务失败,属于故障信号,需要立刻关注。日志级别为 ERROR,并记录完整堆栈和上下文信息。

代码中应明确区分两类异常的抛出:

public void deductStock(Long productId, int quantity) {
    // 库存不足是业务异常
    if (currentStock < quantity) {
        throw new BusinessException(40002, "商品库存不足");
    }
    try {
        inventoryDao.deduct(productId, quantity);
    } catch (DataAccessException ex) {
        // 数据库异常是系统异常
        throw new SystemException("扣减库存时数据库异常", ex);
    }
}

全局异常处理器中,对 SystemException 应当记录完整堆栈,并可能触发报警;对 BusinessException 只记录简短信息即可。

22.3.3 日志最佳实践:让每一行日志都发挥作用

日志是事后追溯问题的唯一凭据。以下原则来自大量线上排障的教训。

1. 使用 SLF4J 门面 + Logback 实现

Spring Boot 默认使用 Logback,通过 SLF4J 获得统一的 API。避免在代码里使用特定日志实现(如直接写 log4j),以便未来切换日志框架时无需修改业务代码。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RestController
public class OrderController {
    private static final Logger log = LoggerFactory.getLogger(OrderController.class);
}

也可以使用 Lombok 的 @Slf4j 注解,直接生成 log 对象。

2. 合理使用日志级别

  • ERROR:系统级故障,需要立即响应。记录完整堆栈,并尽量携带失败的关键参数。
  • WARN:潜在风险,如业务异常、降级处理、配置缺失但使用默认值。用于提醒但不需要立即行动。
  • INFO:关键流程节点,如服务启动完成、重要业务操作成功(下单、支付)、定时任务执行摘要。生产环境必须开启 INFO,它是运维监控的基准。
  • DEBUG:开发调试信息,通常只在开发或灰度环境启用,不可在生产环境开启(大量 DEBUG 会拖慢性能并淹没有用信息)。
  • TRACE:极细粒度信息,很少使用。

3. 日志必须包含足够上下文

单条日志“库存扣减失败”毫无价值;加上“productId=12345, quantity=10”才能定位。推荐使用占位符而非字符串拼接:

// 正确:使用占位符,避免无谓的字符串拼接
log.error("扣减库存失败, productId={}, quantity={}", productId, quantity, exception);

// 错误:字符串拼接,即便日志级别未开启也会执行拼接
log.debug("处理订单" + orderId + ",金额" + amount);

4. 利用 MDC 注入全链路追踪 ID

微服务架构下,一个请求可能跨越多个服务。通过 SLF4J 的 Mapped Diagnostic Context (MDC),可以在日志中自动打印 traceId,将同一个请求的所有日志串联起来。

在 Spring Boot 中,结合 Spring Cloud Sleuth 或 Micrometer Tracing,只需引入 spring-cloud-starter-sleuth,框架会自动将 traceIdspanId 注入 MDC,配置日志 pattern 即可显示:

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{36} - %msg%n

这样每一行日志都会带上 [traceId],搜索出该 ID 就能重现整个请求的完整链路。

如果是非微服务环境,也可以手写一个 Filter 在请求进入时为每个请求生成 UUID 并放入 MDC:

@WebFilter(urlPatterns = "/*")
public class TraceFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        try {
            MDC.put("traceId", UUID.randomUUID().toString().replace("-", ""));
            chain.doFilter(request, response);
        } finally {
            MDC.clear(); // 必须清除,避免内存泄漏或线程池复用串ID
        }
    }
}

5. 切面统一记录 Controller 层日志

虽然日志应该由业务代码按需输出,但可以借助 AOP 做一个辅助性的耗时统计,而不是替代业务日志。下面是一个实用的切面示例,记录每个 API 的调用参数、返回值和耗时:

@Aspect
@Component
@Slf4j
public class WebLogAspect {

    @Around("execution(* com.example.controller..*(..))")
    public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        String method = pjp.getSignature().toShortString();
        Object[] args = pjp.getArgs();
        log.info("API调用开始: {} 参数={}", method, JSON.toJSONString(args));
        Object result = null;
        try {
            result = pjp.proceed();
            return result;
        } finally {
            long cost = System.currentTimeMillis() - start;
            log.info("API调用结束: {} 耗时={}ms 结果={}", 
                     method, cost, JSON.toJSONString(result));
        }
    }
}

注意:这种切面会记录完整的请求和响应数据,耗时统计很有用,但敏感数据必须脱敏,否则可能造成信息泄露。另外此切面不能替代业务级别的关键操作日志,后者仍需由业务代码显式输出。

22.3.4 异常与日志协同:从记录到闭环

最佳实践不仅仅是“记下来”,还要形成发现 → 记录 → 通知 → 定位的闭环。

1. 异常发生后立即通知

在全局异常处理的兜底逻辑中,除了 log.error,还可以触发邮件、企业微信或钉钉报警。但务必做好收敛:同一类异常短时间内大量爆发,只发送一条聚合消息,避免通知风暴。

2. 日志集中收集与检索

应用日志应当输出到标准输出(控制台),由容器编排平台或日志采集系统(Filebeat、Fluentd)统一收集到 Elasticsearch 或日志服务中。这样可以利用全文检索,通过 traceIdorderId 等关键词秒级定位。

3. 结合监控指标

ERROR 日志的增量变化应该作为监控面板中的一个关键指标。可借助 Micrometer 将 ERROR 计数暴露给 Prometheus,设置告警阈值。

4. 避免常见反模式

  • 吞异常:空的 catch (Exception e) {} 是最危险的代码,会导致问题彻底隐形。如果确实需要忽略某个异常,务必加上注释说明原因,并至少输出一条 WARN 日志。
  • 打日志后重新抛出:不需要在每一层都打一遍异常,那样会制造大量重复堆栈。通常在最外层(全局异常处理器)统一记录即可,中间层只需抛出,除非有重要上下文需要补充。
  • 使用 System.out.println:性能低下,无法分级,无法被日志框架管理,绝对禁止在生产代码中使用。
  • 记录超大对象:尤其是在循环中打印完整对象或集合,会瞬间消耗内存与磁盘 I/O。

将异常处理与日志作为一个整体来设计,你的系统就能在故障发生时给出明确、可读的反馈,同时为开发者留下清晰的排查路径。这不仅提升了系统的健壮性,更是专业素养的直接体现。