Java多线程实战-异步操作日志记录解决方案(AOP+注解+多线程)

本文涉及的产品
云原生数据库 PolarDB MySQL 版,Serverless 5000PCU 100GB
简介: Java多线程实战-异步操作日志记录解决方案(AOP+注解+多线程)

前言


在现代分布式系统中,操作日志记录扮演着非常重要的角色。它不仅能够帮助我们追踪系统的运行状态,还可以提供关键的审计线索,对于系统的运维和问题排查都有着重要意义。传统的日志记录方式通常是在相关的业务逻辑代码中直接插入日志记录语句,这种方式虽然直观简单,但存在一些明显的缺陷:


日志记录代码和业务逻辑代码高度耦合,不利于代码的可维护性。

新增或修改日志记录需求时,需要修改多处代码,工作量较大。

由于日志记录操作通常需要进行IO操作,会对业务响应时间产生一定影响。

为了解决这些问题,我们可以考虑采用基于注解和AOP切面的异步日志记录解决方案。它能够有效地将日志记录代码和业务逻辑代码解耦,同时通过异步的方式避免日志记录阻塞主线程,从而提高系统的响应速度和吞吐量。

实现思路

自定义OperationLog注解

我们首先定义一个OperationLog注解,用于标记需要记录操作日志的方法。该注解可以包含一些属性,如操作描述、操作类型等,方便后续记录日志时获取相关信息。

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    /**
     * @return 操作描述
     */
    String value() default "";
}

使用AOP切面拦截被注解标记的方法

接下来,我们需要定义一个AOP切面,通过切点表达式拦截被OperationLog注解标记的方法。在切面的增强方法中,我们可以获取方法的元数据信息、请求参数等,并与HTTP请求信息一起构建出OperationLogVo对象。

@Aspect
@Component
@Slf4j
public class OperationLogAspect {
 
    @Pointcut("@annotation(com.luckysj.demo.annotation.OperationLog)")
    public void optLogPointCut() {}
 
    @Around("optLogPointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        // 环绕增强方法...
    }
}


AOP配合注解注解使用是一种很常见且使用的手段,像限流,鉴权之类与业务无关的操作,我们都可以通过这种方法来将这些辅助业务从主业务中拆开来,减少代码耦合度。

获取请求信息构建OperationLogVo对象


在切面的增强方法中,我们使用反射的方式获取目标方法的元数据信息,包括方法名、所在类名等。同时,我们还需要从当前线程绑定的RequestContextHolder中获取HttpServletRequest对象,以获取请求的URI、请求方法、IP地址等信息。将这些信息与操作描述等数据组合,即可构建出完整的OperationLogVo对象。日志实体对象OperationLogVo:

@Data
@TableName("operation_log")
public class OperationLogVo {
 
    @TableId(type = IdType.AUTO)
    private Long logId;
 
    private String type;
 
    @TableField("request_uri")
    private String uri;
 
    private String name;
 
    @TableField("ip_address")
    private String ipAddress;
 
    private String method;
 
    private String params;
 
    private String data;
 
    @TableField("nick_name")
    private String nickname;
 
    private Integer userId;
 
    private Long times;
 
    private String errorMessage;
 
}

在AOP切面类中定义一个从织入点中获取数据组装OperationLogVo 实体的方法:

    private OperationLogVo recordLog(ProceedingJoinPoint joinPoint) {
        // 从切面织入点处通过反射机制获取织入点处的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切入点所在的方法
        Method method = signature.getMethod();
        // 获取操作
        OperationLog optLogger = method.getAnnotation(OperationLog.class);
        // 获取request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
        // 日志保存到数据库
        OperationLogVo operationLogVo = new OperationLogVo();
        // 操作类型
        operationLogVo.setType(optLogger.value());
        // 请求URI
        operationLogVo.setUri(request.getRequestURI());
        // 获取请求的类名
        String className = joinPoint.getTarget().getClass().getName();
        // 获取请求的方法名
        String methodName = method.getName();
        methodName = className + "." + methodName;
        // 请求方法
        operationLogVo.setName(methodName);
 
        // 请求参数
        if (joinPoint.getArgs()[0] instanceof MultipartFile) {
            operationLogVo.setParams(((MultipartFile) joinPoint.getArgs()[0]).getOriginalFilename());
        } else {
            operationLogVo.setParams(JSON.toJSONString(joinPoint.getArgs()));
        }
        // 请求方式
        operationLogVo.setMethod(Objects.requireNonNull(request).getMethod());
        // 请求用户ID 先写死
        operationLogVo.setUserId(22);
//        operationLogVo.setUserId(SecurityUtils.getUserId());
        // 请求用户昵称 先写死
        operationLogVo.setNickname("woniu");
        // 操作ip地址
        String ip = request.getRemoteAddr();
        operationLogVo.setIpAddress(ip);
        return operationLogVo;
    }


我们这里还需要一个方法来处理异常信息,将异常信息格式化为字符串,方便存储

// 将异常相关的全部信息(类名、描述、堆栈跟踪)格式化为一个字符串,方便存储到日志记录对象OperationLogVo的errorMessage属性中。
public String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
    StringBuilder stringBuilder = new StringBuilder();
    for (StackTraceElement stet : elements) {
        stringBuilder.append(stet).append("\n");
    }
    return exceptionName + ":" + exceptionMessage + "\n" + stringBuilder;
}


编写线程池封装类,封装类工厂

AsyncManager类是一个单例类,内部维护了一个ScheduledExecutorService线程池executor。

我们封装了一些常用的方法:

public class AsyncManager {
 
    /**
     * 单例模式,确保类只有一个实例
     */
    private AsyncManager() {
    }
 
    /**
     * 饿汉式,在类加载的时候立刻进行实例化
     */
    private static final AsyncManager INSTANCE = new AsyncManager();
 
    public static AsyncManager getInstance() {
        return INSTANCE;
    }
 
    /**
     * 异步操作任务调度线程池
     */
    private final ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
 
    /**
     * 执行任务
     *
     * @param task 任务
     */
    public void execute(TimerTask task) {
        executor.schedule(task, 10, TimeUnit.MILLISECONDS);
    }
 
    /**
     * 停止任务线程池
     */
    public void shutdown() {
        ThreadUtils.shutdownAndAwaitTermination(executor);
    }
 
}


工厂类:

public class AsyncFactory {
 
    /**
     * 记录操作日志
     * @param operationLog 操作日志信息
     * @return 任务task
     */
    public static TimerTask recordOperation(OperationLogVo operationLog) {
        return new TimerTask() {
            @Override
            public void run() {
                // 找到日志服务bean,进行日志持久化操作
                SpringUtils.getBean(OperationLogService.class).saveOperationLog(operationLog);
            }
        };
    }
 
 
}

这里的OperationLogService就是日志服务类,我们可以在里面进行日志信息的入库等,具体内容要根据你的实际情况来调整,我这里是存入到msql数据库中持久化,源码仓库会在文末贴出,这里就不细讲了。

使用线程池异步执行日志记录操作


为了避免日志记录操作阻塞主线程,影响业务响应时间,我们可以使用线程池异步执行日志记录操作。在切面的最后,我们将构建好的OperationLogVo对象提交到线程池中,由工作线程异步完成日志的存储操作。

@Around("optLogPointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        String methodName = joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName();
        OperationLogVo operationLogVo = null;
        try {
            operationLogVo = this.recordLog(joinPoint);
        } catch (IllegalStateException e) {
            log.error("no web request:{}", e.getMessage());
        }
        long startTime = System.currentTimeMillis();
        Object result = null;
        try {
            result = joinPoint.proceed();
            // 正常返回数据
            operationLogVo.setData(JSON.toJSONString(result));
        } catch (Throwable e) {
            log.info("method: {}, throws: {}", methodName, ExceptionUtils.getStackTrace(e));
            if (operationLogVo != null) {
                operationLogVo.setErrorMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace()));
            }
        } finally {
            long endTime = System.currentTimeMillis();
            if (operationLogVo != null) {
                operationLogVo.setTimes(endTime - startTime);
                //异步记录操作日志
                AsyncManager.getInstance().execute(AsyncFactory.recordOperation(operationLogVo));
            }
        }
        return result;
    }


使用日志注解,测试

@RestController
@RequestMapping("/user")
public class UserController {
 
    @Autowired
    private UserService userService;
 
 
    @PostMapping("/add")
    @OperationLog("添加用户")// 这里可以写上操作日志的描述
    public ResponseEntity<String> addUser(@RequestBody UserReq addReq) {
        return userService.addUser(addReq);
    }
 
 
}

启动项目后,我们尝试插入一个用户,可以看到日志已经记录到了库中

异步日志记录的优缺点分析

优点

提高响应速度和系统吞吐量:通过异步记录日志,可以避免因日志记录操作中的I/O操作而阻塞主线程,从而提高系统的响应速度和处理能力。

解耦日志记录与业务逻辑:异步记录机制使得日志记录的逻辑与业务逻辑分离,有助于保持代码的整洁和易于维护。

提高系统的健壮性:在面对大量日志写入操作时,异步机制可以平滑处理高峰,避免系统因同步写入日志而出现性能瓶颈。

缺点

  1. 可能丢失日志:在极端情况下,如系统突然崩溃,可能会丢失还未来得及持久化的日志。
  2. 日志顺序无法保证:由于是异步操作,无法完全保证日志按照发生顺序进行记录,尤其是在高并发场景下。
  3. 增加系统复杂性:引入异步日志记录机制,增加了系统的复杂性,需要额外的线程管理和错误处理机制。


日志持久化方案分析

日志数据的持久化是确保操作记录可追溯和审计的重要环节,本文章使用的持久化方案是关系型数据库,当然还有很多其他的方案,常见的日志持久化方案包括:


关系型数据库:将日志数据存储在关系型数据库中,如MySQL、PostgreSQL等。这种方案便于日志的查询、管理和维护,但在高并发场景下可能会成为瓶颈。


日志文件:直接将日志写入文件系统,这种方式简单高效,适用于大部分场景。但需要合理规划日志的切割、备份和清理策略,以避免文件过大或过多导致的问题。


消息队列(如Kafka):将日志作为消息发送到Kafka等消息队列系统中,可以实现高吞吐量的日志处理。这种方案适用于日志量巨大且需要快速处理的场景,同时也便于实现日志数据的分布式处理和存储。


每种方案都有其适用场景和限制,实际选择时需要根据系统的具体需求和现有架构做出合理的决策。

总结


异步日志记录是一种提升系统性能和可维护性的有效手段,通过将日志记录操作异步化,不仅可以减少对业务处理流程的影响,还可以提高日志处理的灵活性和扩展性。然而,实现异步日志记录机制也伴随着一定的挑战,如日志的实时性、顺序性和丢失风险等问题。


在选择日志持久化方案时,应根据系统的实际需求考虑日志数据的安全性、查询效率、成本等因素,选择最适合的存储介质和技术方案。无论采取哪种方案,都应该注意日志系统的健壮性设计,确保日志数据的完整性和可靠性。


多线程编程系列的源码都放在我的github仓库啦,有需要的可以点点小star,感谢支持~

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
2天前
|
Java 调度
【Java基础】 线程状态转化
Java线程状态转化
15 2
|
5天前
|
缓存 NoSQL Java
Java高并发实战:利用线程池和Redis实现高效数据入库
Java高并发实战:利用线程池和Redis实现高效数据入库
22 0
|
1天前
|
Java 程序员
Java多线程编程是指在一个进程中创建并运行多个线程,每个线程执行不同的任务,并行地工作,以达到提高效率的目的
【6月更文挑战第18天】Java多线程提升效率,通过synchronized关键字、Lock接口和原子变量实现同步互斥。synchronized控制共享资源访问,基于对象内置锁。Lock接口提供更灵活的锁管理,需手动解锁。原子变量类(如AtomicInteger)支持无锁的原子操作,减少性能影响。
15 3
|
1天前
|
数据采集 安全 算法
Java并发编程中的线程安全与性能优化
在Java编程中,多线程并发是提升程序性能的关键之一。本文将深入探讨Java中的线程安全性问题及其解决方案,并介绍如何通过性能优化技术提升多线程程序的效率。
9 3
|
1天前
|
Java 调度
【Java基础】 多线程
Java、多线程编程
11 0
|
4天前
|
监控 Java API
Java 程序设计 第八章 线程
Java 程序设计 第八章 线程
|
4天前
|
存储 安全 Java
Java多线程编程--JUC
Java多线程编程
|
1月前
|
Java 数据库连接 应用服务中间件
Spring5源码(39)-Aop事物管理简介及编程式事物实现
Spring5源码(39)-Aop事物管理简介及编程式事物实现
30 0
|
1月前
AOP&面向切面编程
AOP&面向切面编程
62 0
|
4天前
|
Java Maven 数据安全/隐私保护
详解 Java AOP:面向方面编程的核心概念与 Spring 实现
详解 Java AOP:面向方面编程的核心概念与 Spring 实现
16 1