基于注解脱敏+链路追踪traceId 快速定位错误
日常开发中,你如何快速定位问题的?
a. 排除条件
特别是处理并发请求时,通过对每个方法生成traceId(自定义唯一标识符)用于快速定位目标日志,追踪整个系统中的请求流程。
b. 日志脱敏增强
返回值带着敏感信息,要么不返回,要么加密
请求参数带着敏感信息,要么不记录,要么加密
c. 解决方案
服务端入口处可以生成唯一的id,叫做:traceId
日志中均需要输出traceId的值
接口返回值中,添加一个通用的字段: traceld,将上面的traceld作为这个字段的值
这样前端发现接口有问题的时候,直接将这个traceld提供给我们,我们便可以在日志中快速查询出对应的日志。
MDC组件
使用Slf4j.MDC
日志功能增强
org.slf4j.MDC
是 SLF4J库中的一个组件,MDC提供了一种机制,允许在日志消息中插入上下文信息,这些信息可以跨多个方法调用和线程边界传播。这对于跟踪和调试分布式系统或多线程应用程序中的请求非常有用。
重点:**MDC 允许将键值对与当前线程关联起来(类似ThreadLocal)**然后,可以在我们的日志语句中引用这些值,从而能够更容易地识别和理解日志消息产生的上下文。
例如,你可能会在 Web 应用程序的每个请求开始时,将用户的 ID 或会话 ID 放入 MDC,然后在你的日志语句中引用这个值。这样,当你查看日志时,你可以很容易地看到哪个用户的哪个请求产生了哪些日志消息。
使用方式
Logback学习系列4(Pattern使用方法)https://www.jianshu.com/p/5cfc26caf50d
- 设置值:
MDC.put("userId", "12345");
- 在日志语句中使用值:
logger.info("Processing request for user: {}", MDC.get("userId"));
- 清除值
MDC.remove("userId")
例子
假设我们正在开发一个电商网站,该网站由多个微服务组成,包括用户服务、订单服务、支付服务等。每个服务都运行在独立的服务器上,并通过REST API相互调用。为了方便调试和监控,我们需要在整个请求处理过程中跟踪每一个请求,并确保所有相关的日志条目都能关联起来。
-
当请求到达第一个服务(例如用户服务)时,
TraceFilter
生成一个唯一的traceId
并将其放入MDC中。 -
当用户服务需要调用订单服务时,将当前线程中的
traceId
作为HTTP请求头的一部分传递给下游服务(订单服务)。在订单服务接收到请求时,从请求头中读取traceId
并再次放入MDC中。// 客户端拦截器:将TraceId注入HTTP头 public class OpenFeignRequestInterceptor implements RequestInterceptor {@Overridepublic void apply(RequestTemplate requestTemplate) {String traceId = MDC.get("traceId"); // 从当前线程上下文中获取TraceIdrequestTemplate.header("X-Trace-ID", traceId); // 注入到HTTP头} }// 服务端处理:从HTTP头提取TraceId public class TraceFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {String traceId = request.getParameter("X-Trace-ID"); // 从HTTP头获取TraceIdMDC.put("traceId", traceId); // 写入当前线程上下文chain.doFilter(request, response);} }
-
在每个服务的日志配置文件中,添加
traceId
到日志格式中,以便每条日志都包含这个信息。开发人员只需搜索traceId: xyz789
即可快速定位问题根源。[订单服务] [traceId: xyz789] - 开始创建订单 [库存服务] [traceId: xyz789] - 扣减库存成功 [支付服务] [traceId: xyz789] - 支付失败,数据库连接超时
-
现在,无论请求跨越了多少个服务,只要查看日志,就可以通过
traceId
轻松地将所有相关的日志条目关联起来,从而快速定位问题或理解系统的运行情况。
核心代码
自定义注解
脱敏注解@NoLogAnnotation
相关方法加上注解后隐藏参数信息,脱敏前 密码没加密时会暴露用户密码等敏感信息
@Target({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoLogAnnotation {//可以用在参数列表或者方法上,屏蔽不愿意记录的参数
}
日志配置logback.xml
展示TraceId
<?xml version="1.0" encoding="UTF-8"?>
<configuration><appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"><encoder><!-- 日志输出格式 将MDC中的traceId自动嵌入每条日志(唯一)--><pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [traceId:%X{traceId}] - %msg%n</pattern></encoder></appender><logger name="com.zr.study" level="info" /><root level="info"><appender-ref ref="STDOUT" /></root>
</configuration>
链路追踪组件
TraceConfiguration
,配置类注入过滤器和切面类进ioc容器
package com.zr.study.trace;
import com.zr.study.aop.ResultTraceIdAspect;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/*** proxyBeanMethods属性* 指定@Bean注解标注的方法是否使用代理,* 默认是true使用代理,直接从IOC容器之中取得对象;* false:每次调用@Bean标注的方法获取到的对象和IOC容器中的都不一样,是一个新的对象,以此提高性能*/
@Configuration(proxyBeanMethods = false)
public class TraceConfiguration {//注册过滤器与切面@Beanpublic TraceFilter traceFilter() {return new TraceFilter();}@Beanpublic ResultTraceIdAspect fillRequestIdAspect() {return new ResultTraceIdAspect();}
}
链路工具类
TraceUtils
工具类封装了ThreadLocal
存储获取删除traceId和MDC增删traceId的方法
- 存到
ThreadLocal
:每个请求都运行在其自己的线程上,通过将traceId
绑定到线程本地存储(ThreadLocal
)- 可以确保每个线程都有其独立的
traceId
,避免了多线程环境下数据混淆问题。 - 在请求任意过程中都可以获取traceId
- 可以确保每个线程都有其独立的
- 存到
MDC
中:可以在日志条目中自动包含这个traceId
(底层也是基于ThreadLocalMap
实现)
import org.slf4j.MDC;public class TraceUtils {//结合线程本地存储和日志上下文,实现 traceId 的传递与日志关联public static final String TRACE_ID = "traceId";public static ThreadLocal<String> traceIdThreadLocal = new ThreadLocal<>();//设置 traceId 到当前线程的上下文中public static void setTraceId(String traceId) {traceIdThreadLocal.set(traceId);MDC.put(TRACE_ID, traceId); // 同步到 MDC}public static String getTraceId() {return traceIdThreadLocal.get();}public static void removeTraceId() {traceIdThreadLocal.remove();MDC.remove(TRACE_ID);}
}
链路过滤器
TraceFilter解耦业务,通过过滤器生成TraceId
+插入TreadLocal和MDC
中,并通过时间戳记录方法执行时间打印到控制台
import cn.hutool.core.util.IdUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;//确保该过滤器是第一个执行的,优先设置 traceId
@Order(Ordered.HIGHEST_PRECEDENCE)
//Web 过滤器,匹配所有请求路径
@WebFilter(urlPatterns = "/**", filterName = "TraceFilter")
public class TraceFilter extends OncePerRequestFilter {private Logger log = LoggerFactory.getLogger(this.getClass());@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {//尝试 从HTTP头获取TraceIdString traceID =request.getParameter("X-Trace-ID"); if (traceID == null) {traceID = IdUtil.fastSimpleUUID(); //生成唯一traceId}TraceUtils.setTraceId(traceID); //同步到线程上下文和MDC日志中long startTime = System.currentTimeMillis();try {filterChain.doFilter(request, response);} finally {//请求后续处理完成或异常,都会返回这里清理traceIdTraceUtils.removeTraceId();}long endTime = System.currentTimeMillis(); //记录请求耗时log.info("请求地址:{},耗时(毫秒):{}", request.getRequestURL().toString(), (endTime - startTime));}
}
切面类
ResultTraceIdAspect
:环绕通知切点为Controller
和全局异常处理器,目的是无论方法是否异常,给统一方法返回类插入traceId
🔁 执行流程如下:
- 进入切面
- 调用
proceed()
方法 → 实际上就是调用目标方法(Controller 或 ExceptionHandler 的方法) - 获取返回值
result
- 判断是否是
ResultData
类型(自定义统一返回包装类) - 如果是,则设置
traceId
- 返回增强后的结果
@Order
@Aspect
@Component
public class ResultTraceIdAspect {//拦截所有 Controller 方法和全局异常处理器方法//对所有控制器层和异常处理器的返回结果进行统一增强处理@Pointcut("execution(* com.zr..*Controller.*(..)) || execution(* com.zr.study.exp.GlobalExceptionHandler.*(..))")public void pointCut() {}@Around("pointCut()") //环绕通知,如针对controller方法的前后进行增强public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {Object result = null;result = proceedingJoinPoint.proceed(); //放行if (result instanceof ResultData) {((ResultData<?>) result).setTraceId(TraceUtils.getTraceId());}return result;}
}
ResultData
是统一返回类,这块相比于之前的,多了traceId
链路追踪ID字段给前端
@Data
@Accessors(chain = true)
public class ResultData<T> {private String code;/** 结果状态 ,具体状态码参见枚举类ReturnCodeEnum.java*/private String message;private T data;private long timestamp ;private String traceId;public ResultData (){this.timestamp = System.currentTimeMillis();}public static <T> ResultData<T> success(T data) {ResultData<T> resultData = new ResultData<>();resultData.setCode(ReturnCodeEnum.RC200.getCode());resultData.setMessage(ReturnCodeEnum.RC200.getMessage());resultData.setData(data);return resultData;}public static <T> ResultData<T> fail(String code, String message) {ResultData<T> resultData = new ResultData<>();resultData.setCode(code);resultData.setMessage(message);return resultData;}}
增强Controller
ControllerLogAspect
:环绕通知切点为Controller
对日志参数做封装并打印
//增强接口的日志监控能力,提升调试和排查问题的效率,同时提供灵活的控制开关(如脱敏、禁用日志等)
@Order(value = Ordered.HIGHEST_PRECEDENCE) //最高优先级
@Aspect
@Component
public class ControllerLogAspect {private Logger log = LoggerFactory.getLogger(this.getClass());private static final String SALT = "zr"; // 可以使用随机盐或固定盐//拦截以 Controller 结尾的所有方法@Around("execution(* com.zr..*Controller.*(..))")public Object around(ProceedingJoinPoint joinPoint) throws Throwable {Object result = null;MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();try {//打印处理当前请求的完整类名和方法名称//例如: 接口方法:UserController.loginlog.info("接口方法:{}.{}", methodSignature.getDeclaringTypeName(), methodSignature.getName());//获取所有要打印的参数,丢到map中,key为参数名称,value为参数的值,然后会将这个map以json格式输出Map<String, Object> logParamsMap = new LinkedHashMap<>();String[] parameters = methodSignature.getParameterNames();Object[] args = joinPoint.getArgs();if(!ArrayUtil.isEmpty(parameters)){for (int i = 0; i < args.length; i++) {//判断是否需要脱敏if (parameterIsLog(methodSignature, i)) {//参数名称String parameterName = parameters[i];//参数值Object parameterValue = args[i];//将其放入到map中,稍后会以json格式输出logParamsMap.put(parameterName, parameterValue);}}}//输出示例: 方法参数列表:{"username":"admin","password":"secret"}log.info("方法参数列表:{}", JSONUtil.toJsonStr(logParamsMap));result = joinPoint.proceed(); //程序放行......return result;} finally {//判断方法的返回值是否需要打印?方法上有 @NoLogAnnotation 注解的,表示结果不打印方法返回值if (this.resultIsLog(methodSignature)) {log.info("方法返回值:{}", JSONUtil.toJsonStr(result));}}}/*** 指定位置的参数是否需要打印出来?(脱敏操作)*/private boolean parameterIsLog(MethodSignature methodSignature, int paramIndex) {if (methodSignature.getMethod().getParameterCount() == 0) {return false;}// 参数上有 @NoLogAnnotation注解的不会打印Annotation[] parameterAnnotation = methodSignature.getMethod().getParameterAnnotations()[paramIndex];if (parameterAnnotation != null && parameterAnnotation.length > 0) {for (Annotation annotation : parameterAnnotation) {if (annotation.annotationType() == NoLogAnnotation.class) {return false;}}}//参数类型是 ServletRequest / ServletResponse / 其他配置的敏感类型?→ 不打印Class parameterType = methodSignature.getParameterTypes()[paramIndex];for (Class<?> type : noLogTypes) {if (type.isAssignableFrom(parameterType)) {return false;}}return true;}// 参数类型是下面这些类型的,也不会打印,比如:ServletRequest、ServletResponse,大家可以扩展private static List<Class<?>> noLogTypes = Arrays.asList(ServletRequest.class, ServletResponse.class);/*** 判断方法的返回值是否需要打印?方法上有 @NoLogAnnotation 注解的,表示结果不打印方法返回值*/private boolean resultIsLog(MethodSignature methodSignature) {return methodSignature.getMethod().getAnnotation(NoLogAnnotation.class) == null;}
}
测试
查询数据效果