第2章:分布式追踪基本原理 — Span、Trace 与 Context Propagation

做分布式系统,最怕什么?出了问题不知道根在哪。我跟很多团队聊过,大家第一反应就是「上链路追踪」。但链路追踪到底在追踪什么?说白了,就是三个核心概念:Span、Trace 和 Context Propagation。这一章,我把这三个东西掰开揉碎讲清楚。

2.1 Span:追踪的最小单元

Span 是分布式追踪的最小工作单元。你每次发起一个 RPC 调用、执行一条 SQL 查询、或者往消息队列里发一条消息,都可以用一个 Span 来记录。每个 Span 描述了一次有开始和结束的操作。

我习惯把 Span 想象成一个「操作快照」。它记录了什么操作、什么时候开始、什么时候结束、结果如何。一个 Span 的核心属性包括:

属性 说明 示例值
name 操作名称,描述这个 Span 在做什么 /api/order/create
spanId Span 的唯一标识,64 位或 128 位 abc123def456
parentSpanId 父 Span 的 ID,形成树形结构的关键 parentSpanId: xyz789
traceId 所属 Trace 的唯一标识,所有 Span 共享 trace-001
startTime / endTime 操作开始和结束的时间戳 1700000000000
status 操作结果状态:OK / ERROR / UNSET OK
tags 键值对,附加业务或技术属性 http.method=POST
events 时间戳 + 消息,记录关键事件 缓存命中

我在项目中遇到过一个问题:一个接口偶尔超时,但没法复现。后来我在 Span 里加了 tags,把请求参数和响应大小打进去,一查发现是某个大订单的查询走了全表扫描。嗯,Span 的 tags 用好了,排查效率能翻倍。

我的习惯:每个 Span 至少打三个 tags——service.name、operation.type、business.key。这样不管从哪个维度查,都能快速定位。

2.2 Trace:一次请求的完整视图

一个 Span 只能看到单次操作。但一次请求往往经过多个服务、多个步骤。把这些 Span 按照调用关系串起来,就形成了Trace。

Trace 的本质是一个有向无环图(DAG),或者更具体一点——一棵树。根 Span 是请求的入口,子 Span 是后续的每一步调用。同一 Trace 里的所有 Span 共享同一个 traceId。

你想想看,没有 Trace 的话,你只能看到一个个孤立的 Span。有了 Trace,你才能回答这些问题:

  • 这个请求总共花了多长时间?
  • 哪个环节最慢?
  • 哪个服务抛了异常?
  • 调用链路有没有环路或断点?

我记得有一次线上告警说下单成功率下降。我打开 Trace 视图,发现从入口到支付服务之间多了一个没见过的跳转节点。顺着 Trace 树一查,原来是灰度发布配错了路由规则。要是没有 Trace,光看各个服务的日志,可能查到明天都找不到根因。

这里有个容易混淆的点——Trace 不是日志,而是 Span 的组合。日志是离散的,Trace 是有结构的。你可以通过 Trace 还原出请求的完整执行路径。

2.3 Context Propagation:让 Span 不再孤立

Span 和 Trace 听着挺美好,但有个现实问题:服务 A 调用服务 B 的时候,服务 B 怎么知道当前属于哪个 Trace?怎么知道自己的父 Span 是谁?

这就需要Context Propagation(上下文传播)。说白了,就是把 traceId 和 spanId 从调用方传到被调用方。没有这一步,链路就断了。

传播的方式主要有以下几种:

传播方式 载体 典型字段
HTTP 请求头 traceparent、tracestate
gRPC Metadata grpc-trace-bin
消息队列 消息头 traceparent
进程内 线程局部变量 / 协程上下文 Context.current()

目前业界的主流标准是 W3C Trace Context,它定义了 traceparent 和 tracestate 两个 header。前者传递核心的 traceId 和 spanId,后者传递额外的供应商信息。

我曾经踩过的坑:有一次链路追踪系统突然看不到后半段调用,查了半天,发现是团队新引入的一个 HTTP 客户端库默认没有透传 traceparent header。每次请求到那个服务,上下文就丢了,后面生成的 Span 成了一个「孤儿 Trace」。从那以后,我定了一条规矩:所有 HTTP 客户端必须显式开启 context propagation。

为什么会丢?因为 Context Propagation 不是自动发生的。你需要在发送请求时从当前线程或协程里取出上下文,塞到请求头里;接收方再从请求头里取出上下文,恢复成新的 Span。这一步遗漏了,链路就断了。

2.4 三者如何协同工作

Span、Trace、Context Propagation 不是三个独立的概念,它们是一个整体:

  • Span 是砖块,记录每一次操作。
  • Trace 是房子,把所有 Span 组织成一棵完整的调用树。
  • Context Propagation 是水泥,让 Span 跨服务也能保持父子关系。

下面这张图展示了三者如何配合:

分布式追踪三大核心概念关系图 分布式追踪核心概念:Span、Trace 与 Context Propagation Trace(完整请求链路) 所有 Span 共享同一个 traceId API Gateway(根 Span) spanId: abc-123 traceId: trace-001 Service A spanId: def-456 parentSpanId: abc-123 Service B spanId: ghi-789 parentSpanId: abc-123 Context Propagation Context Propagation 图例: Span(操作单元) Trace(Span 集合) Context Propagation(上下文传递) Span 之间通过 parentSpanId 形成树,通过 traceId 归属于同一个 Trace

从图中可以看到:根 Span 是入口,子 Span 分别在不同的服务中执行。Context Propagation 把 traceId 和 parentSpanId 从 API Gateway 传递到 Service A 和 Service B,让它们知道自己的「身世」。

2.5 实战:用 OpenTelemetry 构建第一个 Trace

理论讲完了,上手敲一个。下面是用 OpenTelemetry Python SDK 创建一个 Trace 的示例。代码很简单,但涵盖了 Span、Trace 和 Context Propagation 的核心动作:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor

# 1. 初始化 TracerProvider(创建 Trace 的工厂)
provider = TracerProvider()
processor = SimpleSpanProcessor(ConsoleSpanExporter())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

# 2. 获取 Tracer 实例
tracer = trace.get_tracer("order-service")

# 3. 创建根 Span(开启一个 Trace)
with tracer.start_as_current_span("create_order") as root_span:
    root_span.set_attribute("http.method", "POST")
    root_span.set_attribute("business.order_id", "ORD-2024-001")

    # 4. 在根 Span 下创建子 Span(模拟调用库存服务)
    with tracer.start_as_current_span("inventory.deduct") as child_span:
        child_span.set_attribute("inventory.sku", "SKU-888")
        child_span.add_event("库存扣减成功")
    
    # 5. 另一个子 Span(模拟调用支付服务)
    with tracer.start_as_current_span("payment.charge") as child_span2:
        child_span2.set_attribute("payment.amount", "99.00")
        child_span2.set_status(trace.status.Status(trace.status.StatusCode.OK))

# 6. 程序退出时,Trace 自动导出到控制台

运行这段代码,你会看到控制台输出了三个 Span,它们共享同一个 traceId,并且 child_span 和 child_span2 的 parentSpanId 指向了根 Span 的 spanId。这就是一个完整的 Trace。

关键点:注意代码中的 start_as_current_span 会自动处理 Context Propagation。在第一个 with 块内部,当前上下文被设为根 Span;第二个 with 块启动时,SDK 自动从上下文中取出父 Span 信息,把新 Span 挂到根 Span 下面。你不需要手动传 traceId 和 parentSpanId。

在真实分布式环境中,跨服务的 Context Propagation 需要手动或通过拦截器处理。比如在 Flask 中,你需要在请求入口处从 traceparent header 中提取上下文,再创建 Span。不过核心原理都是一样的——传递 traceId,维护 parentSpanId。

避坑指南:我曾经见过一个团队在异步任务(比如 Celery)里忘了传递上下文。主线程的 Trace 到了异步任务就断了,变成两个独立的 Trace。解决办法是:在提交异步任务时,把当前 spanContext 序列化到消息中,执行时反序列化恢复。OpenTelemetry 提供了 propagate 模块专门干这事。

好了,这一章的核心就是这三样东西:Span 打点、Trace 组树、Context Propagation 传上下文。把这三个概念搞明白,后面讲分布式追踪的架构设计、数据采样、存储优化你都能很快上手。