基于 Spring AOP 构建系统操作日志:从注解设计到异步落库的完整实战

前言

在企业级系统开发中,"谁在什么时间、对什么数据、做了什么操作"------这条审计链路的重要性不言而喻。尤其在 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 接入监控体系,实时观察线程池状态和消费延迟。

技术没有银弹,只有在具体场景下做出合理取舍。希望本文的分享能给你带来启发。

觉得有收获?欢迎关注公众号【技海拾贝】,星标不迷路,更多技术实战持续更新。

相关推荐
RainCity1 小时前
Java Swing 自定义组件库分享(十五)
java·笔记·后端
farerboy1 小时前
23-Java 构造函数
java·后端
坚持的小马2 小时前
Rocketmq搭建操作步骤
java·rocketmq·java-rocketmq
砚底藏山河2 小时前
多家股票数据接口对比、企业级股票数据API
java·python·金融·maven
VX_bysjlw9852 小时前
基于微信小程序的宠物用品商城系统-后端74346-计算机毕设原创(免费领源码+带部署教程)
java·redis·微信小程序·eclipse·mybatis·idea·微信开发者工具
JAVA面经实录9173 小时前
图解23种设计模式完整知识体系(Java后端面试完整版)
java·架构
会飞的大鱼人3 小时前
一文搞懂 Java HashSet:把它想成游乐园里只允许一次入场的盖章名单
java·开发语言·windows
AI_小站3 小时前
Loop Engineering又是啥?一文讲清企业Agent落地的四层工程进化论
java·人工智能·架构·prompt·大模型开发·智能体·大模型应用
jiay23 小时前
【.net10】顶级程序语句
java·开发语言