2543 字
约 8 分钟
17
S16-X11 · 链路追踪:Trace 与 Span 的落地
2024-12-31
2026-11-08

"下单接口慢"——服务有六个,日志各写各的,能做的只有把时间戳排在一条时间轴上猜。链路追踪解决的正是这件事:把"一次请求"变成可以在进程之间传递的对象,于是耗时能被切开,责任能被定位。

难点不在装 SDK,而在上下文能不能一路传下去。传丢了,你得到的是六条互不相关的短链,比没有追踪更误导。


1. 要回答的三个问题

问题 需要的数据 没有时的替代方案
请求经过了哪些服务 服务名 + span 父子链 靠时间戳猜顺序
每一段耗时多少 每个 span 的起止时间 只有端到端总耗时
慢在哪一段 关键段的 duration 分布 逐条翻日志对齐

三个里第三个才是日常要答的:总耗时告诉你慢,分段耗时告诉你改哪里。


2. 数据模型:一条 trace 是一棵树

概念 含义 关键字段
Trace 一次请求的完整调用链 traceId(32 位十六进制,全局唯一)
Span 一次操作:一次 RPC、一次 SQL、一段业务逻辑 spanId(16 位十六进制)、parentSpanId、name、kind、起止时间、attributes、status
SpanContext 跨进程传递的最小集合 traceId、spanId、采样标志
Resource 产生 span 的实体 service.name、host.name、service.version

kind 只有五种,判断方向靠它:SERVER(收到请求)、CLIENT(发出调用)、PRODUCER/CONSUMER(消息)、INTERNAL(进程内)。一条链上,下游的 SERVER 的父节点应该是上游的 CLIENT,这条对应关系断了就是上下文传播出了问题。

{
  "resourceSpans": [{
    "resource": {"attributes": [{"key": "service.name",
                                 "value": {"stringValue": "order-service"}}]},
    "scopeSpans": [{"spans": [{
      "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
      "spanId": "00f067aa0ba902b7",
      "parentSpanId": "b7ad6b7169203331",
      "name": "GET /api/orders/{id}",
      "kind": "SPAN_KIND_SERVER",
      "startTimeUnixNano": "1700000000000000000",
      "endTimeUnixNano": "1700000000123000000"
    }]}]
  }]
}

parentSpanId 为空的是根 span。name 写 {id} 而非真实 id——名字的基数决定后端能否聚合。


3. 传播:载体不同,失效点也不同

通道 载体 典型失效
HTTP 入站/出站 W3C traceparent、b3/b3multi 网关或代理丢弃未知头;两侧采样标志不一致
消息队列 消息 header/properties 生产端写入、消费端没提取;批量消费只取第一条
线程池 / @Async ThreadLocal + 显式装饰 复用线程不带上下文;默认不传播
事件总线 / 定时任务 手动注入父上下文 无入口 span,链断在起点
数据库 / 缓存 通常不带 只能看 SQL 归属哪个 span

W3C 头格式固定,便于核对:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └── trace-id(32 hex)        └── parent-id(16 hex) └ sampled
             └ version

最后两位是采样标志:01 表示采样。上游决定不采,下游再怎么配采样率也不会补——这就是头部采样最容易被误判的地方。


4. 线程池与异步:上下文最常丢的地方

// Micrometer 提供现成的装饰器:io.micrometer:context-propagation
@Bean
ThreadPoolTaskExecutor orderExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(8);
    ex.setQueueCapacity(200);
    ex.setTaskDecorator(new ContextPropagatingTaskDecorator());  // 提交时捕获,执行时恢复
    ex.initialize();
    return ex;
}

没有装饰器时,等价的手写形态是"提交时取、执行时装、结束恢复":

Span parent = Span.current();
executor.submit(() -> {
    try (Scope ignored = parent.makeCurrent()) {   // 必须在子线程里 makeCurrent
        doWork();
    }
});

@Async 走容器里的 TaskExecutor,装饰器加上就跟着修好;CompletableFuture.supplyAsync() 用公共 ForkJoinPool 时不经过这个 bean。反应式(WebFlux)靠 ReactorContext 而非 ThreadLocal,混用时要单独核对。


5. 埋点:一次埋点出两种数据

Spring Boot 3.3.x 的 Observation API 一次调用同时产出 span 和指标:

private final ObservationRegistry registry;

public OrderView load(long id, String channel) {
    return Observation.createNotStarted("order.load", registry)
            .lowCardinalityKeyValue("channel", channel)        // 进指标,必须低基数
            .highCardinalityKeyValue("order.id", String.valueOf(id))  // 只进 span
            .observe(() -> repository.findView(id));
}

lowCardinalityKeyValue 的值会成为指标标签,另一个只写进 span。把 order.id 写成 low cardinality,等于在指标系统里埋基数炸弹。


6. 埋点位置与粒度

位置 建议粒度 反例
入口(HTTP / 消费者) 每个请求 1 个 server span controller 方法里再包一层
出口(HTTP 客户端 / SQL / 缓存 / 消息) 每次调用 1 个 client span 循环内逐条查询,产生 N 个 span
关键业务段 有独立耗时意义、可能失败的段 每个方法都包一层
循环体 不埋,用聚合指标替代 一千次循环一千个 span
纯计算工具方法 不埋 出现大量 0.01ms 的 span

判据:有人会单独看这个 span 的耗时或错误率吗? 否就不要埋。span 数量直接换算成存储成本,按 Collector 的批处理能力核对。


7. 采样:成本与漏采的取舍

策略 决定时机 优点 代价
头部采样(按比例) 请求入口 实现简单、开销低 无法按结果补采,慢请求与错误大概率被丢
尾部采样 Collector 收齐全链后 可按延迟、状态码、错误保留 需要缓冲整条链,内存与延迟上升
组合 入口按比例 + 尾部兜住关键样本 成本可控且不漏慢/错 两处配置必须对齐
# Spring Boot 3.3.x:头部比例采样 + OTLP 导出
management:
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
# OTel Collector:尾部采样,错误与慢请求全留
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow
        type: latency
        latency: {threshold_ms: 800}
      - name: baseline
        type: probabilistic
        probabilistic: {sampling_percentage: 5}

只按比例采样,等于系统性看不见最慢的那 1%。要么尾部采样,要么对超阈值请求强制打采样标记,否则 p99 的现场永远缺失。


8. 与日志的关联:把 traceId 打进 MDC

<!-- logback-spring.xml:traceId/spanId 由 Micrometer Tracing 写入 MDC -->
<pattern>%d{ISO8601} %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n</pattern>

MDC 只对当前线程有效,第 4 节的线程池装饰器同时是日志关联的前提。日志平台按 traceId 聚合后,链路视图能直接跳日志,日志也能反查整条链。

traceId 交给 MDC 统一注入,不要在业务日志里手写,避免两处不一致。


9. 与指标的关系:span 转指标

由 span 派生 指标形态 用途
调用次数 Counter → rate 服务拓扑、流量分布
耗时 Histogram 分位延迟、慢依赖排序
错误数 Counter(按 status_code) 依赖侧错误率

两种派生:Collector 的 spanmetrics connector(不改代码、全链路视角),或应用内 Observation 产 Timer(精度更高、只覆盖本进程)。两者互补:只信 span 指标会漏掉没有 span 的路径,只信应用指标又看不出拓扑。


10. 接入形态:agent 与手动埋点

形态 侵入性 覆盖面 适用
Java agent 零代码,仅加 -javaagent 框架、驱动、线程池自动埋点 快速铺开、版本统一
SDK 自动配置 加依赖 + 少量配置 需显式埋点的部分才有 业务语义 span
混合 agent 打底 + 手动补语义 覆盖最全 生产常态

agent 的代价是版本绑定:agent 与 JDK、框架版本要对齐,升到 JDK 17 后要按 release notes 核对支持的库。手动埋点没这个约束,但要记得给每个新入口加。


11. 排错:六种链路断法

现象 原因 处理
链路只有一半,下游换了 traceId 出站没注入头,或网关剥离未知头 查 client instrumentation 与网关白名单
异步任务里 traceId 为空 线程池没装饰 加 ContextPropagatingTaskDecorator 或手动 makeCurrent
慢请求在追踪里查不到 头部采样丢弃 尾部采样按延迟保留,或强制采样标记
同一接口 span 名上千种 名字里带 id 或参数 用模板化路由名 /orders/{id}
只有成功请求有 span 导出写在成功分支 异常路径也要 recordException 并正确结束 span
消费者侧 parent 断链 消息 header 没透传 生产端注入、消费端显式提取

第二条在 Spring 项目里最常出现。判断只需看子线程里 Span.current().getSpanContext().isValid(),false 就是没传进去。


12. 边界:追踪回答不了的问题

场景 追踪的局限 该补什么
单机 CPU / GC / 内存 只有请求视角 指标 + profiling(见 S16-X09 一类的工具)
数据库内部执行细节 只有客户端耗时 慢查询日志、执行计划
跨天批处理 链路过长无意义 用 job run id 关联,不用 traceId
极高 QPS 全采不可行 采样 + 聚合
安全审计 span 不是审计流水 独立审计日志

边界一句话:追踪答"这一次请求怎么走的",不答"这一类请求长期什么样"。 后者是指标的活。


13. 自测

  1. 一条链上 parentSpanId 对应关系断了,最可能断在哪两类通道?
  2. lowCardinalityKeyValue 与 highCardinalityKeyValue 分别流向哪里?写反了会怎样?
  3. 为什么只做头部按比例采样会系统性缺失慢请求样本?
  4. @Async 与 CompletableFuture.supplyAsync() 在上下文传播上为何表现不同?
  5. span 名里带真实 id 会造成什么后果,正确的命名形态是什么?
  6. 追踪和指标各自擅长回答哪类问题,为什么两者不能互相替代?
S16-X11 · 链路追踪:Trace 与 Span 的落地
https://www.clxhxhhr.top/posts/5834/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。