全局异常处理与异常日志自动化采集
全局异常处理与异常日志自动化采集
引言
在软件系统中,异常处理的质量直接影响系统的稳定性和可维护性。如果没有统一的异常处理机制,开发者往往会在每个 Controller 方法中写 try-catch,导致代码冗余、异常信息丢失、日志格式不一致等问题。
Spring Boot 提供了 @RestControllerAdvice 注解,可以优雅地实现全局异常处理。本文将分享全局异常处理器的设计、业务异常与系统异常的分类处理、异常日志自动记录以及堆栈信息截取的最佳实践。
一、异常分类体系
1.1 异常分类设计
graph TD
A[Throwable] --> B[Exception]
B --> C[RuntimeException]
C --> D[BaseException<br/>code + message]
D --> E[BusinessException<br/>业务异常 5xx]
D --> F[AuthException<br/>认证异常 401]
D --> G[ForbiddenException<br/>授权异常 403]
D --> H[ValidationException<br/>参数异常 400]
D --> I[NotFoundException<br/>资源不存在 404]
B --> J[SQLException]
B --> K[IOException]
B --> L[其他Checked Exception]
style D fill:#ffcdd2,stroke:#c62828
style E fill:#fff9c4,stroke:#f9a825
style F fill:#e1bee7,stroke:#7b1fa2
style G fill:#e1bee7,stroke:#7b1fa2
style H fill:#bbdefb,stroke:#1565c0
style I fill:#bbdefb,stroke:#1565c0
1.2 异常码规范
| 异常码区间 | 分类 | 示例 |
|---|---|---|
| 400 | 参数校验异常 | 缺少必填参数、格式不正确 |
| 401 | 认证异常 | Token 过期、未登录 |
| 403 | 授权异常 | 无权限访问 |
| 404 | 资源不存在 | 数据不存在 |
| 1000-1999 | 用户模块 | 1001 用户不存在、1002 密码错误 |
| 2000-2999 | 权限模块 | 2001 角色已存在、2002 权限不足 |
| 3000-3999 | 业务模块 | 3001 订单已取消、3002 库存不足 |
| 5000-5999 | 系统模块 | 5000 系统繁忙、5001 文件上传失败 |
二、自定义异常类
2.1 基础异常
/**
* 基础异常类
* 所有自定义异常的父类
*/
public class BaseException extends RuntimeException {
/** 错误码 */
private final int code;
/** 错误模块 */
private final String module;
/** 错误详情(用于日志,不返回给前端) */
private final String detail;
public BaseException(int code, String module, String message) {
super(message);
this.code = code;
this.module = module;
this.detail = null;
}
public BaseException(int code, String module, String message, String detail) {
super(message);
this.code = code;
this.module = module;
this.detail = detail;
}
public BaseException(int code, String module, String message, Throwable cause) {
super(message, cause);
this.code = code;
this.module = module;
this.detail = cause.getMessage();
}
public int getCode() { return code; }
public String getModule() { return module; }
public String getDetail() { return detail; }
}
2.2 业务异常
/**
* 业务异常
* 用于可预期的业务错误
*/
public class BusinessException extends BaseException {
public BusinessException(String message) {
super(500, "business", message);
}
public BusinessException(int code, String message) {
super(code, "business", message);
}
public BusinessException(int code, String module, String message) {
super(code, module, message);
}
}
/**
* 认证异常
*/
public class AuthException extends BaseException {
public AuthException(String message) {
super(401, "auth", message);
}
}
/**
* 授权异常
*/
public class ForbiddenException extends BaseException {
public ForbiddenException(String message) {
super(403, "forbidden", message);
}
}
/**
* 资源不存在异常
*/
public class NotFoundException extends BaseException {
public NotFoundException(String message) {
super(404, "not_found", message);
}
}
2.3 异常码枚举
/**
* 异常码枚举
* 统一管理所有异常码,避免硬编码
*/
public enum ErrorCode {
// 通用异常
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或Token已过期"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "请求资源不存在"),
INTERNAL_ERROR(500, "系统繁忙,请稍后重试"),
// 用户模块 1xxx
USER_NOT_FOUND(1001, "用户不存在"),
USER_PASSWORD_ERROR(1002, "密码错误"),
USER_DISABLED(1003, "用户已被禁用"),
USER_LOCKED(1004, "用户已被锁定"),
// 权限模块 2xxx
ROLE_EXISTS(2001, "角色已存在"),
ROLE_IN_USE(2002, "角色正在使用中,无法删除"),
// 文件模块 5xxx
FILE_UPLOAD_ERROR(5001, "文件上传失败"),
FILE_NOT_FOUND(5002, "文件不存在"),
FILE_TYPE_NOT_ALLOWED(5003, "不支持的文件类型");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() { return code; }
public String getMessage() { return message; }
}
三、全局异常处理器
3.1 核心处理器
/**
* 全局异常处理器
* 统一处理所有Controller层抛出的异常
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
private final ErrorLogService errorLogService;
public GlobalExceptionHandler(ErrorLogService errorLogService) {
this.errorLogService = errorLogService;
}
/**
* 处理业务异常
* 业务异常是可预期的,不需要记录完整堆栈
*/
@ExceptionHandler(BaseException.class)
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
log.warn("业务异常: code={}, module={}, msg={}",
e.getCode(), e.getModule(), e.getMessage());
// 记录业务异常日志(简化版)
errorLogService.recordBusinessError(e, request);
return R.fail(e.getCode(), e.getMessage());
}
/**
* 处理参数校验异常(@Valid)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public R<Void> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return R.fail(400, message);
}
/**
* 处理参数绑定异常
*/
@ExceptionHandler(BindException.class)
public R<Void> handleBindException(BindException e) {
String message = e.getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("参数绑定失败: {}", message);
return R.fail(400, message);
}
/**
* 处理请求方式不支持异常
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public R<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("请求方式不支持: {}", e.getMethod());
return R.fail(405, "不支持的请求方式: " + e.getMethod());
}
/**
* 处理认证异常(Sa-Token)
*/
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e) {
log.warn("未登录访问: {}", e.getMessage());
return R.fail(401, "未登录或Token已过期");
}
/**
* 处理授权异常(Sa-Token)
*/
@ExceptionHandler(NotPermissionException.class)
public R<Void> handleNotPermissionException(NotPermissionException e) {
log.warn("权限不足: {}", e.getPermission());
return R.fail(403, "无权限访问: " + e.getPermission());
}
/**
* 兜底异常处理
* 所有未捕获的异常统一处理,记录完整堆栈
*/
@ExceptionHandler(Exception.class)
public R<Void> handleException(Exception e, HttpServletRequest request) {
log.error("系统异常: uri={}, method={}",
request.getRequestURI(), request.getMethod(), e);
// 记录系统异常日志(完整堆栈)
errorLogService.recordSystemError(e, request);
// 返回友好提示,不暴露系统内部信息
return R.fail(500, "系统繁忙,请稍后重试");
}
}
3.2 异常处理流程
flowchart TD
A[Controller抛出异常] --> B{异常类型?}
B -->|BaseException| C[业务异常处理]
B -->|ValidException| D[参数校验处理]
B -->|NotLoginException| E[认证异常处理]
B -->|NotPermissionException| F[授权异常处理]
B -->|其他Exception| G[兜底异常处理]
C --> H[WARN日志 + 业务日志]
D --> I[WARN日志]
E --> J[WARN日志]
F --> K[WARN日志]
G --> L[ERROR日志 + 系统日志]
H --> M[返回具体错误码和消息]
I --> N[返回400 + 校验消息]
J --> O[返回401 + 未登录提示]
K --> P[返回403 + 无权限提示]
L --> Q[返回500 + 友好提示]
style C fill:#fff9c4,stroke:#f9a825
style G fill:#ffcdd2,stroke:#c62828
style M fill:#c8e6c9,stroke:#2e7d32
style Q fill:#c8e6c9,stroke:#2e7d32
四、异常日志自动化采集
4.1 异常日志实体
/**
* 异常日志实体
*/
public class ErrorLog {
private Long id;
/** 异常类型:1-业务异常 2-系统异常 */
private Integer errorType;
/** 异常码 */
private Integer errorCode;
/** 异常模块 */
private String errorModule;
/** 异常消息 */
private String errorMessage;
/** 堆栈信息(截取后) */
private String stackTrace;
/** 请求URI */
private String requestUri;
/** 请求方式 */
private String requestMethod;
/** 请求参数 */
private String requestParams;
/** 操作用户ID */
private Long userId;
/** 客户端IP */
private String clientIp;
/** 创建时间 */
private LocalDateTime createTime;
}
4.2 异常日志服务
/**
* 异常日志服务
*/
@Service
@Slf4j
public class ErrorLogService {
private final ErrorLogMapper errorLogMapper;
private final AsyncTaskExecutor asyncExecutor;
/**
* 记录业务异常日志
* 异步执行,不影响接口响应速度
*/
public void recordBusinessError(BaseException e, HttpServletRequest request) {
asyncExecutor.execute(() -> {
ErrorLog errorLog = new ErrorLog();
errorLog.setErrorType(1);
errorLog.setErrorCode(e.getCode());
errorLog.setErrorModule(e.getModule());
errorLog.setErrorMessage(e.getMessage());
errorLog.setRequestUri(request.getRequestURI());
errorLog.setRequestMethod(request.getMethod());
errorLog.setClientIp(getClientIp(request));
errorLog.setCreateTime(LocalDateTime.now());
errorLogMapper.insert(errorLog);
});
}
/**
* 记录系统异常日志
* 包含截取后的堆栈信息
*/
public void recordSystemError(Exception e, HttpServletRequest request) {
asyncExecutor.execute(() -> {
ErrorLog errorLog = new ErrorLog();
errorLog.setErrorType(2);
errorLog.setErrorCode(500);
errorLog.setErrorMessage(e.getClass().getName() + ": " + e.getMessage());
errorLog.setStackTrace(truncateStackTrace(e, 10));
errorLog.setRequestUri(request.getRequestURI());
errorLog.setRequestMethod(request.getMethod());
errorLog.setRequestParams(getRequestParams(request));
errorLog.setClientIp(getClientIp(request));
errorLog.setCreateTime(LocalDateTime.now());
errorLogMapper.insert(errorLog);
});
}
/**
* 获取客户端IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* 获取请求参数
*/
private String getRequestParams(HttpServletRequest request) {
Map<String, String[]> paramMap = request.getParameterMap();
if (paramMap.isEmpty()) return null;
// 过滤敏感参数
Set<String> sensitiveKeys = Set.of("password", "token", "secret");
return paramMap.entrySet().stream()
.filter(e -> !sensitiveKeys.contains(e.getKey()))
.map(e -> e.getKey() + "=" + String.join(",", e.getValue()))
.collect(Collectors.joining("&"));
}
}
五、堆栈信息截取
5.1 为什么需要截取
完整的异常堆栈可能非常长(数百行),直接存储到数据库会浪费存储空间,且不利于阅读。实际上,排查问题通常只需要关键的前几行堆栈。
5.2 截取工具方法
/**
* 堆栈信息截取工具
*/
public class StackTraceTruncator {
/** 默认保留的堆栈层数 */
private static final int DEFAULT_MAX_DEPTH = 10;
/** 堆栈信息最大长度 */
private static final int MAX_LENGTH = 2000;
/**
* 截取异常堆栈信息
* @param e 异常对象
* @param maxDepth 保留的堆栈层数
* @return 截取后的堆栈字符串
*/
public static String truncateStackTrace(Throwable e, int maxDepth) {
if (e == null) return null;
StringBuilder sb = new StringBuilder();
sb.append(e.getClass().getName());
if (e.getMessage() != null) {
sb.append(": ").append(e.getMessage());
}
sb.append("
");
StackTraceElement[] elements = e.getStackTrace();
int depth = Math.min(elements.length, maxDepth);
for (int i = 0; i < depth; i++) {
sb.append(" at ").append(elements[i]).append("
");
}
// 如果还有更多堆栈,添加省略标记
if (elements.length > maxDepth) {
sb.append(" ... ").append(elements.length - maxDepth)
.append(" more
");
}
// 处理Caused by
Throwable cause = e.getCause();
if (cause != null) {
sb.append("Caused by: ");
String causeTrace = truncateStackTrace(cause, maxDepth - 2);
sb.append(causeTrace);
}
// 最终长度限制
String result = sb.toString();
if (result.length() > MAX_LENGTH) {
result = result.substring(0, MAX_LENGTH) + "...(truncated)";
}
return result;
}
/**
* 使用默认深度截取
*/
public static String truncateStackTrace(Throwable e) {
return truncateStackTrace(e, DEFAULT_MAX_DEPTH);
}
}
5.3 截取效果对比
# 完整堆栈(可能50+行)
java.lang.NullPointerException: Cannot invoke method on null object
at com.example.service.UserService.getUser(UserService.java:45)
at com.example.service.UserService.getUserDetail(UserService.java:78)
at com.example.controller.UserController.getUser(UserController.java:32)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.springframework.web.method.support.InvocableHandlerMethod.invoke(...)
... 40 more
# 截取后(10行,保留关键信息)
java.lang.NullPointerException: Cannot invoke method on null object
at com.example.service.UserService.getUser(UserService.java:45)
at com.example.service.UserService.getUserDetail(UserService.java:78)
at com.example.controller.UserController.getUser(UserController.java:32)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.springframework.web.method.support.InvocableHandlerMethod.invoke(...)
... 40 more
六、异常日志查询与告警
6.1 异常日志查询接口
/**
* 异常日志管理接口
*/
@RestController
@RequestMapping("/api/monitor/error-log")
public class ErrorLogController {
private final ErrorLogService errorLogService;
/**
* 分页查询异常日志
*/
@GetMapping("/page")
public R<IPage<ErrorLog>> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer errorType,
@RequestParam(required = false) String requestUri) {
Page<ErrorLog> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<ErrorLog> wrapper = new LambdaQueryWrapper<ErrorLog>()
.eq(errorType != null, ErrorLog::getErrorType, errorType)
.like(requestUri != null, ErrorLog::getRequestUri, requestUri)
.orderByDesc(ErrorLog::getCreateTime);
return R.ok(errorLogService.page(page, wrapper));
}
}
6.2 异常告警机制
/**
* 异常告警服务
* 当系统异常频率超过阈值时触发告警
*/
@Service
@Slf4j
public class ErrorAlertService {
private final RedisTemplate<String, Object> redisTemplate;
/** 告警阈值:5分钟内系统异常超过10次 */
private static final int ALERT_THRESHOLD = 10;
/** 统计窗口(分钟) */
private static final int WINDOW_MINUTES = 5;
/**
* 检查是否需要告警
*/
public void checkAlert() {
String key = "alert:error:count:" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
Long count = redisTemplate.opsForValue().increment(key);
// 首次设置过期时间
if (count != null && count == 1) {
redisTemplate.expire(key, WINDOW_MINUTES, TimeUnit.MINUTES);
}
// 超过阈值触发告警
if (count != null && count == ALERT_THRESHOLD) {
sendAlert("系统异常频率过高,最近" + WINDOW_MINUTES +
"分钟内已发生" + count + "次系统异常");
}
}
/**
* 发送告警(可对接钉钉/企业微信/邮件等)
*/
private void sendAlert(String message) {
log.error("【异常告警】{}", message);
// 实际项目中对接告警通道
}
}
七、异常处理全景流程
flowchart TD
A[请求进入Controller] --> B{业务处理}
B -->|正常| C[返回R.ok数据]
B -->|抛出BaseException| D[GlobalExceptionHandler]
B -->|抛出ValidException| D
B -->|抛出其他Exception| D
D --> E{异常分类}
E -->|业务异常| F[WARN日志]
E -->|参数异常| G[WARN日志]
E -->|认证/授权异常| H[WARN日志]
E -->|系统异常| I[ERROR日志]
F --> J[异步记录业务日志]
I --> K[异步记录系统日志<br/>含截取堆栈]
J --> L[返回具体错误码+消息]
G --> L
H --> L
K --> M[返回500+友好提示]
K --> N[检查告警阈值]
N -->|超阈值| O[发送告警通知]
style I fill:#ffcdd2,stroke:#c62828
style K fill:#ffcdd2,stroke:#c62828
style L fill:#c8e6c9,stroke:#2e7d32
style M fill:#fff9c4,stroke:#f9a825
结论与建议
核心设计原则
- 分类处理:业务异常返回具体信息,系统异常返回友好提示,绝不暴露堆栈给前端
- 异步记录:异常日志写入数据库使用异步线程,不影响接口响应速度
- 堆栈截取:只保留关键堆栈行,节省存储空间,提高可读性
- 告警机制:系统异常频率超阈值自动告警,及时发现线上问题
最佳实践
- 异常码统一管理:使用枚举类管理所有异常码,避免硬编码和重复
- 敏感信息过滤:记录请求参数时过滤 password、token 等敏感字段
- 异常不吞掉:
catch块中至少记录日志,不要空catch - 业务异常用 WARN:业务异常是可预期的,使用 WARN 级别;系统异常用 ERROR
- Caused by 递归:截取堆栈时递归处理 cause chain,保留根因信息