ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot AOP统一处理Web请求日志实战:从切面设计到性能优化

2026/10/1 1:14:26 拓冰建站 浏览量
Spring Boot AOP统一处理Web请求日志实战:从切面设计到性能优化 开头做Web开发这些年接手的每个项目几乎都绕不开一个需求把请求日志记录下来。刚开始我习惯在每个Controller方法里手动打印日志一天下来接口多了代码里到处是log.info又丑又难维护。后来尝试用Servlet Filter统一拦截能拿到URL、IP这些基础信息可想要获取具体的方法参数、返回结果、业务描述就得自己反射解析写出来的Filter又笨又重。直到切面编程AOP真正用上手我才觉得这问题算是被彻底解决了。用AOP统一处理WEB请求日志本质就是把这个横跨所有接口的日志动作从业务代码中抽离出来在Spring容器内部通过切面去拦截Controller方法的调用在方法执行前后记录请求参数、响应结果、耗时、异常信息整个过程对业务代码零侵入。你只需在类上标一个注解或者在切点表达式里配置包路径剩下的事情切面替你干了。这篇文章我会把从设计思路上踩过的坑、最终落地的完整代码、还有后面维护过程中遇到的问题排查方法全部拆开讲适合正在做Java Web项目尤其是Spring Boot项目且想把手动日志彻底干掉的同学参考。看完你就能直接在项目里抄作业。1. AOP统一日志方案设计与思路拆解1.1 先搞清楚要记录哪些日志内容动手写代码之前我建议你先花点时间把需求理清楚。很多同学一上来就写切面结果日志是打出来了后面排查问题的时候发现缺这缺那又回过头来改。我在项目里总结下来一份合格的不止是请求日志而是一条完整请求链路的必要信息大致包含这几类。第一类是基础请求信息包括请求方法GET/POST等、请求URL、请求路径、来源IP、User-Agent等。这部分是定位问题的基础缺了它你都不知道这条日志对应哪台机器、哪个用户发起的。第二类是业务参数信息也就是接口接收到的入参和响应结果。这里有个细节入参不仅仅是URL上的查询参数还包括请求体里的JSON或者表单数据以及方法上通过PathVariable、RequestParam、RequestBody等注解绑定的值。我们做日志的目的是还原请求现场参数不完整现场就还原不出来。第三类是链路耗时信息包括方法执行时间、总耗时。这个对性能分析特别重要线上如果某个接口响应变慢了靠接口层面的监控很难定位是哪个方法拖了后腿但通过AOP日志里记录的方法执行耗时能很快筛出来。第四类是异常错误信息。接口一旦抛异常日志里必须记录异常类型和堆栈信息判断是业务主动抛出还是系统内部错误。AOP在Around通知里拿到throwable之后既可以把异常信息写进日志又可以不吞掉异常继续往上抛让全局异常处理器统一处理返回客户端。还有一个容易被忽略的细节就是日志内容里尽量包含一个区分请求的唯一标识。分布式场景下我们管这个叫traceId单机场景下至少也要生成一个随机请求号。否则多个并发请求交错打印日志你根本分不清哪些日志属于同一个请求链路。1.2 为什么是AOP而不是Filter或拦截器我在面试中经常被问到一个问题实现请求日志用AOP还是Filter大多数人的第一反应是用Filter因为Servlet的Filter天然就能拦截HTTP请求。但真正做过一次对比之后你会发现AOP带来的收益远超Filter。Filter是Servlet规范层面的东西在请求进入Servlet容器、找到对应的Handler之前就已经执行了。它能拿到的是HttpServletRequest、HttpServletResponse这种原始请求对象可以拿到URL、参数、请求体但拿不到的是Spring MVC处理请求时的方法信息比如这个方法叫什么、方法上有没有自定义注解、方法签名里的参数名是什么。如果你需要在日志里记录当前调用了哪个Controller方法、这个操作属于什么业务场景用Filter就得通过反射去猜测Handler方法或者结合拦截器Interceptor的HandlerMethod参数来获取代码绕来绕去非常别扭。而AOP直接工作在Spring的代理对象上。Controller类被Spring管理后本身就是一个由Spring容器创建的BeanAOP通过动态代理可以精准拦截到每个方法调用。切面里你能拿到完整的MethodSignature关方法参数名、参数注解、自定义注解全部信手拈来。更关键的是AOP的切点表达式非常灵活你可以用execution或者annotation的方式精确控制哪些方法记录日志、哪些方法跳过不需要像Filter那样去和URL匹配规则较劲。再谈一下性能损耗的差异。Filter在一次请求中只会触发一次而AOP拦截的是方法调用如果一个请求内部层层调用多个被切点覆盖的方法理论上会触发多次切面逻辑。所以在设计上AOP方案通常只有一个切点落在Controller方法上每个请求最多触发一次切面性能和Filter并没有本质差别。如果项目里已经依赖了Spring Boot引入spring-boot-starter-aop的成本极低代码侵入性和维护成本反而比Filter更低。1.3 整体方案的结构设计统一日志切面在项目里的正确打开方式是把如何取日志和日志写到哪两个问题分开。前者是切面做的事情后者应该交给专门的日志组件或者异步队列处理否则切面打的日志写入磁盘太慢就拖累了业务接口。所以整体方案我拆成三块切面模块负责采集请求信息、组装成日志对象并发出去日志落地模块负责把日志对象写入Elasticsearch、文件中或者发到Kafka业务侧只需要在需要记录操作的接口上加一个自定义注解。单独做请求日志其实只需要一个Aspect切面类就够了但我在经历了生产环境的日志排查后强烈建议你连业务操作日志一起做统一注解。比如修改了用户信息、导出报表这种带有业务语义的日志和请求日志混在一起输出排查问题时你能直接看到某个用户在某段时间内做了哪些操作这个信息对运维和产品回看异常场景都极有价值。后面讲代码的时候我会把请求日志和业务操作日志统一放在一个切面里来处理。2. AOP核心原理与关键概念2.1 切面、切点、通知一篇文章讲透AOP这套概念对第一次接触的人来说劝退感很强什么切面、切点、通知、连接点、织入每个词都像在说黑话。但你把它映射到现实生活里就好懂了。假如你家小区门口有个保安岗亭所有业主进出小区都要经过这个岗亭保安这个角色就是一个切面。岗亭的位置也就是所有业主出行的必经之路就是切点。保安在业主进出时需要做几件事登记业主信息、确认身份、目送离开这些不同时机的动作就叫通知。Spring AOP中的切点Pointcut解决的是拦哪些方法的问题通知Advice解决的是在方法执行的哪个时机做什么动作的问题。通知一共有五种Before指方法执行前执行AfterReturning指方法正常返回后执行AfterThrowing指方法抛出异常后执行After指方法结束之后执行不管正常返回还是抛异常都会执行Around是其中最强大的它包住了整个方法执行过程可以在方法前、方法后、异常时都做处理。写日志用的实际上主要是Around通知因为日志需要记录调用前的时间、调用后的时间、正常返回的结果和异常信息。如果只用Before和AfterReturning组合异常场景下返回结果就完全拿不到了还需要借助AfterThrowing再补一份代码会分散到三个方法中同一个请求的信息也很难聚合到一起。在Around里你用一句Object result point.proceed()就可以触发目标方法执行proceed返回的就是目标方法的返回值抛出异常也可以直接在catch里捕获。2.2 动态代理JDK Proxy与CGLIB的取舍Spring AOP底层靠动态代理来实现。Java为我们内置了JDK动态代理它要求被代理的目标类必须实现一个接口代理对象只能通过接口来暴露方法。如果目标类没有实现任何接口JDK动态代理就无能为力了这时候会退回到CGLIB通过生成目标类的子类来创建代理对象。这里有一个在Spring Boot项目里经常让人困惑的点。Spring Boot 2.x之后默认代理模式改成CGLIB也就是说即使你写了Controller的接口Spring也会用CGLIB生成子类代理不再要求目标类必须实现接口。CGLIB生成的代理类是目标类的子类所以目标方法不能是final的否则无法拦截。好在Controller方法基本不会写final这一点在实际开发里很少踩雷。那这个原理对写日志切面有什么实际影响呢主要影响出现在同一个类内部方法调用的问题上。如果你在一个Controller类里定义了一个private方法然后在另一个public方法内部调用它切点即使匹配到了这个private方法AOP也不会生效。原因很简单Spring的AOP代理是对Bean生成代理对象外部调用注入进来的其实是代理对象但类内部方法之间的调用走的是this调用根本不经过代理对象。我把这个规律叫做AOP只能拦截从外部进到代理对象的那一条路。所以写切面时一定要把切点落在被外部直接调用的Controller方法上别天真地以为一个类内部所有方法调用都会被切到。2.3 切点表达式先用对再谈优化Spring AOP中切点表达式支持execution、within、this、target、args、annotation等几种指示器。web请求日志场景里最常用的是execution和annotation。execution表达式长这样execution(* com.example.controller...(..))。不要被这段字符吓到拆开看就很清晰。第一个*是返回值通配符表示匹配任意返回类型com.example.controller..表示com.example.controller包及子包下的所有类中间的.表示匹配这些包下任意类再往后的.(..)表示匹配这个类中的任意方法括号里的两个点表示参数任意。这个表达式匹配的是Controller层下所有类所有方法适用于你整个包路径下都要记录日志的需求。只用execution有一个缺点就是不够精准。比如你在同一个包里放了一个只用来看下拉数据的Controller日志量特别大但对排查问题没太大价值用execution表达式就只能把整个包都排除掉。所以我更推荐在需要记录操作留痕的接口上打自定义注解切点用annotation指示器来匹配。annotation的表达式长这样annotation(com.example.log.annotation.WebLog)表示只拦截那些标注了WebLog注解的方法。用注解方式的好处在于哪些接口需要记录日志完全由代码自己表达写接口的人一眼就知道这个方法会被记录日志而不是去猜测切面表达式到底覆盖了哪些包。在实际项目里我自己的习惯是请求日志用execution表达式匹配所有接口业务操作日志用annotation匹配特定方法。两者分工不同采集到的日志类型也区分开。3. 完整实现从依赖到切面的落地过程3.1 项目依赖与基础准备先来把依赖加上。如果你的项目是Spring Boot框架引入spring-boot-starter-aop这一个依赖就够了。这个starter会帮我们把aspectjweaver以及相关的Spring AOP模块全部拉进来。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency如果你用的是Spring Boot 3.x对应的AOP starter版本会跟着Boot父版本走不用额外写版本号。有一点需要注意Spring Boot 3.x要求JDK 17及以上如果你的项目还在JDK 8上请保持Spring Boot 2.x不变两者不要混用。项目结构上我建议建一个独立的log模块或者包专门存放切面类和日志模型避免散落在各业务模块里面。下面是我常用的一套包结构com.example.log ├── annotation │ └── WebLog.java ├── aspect │ └── WebLogAspect.java ├── model │ └── WebLogInfo.java └── service └── LogSaveService.java这个结构把切面的依赖关系理得很清楚annotation放注解aspect放切面逻辑model放日志对象service放日志落地的异步服务。后面每块都是围绕这个包结构展开。3.2 自定义注解让代码有自我表达能力自定义注解这个环节看似简单但里面有一个设计选择值得展开说一下。你可以只用一个空注解当标记切面类通过annotation的表达式把方法拦下来日志内容完全自动生成。也可以在注解里定义一些属性比如操作描述、是否记录返回值、是否需要脱敏等让开发者在打注解时顺便声明这些信息。我的做法是让注解支持操作描述和是否记录响应两个属性。这样日志里能直接看到这个操作对应什么业务含义而不是只看一个方法名。package com.example.log.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface WebLog { String description() default ; boolean recordResponse() default true; }关键点在于Retention(RetentionPolicy.RUNTIME)这个必须写。切面在运行时需要通过反射读取注解信息如果留的是CLASS或者SOURCE级别运行时拿不到。我之前见过有同事把注解写成了SOURCE级别结果切面一直不生效排查了半天才发现是这个原因。在Controller方法上用起来也很简单直接在方法上标注即可。PostMapping(/user/save) WebLog(description 新增用户, recordResponse true) public Result saveUser(RequestBody UserSaveRequest request) { return userService.save(request); }3.3 切面类的完整实现与参数计算过程下面这段是核心我把完整的切面类代码贴出来然后逐行讲逻辑。package com.example.log.aspect; import com.alibaba.fastjson2.JSON; import com.example.log.annotation.WebLog; import com.example.log.model.WebLogInfo; import com.example.log.service.LogSaveService; import io.micrometer.tracing.Tracer; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; Aspect Component public class WebLogAspect { private static final Logger log LoggerFactory.getLogger(WebLogAspect.class); private final LogSaveService logSaveService; public WebLogAspect(LogSaveService logSaveService) { this.logSaveService logSaveService; } Pointcut(execution(public * com.example.controller..*.*(..))) public void webLog() { } Pointcut(annotation(com.example.log.annotation.WebLog)) public void operationLog() { } Around(webLog() || operationLog()) public Object around(ProceedingJoinNode joinPoint) throws Throwable { WebLogInfo info buildBaseLog(joinPoint); long startTime System.currentTimeMillis(); Object result null; try { result joinPoint.proceed(); info.setSuccess(true); return result; } catch (Throwable throwable) { info.setSuccess(false); info.setErrorMsg(throwable.getMessage()); throw throwable; } finally { long costTime System.currentTimeMillis() - startTime; info.setCostTime(costTime); if (shouldRecordResponse(joinPoint, result)) { try { info.setResponse(JSON.toJSONString(result)); } catch (Exception ex) { info.setResponse([serialize-error]); } } info.setTraceId(generateTraceId()); logSaveService.saveAsync(info); } } private WebLogInfo buildBaseLog(ProceedingJoinPoint joinPoint) { ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request attributes ! null ? attributes.getRequest() : null; MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); WebLog webLog method.getAnnotation(WebLog.class); String description webLog ! null ? webLog.description() : ; WebLogInfo info new WebLogInfo(); info.setDescription(description); info.setClassName(joinPoint.getTarget().getClass().getName()); info.setMethodName(method.getName()); info.setRequestMethod(request ! null ? request.getMethod() : ); info.setRequestUri(request ! null ? request.getRequestURI() : ); info.setRemoteAddr(getClientIp(request)); info.setParams(getParams(joinPoint, request)); info.setUserAgent(request ! null ? request.getHeader(User-Agent) : ); info.setCreateTime(System.currentTimeMillis()); return info; } private String getParams(ProceedingJoinPoint joinPoint, HttpServletRequest request) { ListObject args Arrays.stream(joinPoint.getArgs()) .filter(arg - !(arg instanceof MultipartFile) !(arg instanceof HttpServletRequest) !(arg instanceof HttpServletResponse)) .collect(Collectors.toList()); if (args.isEmpty()) { return null; } try { return JSON.toJSONString(args); } catch (Exception ex) { return [params-serialize-error]; } } private String getClientIp(HttpServletRequest request) { if (request null) { return ; } String ip request.getHeader(X-Forwarded-For); if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(X-Real-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getRemoteAddr(); } return ip; } private boolean shouldRecordResponse(ProceedingJoinPoint joinPoint, Object result) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); WebLog webLog signature.getMethod().getAnnotation(WebLog.class); if (webLog ! null) { return webLog.recordResponse(); } return true; } private String generateTraceId() { Tracer tracer ...; return UUID.randomUUID().toString().replace(-, ); } }这个切面类有几个必须讲的点。第一Aspect和Component两个注解缺一不可Aspect声明这是一个切面Component把这个类交给Spring容器管理。第二Pointcut方法本身不需要任何逻辑方法名只是表达式的引用标识。第三Around的返回值就是目标方法的返回值业务接口返回什么切面就必须原样返回什么否则前端拿到的响应体就变了。参数序列化的时候经常会出现一个问题请求参数里带有HttpServletRequest、HttpServletResponse、MultipartFile这类对象直接JSON序列化会报循环引用或者序列化异常。我在这里做了一个过滤处理把这些非业务参数直接过滤掉。这个细节如果你不注意接口第一个请求打日志时就会报错然后整个接口都可能被影响。我在生产环境真的见过这种坑一个附件上传的接口因为参数里有MultipartFile序列化直接把接口打挂了。3.4 日志落地的几个方案选择切面拿到了日志对象接下来要决定往哪儿写。我梳理过常见的几种方案并根据项目规模帮你排了一个参考优先级。第一档是可以直接写到日志文件。切面类里通过Logger把序列化后的日志对象打印出去然后交给logback或者log4j2的Appender去刷盘。大多数中小项目用这一种就够了日志文件里每行一条JSON格式的完整请求日志排查问题的时候直接grep就行。代码上也最简单把LogSaveService的实现里做成log.info(JSON.toJSONString(info))即可。第二档是异步落地。这里又分两种做法一种是你自己包一个线程池在切面的finally块中把日志提交到线程池异步消费另一种是借用Spring框架自带的Async方法做异步。不管是哪种核心目的都是不让日志写入拖慢业务接口的响应。需要说明的是异步化虽然可以降低日志对主流程的影响但要注意线程池别被消退建议用有界队列并配合拒绝策略避免高并发下日志任务堆积导致OOM。第三档是上组件把日志发到Kafka、Elasticsearch或者通过SkyWalking这类APM工具采集。这类方案适合微服务规模、多个服务要汇总统一查询的场景。如果日志要进ES我建议不要直接用客户端往ES里面push而是先发到Kafka做削峰由独立消费程序写入ES。理由我后面会详说这里你先记住这个方案链路的合理性即可。4. 核心细节打磨与实操要点4.1 入参序列化与敏感信息脱敏日志采集了所有参数之后紧接着要面对一个安全问题直接在日志里打印用户手机号、身份证、银行卡号这些敏感字段日志一旦泄露或被人拿到后果很严重。我在实际的微服务项目里被安全团队约谈过后来梳理了一套可落地的脱敏策略。脱敏的策略要分几个层次来做。最简单的是在日志对象即将输出的时候对JSON字符串做一个正则替换把手机号、身份证等字段值打马赛克。正则匹配的方式快但风险在于你替换的字段名和实际值可能对不上不够通用。更好一点的方式是引入一个统一的序列化器对特定字段名做处理。比如在把WebLogInfo转成JSON字符串时通过Fastjson或者Jackson的序列化拦截器找出字段名包含mobile、phone、idCard、password等关键字的字段整体做脱敏后再输出。我实际验证过用正则全局替换有个隐蔽的坑。万一请求入参里有一个字段叫remark用户在备注里填了一段包含手机号的文本正则就会把备注里的手机号也脱敏掉。这在业务排障时会误导人因为你看到的是一个被脱敏的手机号但不知道原始值到底是什么也就没法判断是不是自己传错参数。所以我在生产环境采用的还是字段名精确匹配的方式宁可漏掉一些疑似敏感信息也不要误伤正常的业务字段。还有一类数据要谨慎处理的是涉及文件上传的场景。切面拿到的MultipartFile对象里保存的是整个文件流你总不能把上传的文件内容也打进日志。我在方案里已经做了一层过滤凡是MultipartFile类型的参数直接忽略只记录文件名和文件大小。这一点其实是很多AOP日志方案容易踩的大坑建议你在设计参数过滤逻辑时把MultipartFile、HttpServletRequest、HttpServletResponse、BindingResult这类Spring框架自身的对象全部排除掉否则序列化一个复杂对象可能直接把接口拖垮。4.2 请求耗时统计的准确做法请求耗时的统计看起来很简单一个startTime一个endTime做差值就行。但有两个细节值得琢磨。第一个是startTime应该放在切面进入点的最开始而不是放在构建日志对象之后否则构建日志对象本身花了多少时间都会被算进方法耗时里得到的数据是不准的。尤其是构建日志时要序列化参数、要拼接字符串这些操作在低性能环境里可能毫秒级波动虽然影响不大但从严谨角度说startTime必须放在方法最早的入口处。第二个细节是如果你的日志方案选了异步落地那耗时统计中不要包含日志写队列的时间只统计业务方法的纯执行时间用System.currentTimeMillis()在proceed()前后做差值就够了。如果你需要更精确的时间测量可以用System.nanoTime()来计算纳秒级耗时但在毫秒精度已经满足绝大多数业务分析的场景下currentTimeMillis已经足够没必要为了微毫秒级别的精确引入性能损耗。耗时数据存进日志之后还有个统计技巧按周、按天定时跑一个脚本找出平均耗时最长的Top接口。这些耗时数据会暴露很多显性问题比如某接口在数据库慢查询时耗时飙升你就能通过日志快速关联到是哪次请求、什么时候发生的、当时传了什么参数这比冷冰冰的APM指标可排查范围大多了。4.3 一个细节如何正确获取方法参数名用AOP记录日志时有一个特别容易踩的细节就是方法参数名的获取。很多同学在切面里通过MethodSignature拿到Method对象后直接调用method.getParameterNames()这个方法在Class文件中没有保留参数名的情况下返回的是arg0、arg1这种占位符打出来的日志没有任何意义。真正可靠的方案是结合Spring MVC的参数名发现机制来获取。在Spring Boot中只要编译时开启了-parameters参数method.getParameterNames()就能拿到正确的参数名。在Maven的pom.xml中可以这样配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin还有一种更稳妥的方式是不要依赖参数名直接用参数的JSON序列化后的值来打日志。因为大多数Java Web项目里Controller方法入参都是一个DTO对象我们其实只需要这个DTO对象的字段名就够了不需要知道方法参数名叫什么。如果你用了RequestBody参数对象就是整个请求体参数的字段名已经包含了全部信息直接序列化Object比反射获取参数名更实用。我个人在项目里的做法是两者结合对于RequestBody参数类型直接序列化参数对象对于简单类型参数比如RequestParam(userId) Long userId用参数名拼接参数值输出。这样既能记录完整的请求内容又不依赖编译参数配置。4.4 通过MDC实现全链路追踪如果说前面的内容解决的是日志怎么打那链路追踪解决的问题就是日志怎么串。在单机应用里多个请求并发日志文件里的log行是交错的没有一个唯一标识的话同一个请求的日志很难筛选出来。我之前单纯靠参数去对应请求一个接口几十个请求的时候眼睛都快看花了。MDCMapped Diagnostic Context就是解决这个问题的。MDC是Slf4j提供的一个线程上下文的Map容器你往里面放入键值对日志框架在输出日志时会自动带上这些键值对的值。最常见的用法是在请求进来时生成一个traceId丢进MDC然后在logback的Pattern里配置traceId输出。property namepattern value%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId}] %msg%n/AOP切面和MDC配合有一个天然的便利请求进来先进入切面此时你就在同一个线程里把traceId放入MDC整个请求链路中后续所有日志输出都会带上traceId。等切面把日志发送完成后在finally块里执行MDC.remove(traceId)避免线程池复用线程后traceId串号。有一点要特别提醒的是Async异步方法默认会另起一个线程子线程里的MDC不会自动继承父线程中的上下文如果你的代码里有异步调用需要手动手动从父线程传递traceId到子线程或者干脆在进入异步方法时主动读取当前请求的traceId再放入新线程的MDC。这块如果不做异步线程内打印的日志就会丢traceId最终还是对不上链路。5. 常见问题与排查技巧实录5.1 切面不生效先别急着怀疑注解位置我见过很多次同事在群里问我这个切面怎么不生效打开代码一看前十分钟都没找出问题。常见原因集中在三个方面我建议你按照这个顺序排查。第一切面类上的Aspect和Component确认都加了。少了AspectSpring不会识别为切面少了ComponentSpring不会创建这个切面Bean。两个都必须有。第二切点表达式是否真的匹配到了目标类。你可以在切面类的Around方法里临时加一行调试日志比如log.info(切面拦截到了{}, joinPoint.getSignature().getName())。如果日志没打出来基本可以断定是表达式匹配不到目标方法。第三Spring AOP默认基于代理final方法不能被代理内部方法调用不走代理。上面说过private方法、final方法以及类内部this调用都不会触发切面。如果你用JDK动态代理模式还要确认Controller类是否实现了接口没实现的话代理创建会失败Spring Boot 2.x默认CGLIB所以这块问题不大。5.2 序列化循环引用与异常处理切面里对参数做JSON序列化最容易踩的坑就是循环引用。比如两个对象互相持有对方的引用直接JSON序列化会无限递归最终抛出StackOverflowError。Fastjson在检测到循环引用时默认会通过$ref引用标记来避免死循环这是一个默认开启的引用检测机制。但如果你使用的是某些旧版本Fastjson或者设置了关闭引用检测循环引用就会直接报错。更可靠的方案是在序列化时对字段做深度裁剪只序列化DTO的顶层字段过滤掉复杂关联对象。在Fastjson2中可以通过JSONField(serialize false)字段上排除不需要序列化的属性或者在切面里调用JSON.toJSONString时传入一个自定义的ValueFilter。无论如何异常处理不能省序列化失败时要捕获并记录为字符串[serialize-error]而不是让异常直接抛给业务代码。5.3 切面执行顺序混乱如果项目里同时存在多个切面比如一个日志切面、一个权限校验切面、一个接口幂等切面你就需要关心它们的执行顺序。Spring AOP中多个切面的执行顺序由Order注解决定数值越小优先级越高越先执行。在环绕通知中高优先级的切面在外层低优先级在内层。也就是说如果日志切面优先级更高它包住的范围就更大能包含权限校验、幂等校验这些整体耗时如果日志切面优先级更低它记录的只是权限校验之后的业务执行时间。这个顺序没有绝对的对错取决于你想要的日志口径但我更习惯把日志切面放在最外层这样请求从进来到出的全过程时间都覆盖到了对排查慢接口更有价值。5.4 别把日志写成了性能杀手最后还提醒一个生产环境很要命的问题。很多人实现了切面日志之后上线没几天就遇到接口响应变慢查来查去最后定位到日志写入上。一个请求产生一条大JSON日志写入磁盘本身很快但如果你的日志框架没有配置异步Appender写日志是在业务线程中同步执行的高并发下会直接阻塞接口。解决这个问题的思路有两层。第一层是控制日志量不要把所有请求的所有参数都原样打出来响应体特别大的接口设置recordResponse为false敏感接口干脆连参数都不要写。第二层是日志写入异步化用logback的AsyncAppender包装文件Appender让日志在独立线程中处理。通过这两个手段我在项目里把请求日志对接口耗时的影响控制在1%以内基本无感知。还有一个容易被忽视的性能隐患是日志框架默认的队列容量。logback的AsyncAppender默认有一个队列容量如果大量日志瞬间涌入队列满了之后会丢弃日志生产环境里你想排查问题时结果日志丢了那才是真正让人崩溃的。所以在线程池和异步队列的参数上一定要预留足够的空间并配置合理的拒绝丢弃策略在高并发和日志完整性之间找到平衡点。最后再分享一个经验切面日志上线后先在一两个接口上观察一两天的输出效果确认格式和序列化内容都符合预期再全量铺开。我在项目里吃过一次亏当时一次性把几十个接口全加了日志切面结果有个接口的参数对象里藏了一张很大的Base64图片字符串每个请求打出来的日志都有几十兆直接把好几台机器的磁盘空间打满了。这种问题如果你先小范围验证几乎不可能出现。现在这套切面日志方案在我负责的几个项目里已经稳定运行了很长时间业务方要排查问题直接把请求号和接口名丢给我我去日志平台一搜整个链路的请求参数、响应结果、耗时、异常信息都在效率比之前翻了好几倍。如果你正被手动打日志折磨不妨照着这个方案在自己的项目里试一试代码量不大收益却很直接。