前言
在企业级系统开发中,"谁在什么时间、对什么数据、做了什么操作"------这条审计链路的重要性不言而喻。尤其在 ERP、政务系统等对数据安全和操作追溯有严格要求的场景下,一套完善的操作日志体系几乎是标配。
但日志记录最怕两件事:一是侵入业务代码 ------每个接口里手动塞一堆日志代码,维护成本高、遗漏风险大;二是拖慢主流程------日志写库是 I/O 操作,同步执行会拉高接口 RT,影响用户体验。
本文将以一个真实的管理系统为背景,完整拆解一套基于 Spring AOP + 自定义注解 + 线程池异步 + Redis 队列缓冲 + 定时批量入库 的操作日志方案。从注解定义、切面拦截、异步处理、消费者落库到踩坑优化,逐一展开。文中代码均可直接参考落地。
更多技术实战分享,欢迎关注公众号 【技海拾贝】 ,星标不迷路。

一、整体架构设计
在看代码之前,先理解整条数据流:
bash
业务方法被调用
↓
@AuditLog 注解标记 → AOP 切面拦截
↓
主线程:收集请求上下文、用户信息、入参 → 构建日志 VO
↓
主线程:执行目标方法(joinPoint.proceed())
↓
主线程:记录耗时、执行结果、异常信息
↓
异步线程池:序列化 JSON → 推入 Redis List(audit_log_queue)
↓
定时任务(@Scheduled):批量从 Redis 取出 → 反序列化 → 批量写入 MySQL
核心设计思想是三层解耦:
-
• 采集层(AOP 切面):零侵入地收集日志信息,不污染业务代码;
-
• 缓冲层(Redis List):削峰填谷,日志写入 Redis 是微秒级操作,几乎不影响主流程;
-
• 持久层(定时消费者):批量入库,降低数据库写入压力,支持重试和降级。
二、第一步:定义自定义注解 @AuditLog
java
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
/**
* 操作模块名称(一级)
*/
String firstModule() default "";
/**
* 操作模块名称(二级)
*/
String secondModule() default "";
/**
* 操作类型
*/
String operationType();
/**
* 操作描述
*/
String operationDesc() default "";
}
设计解读
这个注解的职责非常清晰------用声明式的方式描述"这次操作是什么" :
| 属性 | 作用 | 示例 | | --- | --- | --- | | firstModule | 一级模块,标识业务大类 | "基础设置" | | secondModule | 二级模块,标识具体子模块 | "基地管理" | | operationType | 操作类型,标识动作 | "新增"、"修改"、"删除"、"导出" | | operationDesc | 操作描述,补充说明 | "新增基地" |
使用 @Target(ElementType.METHOD) 限制只能标注在方法上,@Retention(RetentionPolicy.RUNTIME) 确保运行时可通过反射读取------这是 AOP 切面能拦截的前提。
使用方式
只需在 Controller 方法上加一行注解,零侵入:
java
@AuditLog(
firstModule = SysAuditLogEnum.FIRST_LEVEL_BASIC_SET,
secondModule = SysAuditLogEnum.SECOND_LEVEL_BASE_MANAGE,
operationType = SysAuditLogEnum.ADD,
operationDesc = "新增基地"
)
@PostMapping("/create")
public ServerResponse create(@RequestBody @Validated(OperateGroup.Add.class) BaseVO baseVO) {
return owmsBaseService.add(baseVO);
}
业务代码中没有任何日志相关逻辑,所有的采集工作都由切面自动完成。
三、第二步:AOP 切面------日志采集的核心
切面是整套方案的"大脑",负责在方法执行前后收集所有审计信息。完整代码如下:
java
@Aspect
@Component
@Slf4j
public class AuditLogAspect {
private static final String AUDIT_LOG_QUEUE = "audit_log_queue";
private static final String SUCCESS_STATUS = "success";
private static final String FAIL_STATUS = "fail";
@Resource
private ThreadPoolTaskExecutor auditLogExecutor;
@Resource
private RedisUtil redisUtil;
@Around("@annotation(auditLog)")
public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
log.info("审计日志构建,开始--->>>,入参:{}||{}", joinPoint, auditLog);
// 1. 获取请求上下文
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (ObjectUtil.isNull(attributes)) {
log.warn("当前线程未绑定请求上下文");
return joinPoint.proceed(); // ⚠️ 注意:不能返回 null,必须放行目标方法
}
// 2. 获取请求对象和线程会话
HttpServletRequest request = attributes.getRequest();
ThreadSession threadSession = ThreadSession.getThreadSession();
// 3. 构建日志基础信息(方法执行前可获取的部分)
AuditLogRecordVO auditLogRecordVO = buildAuditLog(request, threadSession, auditLog, joinPoint);
// 4. 执行目标方法,并记录结果
StopWatch stopWatch = new StopWatch("auditLog");
stopWatch.start();
Object result = null;
String status = SUCCESS_STATUS;
String errorMsg = null;
try {
result = joinPoint.proceed();
} catch (Exception e) {
status = FAIL_STATUS;
errorMsg = e.getMessage();
throw e; // 异常继续抛出,不影响业务行为
} finally {
// 5. 补充执行耗时和结果
stopWatch.stop();
auditLogRecordVO.setExecutionTime(stopWatch.getTotalTimeMillis());
auditLogRecordVO.setStatus(status);
auditLogRecordVO.setErrorMessage(errorMsg);
// 6. 异步写入 Redis
auditLogExecutor.execute(() -> {
try {
String auditLogJson = JSONUtil.toJsonStr(auditLogRecordVO);
log.info("审计日志构建,最终结果:{}", auditLogJson);
redisUtil.lRightPush(AUDIT_LOG_QUEUE, auditLogJson);
} catch (Exception e) {
log.error("保存审计日志到Redis失败", e);
}
});
}
return result;
}
private AuditLogRecordVO buildAuditLog(HttpServletRequest request,
ThreadSession threadSession, AuditLog auditLog, ProceedingJoinPoint joinPoint) {
// 注解信息
String firstModule = auditLog.firstModule();
String secondModule = auditLog.secondModule();
String operationDesc = auditLog.operationDesc();
String operationType = auditLog.operationType();
// 方法入参
Object[] args = joinPoint.getArgs();
String operationContext = JSONUtil.toJsonStr(args);
// 用户信息(从自定义的 ThreadSession 中获取)
String operatorAccount = threadSession.getLoginName();
String operatorName = threadSession.getFiled(Constant.REAL_NAME);
String operatorIp = threadSession.getIp();
String pdaVersion = threadSession.getFiled(Constant.PDA_VERSION);
String owmsTraceId = threadSession.getTraceId();
String loginBaseCode = threadSession.getLoginBaseCode();
// 请求信息
String requestUrl = request.getRequestURI();
String requestMethod = request.getMethod();
return AuditLogRecordVO.builder()
.operatorAccount(operatorAccount)
.operatorName(operatorName)
.operatorIp(operatorIp)
.operationType(operationType)
.baseCode(loginBaseCode)
.operationDesc(operationDesc)
.operationContext(operationContext)
.operationFirstModule(firstModule)
.operationSecondModule(secondModule)
.owmsTraceId(owmsTraceId)
.pdaVersion(pdaVersion)
.requestUrl(requestUrl)
.requestMethod(requestMethod)
.createBy(operatorAccount)
.createTime(LocalDateTime.now())
.enableFlag(Constant.ENABLE_TEN)
.build();
}
}
切面执行流程拆解
用一张时序图来说明:
bash
请求进入 Controller 方法
→ AOP 拦截,进入 around() 方法
→ Step1: 获取 HttpServletRequest、ThreadSession
→ Step2: buildAuditLog() ------ 采集注解属性、入参、用户信息、请求 URL 等
→ Step3: joinPoint.proceed() ------ 执行真正的业务方法
→ Step4(finally): 记录耗时、状态、异常信息
→ Step5(异步): 序列化 → 推入 Redis List
→ 返回 result 给 Controller 调用方
几个关键设计点:
① 为什么用 **@Around而不是 @Before+ @After****?**
@Around 可以在同一个方法内同时拿到入参、返回值和异常,并且能精确控制 joinPoint.proceed() 的执行时机。对于需要计算方法执行耗时的场景,@Around 是唯一合理选择。
② **finally**块保证日志不丢
无论目标方法正常返回还是抛异常,finally 块都会执行。通过 catch 块捕获异常后设置 status = "fail" 并记录 errorMsg,然后继续 throw e------业务行为不受影响,异常原样抛出,但日志已经完整记录。
③ **buildAuditLog()在 proceed()**之前调用
入参、用户信息、URL 这些数据在方法执行前就能获取,所以先构建基础信息。耗时和执行结果是方法执行后才有的,在 finally 中补充。这样拆分逻辑更清晰。
四、第三步:线程池配置------为什么要异步?怎么异步?
4.1 为什么必须用多线程?
审计日志写 Redis 属于 I/O 操作。虽然正常情况下 Redis 写入只需 1-2ms,但一旦遇到网络抖动,可能飙升到 50ms 甚至超时。如果同步执行,这个延迟会直接叠加到接口 RT 上。
| 场景 | 同步写 Redis | 异步线程池 | | --- | --- | --- | | Redis 正常(1ms) | 接口 RT +1ms | 主线程 +0ms | | Redis 抖动(50ms) | 接口 RT +50ms | 主线程 +0ms | | Redis 宕机 | 接口阻塞至超时 | 主线程立即返回 |
审计日志是典型的 fire-and-forget (发后即忘)任务------可降级、可重试,但绝不能拖慢业务主流程。因此,异步线程池是必须的。
4.2 线程池配置
java
@Configuration
@EnableAsync
@Slf4j
public class ThreadPoolConfig {
public static final int MULTIPLE = 5;
public static final int ALIVE_TIMEOUT = 30;
// 核心线程数 = CPU 核数
public static final int corePoolSize = Runtime.getRuntime().availableProcessors();
// 最大线程数 = CPU 核数 × 5
public static final int maxPoolSize = Runtime.getRuntime().availableProcessors() * MULTIPLE;
// 队列容量 = 最大线程数 × 5
public static final int queueCapacity = maxPoolSize * MULTIPLE;
// 空闲线程存活时间
public static final int keepAliveSeconds = ALIVE_TIMEOUT;
private String threadNamePrefix = "warehouse-";
@Bean(name = "auditLogExecutor")
@Primary
public ThreadPoolTaskExecutor getAuditLogExecutor() {
return generateThreadPoolTaskExecutor(
corePoolSize, maxPoolSize, queueCapacity,
keepAliveSeconds, threadNamePrefix,
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
4.3 线程池参数解读
| 参数 | 取值逻辑 | 说明 | | --- | --- | --- | | corePoolSize | CPU 核数(如 4 或 8) | 日志序列化 + Redis 写入是 I/O 操作,CPU 核数作为基线足够 | | maxPoolSize | CPU 核数 × 5 | 允许突发流量时临时扩容 | | queueCapacity | maxPoolSize × 5 | 缓冲队列,容纳突发任务 | | keepAliveSeconds | 30s | 非核心线程空闲 30 秒后回收 | | RejectedExecutionHandler | CallerRunsPolicy | 队列满时由调用线程(主线程)执行,保证日志不丢 |
CallerRunsPolicy 是关键选择 。相比 AbortPolicy(直接丢弃)和 DiscardPolicy(静默丢弃),CallerRunsPolicy 在队列满时会退化为同步执行------相当于"限流降速",而不是"丢弃日志"。对于审计日志这种不允许丢失的场景,这是最稳妥的拒绝策略。
4.4 异步任务提交方式
java
auditLogExecutor.execute(() -> {
try {
String auditLogJson = JSONUtil.toJsonStr(auditLogRecordVO);
redisUtil.lRightPush(AUDIT_LOG_QUEUE, auditLogJson);
} catch (Exception e) {
log.error("保存审计日志到Redis失败", e);
}
});
使用 execute() 而非 submit(),因为这里不需要返回值(Future)。Lambda 表达式内只引用了 VO 对象和 Redis 工具,不涉及数据库连接或事务资源,无线程安全风险。
五、第四步:消费者------从 Redis 到数据库的批量落库
日志推入 Redis List 后,需要一个消费者定时拉取并持久化到 MySQL。这里提供两个版本:基础版和分布式锁版。
5.1 基础版(单机部署适用)
java
@Component
@Slf4j
public class SysAuditLogConsumer {
private static final String AUDIT_LOG_QUEUE = "audit_log_queue";
@Resource
private RedisUtil redisUtil;
@Resource
private SysAuditLogService auditLogService;
@Scheduled(fixedDelay = 3000)
public void consumeAuditLogs() {
List<SysAuditLogDO> auditLogs = new ArrayList<>();
try {
// 从 Redis List 中读取前 200 条
List<String> strings = redisUtil.lRange(AUDIT_LOG_QUEUE, 0, 199);
log.debug("审计日志消费,获取日志条数:{}", strings.size());
if (CollUtil.isNotEmpty(strings)) {
// 原子裁剪:删除已读取的数据
redisUtil.lTrim(AUDIT_LOG_QUEUE, strings.size(), -1);
// JSON → DO 对象
for (String string : strings) {
try {
SysAuditLogDO sysAuditLogDO = JSON.parseObject(string, SysAuditLogDO.class);
auditLogs.add(sysAuditLogDO);
} catch (Exception e) {
log.error("解析审计日志JSON失败", e);
}
}
// 批量写入数据库
if (CollUtil.isNotEmpty(auditLogs)) {
auditLogService.saveBatch(auditLogs);
}
}
} catch (Exception e) {
log.error("消费审计日志失败", e);
} finally {
auditLogs.clear();
}
}
}
5.2 分布式锁版(集群部署适用)
当系统部署多个实例时,需要用分布式锁防止多个节点同时消费同一批日志:
java
@Scheduled(fixedDelay = 3000)
public void consumeAuditLogs() {
String value = IdUtil.fastSimpleUUID();
boolean lock = redisUtil.getLock("audit_log_lock", value, 2500L);
if (!lock) {
log.warn("审计日志消费者正在运行,跳过本次执行");
return;
}
try {
// ... 消费逻辑同上 ...
} finally {
try {
redisUtil.releaseLock("audit_log_lock", value);
} catch (Exception e) {
log.warn("释放审计日志锁异常", e);
}
}
}
5.3 消费者设计要点
① **lRange+ lTrim而非 lPop**
这里用 lRange 一次性读取前 N 条,再用 lTrim 原子裁剪,等价于批量 lPop。相比循环调用 lPop,减少 Redis 通信次数,效率更高。
② 每条 JSON 独立 try-catch
某条日志 JSON 解析失败不应影响整批数据。独立捕获后记录错误日志,其余正常入库------局部失败不扩散。
③ **saveBatch**批量入库
使用 MyBatis-Plus 的 saveBatch 方法,将多条 INSERT 合并为一次数据库交互,显著降低 DB 写入压力。200 条日志一次提交,比逐条 INSERT 快一个数量级。
④ 消费周期 3 秒
fixedDelay = 3000 表示上一次消费完成后等待 3 秒再执行下一次。这个间隔需要根据业务量调整:日志量大可缩短到 1 秒;日志量小可拉长到 5-10 秒。
六、完整数据流回顾
bash
┌──────────────────────────┐
│ 业务方法(Controller) │
│ @AuditLog + @PostMapping │
└──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ AOP 切面拦截 │
│ ① 采集注解属性 │
│ ② 采集请求上下文 │
│ ③ 采集用户信息 │
│ ④ 执行目标方法 │
│ ⑤ 记录耗时 & 结果 │
└──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ 异步线程池(auditLogExecutor)│
│ 序列化 JSON → Redis List │
│ audit_log_queue │
└──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ 定时消费者(@Scheduled) │
│ 每 3 秒批量拉取 200 条 │
│ 反序列化 → saveBatch → MySQL│
└──────────────────────────┘
三个层面各司其职:
-
• AOP 切面负责"采集"------零侵入,声明式;
-
• Redis 队列负责"缓冲"------削峰填谷,解耦生产与消费;
-
• 定时消费者负责"持久化"------批量入库,降低 DB 压力。
结束语
一套好的操作日志方案,应该像空气一样存在------开发者只需要在方法上加一个注解,剩下的事情全部自动化完成 。本文介绍的 @AuditLog + AOP 切面 + 线程池异步 + Redis 缓冲 + 定时批量入库的方案,正是这一理念的落地实践。
核心要点总结:
当然,这套方案仍有优化空间:可以引入 MQ(如 RabbitMQ、Kafka)替代 Redis List ,获得更可靠的消息投递语义;也可以结合 Elasticsearch 做日志检索和分析;还可以通过 Micrometer 接入监控体系,实时观察线程池状态和消费延迟。
技术没有银弹,只有在具体场景下做出合理取舍。希望本文的分享能给你带来启发。
觉得有收获?欢迎关注公众号【技海拾贝】,星标不迷路,更多技术实战持续更新。
