Spring Boot 全局异常处理与日志监控实战

目标:构建一套健壮的全局异常处理方案,统一错误响应可追踪的日志(requestId/MDC) ,并把异常上报为监控指标(使用 Micrometer),方便在生产环境定位与统计异常。


1. 背景与目标

生产环境中,异常无处不在。我们要解决三件事:

  • 对外统一 JSON 错误格式,便于前端/客户端解析与展示;
  • 在日志中携带可追溯的 requestId(MDC),便于从日志中串联一条请求的全部操作;
  • 对异常做指标统计(例如按异常类型/状态码计数),能在监控平台(Prometheus/Grafana)上报警与分析。

2. 设计思路(要点)

  • 使用 @RestControllerAdvice + @ExceptionHandler 进行全局捕获;
  • 返回标准 ErrorResponse(包含时间戳、HTTP 状态码、业务错误码、message、path、requestId);
  • 在异常处理器里同时 log.error(...) 并把异常计数交给 MeterRegistry(Micrometer);
  • 通过 OncePerRequestFilter 在每个请求开始时生成 requestId 并放入 SLF4J 的 MDC(MDC.put("requestId", id));
  • 配置 logback-spring.xml%X{requestId} 输出到日志 pattern,建议也输出 JSON(视需求)。

3. 项目依赖(Maven)

xml 复制代码
<!-- pom.xml 依赖片段 -->
<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 日志 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>

    <!-- Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Actuator & Micrometer (Prometheus) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

AI写代码xml
123456789101112131415161718192021222324252627282930

4. 通用错误响应 DTO

src/main/java/com/example/demo/api/ErrorResponse.java

arduino 复制代码
package com.example.demo.api;

import java.time.Instant;
import java.util.Map;

public class ErrorResponse {
    private Instant timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private String requestId;
    private Map<String, Object> details; // 可选扩展字段

    public ErrorResponse() {}

    public ErrorResponse(int status, String error, String message, String path, String requestId) {
        this.timestamp = Instant.now();
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
        this.requestId = requestId;
    }

    // getters & setters omitted for brevity
}

AI写代码java
运行
123456789101112131415161718192021222324252627

5. 自定义业务异常示例

src/main/java/com/example/demo/exception/BusinessException.java

scala 复制代码
package com.example.demo.exception;

public class BusinessException extends RuntimeException {
    private final String code;

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

    public String getCode() {
        return code;
    }
}

AI写代码java
运行
1234567891011121314

6. 全局异常处理实现(日志 + 指标)

src/main/java/com/example/demo/exception/GlobalExceptionHandler.java

java 复制代码
package com.example.demo.exception;

import com.example.demo.api.ErrorResponse;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Counter;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    private final MeterRegistry meterRegistry;

    // 一个简单的异常计数器前缀(可按异常 class、path、status 维度构造标签)
    private final Counter genericExceptionCounter;

    public GlobalExceptionHandler(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.genericExceptionCounter = Counter.builder("exceptions.total")
                .description("Total number of handled exceptions")
                .register(meterRegistry);
    }

    // 业务异常处理
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex, HttpServletRequest request) {
        String requestId = MDC.get("requestId");
        log.warn("BusinessException - requestId={}, path={}, code={}, msg={}",
                requestId, request.getRequestURI(), ex.getCode(), ex.getMessage());

        // 增加监控计数(按业务码)
        meterRegistry.counter("exceptions.by_code", "code", ex.getCode()).increment();

        ErrorResponse err = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Business Error",
                ex.getMessage(),
                request.getRequestURI(),
                requestId
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
        String requestId = MDC.get("requestId");
        String msg = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> fe.getField() + ":" + fe.getDefaultMessage())
                .collect(Collectors.joining("; "));
        log.info("Validation failed - requestId={}, path={}, errors={}", requestId, request.getRequestURI(), msg);

        meterRegistry.counter("exceptions.validation").increment();

        ErrorResponse err = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Validation Error",
                msg,
                request.getRequestURI(),
                requestId
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(err);
    }

    // 通用异常处理
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex, HttpServletRequest request) {
        String requestId = MDC.get("requestId");
        log.error("Unhandled exception - requestId={}, path={}", requestId, request.getRequestURI(), ex);

        // 总量计数
        genericExceptionCounter.increment();
        // 按异常类计数标签
        meterRegistry.counter("exceptions.by_type", "type", ex.getClass().getSimpleName()).increment();

        ErrorResponse err = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "服务器繁忙,请稍后重试",
                request.getRequestURI(),
                requestId
        );

        // 在开发环境可以把 ex.getMessage() 或堆栈信息放到 details 中(生产环境慎用)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(err);
    }
}

AI写代码java
运行
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101

说明:

  • 在处理器中我们同时 logmeterRegistry.counter(...).increment(),用于日志与监控;
  • MDC.get("requestId") 用于把请求的 requestId 写入返回体,方便客户端带回查日志。

7. 请求 ID 与 MDC 过滤器(保证每条请求都有 requestId)

src/main/java/com/example/demo/filter/RequestIdFilter.java

java 复制代码
package com.example.demo.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    private static final String REQUEST_ID_HEADER = "X-Request-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        try {
            String requestId = request.getHeader(REQUEST_ID_HEADER);
            if (requestId == null || requestId.isBlank()) {
                requestId = UUID.randomUUID().toString();
            }
            MDC.put("requestId", requestId);
            // 同时将 requestId 放回响应头,便于前端或网关追踪
            response.setHeader(REQUEST_ID_HEADER, requestId);

            filterChain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

AI写代码java
运行
1234567891011121314151617181920212223242526272829303132333435363738

说明:

  • 每次请求都会生成(或沿用上游)X-Request-Id,并放到 MDC,日志 pattern 能输出 %X{requestId}
  • 响应中返回该 header,有利于客户端/运维串联。

8. 日志配置与示例输出

application.properties(关键项)

ini 复制代码
# 暴露 Actuator prometheus 端点
management.endpoints.web.exposure.include=health,info,prometheus,metrics
management.endpoint.prometheus.enabled=true

# 日志级别(根据环境调整)
logging.level.root=INFO
logging.level.com.example=DEBUG

AI写代码properties
1234567

logback-spring.xml(pattern 示例)

放在 src/main/resources/logback-spring.xml

xml 复制代码
<configuration>
    <springProfile name="prod">
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <!-- 输出包含 requestId -->
                <pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%thread] %-5level %logger{36} - %msg - requestId=%X{requestId}%n</pattern>
            </encoder>
        </appender>
        <root level="INFO">
            <appender-ref ref="STDOUT"/>
        </root>
    </springProfile>

    <springProfile name="!prod">
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg - requestId=%X{requestId}%n</pattern>
            </encoder>
        </appender>
        <root level="DEBUG">
            <appender-ref ref="STDOUT"/>
        </root>
    </springProfile>
</configuration>

AI写代码xml
123456789101112131415161718192021222324

日志示例(一条报错请求)

ini 复制代码
2025-08-10T18:34:10.123+03:00 [http-nio-8080-exec-1] ERROR com.example.demo.exception.GlobalExceptionHandler - Unhandled exception - requestId=2f1a8c7f-1d2b-4f0a-9b2a-123456789abc, path=/api/orders
java.lang.NullPointerException: ...
    at com.example.demo.service.OrderService.create(OrderService.java:45)
    ...

AI写代码
1234

你会看到 requestId 出现在每条日志,便于用 grep 或日志平台(ELK/EFK)按 requestId 过滤整条调用链。


9. 将异常计数暴露到监控(Actuator + Micrometer)

前文 GlobalExceptionHandler 已经把计数器注册到 Micrometer:

  • exceptions.total
  • exceptions.by_code{code=...}
  • exceptions.by_type{type=...}

在 Prometheus 中抓取 Spring Boot 的 /actuator/prometheus 指标,就能在 Grafana 中根据 exceptions.by_type 做报警规则。例如:如果 exceptions.by_type{type="NullPointerException"} 在 5 分钟内增幅过大,就触发报警。


10. 常见场景与处理建议

  • 参数校验失败(MethodArgumentNotValidException

    • 建议把字段错误拼成单行 message(示例中已实现),并返回 400。
  • 业务异常(自定义 BusinessException

    • 业务异常可携带 code,前端可根据 code 做差异化提示或重试策略;监控中也可以以 code 为标签统计。
  • 第三方超时/HTTP 错误(RestTemplate/WebClient)

    • 在调用处抛出有意义的自定义异常或将原异常包装后抛出;在全局异常处理器中根据异常类型映射为 502/504 等状态,并计数。
  • 链路追踪(可选)

    • 若有分布式追踪需求,可接入 OpenTelemetry/Zipkin/Jaeger,但仍保留 requestId 做本地快速查找。
  • 安全注意

    • 生产环境不要在 API 返回中包含完整堆栈或敏感字段(示例中仅返回通用 message)。可以在开发 profile 下增加 details

11. 小结与部署建议

  • 统一异常处理 可以显著提升前后端协作效率与错误可观察性;
  • MDC + requestId 是生产排查的第一要素,务必保证上游(网关)能传递 X-Request-Id,否则服务端生成并回传;
  • 监控计数(Micrometer)使异常不再是"偶发的黑盒",可以在 Grafana/Prometheus 上设定阈值与报警;
  • 日志集中化 建议配合 ELK/EFK(或云日志)保存结构化日志(JSON)以便于按 requestIdcodetype 聚合查询;
  • 对外返回 应保持稳定的 JSON 格式与明确的状态码,避免泄露内部实现细节。
相关推荐
Lucaju2 分钟前
吃透 Spring AI Alibaba 多智能体|四大协同模式+完整代码
后端
Nyarlathotep01133 分钟前
Redis的对象(5):有序集合对象
redis·后端
wertyuytrewm10 分钟前
Java面试——Java基础
java·jvm·面试
Java水解10 分钟前
Spring Boot 消息队列与异步处理
spring boot·后端
桦说编程25 分钟前
AI 真的让写代码变快了吗?
后端
studyForMokey35 分钟前
【Android面试】View绘制流程专题
android·面试·职场和发展
AskHarries2 小时前
openclaw升级和参数调整
后端·ai编程
creaDelight2 小时前
基于 Django 5.x 的全功能博客系统 DjangoBlog 深度解析
后端·python·django
前端Hardy2 小时前
Qwik 2.0 Beta 来了:不靠 AI,只靠 Resumability,首屏交互快到离谱
前端·javascript·面试