"下单接口慢"——服务有六个,日志各写各的,能做的只有把时间戳排在一条时间轴上猜。链路追踪解决的正是这件事:把"一次请求"变成可以在进程之间传递的对象,于是耗时能被切开,责任能被定位。
难点不在装 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. 自测
- 一条链上
parentSpanId对应关系断了,最可能断在哪两类通道? lowCardinalityKeyValue与highCardinalityKeyValue分别流向哪里?写反了会怎样?- 为什么只做头部按比例采样会系统性缺失慢请求样本?
@Async与CompletableFuture.supplyAsync()在上下文传播上为何表现不同?- span 名里带真实 id 会造成什么后果,正确的命名形态是什么?
- 追踪和指标各自擅长回答哪类问题,为什么两者不能互相替代?