Spring Boot实现AOP记录操作日志案例:从入门到生产级实践
目录导读
- 为什么需要AOP日志? —— 传统日志切面痛点与AOP优势
- 核心概念速览 —— 切点、通知、连接点、切面
- 环境准备 —— Spring Boot 3.x + Maven依赖配置
- 自定义注解设计 —— @OperationLog注解的完整定义
- 切面实现核心代码 —— @Around环绕通知详解
- 日志入库与异步优化 —— 防止日志拖垮主业务流程
- 常见问题FAQ —— 自调用失效、多线程、性能损耗等陷阱
- 生产级扩展建议 —— 结合SpEL表达式与ELK日志追踪
为什么需要AOP记录操作日志?
在电商、金融等系统中,每次用户操作(下单、退款、删除数据)都需要记录“谁在什么时间干了什么”,传统做法是在每个业务方法中手动添加日志代码:

public void deleteOrder(Long orderId) {
// 手动写日志
log.info("用户{}删除了订单{}", currentUser, orderId);
// 业务逻辑...
}
这种方式的致命缺陷:日志代码侵入业务代码,重复且难以维护,当你想统一增加“操作IP”字段时,需要改动几十个方法。
AOP(面向切面编程)的解决方案:通过动态代理,在不修改原有业务代码的情况下,横切地添加日志记录逻辑,Spring Boot对AOP提供了开箱即用的支持,结合自定义注解,能实现优雅的“声明式日志”。
核心概念速览(必懂)
| 概念 | 说明 | 本例对应实现 |
|---|---|---|
| 连接点 | 程序执行的某个位置 | 目标方法执行时 |
| 切点(PoinCut) | 匹配连接点的表达式 | @annotation(operationLog) |
| 通知(Advice) | 在切点执行的动作 | @Around环绕通知 |
| 切面(Aspect) | 切点+通知的集合 | OperationLogAspect类 |
| 织入(Weaving) | 将切面应用到目标对象的过程 | Spring启动时自动通过代理完成 |
关键理解:Spring AOP默认使用JDK动态代理(接口)或CGLIB代理(类),这决定了后续“自调用失效”问题的根源。
环境准备:Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 根据需要引入MyBatis-Plus或JPA等 -->
自定义注解设计(高扩展性)
@Target(ElementType.METHOD) // 作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,供反射读取
public @interface OperationLog {
String module() default ""; // 模块名,如"订单模块"
String operation() default ""; // 操作类型,如"删除"
String detail() default ""; // 描述,支持Spring EL表达式
}
使用示例:
@OperationLog(module = "订单模块", operation = "删除",
detail = "#orderId") // SpEL引用参数
public void deleteOrder(Long orderId) { ... }
切面实现核心代码(AOP灵魂)
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
@Autowired
private OperationLogService logService; // 异步日志服务
@Around("@annotation(operationLog)") // 绑定注解参数
public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog)
throws Throwable {
// 1. 前置记录:获取请求上下文
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest();
String ip = getIpAddress(request);
String username = SecurityContextHolder.getContext()
.getAuthentication() != null ?
SecurityContextHolder.getContext().getAuthentication().getName() : "anonymous";
long startTime = System.currentTimeMillis();
Object result = null;
boolean success = true;
String errorMsg = "";
try {
// 2. 执行原业务方法
result = joinPoint.proceed();
return result;
} catch (Throwable e) {
success = false;
errorMsg = e.getMessage();
throw e; // 继续向上抛出,事务正常回滚
} finally {
// 3. 后置日志组装(使用SpEL解析detail)
OperationLogDTO dto = buildDTO(joinPoint, operationLog,
username, ip, success, errorMsg,
System.currentTimeMillis() - startTime);
// 4. 异步保存,不阻塞主流程
logService.saveLogAsync(dto);
}
}
// 解析SpEL表达式
private String resolveExpression(String expr, ProceedingJoinPoint pjp) {
// 标准实现:StandardEvaluationContext + SpelExpressionParser
// 具体代码略,核心是methodArgs映射参数名
}
private String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
// ... 处理代理IP逻辑
return ip != null ? ip.split(",")[0] : request.getRemoteAddr();
}
}
为何使用@Around而不是@AfterReturning?
因为@Around可以同时捕获正常返回和异常,并且能够获取方法的返回值,让日志记录包含“操作结果”这一关键信息。
日志入库与异步优化——避免性能陷阱
如果同步执行数据库INSERT(每次日志消耗5ms),高并发下会拖垮接口响应,最佳实践:
@Service
@Slf4j
public class OperationLogService {
@Async("logExecutor") // 自定义线程池
public void saveLogAsync(OperationLogDTO dto) {
// 1. 可先缓存到Redis List,批量落库
// 2. 或直接使用MyBatis-Plus的save()
}
@Bean("logExecutor")
public Executor executor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(1000); // 积压缓冲
executor.initialize();
return executor;
}
}
注意:必须保证主方法执行完毕后日志保存完成(异步的“最终一致”),因此可在finally中先放入内存队列,再批量消费。
常见问题FAQ(90%开发者会踩坑)
Q1:在同一个类中,Controller调用Service的A方法,而A方法内部又调用了B方法,为什么B上的@OperationLog不生效?
A:Spring AOP基于代理,调用A方法是走代理,但A方法内部的this.B()调用的是原始对象的方法,绕过了代理,因此切面失效,解决方案:
- 将B方法拆到另一个Service中(推荐)
- 或使用
AopContext.currentProxy()获取代理对象调用
Q2:异步日志的线程如何获取当前登录用户?
A:SecurityContextHolder默认使用ThreadLocal存储上下文,子线程无法获取,需要在主线程中取出用户信息,作为参数传入异步方法,切不可在异步线程中读取。
Q3:AOP日志导致接口性能下降50ms+,如何处理?
A:优化三连:
- 使用异步线程池(如上)
- 日志信息存Redis而不是直接写MySQL
- 增加采样率(比如每秒只记录20%的请求)
Q4:如何记录操作前的原始数据(比如修改前和修改后的值)?
A:可以在@Around中通过joinPoint.getArgs()获取参数,对于更新方法,查询数据库中的旧值,然后在proceed()后获取新值,组装差异,这适合审计需求。
生产级扩展建议(加分项)
-
结合SpEL表达式动态记录
让用户自定义detail内容,比如detail = "订单号:#orderId, 金额:#order.amount",切面解析参数对象属性。 -
链路追踪ID整合
在切面中提取MDC.get("traceId"),与日志框架集成,保证日志与业务流程的串联。 -
日志级别差异化
操作成功使用INFO,失败使用ERROR并记录堆栈,便于告警。 -
绕过安全风险
日志切面不应记录敏感信息(密码、支付二维码),可通过注解属性isSensitive过滤字段。 -
使用Spring AOP vs AspectJ
若需要给类(而非方法)加日志,或需要编译期织入,可迁移至AspectJ,但Spring Boot默认AOP已覆盖98%场景。
总结思维导图
@OperationLog注解 --> 定义模块、操作、SpEL详情
↓
OperationLogAspect切面
↓ @Around
前置:提取IP、用户、参数
执行:joinPoint.proceed()
后置:组装DTO(耗时、成功状态)
↓
异步服务(线程池) --> Redis队列 --> 批量入库
完整问答速查(面试必备)
问:AOP通知类型有几个?分别场景?
答:Before(权限检查)、After(资源释放)、AfterReturning(统计成功指标)、AfterThrowing(异常监控)、Around(最强大,可控制事务与缓存)。
问:CGLIB和JDK动态代理如何选择?
答:默认如果目标类有接口,则JDK;否则CGLIB,可在SpringBoot配置spring.aop.proxy-target-class=true强制CGLIB,这样所有类都支持切面,但会带来代理对象创建开销。
问:日志写入失败会影响原业务吗?
答:不会,异步日志在finally中执行,且内部有try-catch,保证日志失败只记录error,不抛出异常。
本文从实际项目出发,完整展示了Spring Boot集成AOP记录操作日志的每一步,从设计到优化,从常见坑到生产扩展,最后回到那句核心经验:“AOP不仅是一种技术,更是一种解耦思想”——它能帮你把横切关注点与核心业务彻底分离,让代码更加优雅可靠。