链路追踪实现原理简述

17 阅读7分钟

链路追踪实现原理简述

链路追踪(Distributed Tracing)是一种用于监控和分析分布式系统中请求调用链路的技术。通过为每个请求分配唯一的追踪标识(TraceId)和跨度标识(SpanId),可以追踪请求在微服务架构中的完整调用路径。

核心概念

  • TraceId(追踪ID):唯一标识一次完整的请求调用链路,在整个调用过程中保持不变

  • SpanId(跨度ID):标识调用链路中的一个节点或操作,每次调用第三方服务时会生成子SpanId

  • ThreadLocal:Java中的线程局部变量,确保在多线程环境下每个线程都有独立的TraceId和SpanId副本,避免数据混乱

  • HandlerInterceptor: Spring MVC请求拦截器接口

    方法名执行时机核心作用
    preHandle控制器(Controller)方法执行之前(最先执行)前置拦截:校验权限、登录状态、IP 白名单等,返回 false 直接终止请求
    postHandle控制器(Controller)方法执行之后、视图(View)渲染之前后置增强:修改响应数据、添加通用视图参数(如登录用户信息)、处理模型数据
    afterCompletion视图渲染完成(整个请求处理完毕)之后(最后执行,无论是否抛异常)最终清理:释放资源(如关闭流、删除临时文件)、记录请求耗时、处理异常日志

实现流程

1. 请求入口 - 拦截器初始化

  • 当请求进入系统时,TraceIdInterceptorpreHandle 方法首先被触发

  • 检查请求头(Header)中是否已存在TraceId和SpanId:

    • 如果存在(从Nginx或上游服务传递过来),则复用这些ID
    • 如果不存在,则生成新的TraceId和SpanId
  • 将TraceId和SpanId存储到ThreadLocal中,确保当前线程在后续处理过程中可以随时访问

  • 将TraceId和SpanId添加到响应头(Response Header)中,便于前端或下游服务获取

2. 业务处理阶段

  • 在Controller层处理业务逻辑时,可以通过Traces.getTraceId()Traces.getSpanId()获取当前请求的追踪信息
  • 日志记录时会自动携带TraceId和SpanId,便于日志聚合和问题排查

3. 调用第三方服务 - 传递追踪信息

  • 在调用第三方服务(如HTTP请求)时,通过setHeader方法将当前的TraceId传递到请求头中
  • 生成新的子SpanId(Traces.generateSubSpanId()),标识这是一次新的服务调用
  • 将TraceId和子SpanId放入HTTP请求头,确保下游服务能够继续追踪

4. 请求结束 - 清理资源

  • afterCompletion方法在请求处理完成后执行(无论成功或异常)
  • 调用LogMessage.remove()Traces.reset()清理ThreadLocal中的数据,避免内存泄漏
  • 清理请求路径等临时信息
@Component
public class TraceIdInterceptor implements HandlerInterceptor {

  private static final Logger logger = LoggerFactory.getLogger(TraceIdInterceptor.class);

  @Override
  public boolean preHandle(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Object object) throws Exception {
    iniTrace(httpServletRequest);
    httpServletResponse.addHeader(LogMessage.HEADER_TRACEID, LogMessage.getTraceId());
    httpServletResponse.addHeader(LogMessage.HEADER_SPANID, LogMessage.getSpanId());
    return true;
  }

  private void iniTrace(HttpServletRequest httpServletRequest) throws Exception{
    String path = httpServletRequest.getRequestURI();
    RequestExtHolder.setPath(path);
    putTraceToThreadLocal(httpServletRequest);
  }

  private void putTraceToThreadLocal(HttpServletRequest httpServletRequest) {
    String traceId = httpServletRequest.getHeader(LogMessage.HEADER_TRACEID);
    if (StringUtils.isBlank(traceId)) {
      traceId = LogMessage.genertorNewTraceid();
    } else {
      LoggerFactory.getLogger(TraceIdInterceptor.class)
          .info("from nginx, trace_id:{}", traceId);
    }
    LogMessage.setTraceId(traceId);
    String spanId = httpServletRequest.getHeader(LogMessage.HEADER_SPANID);
    if (StringUtils.isBlank(spanId)) {
      spanId = LogMessage.generatorNewSpanid();
    } else {
      LoggerFactory.getLogger(TraceIdInterceptor.class).info("from nginx, span_id:{}", spanId);
    }
    LogMessage.setSpanId(spanId);
    Traces.setTraceId(LogMessage.getTraceId());
    Traces.setSpanId(LogMessage.getSpanId());
  }

  @Override
  public void postHandle(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Object object, ModelAndView modelAndView)
      throws Exception {
  }

  @Override
  public void afterCompletion(HttpServletRequest httpServletRequest,
      HttpServletResponse httpServletResponse, Object object, Exception exception)
      throws Exception {
    LogMessage.remove();
    Traces.reset();
    RequestExtHolder.setPath(null);
  }
}
  • 三方服务调用,在请求header中添加traceId和spanId
private static void setHeader(HttpRequest request, HttpMessage httpMessage, HttpContext httpContext) {
    if (request != null) {
        Optional.ofNullable(request.getHeaders()).filter((map) -> !map.isEmpty()).ifPresent((map) -> map.forEach(httpMessage::setHeader));
        httpMessage.setHeader(LogMessage.HEADER_TRACEID, Traces.getTraceId());
        httpMessage.setHeader(LogMessage.HEADER_SPANID, Traces.generateSubSpanId());
        httpMessage.setHeader(HeaderEnum.ContentType.getHeaderName(), StringUtils.isNotBlank(request.getContentType()) ? request.getContentType() : "application/json");
        BasicCookieStore cookieStore = new BasicCookieStore();
        Optional.ofNullable(request.getCookies()).filter((map) -> !map.isEmpty()).ifPresent((map) -> map.forEach((key, value) -> {
                BasicClientCookie cookie = new BasicClientCookie(key, value);
                cookieStore.addCookie(cookie);
            }));
        if (CollectionUtils.isNotEmpty(cookieStore.getCookies())) {
            httpMessage.setHeader(HeaderEnum.Cookie.getHeaderName(), (String)cookieStore.getCookies().stream().map((cookie) -> cookie.getName() + "=" + cookie.getValue()).collect(Collectors.joining(";")));
        }
    }
}

关键技术点

ThreadLocal的使用

使用ThreadLocal存储TraceId和SpanId是为了解决多线程环境下的数据隔离问题。每个线程都有自己的副本,互不干扰。

public class LogMessage {
	  private static ThreadLocal<ExtElement> extElements = new InheritableThreadLocal<ExtElement>() {
    
    public static String getTraceId() {
        return ((ExtElement)extElements.get()).getTraceId();
    }

    public static String getSpanId() {
        return ((ExtElement)extElements.get()).getSpanId();
    }

    public static void setTraceId(String traceId) {
        ((ExtElement)extElements.get()).setTraceId(traceId);
    }

    public static void setSpanId(String spanId) {
        ((ExtElement)extElements.get()).setSpanId(spanId);
    }

    public static String generatorNewSpanid() {
        String newSpanid = SpanIdGenerator.getSpanId();
        return newSpanid;
    }

    public static String genertorNewTraceid() {
        String traceid = SpanIdGenerator.getTraceId();
        return traceid;
    }
}

public class ExtElement {
    private String traceId = "";
    private String spanId = "";

    public String getTraceId() {
        return this.traceId;
    }

    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }

    public String getSpanId() {
        if (this.spanId == null || this.spanId.equals("")) {
            this.spanId = SpanIdGenerator.getSpanId();
        }

        return this.spanId;
    }

    public void setSpanId(String spanId) {
        this.spanId = spanId;
    }
}

为什么使用 InheritableThreadLocal?

InheritableThreadLocalThreadLocal 的子类,它的核心特性是能够将父线程的 ThreadLocal 变量值自动传递给子线程。在链路追踪场景中使用它有以下重要原因:

1. 解决异步线程场景下的追踪信息丢失问题

在实际业务中,经常会遇到异步处理的场景,比如:

  • 使用 @Async 注解创建异步任务
  • 使用线程池 ExecutorService 提交任务
  • 使用 CompletableFuture 进行异步编排

如果使用普通的 ThreadLocal,当主线程创建子线程时,子线程无法访问到父线程的 TraceId 和 SpanId,导致链路追踪断裂。

2. 代码示例对比

使用普通 ThreadLocal 的问题:

// 普通 ThreadLocal
private static ThreadLocal&lt;String&gt; normalThreadLocal = new ThreadLocal&lt;&gt;();

public void testNormalThreadLocal() {
    normalThreadLocal.set("trace-123");
    System.out.println("主线程: " + normalThreadLocal.get()); // 输出: trace-123
    
    new Thread(() -> {
        System.out.println("子线程: " + normalThreadLocal.get()); // 输出: null (获取不到)
    }).start();
}

使用 InheritableThreadLocal 的优势:

// InheritableThreadLocal
private static InheritableThreadLocal&lt;String&gt; inheritableThreadLocal = new InheritableThreadLocal&lt;&gt;();

public void testInheritableThreadLocal() {
    inheritableThreadLocal.set("trace-123");
    System.out.println("主线程: " + inheritableThreadLocal.get()); // 输出: trace-123
    
    new Thread(() -> {
        System.out.println("子线程: " + inheritableThreadLocal.get()); // 输出: trace-123 (自动继承)
    }).start();
}

3. 链路追踪中的实际应用场景

假设服务 A 在处理请求时需要异步调用多个服务:

@RestController
public class OrderController {
    
    @Autowired
    private ExecutorService executorService;
    
    @GetMapping("/createOrder")
    public String createOrder() {
        // 主线程已经设置了 TraceId
        String traceId = Traces.getTraceId(); // trace-123
        
        // 异步扣减库存
        executorService.submit(() -> {
            // 如果使用 InheritableThreadLocal,这里能获取到 trace-123
            // 如果使用普通 ThreadLocal,这里获取到 null
            String subTraceId = Traces.getTraceId();
            inventoryService.reduce(subTraceId);
        });
        
        // 异步发送消息
        executorService.submit(() -> {
            String subTraceId = Traces.getTraceId();
            notificationService.send(subTraceId);
        });
        
        return "success";
    }
}

4. InheritableThreadLocal 的工作原理

当创建新线程时,Java 会检查父线程是否有 InheritableThreadLocal 变量:

  • 如果有,则自动调用 Thread.init() 方法
  • 将父线程的 inheritableThreadLocals 表复制到子线程
  • 子线程就能访问到父线程设置的值

5. 注意事项与局限性

  • 线程池复用问题: InheritableThreadLocal 只在线程创建时复制父线程的值。如果使用线程池,线程被复用时不会重新复制,可能导致数据错乱
  • 解决方案: 使用阿里巴巴的 TransmittableThreadLocal (TTL) 库,或者在任务提交时手动传递 TraceId
  • 性能开销: 相比普通 ThreadLocal 有额外的复制开销,但在链路追踪场景下是可接受的
  • 内存泄漏风险: 同样需要在合适的时机调用 remove() 清理资源

6. 更好的替代方案:TransmittableThreadLocal

对于生产环境,建议使用阿里开源的 TransmittableThreadLocal(TTL):

// 使用 TTL
private static TransmittableThreadLocal&lt;String&gt; traceIdHolder = new TransmittableThreadLocal<>();

// 装饰线程池
ExecutorService executorService = TtlExecutors.getTtlExecutorService(
    Executors.newFixedThreadPool(10)
);

TransmittableThreadLocal 解决了线程池复用场景下的值传递问题,是更加健壮的解决方案。

SpanId的层级关系

SpanId通常采用层级结构,例如:

  • 服务A:SpanId = 1
  • 服务A调用服务B:SpanId = 1.1
  • 服务A调用服务C:SpanId = 1.2
  • 服务B调用服务D:SpanId = 1.1.1

这种层级结构可以清晰地展示调用关系和调用深度。

完整调用链示例

  1. 前端发起请求:请求到达Nginx
  2. Nginx转发:Nginx可能会在请求头中添加初始的TraceId(如果配置了的话)
  3. 服务A接收请求TraceIdInterceptor拦截,初始化或复用TraceId,生成SpanId
  4. 服务A处理业务:记录日志,日志中包含TraceId和SpanId
  5. 服务A调用服务B:在HTTP请求头中传递TraceId和新的子SpanId
  6. 服务B接收请求:复用TraceId,使用传递过来的SpanId
  7. 服务B处理并返回:日志记录,响应返回给服务A
  8. 服务A返回响应:将TraceId和SpanId放入响应头,返回给前端
  9. 清理资源afterCompletion执行,清理ThreadLocal

注意事项与最佳实践

  • 必须清理ThreadLocal:在请求结束时务必调用remove(),否则在线程池复用场景下会导致内存泄漏或数据污染
  • 异步调用处理:如果使用异步线程(如@Async),需要手动传递TraceId和SpanId到新线程,可以使用InheritableThreadLocal或手动传递
  • 日志框架集成:通常会配合MDC(Mapped Diagnostic Context)使用,自动在日志中添加TraceId和SpanId
  • 统一格式:TraceId和SpanId的Header名称应在整个系统中保持一致(如X-Trace-IdX-Span-Id
  • 性能考虑:链路追踪会带来一定的性能开销,需要在可观测性和性能之间找到平衡

扩展:与开源链路追踪框架的对比

常见的开源链路追踪框架包括:

  • Zipkin:Twitter开源,轻量级,支持多种语言
  • Jaeger:Uber开源,云原生架构,支持OpenTracing标准
  • SkyWalking:Apache项目,专为微服务设计,支持自动埋点
  • Sleuth + Zipkin:Spring Cloud生态,与Spring Boot无缝集成

这些框架提供了更完善的功能,如可视化界面、性能分析、服务依赖图等,但实现原理与自定义实现类似。