琳宁 發表於 2025-11-14 10:37:00

巧用异步监听切面,提高系统性能

<h1 id="使用异步监听切面提高系统性能">使用异步监听切面,提高系统性能</h1>
<blockquote>
<p>💡 <strong>作者</strong>:古渡蓝按</p>
<p>💡<strong>个人微信公众号</strong>:微信公众号(深入浅出谈java)<br>
感觉本篇对你有帮助可以关注一下,会不定期更新知识和面试资料、技巧!!!</p>
</blockquote>
<p><strong>摘要:</strong> 在构建高并发、高性能的现代Web应用时,如何优雅地记录系统日志、监控API调用而不影响核心业务逻辑的执行效率,是一个至关重要的课题。本文将深入探讨一种结合面向切面编程(AOP)、异步处理和事件驱动模型的强大模式——异步监听切面。我们将剖析其实现原理,演示如何在Spring Boot环境中运用此模式进行API调用日志记录,并分析其对系统性能带来的显著益处。</p>
<br>
<p>本文是对之前作者写的进行补充。之前文章地址:<strong>https://www.cnblogs.com/blbl-blog/p/17944006</strong></p>
<br>
<h2 id="1-引言"><strong>1. 引言</strong></h2>
<p>在复杂的软件系统中,诸如日志记录、安全校验、事务管理等功能往往是横切关注点(Cross-Cutting Concerns),它们散布于应用的核心业务逻辑之中。传统的硬编码方式不仅使得代码耦合度高、难以维护,更严重的是,这些辅助性操作(尤其是I/O密集型操作,如数据库写入、网络调用)若在主线程同步执行,会直接阻塞业务流程,成为系统性能的瓶颈。</p>
<p>Spring Framework 提供的面向切面编程(AOP)功能,为我们提供了分离关注点、模块化横切逻辑的有效途径。然而,仅仅将逻辑抽取到切面中还不够,对于耗时的操作,我们还需要进一步解耦其执行时机。此时,“异步”便成了破局的关键。</p>
<p>本文将以一个具体的场景——记录API调用日志为例,详细介绍如何设计并实现一个基于“异步监听切面”的解决方案,从而在不牺牲核心业务性能的前提下,高效地收集系统运行时信息。</p>
<br>
<h2 id="2-核心思想aop--异步--事件驱动"><strong>2. 核心思想:AOP + 异步 + 事件驱动</strong></h2>
<p>我们的目标是:当一个被监控的API被调用时,主业务逻辑能够快速响应,而日志记录等辅助任务则在后台悄然完成。</p>
<ul>
<li><strong>AOP(面向切面编程):</strong> 用于在不侵入原有代码的情况下,拦截特定的方法调用(如Controller中的方法)。通过定义切入点(Pointcut)和通知(Advice),我们可以精确地知道何时何地需要触发日志记录行为。</li>
<li><strong>事件驱动(Event-Driven):</strong> 当AOP拦截到目标方法调用后,不直接执行耗时的日志保存操作,而是发布一个“API调用完成”的事件。这种方式将触发动作(方法调用)与处理逻辑(日志保存)解耦。</li>
<li><strong>异步处理(Asynchronous Processing):</strong> 专门的监听器(Listener)订阅上述事件。这些监听器被标记为 <code>@Async</code>,意味着它们将在独立的线程池中执行,彻底释放主线程,让其专注于业务逻辑的处理。</li>
</ul>
<p>这种组合模式的优势在于:它既利用了AOP的非侵入性和灵活性来定位需要监控的点,又通过事件驱动实现了松耦合,最终依靠异步处理保证了主线程的高效运行。</p>
<br>
<h2 id="3代码实现">3.代码实现</h2>
<h3 id="31-环境依赖">3.1 环境依赖</h3>
<pre><code class="language-xml">&lt;dependency&gt;
   &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
   &lt;artifactId&gt;spring-boot-starter-aop&lt;/artifactId&gt;
&lt;/dependency&gt;
</code></pre>
<br>
<p><strong>日志实体类,这个可根据直接要求修改</strong></p>
<pre><code class="language-java">@Data
@TableName("sys_log")
public class SysLog implements Serializable { // 实现 Serializable 是一个好的实践

    private static final long serialVersionUID = 1L;

    /** ID */
    // 指定主键,并使用数据库自增策略

    private String id;

    /** 日志类型 */
    @TableField("log_type")
    private String logType;

    /** 创建用户编码 */
    @TableField("create_user_code")
    private String createUserCode;

    /** 创建用户名称 */
    @TableField("create_user_name")
    private String createUserName;

    /** 创建时间 */
    @TableField("create_date")
    private LocalDateTime createDate;

    /** 请求URI */
    @TableField("request_uri")
    private String requestUri;

    /** 请求方式 */
    @TableField("request_method")
    private String requestMethod;

    /** 请求参数 */
    @TableField("request_params")
    private String requestParams;

    /** 响应参数 */
    @TableField("response_params")
    private String responseParams;

    /** 请求IP */
    @TableField("request_ip")
    private String requestIp;

    /** 请求服务器地址 */
    @TableField("server_address")
    private String serverAddress;

    /** 是否异常 ('0': 正常, '1': 异常) */
    @TableField("is_exception")
    private String isException;

    /** 异常信息 */
    @TableField("exception_info")
    private String exceptionInfo;

    /** 开始时间 */
    @TableField("start_time")
    private LocalDateTime startTime;

    /** 结束时间 */
    @TableField("end_time")
    private LocalDateTime endTime;

    /** 执行时间 (毫秒) */
    @TableField("execute_time")
    private Integer executeTime;

    /** 用户代理 */
    @TableField("user_agent")
    private String userAgent;

    /** 操作系统 */
    @TableField("device_name")
    private String deviceName;

    /** 浏览器名称 */
    @TableField("browser_name")
    private String browserName;
}

</code></pre>
<br>
<h3 id="32-在主程序开启异步">3.2 在主程序开启异步</h3>
<p>在 主应用类或配置类上启用了异步支持 <strong><mark>@EnableAsync</mark></strong></p>
<pre><code class="language-java">@EnableAsync // &lt;-- 启用异步方法执行
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
      SpringApplication.run(BomCompareApplication.class, args);
    }

}
</code></pre>
<br>
<h3 id="33-编写切面">3.3 编写切面</h3>
<p>切面负责拦截目标方法调用,并在方法执行前后收集所需信息,最后发布事件;如果想监听自己定义的注解,例如:@Pointcut(value = "@annotation(com.xncoding.aop.aspect.UserAccess)") ,具体可以参考之前的文章:https://www.cnblogs.com/blbl-blog/p/17944006</p>
<pre><code class="language-java">@Slf4j
@Aspect
@Component
public class LogAspect {



    @Autowired
    private AsyncLogService asyncLogService; // 注入异步服务


    // 这个目前是对从 com.xncoding.aop.controller.* 下的都进行切入,如果想对上面的自定义注解进行切入,只需改成相对应的路径
    // 例如:@Pointcut(value = "@annotation(com.xncoding.aop.aspect.UserAccess)")

    @Pointcut("execution(public * com.example.bomcompare.controller.*.*(..))")
    public void webLog(){}

    //环绕通知,环绕增强,相当于MethodInterceptor
    @Around("webLog()")
    public Object arround(ProceedingJoinPoint pjp) throws Throwable{
      System.out.println("方法环绕start.....");

      // --- 1. 初始化日志信息 ---
      String requestId = UUID.randomUUID().toString(); // 生成唯一请求ID
      LocalDateTime startTime = LocalDateTime.now();
      long startTimeMillis = System.currentTimeMillis();

      String url = "N/A";
      String method = "N/A";
      String ip = "N/A";
      String userAgent = "N/A"; // 新增获取 User-Agent

      String className = pjp.getSignature().getDeclaringTypeName();
      String methodName = pjp.getSignature().getName();
      String params = Arrays.toString(pjp.getArgs()); // 注意:可能包含敏感信息
      try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes == null) {
                log.warn("Not in a web request context, skipping request log.");
            }

            HttpServletRequest request = attributes.getRequest();
            url = request.getRequestURL().toString();
            method = request.getMethod();
            ip = getClientIpAddress(request);
            userAgent = request.getHeader("User-Agent"); // 获取 User-Agent

            // --- 3. 记录开始日志 (可选,用于调试) ---
            log.info("Captured Request : URL={}, Method={}, IP={}, Class.Method={}.{}, Args={}",
                  requestId, url, method, ip, className, methodName, params);
      } catch (Exception e) {
            log.warn("Could not extract HTTP request details in aspect.", e);
      }

      // --- 4. 执行目标方法并捕获结果/异常 ---
      Object result = null;
      String errorMessage = null;
      Exception caughtException = null; // 用于存储捕获到的异常对象

      try {
            result = pjp.proceed(); // 执行被拦截的方法
      } catch (Exception ex) { // 捕获所有检查和非检查异常
            caughtException = ex;
            errorMessage = ex.getClass().getSimpleName() + ": " + ex.getMessage(); // 记录简化的错误信息
            // 重要:必须重新抛出异常,否则原调用方无法感知到异常的发生
            throw ex;
      } finally {
            // --- 5. 计算执行时间和准备最终日志 ---
            long endTimeMillis = System.currentTimeMillis();
            long executionTime = endTimeMillis - startTimeMillis;
            LocalDateTime endTime = LocalDateTime.now();

            String resultStr = "N/A";
            if (result != null) {
                // 注意:直接 toString() 大对象可能导致性能问题或日志过大
                // 可以考虑限制长度或根据类型特殊处理
                resultStr = result.toString();
            }

            // 将所有收集到的信息传递给异步服务
            asyncLogService.saveRequestLog(
                  requestId,
                  url,
                  method,
                  ip,
                  className,
                  methodName,
                  params,
                  resultStr,
                  errorMessage,
                  executionTime,
                  startTime,
                  endTime,
                  userAgent // 传递 User-Agent
            );

            // 返回目标方法的原始结果
            return result;
       }
    }

    // --- 辅助方法:获取客户端真实IP ---
    private String getClientIpAddress(HttpServletRequest request) {
      String xfHeader = request.getHeader("X-Forwarded-For");
      if (xfHeader != null &amp;&amp; !xfHeader.isEmpty() &amp;&amp; !"unknown".equalsIgnoreCase(xfHeader)) {
            // X-Forwarded-For 可能包含多个IP,取第一个(通常是客户端)
            return xfHeader.split(",").trim();
      }
      String xriHeader = request.getHeader("X-Real-IP");
      if (xriHeader != null &amp;&amp; !xriHeader.isEmpty() &amp;&amp; !"unknown".equalsIgnoreCase(xriHeader)) {
            return xriHeader;
      }
      return request.getRemoteAddr();
    }
}
</code></pre>
<br>
<h3 id="34-处理日志和保存逻辑">3.4 处理日志和保存逻辑</h3>
<p>日志按照自己需求保存到数据库或者写成文档等操作</p>
<pre><code class="language-java">@Slf4j
@Service // 让 Spring 管理这个 Service
public class AsyncLogService {

    @Autowired
    SysLogMapper sysLogMapper;



    /**
   * 异步保存 API 调用日志到数据库。
   *
   * @param requestId       唯一请求ID
   * @param url             请求URL
   * @param method          HTTP方法 (GET, POST等)
   * @param ip            客户端IP地址
   * @param className       被调用的类名
   * @param methodName      被调用的方法名
   * @param params          方法参数 (字符串形式)
   * @param result          方法返回结果 (字符串形式)
   * @param errorMessage    错误信息 (如果没有则为null)
   * @param executionTime   方法执行时间 (毫秒)
   * @param startTime       请求开始时间
   * @param endTime         请求结束时间
   * @param userAgent       用户代理字符串
   */
    @Async // &lt;-- 标记为异步方法
    public void saveRequestLog(String requestId, String url, String method, String ip,
                               String className, String methodName, String params,
                               String result, String errorMessage, long executionTime,
                               LocalDateTime startTime, LocalDateTime endTime, String userAgent) {
      try {

            SysLog logEntry = new SysLog();
            logEntry.setId(requestId); // 设置日志类型
            logEntry.setLogType("API_CALL"); // 设置日志类型


            logEntry.setCreateUserCode("ANONYMOUS"); // 默认匿名用户或系统用户
            logEntry.setCreateUserName("Anonymous");

            logEntry.setCreateDate(LocalDateTime.now()); // 创建时间通常为当前时间
            logEntry.setRequestUri(url);
            logEntry.setRequestMethod(method);
            logEntry.setRequestParams(params); // 注意敏感信息处理
            logEntry.setResponseParams(result); // 注意大对象或敏感信息处理
            logEntry.setRequestIp(ip);

            // 获取服务器地址
            try {
                logEntry.setServerAddress(InetAddress.getLocalHost().getHostAddress());
            } catch (Exception e) {
                log.warn("Could not determine server address", e);
                logEntry.setServerAddress("UNKNOWN");
            }

            // 设置异常状态和信息
            if (errorMessage != null &amp;&amp; !errorMessage.isEmpty()) {
                logEntry.setIsException("1"); // 有异常
                logEntry.setExceptionInfo(errorMessage);
            } else {
                logEntry.setIsException("0"); // 无异常
                logEntry.setExceptionInfo(null); // 清空异常信息
            }

            logEntry.setStartTime(startTime);
            logEntry.setEndTime(endTime);
            logEntry.setExecuteTime((int) executionTime); // 转换为 Integer

            // 设置 User-Agent 和解析的设备/浏览器信息 (简单示例,可用 UserAgentUtils 等库加强)
            logEntry.setUserAgent(userAgent);
            // --- 简单解析 User-Agent 示例 (可选) ---
            if (userAgent != null) {
                if (userAgent.toLowerCase().contains("windows")) {
                  logEntry.setDeviceName("Windows");
                } else if (userAgent.toLowerCase().contains("mac")) {
                  logEntry.setDeviceName("Mac");
                } else if (userAgent.toLowerCase().contains("linux")) {
                  logEntry.setDeviceName("Linux");
                } else {
                  logEntry.setDeviceName("Other");
                }

                if (userAgent.toLowerCase().contains("chrome")) {
                  logEntry.setBrowserName("Chrome");
                } else if (userAgent.toLowerCase().contains("firefox")) {
                  logEntry.setBrowserName("Firefox");
                } else if (userAgent.toLowerCase().contains("safari")) {
                  logEntry.setBrowserName("Safari");
                } else {
                  logEntry.setBrowserName("Other");
                }
            }
            // --- /简单解析 User-Agent 示例 ---

            // 2. 调用 MyBatis-Plus Mapper 保存到数据库
            sysLogMapper.insert(logEntry); // insert 方法由 BaseMapper 提供

            log.debug(" Successfully saved log entry for request ID: {}", requestId);

      } catch (Exception e) {
            // 异步方法内的异常通常不会直接影响主线程,
            // 但需要捕获并记录,防止线程因未捕获异常而终止
            // 并且要确保不会因为日志记录失败导致系统问题
            log.error("Error occurred while saving API call log asynchronously for request ID: {}", requestId, e);
            // 根据需求,可以选择在这里重试、发送告警等
      }
    }
}
</code></pre>
<br>
<h2 id="4-性能优势分析"><strong>4. 性能优势分析</strong></h2>
<p>相较于在主线程中直接执行日志记录(如同步调用 <code>sysLogMapper.insert()</code>),异步监听切面模式带来了显著的性能提升:</p>
<ol>
<li><strong>降低主线程阻塞:</strong> 主业务逻辑在执行完 <code>proceed()</code> 后立即返回(或抛出异常),后续的日志持久化操作交由后台线程处理,主线程得以迅速释放,可以继续处理下一个请求。</li>
<li><strong>提高吞吐量:</strong> 由于主线程不再等待耗时的 I/O 操作,系统的整体请求处理能力(QPS)得到增强。</li>
<li><strong>增强系统稳定性:</strong> 即使日志记录过程出现延迟或暂时性故障(如数据库连接池满),也不会直接影响 API 的响应时间和可用性。异步监听器内部的异常被捕获,避免了因辅助功能失败而导致整个请求失败。</li>
<li><strong>更好的资源隔离:</strong> 日志记录任务在独立的线程池中运行,可以对其进行独立的资源管理和调优(如调整线程池大小),而不会干扰核心业务线程池。</li>
<li><strong>代码清晰度与可维护性:</strong> AOP 将日志记录逻辑从业务代码中剥离,事件驱动使得组件间关系更加松散,易于扩展新的监听器(如发送邮件通知、更新统计指标等)。</li>
</ol>
<br>
<h2 id="5-注意事项与最佳实践"><strong>5. 注意事项与最佳实践</strong></h2>
<ul>
<li><strong>合理配置线程池:</strong> <code>@EnableAsync</code> 默认使用的 <code>SimpleAsyncTaskExecutor</code> 会在每次调用时创建新线程,生产环境务必自定义 <code>TaskExecutor</code> Bean 来复用线程,避免资源耗尽。</li>
<li><strong>处理监听器异常:</strong> 异步监听器内部的异常必须被捕获并妥善处理,否则可能导致线程意外终止,影响日志记录的可靠性。</li>
<li><strong>日志信息脱敏:</strong> 在记录 <code>params</code> 和 <code>result</code> 时,需注意过滤敏感信息(如密码、身份证号等)。</li>
<li><strong>批量处理优化:</strong> 对于高频次的写入场景,可以在监听器内部引入队列和批处理机制,进一步减少数据库交互次数。</li>
<li><strong>监控与告警:</strong> 监控异步监听器的执行情况(如处理延迟、失败率)是保障系统稳定性的关键。</li>
</ul>
<br>
<h2 id="6-结论"><strong>6. 结论</strong></h2>
<p>通过巧妙地融合 AOP、事件驱动和异步处理,我们构建了一套高效、低耦合的系统监控与日志记录方案。该方案不仅提升了系统的响应速度和吞吐量,还增强了架构的健壮性和可维护性。这是一种值得推广的设计模式,尤其适用于那些对性能有较高要求且需要记录大量运行时信息的应用场景。掌握并灵活运用此类技术,是每一位致力于打造高性能Java应用开发者的重要技能。</p><br><br>
来源:https://www.cnblogs.com/blbl-blog/p/19220870
頁: [1]
查看完整版本: 巧用异步监听切面,提高系统性能