3. 链路追踪原理:Trace / Span / SpanContext 的定义与传播

在异步任务编排场景中,一个用户请求往往需要跨多个服务、线程或消息队列才能完成。链路追踪的核心目标,就是为这个分布式请求重建一条完整的调用路径。本章从最基础的三个抽象概念——Trace、Span 和 SpanContext 出发,讲解它们如何定义一次请求的生命周期,以及 TraceId 与 SpanId 如何在异步边界上正确传播。

3.1 核心概念定义

概念 定义 类比(快递追踪)
Trace 一次完整请求从入口到所有下游返回的全过程,由一组具有相同 TraceId 的 Span 构成的有向无环图(DAG)。 一个快递单号对应的完整运输旅程。
Span Trace 中的一个工作单元,记录一个操作(如 RPC 调用、数据库查询、异步任务执行)的开始时间、结束时间、状态和标签。 快递旅程中的“从A分拣中心到B分拣中心”这一段。
SpanContext Span 的上下文元数据,包含 TraceId、SpanId 以及可选的 Baggage(自定义键值对),用于跨进程传递追踪状态。 贴在快递包裹上的运单标签,包含单号和当前站点信息。
关键区分:Trace 是全局视角的“树”,Span 是树上的“节点”,SpanContext 是节点之间传递的“信物”。在异步任务编排中,SpanContext 的正确传递决定了链路能否跨线程、跨进程拼接。

3.2 TraceId 与 SpanId 的生成规则

3.2.1 TraceId

  • 全局唯一性:一次请求生命周期内保持不变,所有 Span 共享同一个 TraceId。
  • 生成方式:通常由入口服务(如 API 网关或前端)在请求开始时生成。
  • 常见算法:
    • 128 位随机数(UUID 或 Snowflake 变体),如 0af7651916cd43dd8448eb211c80319c。
    • 64 位组合:高 32 位为时间戳(秒级),低 32 位为随机数或自增序列,保证分布式环境下的唯一性。

3.2.2 SpanId

  • 父-子关系:每个 Span 有唯一的 SpanId,同时记录 ParentSpanId,形成树形结构。
  • 生成时机:在创建新 Span 时由当前服务或线程生成。
  • 常见算法:64 位随机数,通常使用 ThreadLocalRandom 或 SecureRandom 生成,保证同一 Trace 内不重复。
生产环境建议:TraceId 推荐使用 128 位(16 字节)十六进制字符串,兼容 OpenTelemetry 和 Zipkin 标准;SpanId 使用 64 位(8 字节)十六进制字符串。避免使用 UUID 的连字符格式,减少传输开销。

3.3 传播机制:跨线程与跨进程

链路追踪的难点不在于生成 ID,而在于让 ID 在异步边界上“不丢失”。下面通过一张结构图展示 TraceId 和 SpanId 在典型异步任务编排中的传播路径。

TraceId / SpanId 在异步任务编排中的传播路径 入口服务 TraceId: A 异步任务调度器 TraceId: A 任务A(线程池) SpanId: A1 任务B(线程池) SpanId: B1 任务C(线程池) SpanId: C1 下游服务A SpanId: A2 下游服务B SpanId: B2 传递 TraceId SpanContext 注入 SpanContext 注入 SpanContext 注入 RPC 传播 RPC 传播 所有 Span 共享 同一个 TraceId

3.4 传播的三种典型场景

场景 传播方式 关键实现 常见陷阱
同步 RPC HTTP/gRPC 请求头传递 将 SpanContext 序列化为 traceparent 或 uber-trace-id 头 中间件未解析请求头,导致链路断裂
异步线程池 ThreadLocal 传递 使用 Runnable 或 Callable 包装器,在提交任务前将 SpanContext 注入新线程 线程池复用导致 SpanContext 污染;CompletableFuture 未显式传递上下文
消息队列 消息头/属性传递 将 SpanContext 写入消息的 Header 或 Properties 字段 消息体过大时 Header 被截断;消费端未反序列化 SpanContext
异步场景特别提醒:在 Java 中使用 CompletableFuture.supplyAsync() 或 @Async 注解时,默认不会自动传递 ThreadLocal 中的 SpanContext。必须显式使用 Executor 包装器(如 ContextPropagatingExecutor)或手动在 lambda 中传递 TraceId。

3.5 SpanContext 的序列化与反序列化

SpanContext 在跨进程传播时,需要序列化为传输协议支持的格式。以 OpenTelemetry 的 W3C Trace Context 标准为例:

// 序列化后的 traceparent 头格式
// 版本-版本号(1字节) + TraceId(16字节) + SpanId(8字节) + TraceFlags(1字节)
// 示例:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

// 字段说明:
// 00        : 版本号
// 0af7...   : TraceId(128位,32个十六进制字符)
// b7ad...   : SpanId(64位,16个十六进制字符)
// 01        : TraceFlags(01 表示采样,00 表示不采样)
最佳实践:在微服务架构中,统一使用 W3C Trace Context 标准(traceparent 头),避免不同追踪系统(Jaeger、Zipkin、SkyWalking)之间的协议不兼容。如果使用消息队列,将 traceparent 写入消息的 Header 字段,消费端从 Header 中解析并重建 SpanContext。

3.6 异步任务编排中的传播代码示例

下面展示一个使用 Java CompletableFuture 进行异步任务编排时,如何正确传递 SpanContext 的代码片段:

// 1. 定义上下文传播的 Executor 包装器
public class ContextPropagatingExecutor implements Executor {
    private final Executor delegate;

    public ContextPropagatingExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        // 捕获当前线程的 SpanContext
        SpanContext currentContext = Span.current().getSpanContext();
        delegate.execute(() -> {
            // 在新线程中恢复 SpanContext
            try (Scope scope = Span.makeCurrent(currentContext)) {
                command.run();
            }
        });
    }
}

// 2. 在异步任务编排中使用
Executor executor = new ContextPropagatingExecutor(ForkJoinPool.commonPool());

CompletableFuture<Result> futureA = CompletableFuture
    .supplyAsync(() -> serviceA.call(), executor)
    .thenApplyAsync(result -> transform(result), executor);

CompletableFuture<Result> futureB = CompletableFuture
    .supplyAsync(() -> serviceB.call(), executor);

// 3. 合并结果,所有子任务共享同一个 TraceId
CompletableFuture
    .allOf(futureA, futureB)
    .thenAcceptAsync(ignored -> {
        // 此处 SpanContext 依然有效
        logger.info("所有异步任务完成,TraceId: {}",
            Span.current().getSpanContext().getTraceId());
    }, executor);
核心原则:在异步边界上,SpanContext 的传播遵循“谁创建,谁注入”的原则。创建异步任务的服务负责将当前 SpanContext 序列化并传递给新线程或下游服务;接收方负责反序列化并恢复上下文。只要保证这一原则,无论任务编排多复杂,链路都不会断裂。