4. Span 与 Trace 核心概念:Span 结构、TraceID、SpanContext、父子关系
在分布式追踪系统中,Trace 和 Span 是最基础的两个抽象。理解它们的结构、标识符以及上下文传递机制,是定位慢查询、分析调用链路的基石。本节将深入拆解 Span 的内部结构、TraceID 的生成与传播、SpanContext 的职责,以及父子 Span 之间的拓扑关系。
4.1 Span 结构:追踪的最小工作单元
Span 代表分布式系统中一个具有开始时间和结束时间的逻辑工作单元。例如,一个 HTTP 请求的处理、一次数据库查询、一个 RPC 调用,都可以建模为一个 Span。每个 Span 包含以下核心字段:
| 字段 | 说明 | 示例值 |
|---|---|---|
| SpanID | 当前 Span 的唯一标识符,64 位或 128 位整数,通常以十六进制字符串表示 | a1b2c3d4e5f67890 |
| TraceID | 整个 Trace 的全局唯一标识符,所有属于同一请求的 Span 共享此 ID | abc123def4567890123456789abcdef0 |
| ParentSpanID | 父 Span 的 SpanID,用于构建调用树;根 Span 此字段为空 | 9f8e7d6c5b4a3210 |
| OperationName | Span 所代表的操作名称,通常为 service.method 格式 |
user-service.GET /api/user |
| StartTime / EndTime | Span 的开始和结束时间戳,精度通常为微秒或纳秒 | 1710000000123456 |
| Tags | 键值对形式的元数据,用于描述 Span 的属性(如 HTTP 状态码、数据库语句) | http.status_code=200 |
| Logs | 时间戳 + 键值对,用于记录 Span 生命周期内发生的事件(如异常堆栈) | event=error, message=timeout |
| SpanKind | Span 的角色类型:client、server、producer、consumer、internal |
client |
db.statement、http.url、error 等标准标签,便于后续过滤和聚合分析。
4.2 TraceID:串联全链路的核心标识
TraceID 是一个全局唯一的 128 位标识符,用于将属于同一请求的所有 Span 关联起来。当请求穿越多个微服务时,TraceID 会通过 SpanContext 在进程间传递,确保所有 Span 归属于同一个 Trace。
TraceID 的生成与传播遵循以下原则:
- 入口生成:通常由入口网关或第一个服务生成 TraceID,后续服务从上游请求中提取并复用。
- 全局唯一性:使用 UUID、Snowflake 算法或随机数生成,确保在分布式环境下不冲突。
- 不可变性:一旦生成,在整个请求生命周期内保持不变,任何服务不得修改。
4.3 SpanContext:上下文传递的载体
SpanContext 是跨进程传递追踪信息的最小数据集合。它通常包含以下内容:
- TraceID:标识所属 Trace。
- SpanID:当前 Span 的 ID,用于在接收端创建子 Span 时设置 ParentSpanID。
- Baggage Items:用户自定义的键值对,随请求传播到所有下游服务(需谨慎使用,避免数据膨胀)。
- Trace Flags:采样标记、调试标记等控制信息。
SpanContext 的传递方式因协议而异:
| 协议 | 传递方式 | 示例 Header |
|---|---|---|
| HTTP | 通过请求头传递 | uber-trace-id: {trace-id}:{span-id}:{parent-span-id}:{flags} |
| gRPC | 通过 metadata 传递 | grpc-trace-bin: {binary-encoded-context} |
| 消息队列 | 嵌入消息头或消息体 | traceparent: 00-{trace-id}-{span-id}-01 (W3C 标准) |
4.4 父子关系:构建调用链拓扑
Span 之间的父子关系通过 ParentSpanID 字段建立,形成一棵有向无环的调用树。根 Span(Root Span)没有父 Span,代表整个请求的入口。子 Span 代表在父 Span 范围内执行的子操作。
父子关系的核心规则:
- 时间包含:子 Span 的 StartTime 必须大于等于父 Span 的 StartTime,EndTime 必须小于等于父 Span 的 EndTime。
- 嵌套深度:一个父 Span 可以有多个子 Span,但一个子 Span 只能有一个父 Span。
- 因果关系:子 Span 的完成依赖于父 Span 的上下文,但父 Span 不等待所有子 Span 完成(异步场景下父 Span 可能先结束)。
下图展示了典型的 Span 父子关系与调用链结构:
4.5 父子关系在慢查询定位中的应用
在 Jaeger 中,通过 Span 的父子关系可以快速构建出请求的完整调用拓扑。当定位慢查询时,SRE 通常遵循以下步骤:
- 找到根 Span:通过 TraceID 检索出整个 Trace,根 Span 的耗时即为请求的总耗时。
- 按耗时排序:在 Trace 视图中,按 Span 的持续时间降序排列,耗时最长的 Span 即为候选瓶颈。
- 检查父子关系:如果某个 Span 耗时接近根 Span,说明该 Span 所在的服务或操作是主要瓶颈;如果多个子 Span 耗时之和接近根 Span,说明并行操作较多,需逐一排查。
- 深入子 Span:点击耗时异常的 Span,查看其 Tags 和 Logs,获取数据库语句、HTTP URL、错误信息等细节。
db.statement,发现是一条未命中索引的 JOIN 查询。优化索引后,该 Span 耗时降至 120ms,整体接口响应时间从 3.2s 降至 0.5s。
4.6 Span 生命周期与关键时间点
理解 Span 的创建、传递和结束过程,有助于在代码中正确埋点。以下是一个典型的 Span 生命周期:
- 创建:服务收到请求后,从 SpanContext 中提取 TraceID 和 ParentSpanID,创建新的 Span。
- 注入:将 SpanContext 注入到下游请求的 Header 或 Metadata 中,确保上下文传递。
- 执行:记录 Tags 和 Logs,执行实际业务逻辑。
- 结束:记录 EndTime,将 Span 上报到 Jaeger Collector。
在代码中,Span 的创建和结束通常通过 SDK 的 startSpan 和 endSpan 方法控制。以下是一个 Java 示例:
// 创建 Span
Span span = tracer.buildSpan("user-service.GET /api/user")
.asChildOf(parentSpanContext)
.withTag("http.method", "GET")
.withTag("http.url", "/api/user/123")
.start();
try {
// 执行业务逻辑
User user = userService.findById(123);
span.setTag("user.id", user.getId());
} catch (Exception e) {
span.setTag("error", true);
span.log(Map.of("event", "error", "message", e.getMessage()));
throw e;
} finally {
span.finish(); // 结束 Span
}
finally 块中调用 span.finish(),确保即使发生异常,Span 也能正确结束并上报。否则,Jaeger 中会出现“未结束 Span”,导致耗时统计不准确。