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

本文涉及的产品
云原生数据库 PolarDB MySQL 版,通用型 2核4GB 50GB
日志服务 SLS,月写入数据量 50GB 1个月
云原生数据库 PolarDB PostgreSQL 版,标准版 2核4GB 50GB
简介: 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日志并进行多维度分析。
相关文章
|
1月前
|
Java
Spring5入门到实战------9、AOP基本概念、底层原理、JDK动态代理实现
这篇文章是Spring5框架的实战教程,深入讲解了AOP的基本概念、如何利用动态代理实现AOP,特别是通过JDK动态代理机制在不修改源代码的情况下为业务逻辑添加新功能,降低代码耦合度,并通过具体代码示例演示了JDK动态代理的实现过程。
Spring5入门到实战------9、AOP基本概念、底层原理、JDK动态代理实现
|
5天前
|
设计模式 缓存 Java
谷粒商城笔记+踩坑(14)——异步和线程池
初始化线程的4种方式、线程池详解、异步编排 CompletableFuture
谷粒商城笔记+踩坑(14)——异步和线程池
|
1月前
|
缓存 Java
异步&线程池 线程池的七大参数 初始化线程的4种方式 【上篇】
这篇文章详细介绍了Java中线程的四种初始化方式,包括继承Thread类、实现Runnable接口、实现Callable接口与FutureTask结合使用,以及使用线程池。同时,还深入探讨了线程池的七大参数及其作用,解释了线程池的运行流程,并列举了四种常见的线程池类型。最后,阐述了在开发中使用线程池的原因,如降低资源消耗、提高响应速度和增强线程的可管理性。
异步&线程池 线程池的七大参数 初始化线程的4种方式 【上篇】
|
1月前
|
Java 数据库
异步&线程池 CompletableFuture 异步编排 实战应用 【终结篇】
这篇文章通过一个电商商品详情页的实战案例,展示了如何使用`CompletableFuture`进行异步编排,以解决在不同数据库表中查询商品信息的问题,并提供了详细的代码实现和遇到问题(如图片未显示)的解决方案。
异步&线程池 CompletableFuture 异步编排 实战应用 【终结篇】
|
1月前
|
XML Java 数据格式
Spring5入门到实战------11、使用XML方式实现AOP切面编程。具体代码+讲解
这篇文章是Spring5框架的AOP切面编程教程,通过XML配置方式,详细讲解了如何创建被增强类和增强类,如何在Spring配置文件中定义切入点和切面,以及如何将增强逻辑应用到具体方法上。文章通过具体的代码示例和测试结果,展示了使用XML配置实现AOP的过程,并强调了虽然注解开发更为便捷,但掌握XML配置也是非常重要的。
Spring5入门到实战------11、使用XML方式实现AOP切面编程。具体代码+讲解
|
22天前
|
XML 监控 Java
异步日志:性能优化的金钥匙
本文主要介绍了Log4j2框架的核心原理、实践应用以及一些实用的小Tips,力图揭示Log4j2这一强大日志记录工具在现代分布式服务架构运维中的关键作用。
|
1月前
|
Java
异步&线程池 CompletableFuture 异步编排 【下篇】
这篇文章深入探讨了Java中的`CompletableFuture`类,解释了如何创建异步操作、使用计算完成时的回调方法、异常处理、串行化方法、任务组合以及多任务组合的使用方式,并通过代码示例展示了各种场景下的应用。
异步&线程池 CompletableFuture 异步编排 【下篇】
|
25天前
|
SQL JavaScript 前端开发
【Azure 应用服务】Azure JS Function 异步方法中执行SQL查询后,Callback函数中日志无法输出问题
【Azure 应用服务】Azure JS Function 异步方法中执行SQL查询后,Callback函数中日志无法输出问题
|
1月前
|
XML Java 数据库
Spring5入门到实战------10、操作术语解释--Aspectj注解开发实例。AOP切面编程的实际应用
这篇文章是Spring5框架的实战教程,详细解释了AOP的关键术语,包括连接点、切入点、通知、切面,并展示了如何使用AspectJ注解来开发AOP实例,包括切入点表达式的编写、增强方法的配置、代理对象的创建和优先级设置,以及如何通过注解方式实现完全的AOP配置。
|
1月前
|
Dart API C语言
Dart ffi 使用问题之想在C/C++中创建异步线程来调用Dart方法,如何操作
Dart ffi 使用问题之想在C/C++中创建异步线程来调用Dart方法,如何操作