核心概念
这一章把 Jaeger(以及它脚下的 OpenTelemetry 数据模型)的核心名词逐个钉死:一条 trace 由什么构成、span 的身份信息长什么样、上下文怎样跨进程不断链、服务名从哪来、采样和时钟这些"看起来是配置细节、实际上是概念问题"的东西。后面所有章节的配置项,都建立在这套语言之上。
写法上有一个小偏心:每个概念都尽量回到"存进存储时它是什么"(以 Elasticsearch 索引 schema 为实物参照)和"UI 里它显示成什么“两个视角。概念悬在语义层记不住,落到字段和界面上就踏实了。
还有一点"概念债"的提醒:分布式追踪这个领域名词太爱复用——tag、log、process、reference 这些词在不同年代指的东西不完全一样,OTel 又引入了 attributes、events、resource、links 一套新词。两套语言在 Jaeger v2 里共存(毕竟它是 OTel 内核、Jaeger 外壳),文档和 UI 用哪套并不完全统一。本章的任务就是给你一张不会过期的翻译表,让你读任何资料时都能把词落到正确的字段上。
阅读节奏建议:数据模型三节(span 树/SpanContext/ES 物理视角)是地基,必读;传播、Resource、采样三节是"上线前必须过一遍"的实战概念;其余(kinds、时钟、依赖图、SPM)可以先扫结论、用到时回来细读。
Trace 是一棵 span 树
Span 是一次"有始有终的工作单元”:处理一个 HTTP 请求、执行一条 SQL、发一条 MQ 消息。每个 span 携带:操作名(operationName)、起止时间与时长、父子关系、若干键值标签,以及可选的事件列表。
Trace 就是一组同一个 traceID 串起来的 span,靠父子关系构成一棵树。根 span 是链路的入口(通常是"收到用户请求"那一刻),叶子常常是数据库调用或外部 HTTP 调用:
traceID = 4bf92f3577b34da6a3ce929d0e0e4736
spanID 关系 操作名 时长
00f067aa.. root GET /dispatch 210ms
1a2b3c4d.. child_of POST /route 150ms
9f8e7d6c.. child_of SELECT geo 120ms <- MySQL 客户端 span
5e4d3c2b.. child_of GET /pricing 15ms父子关系有两种表达:物理父子靠 parentSpanID 字段指回调用方 span(ES 文档里就是这么存的);更宽泛的**引用(references)**用 refType 描述非父子关联,历史上常见 child_of 与 follows_from(后者表示"因果相关但不是等待关系",比如消费者接着生产者往下处理,follows_from 语义更贴切;OTSDB 时代的产物,OTel 世界里用得少了)。
一次埋点动作的微观过程值得先建立直觉:SDK 在"开始"时生成 spanID、记下起始时间,并把 span 放进 context.Context;在"结束"时补上时长和状态;一批 span 攒够后由 Exporter 通过 OTLP 发给 Jaeger。span 的 ID 是 SDK 生成的,不是 Jaeger 生成的——这决定了 Jaeger 看到的一切只是"上报上来的事实",断没断链在应用侧就已注定。
两个和"树"直接相关的工程纪律,新人最容易忽视:
- operationName 要低基数。操作名是 UI 搜索和 SPM 聚合的维度之一:写成
GET /order/{id}(路由模板)是对的,写成GET /order/1234567890(带真实 ID 的 URL)会把 UI 的服务下拉和过滤列表刷爆,存储侧索引也遭殃。OTel 语义约定里的http.route、db 语句的参数化形式,都是在替你兜这个底。 - 树允许"森林"。上游断链时,下游 SDK 会发现自己拿不到 parent,于是新开一条 trace 当根。所以排查时看到某服务频繁出现"孤立的根 span",第一含义不是"它被独立调用",而是"它前面的传播断了"——这是最典型的链路断点信号(详见下文传播一节)。
另外明确一点 ID 的作用域:spanID 只要求在同一 trace 内唯一(64-bit 随机数,同 trace 撞车概率可忽略),跨 trace 重复 spanID 完全正常。写查询或外部关联表时,主键永远用 (traceID, spanID) 组合,单拿 spanID 当全局键是事故预定。
最后一个树的本体论问题值得想清楚:trace 树是"查询时拼出来的",不是"写入时存好的"。Jaeger 收到 span 就是平铺着按 traceID 存(ES 里一个 span 一条文档),你搜索一条 trace 时,query 层把该 traceID 的全部 span 捞回来、按 parentSpanID 现场组装成树。由此推出两个日常现象:长链路的 span 分多批到达,你先看到的是一棵"缺枝的树",刷新后才完整——这不是丢数据;而某个进程崩溃、Exporter 里攒着的 span 永远没发出去,那根枝条就真的不会长出来——这才是真丢数据。两者的区分办法只有一个:确认该 span 理论上该由哪个进程产生,再去查那个进程自爆没自爆(它自己的 span 都没到,多半是 Exporter 里的遗物没了)。
SpanContext:span 的身份证
每个 span 有一个轻量可序列化的身份包,叫 SpanContext,核心三件:
- traceID:整棵树的身份证,16 字节(128-bit)。
- spanID:树上每个节点的身份证,8 字节(64-bit)。
- flags:位标志,最关键的是
sampled位(这棵 trace 采没采)和 debug 位。W3Ctraceparent尾部的01就是 sampled=1。
另有一个 traceState 字段留给厂商扩展互相塞值(ES 文档模型里它有独立字段,可见是一等公民)。它不参与树形结构,纯透传——你的组件不该依赖它的内部格式做任何决策,那是厂商私有协议的地盘。
flags 是一个字节位图,读 traceparent 时最常遇到三种取值:00(未采样——数据不导出,但 ID 仍要传播,方便对端日志关联)、01(已采样)、02(debug——不仅采样,还要求链路上所有组件都保留并"不要再降级",故障应急时用它强行保一条全量链路)。网关/代理判断"这条要不要进采样器"只看这一个字节,不必解析 body——这就是 SpanContext 被单独抽成轻量结构的原因。
这三件恰好就是 traceparent 头的内容:版本-traceID-parentID-flags——SpanContext 是内存里的结构,traceparent 是它在线上的序列化形态,学会互相反推,排链路的效率会翻倍。
还有一个概念要提前埋下:remote parent。HTTP 入口的 server span,其父级其实在对端进程里,SDK 拿到的 parent spanID 在本地并不存在对应 span——这完全正常,“引用了不存在的本地 spanID"不是断链,UI 会把 trace 的根画在那个真实存在的祖先上。理解这一点,就不会在自建网关时误以为"父 ID 校验失败"是 bug。
物理视角:一条 span 在 ES 里长什么样
Jaeger 的 ES 索引模板 jaeger-span.json 是现成的"span 数据结构教科书”。字段与含义:
| 字段 | 类型 | 含义 |
|---|---|---|
traceID |
keyword | 16 字节的 hex 表示(宽度话题见下文) |
spanID |
keyword | 8 字节 hex |
parentSpanID |
keyword | 父 span;根 span 此字段为空 |
operationName |
keyword | 操作名 |
serviceName |
keyword | 服务名——来自 Resource 的 service.name(重点在后文) |
startTime |
long | 起始时间,微秒 |
startTimeMillis |
epoch_millis | 同一时间的毫秒冗余列,专为时间窗过滤/排序 |
duration |
long | 时长,微秒 |
flags |
long | 位标志(sampled 等) |
tags |
object 数组 | {key, type, value} 三元组,标签落在这里 |
logs |
nested | span 事件:每项含 timestamp 与 fields[key, value, type] |
references |
object 数组 | {spanID, refType} 的引用列表 |
process |
object | 进程/资源侧信息,含 tag.*(服务名、instance、SDK 版本等) |
traceState |
keyword | W3C tracestate 原样保留 |
一个容易忽略的实现细节:tags 主字段是 {key, type, value} 数组,而查询要按 http.status_code=200 这种具体键过滤,靠的是 dynamic_templates 把任意 tag.*(以及 process.tag.*、scopeTag.*)映射成 keyword 并带 ignore_above: 256。这带来两个直接后果:
注意:
ignore_above: 256意味着超过 256 字符的 tag 值不会被索引进tag.xxx字段——UI 里按长文本标签过滤会查不到(数据本身还在原始 tags 数组里)。埋点时把长内容放 events/logs 或干脆放日志(用 traceID 关联),别塞进标签值。
startTime 用微秒 long 而非 date 类型,是为了精确比较与算术;startTimeMillis 的存在则说明"时间窗扫描"和"精确时长计算"是两种访问模式,schema 为两者各备一列。这类取舍本身就值得学到自己的存储设计里。
把上面字段表拼成一条真实形状的文档(按 schema 字段构造的示意,展示一次"MySQL 查询"的客户端 span):
{
"traceID": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanID": "9f8e7d6c1a2b3c4d",
"parentSpanID": "1a2b3c4d5e6f7a8b",
"operationName": "SELECT geo",
"serviceName": "trip-svc",
"startTime": 1760000000123456,
"startTimeMillis": 1760000000123,
"duration": 120450,
"flags": 1,
"tags": [
{ "key": "span.kind", "type": "string", "value": "client" },
{ "key": "db.system", "type": "string", "value": "mysql" },
{ "key": "db.statement", "type": "string", "value": "SELECT * FROM geo WHERE city=?" }
],
"logs": [
{
"timestamp": 1760000000130000,
"fields": [
{ "key": "level", "value": "error" },
{ "key": "error", "value": "dial tcp: i/o timeout" }
]
}
],
"references": [{ "spanID": "1a2b3c4d5e6f7a8b", "refType": "CHILD_OF" }],
"process": { "serviceName": "trip-svc", "tags": [{ "key": "otel.collector", "value": "..." }] },
"traceState": ""
}读这条文档可以建立三个体感:duration 用微秒(120450 即约 120ms);logs 是数组套数组(nested 类型保证每个 event 的字段不会被 ES 拍平后交叉匹配,“时间戳与字段对不上"的经典 nested 陷阱就是这么防的);而 UI 按 db.system 过滤时命中的其实是 dynamic 生成的 tag.db.system keyword 列,不是上面那个数组。
tags 与 attributes、logs 与 events:名词对齐
OTel 世界和 Jaeger schema 的说法不同,但东西相同:
- OTel 的 span attributes 落到 Jaeger ES 模型就是 tags(数组主存 + dynamic 的
tag.*索引列)。UI 上按标签过滤、openTracing.Attributes之类的显示名,底层都是这组键值对。 - OTel 的 span events 落进 ES 的 logs 字段:每个 event 有
timestamp与一组属性(fields[key,value,type])。最典型的 event 是exception:type/message/stacktrace 三个属性记录一次被捕获的异常。想在 trace 里"看到报错细节”,靠的就是这个,而不是把错误文本塞进 attribute。 - OTel 的 instrumentation scope(原来叫 library name/version)体现在
scopeTag.*这类字段上——记录"这段埋点来自哪个 instrumentation 库"。
event 与 link 的分工要分清:event 是"我这个 span 内部发生的事"(异常、进度点),时间轴上属于自己;link 是"我和另一棵 trace(或本 trace 里不相干的 span)有因果/参考关系",典型场景是批处理消费者引用产生这批消息的 N 个上游 trace、分页拉取时把多次请求关联起来。link 不进父子树,UI 里表现为引用跳转。ES 模型里没有独立的 link 一等字段(references 承担相近角色),需要深度依赖 links 的分析要考虑这个落库现实。
attribute 还是 event?给一条可操作的分界线:描述"这个 span 的状态/身份"的用 attribute(http.status_code、db.system、retry_count),要能被过滤聚合;描述"某个时刻发生了什么"的用 event(三次重试各自的时间点、异常抛出、进度日志),带自己的时间戳、不承诺可过滤。把一堆带时间的事塞进单个 attribute 字符串,是"UI 里看得见却查不动"的常见根因。
OTel 记录的异常是标准化的 event:名称 exception,属性 exception.type / exception.message / exception.stacktrace。在 Jaeger UI 里点开 span 的 Logs 区就能看到;它同时是 SPM 之外的排障线索来源——栈太长时记得 ES 那条 256 字符索引上限只对 tag.* 生效,logs 字段本身走 nested 存储,不受这个过滤限制,这也是"长内容进 event 不进 attribute"的又一个理由。
三套历史名字对照如下,读旧资料和读 UI 时来回翻译用:
| OTel 规范叫法 | Jaeger 数据模型/ES 字段 | UI 里你看到的 |
|---|---|---|
| Span Attributes | tags(+ dynamic tag.*) |
Tags / 过滤标签 |
| Span Events | logs(nested) | 时间线上的展开条目 |
Links / refType=follows_from |
references | “Related” 引用跳转 |
| Resource | process(process.tag.*) |
服务名、实例信息的来源 |
| Instrumentation Scope | scopeTag.* / scopeName |
埋点库归属 |
| Trace State | traceState | 通常不直接展示,透传用 |
术语上,OTel 用 Resource 取代了 Jaeger v1 的 “process” 概念(语义基本等价,Resource 更宽)、用 Instrumentation Scope 取代了 library name/version 的说法——这些历史包袱在 Jaeger 的 ES schema(process、scopeTag)里仍清晰可见,属正常。
traceID 的宽度与各种表示形态
同一个 traceID 在不同环节有四种长相,官方 FAQ 原文可对照(此段已核实):
- 数据模型层:16 字节序列,这是"真身"。
- 后端 Go 域模型:一对无符号 64 位整数(big-endian),官方解释是为了效率——Go 里字节切片要多一次内存分配。
- Protobuf 层:bytes 序列;一旦切到 JSON 编码(比如 collector-Kafka-ingester 管道选了 JSON),你在 Kafka topic 里看到的是 base64,形如
fpDA7KInhOx+kMDsoieE7A==。排障时别以为 ID 被改坏了。 - UI/hex 字符串:32 个 hex 字符(128-bit,OTel 生成器的主流);也可能是 16 个字符——那是legacy 64-bit 模式生成的短 ID。
当前主线是 OTel SDK 的 128-bit 随机 traceID。要不要"继续接受 64-bit"这个问题,官方 2.22 文档没有给出"禁用"承诺——FAQ 仍把 16 字符短 ID 描述为合法的历史表示,所以稳妥的心智是:新系统按 128-bit 理解与对齐(W3C 标准也是 32 hex 字符),遇到 16 字符老 ID 知道它是什么、能查即可。
这个"一个 ID 四种长相"的现实,落到运维上有三个具体提醒:
- 大小写与前后零:hex 比较一律按小写处理最安全;有些语言打印 ID 会吞掉前导零,导致肉眼"对不上"。跨系统贴 traceID 时先统一成 32 位小写 hex。
- 别把 ID 当数字:128-bit 超出常见整型范围,任何中间件想用 uint64 存 traceID 都会截断——截半是"日志能搜到、UI 查不到"的经典成因。
- 混用的真实麻烦在第三方对齐:部分网关/LB 访问日志只记低 64 位,或某些老库只传播一半,这属于集成层问题,排查路径见
08-可观测性联动.md。
上下文传播:trace 的生命线
链路能成树,全靠上下文跨进程传递。分两层记:
进程内:Go 里就是 context.Context 一路向下传。SDK 把当前 span 塞进 ctx,派生 child span 时从 ctx 取 parent。断链第一大原因就在这:函数签名没接 ctx、或起 goroutine 时随手 context.Background()。
// 断链写法:handler 收到了携带 traceparent 的请求,
// 但出站调用用了裸 context,下游收到的是新 trace
func (c *Client) Call(ctx context.Context) {
req, _ := http.NewRequestWithContext(context.Background(), "GET", url, nil)
c.http.Do(req)
}
// 正确写法:ctx 一路贯穿
func (c *Client) Call(ctx context.Context) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
c.http.Do(req) // propagator 在这里把 traceparent 注入 header
}跨进程:OTel 的 Propagator 负责在"进程内的 ctx"和"线上的 header"之间搬运。
- HTTP:W3C traceparent 头,格式
版本-traceID(32hex)-parentSpanID(16hex)-flags(2hex),例如traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01;tracestate 留给厂商扩展透传;baggage 单独放业务键值(可跨服务携带小标签,比如 tenant id)。 - gRPC:走 metadata,内容仍是同一套 W3C 文本格式(key 小写)。
- 消息队列:header/carrier 注入消息属性(Go 里
Inject(ctx, carrier)/Extract(carrier),各 MQ 客户端都套这个模式),producer 的 span 与 consumer 的 span 靠它挂到同一 trace。MQ 场景建议 consumer 侧用 link/follows_from语义而不是硬父子——队列是异步的,强行 child_of 会让时间轴看起来"父 span 等了一个小时"。
格式主线就记住 W3C traceparent + OTLP:Jaeger v2 的一切围绕这套运转;旧世界的格式(jaeger 自家 uber-trace-id 头、Zipkin b3 头)的兼容与收敛,交给 OTel Collector 生态的 propagator 去做,新服务不要选型它们。
baggage 要单独说,因为最容易用过头。它能跨服务携带任意键值(baggage: tenant.id=t123,session=s9),看起来是"免费的上下文传递",实际上每一跳都在 HTTP header 上全文往返:塞大对象、塞用户画像、塞一串 ID 列表,等于给每个请求加 body。生产纪律:baggage 只放短小、全链路都要读的标签(租户 ID、灰度标记、采样优先级),并且预设"链路中间任何一跳都可能改它"的安全预期——它不加密不签名,敏感信息绝不进 baggage。
还有一类隐性断链来自提取时机:propagator 必须在"创建 server span 之前"从入站 header 里 Extract,有些框架中间件顺序搭反(先建了 span 再想起没读 header),结果全服务的 trace 都是新根、和真实入口无关。写网关/公共中间件时把这当作 review 检查项。
断链排查口诀:UI 里如果一个服务孤零零地总是当"根 span",多半是它的入口没 Extract(header 没进来)、或者某个中间件(自研网关、SDK 封装的 client)忘了透传——沿断点往上游查一层,每层只看两件事:ctx 传没传、propagator 执行没执行。
传播与采样是两件事:不采的链路也要传 ID
新手容易把"没采样"等同于"不用管 trace",这是错的。传播和采样是两条正交的线:traceID 必须无条件传播,flags.sampled 才决定数据留不留。
不采样也传播 ID 的理由有三个,每一个都对应生产上的真实用法:
- 日志关联。应用日志里打的 traceID,不依赖这条 trace 进没进 Jaeger。采样 0.1% 意味着绝大多数请求在 UI 查不到,但排障时你可以从某条业务日志抄出 traceID,在日志系统里把同一次请求的所有服务日志捞出来——这条工作流的门票就是"ID 全量传播"。
- 上游已决策,下游要尊重。一个入口网关判断"这条是压测流量不采",下游若各自重新采样,就会抽出半截 trace——比全不采更难读。parent-based 的存在就是为了把决策一次性钉进 flags。
- 事后升级的通道。debug 旗标(
02)允许运维在链路上任一跳"强行转全量",前提是每跳都在正确解析和透传 traceparent——如果中间件把 header 吃了,这条路就断了。
所以 review 埋点代码时把这两问当底线:任何出站调用是否透传了 header(哪怕本条不采)?任何日志是否打了 traceID? 两问全过,采样率才敢往低了调。
与 OpenTelemetry 的关系:为什么 Jaeger 不再坚持自家 SDK
v1 时代 Jaeger 有自己的原生客户端库,团队自己维护协议和埋点。v2 把接入完全押注在 OTLP 上,原因可以讲透三层:
- 生态成本:每种语言维护一套官方 SDK 是天文数字的投入;OTel 用一套跨语言规范(Trace/Metrics/Logs API + 各语言 SDK)把这件事做成了公共品。官方 getting-started 也直说 “We recommend using the OpenTelemetry instrumentation and SDKs”。
- 协议表达力:OTLP 的 span 数据模型(events 带属性、links、resource 一等公民、tracestate)覆盖并超过了 jaeger.thrift;继续维护两套只有历史包袱。
- 架构自洽:Jaeger v2 本身就是 OTel Collector 定制发行版——自家入口与上游 receiver 同源,不存在"第二套协议栈要维护"。
对应到协议状态(官方 APIs 页核实):OTLP 标注 Stable;legacy Protobuf(14250)、legacy Thrift HTTP(14268)、legacy Thrift UDP(6831/6832)全部 Stable, Deprecated;Zipkin 格式(9411)仍是 Stable——别把 Zipkin 入口当 deprecated,它是明确保留的兼容面。查询侧同理:推荐 jaeger.api_v3.QueryService(gRPC 16685),它是 OTLP-based 的模型;v1 时代的 api_v2 还活着但不值得新投入。
顺带把历史讲完:v1 时代 Jaeger 为各语言维护的官方原生客户端(jaeger-client 系列)早已停止演进、仓库归档,官方文档现在只指向 OTel SDK。“Stable, Deprecated” 这组状态词的含义要读准——协议还在、端口还在、不会突然 404,但不会再为它修新问题、写新文档。存量 Thrift 客户端可以继续跑,但每个季度都该问一句:迁移排期了吗?(迁移姿势见 00-概述与安装.md 的映射表与 10-常见陷阱与最佳实践.md。)
Span Kinds:读延迟视图前先对齐语义
OTel 给 span 定了五种 kind,决定了"这个 span 代表谁在计时"。先把对照表立起来:
| kind | 谁在计时 | 典型来源 |
|---|---|---|
| server | 被调用方的处理耗时 | HTTP handler、RPC 服务端 |
| client | 调用方等待响应的耗时 | HTTP client 封装、DB 驱动 |
| producer | 消息发出(含入队)耗时 | MQ 发送 SDK |
| consumer | 消息取出并处理耗时 | MQ 监听器 |
| internal | 进程内一段逻辑 | 手动埋的业务阶段 |
- server:我作为被调用方处理请求的时间(入口侧)。
- client:我作为调用方发出请求、等待对端响应的时间(出口侧)。
- producer / consumer:MQ 发送侧/接收处理侧。
- internal:进程内的逻辑段,不属于以上。
读瀑布图时这几组差值最有信息量:
- client 时长 − server 时长 ≈ 网络往返 + 排队(连接池等待、服务端 accept 队列)。生产环境排查"网络慢还是服务慢"就看这个缺口;两者接近说明瓶颈真的在代码里。
- consumer 开始时间 − producer 结束时间 ≈ 消息在队列里躺了多久。这是 MQ 积压最直观的 trace 视角。
- 同一个服务,通常 server span 包住它的 internal/下游 client spans;如果发现 server 时长明显小于内部 span 之和,多半是埋点父子关系搭错了(parent 没传对)。
注意:client 与 server 是两个独立 span、各自计时,不是重复上报。看到"一个 HTTP 调用出现两个 span、时长还不一样"不要以为是 bug——那正是 kind 的设计意图。
MQ 链路的标准形态值得单独画一遍,因为收发两侧的 gap 是"积压"最诚实的显示器:
producer span |█ 3ms| (发消息本身)
└─ 入队
... 队列里躺了 8s ...
consumer span |██ 45ms| (消费处理)producer 结束与 consumer 开始之间的空档就是队列等待。若两侧挂在同一 trace 上(靠 MQ header 传播),这段空档在 UI 瀑布里直接可见;若这里"父子时间看起来荒谬",先分清是 kind/引用语义没用好(异步建议 link 或 follows_from,见上文),还是消费端真的落后了。
HTTP 内部还有个小陷阱:入口框架(如 Go 的 net/http)的 server span 只覆盖 handler 执行期,连接建立、TLS 握手发生在 handler 之前,这部分时间只会出现在 client span 与 server span 的差值里。做"端到端延迟 vs 服务端处理时间"的归因时,这就是那个差值的去向之一。
Resource 与 service.name:UI 按服务聚合的根基
OTel 把"描述产生数据的实体"的键值对单独建模为 Resource:service.name、service.version、deployment.environment、k8s.pod.name……它们附着在整个进程上,不逐个 span 写。Jaeger 的 UI 搜索、服务下拉、依赖图节点、SPM 维度,全部按 Resource 的 service.name 聚合——落到存储就是 ES 文档的 serviceName 列与 process.tag.*。
由此推出那个最常见的新手现场:“UI 里找不到我的服务”。按顺序检查:
- SDK 有没有显式设置
service.name?OTel SDK 缺省值类似unknown_service:<可执行文件名>,你的 trace 其实进了这个"临时户口",在 UI 下拉里它就是这么丑的一个名字——看到了就去改埋点,不是数据丢了。 - 数据真的到了吗(tracegen 同管道验证法,见
00-概述与安装.md)。 - 采样把这条 trace 丢了吗(下一节)。
反向问题同样常见:同一个服务在 UI 里裂成两个。service.name 是按精确字符串聚合的,大小写差异(order-svc / Order-Svc)、环境后缀写进名字(order-svc-dev 混进 prod 视图)、或者蓝绿部署时两边模板笔误,都会造出"双胞胎服务"。治理办法是命名纪律而非工具:服务名与注册中心/CMDB 的规范名一一对应,环境放 deployment.environment,版本放 service.version,别把它们揉进名字里。
顺手划清一条线:进程级、恒定的属性进 Resource;请求级、会变的事(http.status_code、db.statement、业务单号)进 span attributes。混放会同时污染 UI 聚合和资源浪费存储。
资源属性应该"整个进程生命周期不变",典型集合:
// 概念示意:一个进程一份 resource,随 OTLP 请求整体上报
attrs := attribute.NewSet(
semconv.ServiceNameKey.String("order-svc"), // UI 聚合的根基,必填
semconv.ServiceVersionKey.String("v1.8.2"),
semconv.DeploymentEnvironmentName("prod-cn-1"),
// K8s 环境下由 Detector 自动补充:pod 名、namespace、节点等
)两个有体感的推论:其一,resource 在 OTLP 里是按批随 span 上报的"进程名片",SDK 启动时设好就不再动,运行中修改 service.name 等于给自己新建了一个服务;其二,service.version、deployment.environment 这些字段是同一份 trace 数据里自带的切片维度——发布新版本后想比较新旧版本的延迟分布,数据层面天然支持,缺的只是查询侧的用法(07-查询UI与API.md 与 08-可观测性联动.md 各展开一部分)。Go 侧具体怎么写 resource 与 provider,见 05-Go客户端埋点.md。
采样概念入门:trace 为什么"少了一条"
分布式追踪的代价是:全量 span 的体量可能是业务日志的几十倍。所以几乎没人全采,采样是 trace 系统的第一个"看不见的手"。
两大流派,一句话版:**head-based(头部采样)**在链路入口决定整棵 trace 采不采,便宜、一致(要么整条都要要么整条都无),但决策时看不到结局——你不知道这条 trace 会不会报错、会不会慢;**tail-based(尾部采样)**把数据先接下来,等 trace 结束按条件(慢、错、特定路径)保留,精准但要求全量数据流经一个能缓冲的地方,成本高。Jaeger v2 两条路都支持:SDK 侧做 head 决策,Collector 管线做 tail 决策(tail sampling processor 在 OTel Collector 生态,见 02-v2架构与组件.md 与 06-采样.md)。
v2 里两个"默认值坑"要先打预防针,都是"我的 trace 呢"的头号来源:
- OTel SDK 默认 sampler 是 ParentBased(AlwaysOn):没人显式配置时,本进程根 span 全采——开发环境没问题,生产会直接把 SDK 的全部数据外发。
- Jaeger 的 remote_sampling 默认策略是 probabilistic 0.001(千分之一):当 SDK 接了 Jaeger 的采样策略下发(5778/5779),默认只保留 0.1% 的根 trace。
两者叠加的真实行为由 parent-based 语义支配:span 的 sampled 旗标随 traceparent 的 flags 传播,下游只跟随、不自作主张。所以"上游网关采了,下游服务全链都会带上";反之"上游没采,下游配了 100% 也大概率白配"(旗标已定死 0)。
算一笔账建立量级感:入口 1000 QPS、平均 8 个 span,全量是每秒 8000 个 span 的写入与存储;按 0.1% 概率采样,约 8 span/秒——存储成本差出三个数量级。这就是"默认千分之一"的立场:先活下来,再谈精准。
先给一个自查手法:怀疑"采样把我数据吃了"时,用 SPM 的 calls_total 增速(或网关访问日志 QPS)除以 UI 里该服务近十分钟可搜到的 trace 数量,商值就是你的实际保留率——先量化、再调整,不要凭"感觉少了"改配置。
但概率采样有个结构性盲区:稀有事件不稀有于你,却稀有于概率。万分之一才发生一次的下游依赖抖动,在 0.1% 的采样里几乎必然大规模漏采——你看到的全是"健康样本"。对冲手段两条:关键路径用 debug 旗标(flags=02)或按业务键的确定性采样强行保链路;以及 tail-based 按"有错误就全留"回捞(要求错误发生时全量数据确实流经了某个可缓冲的地方,回到成本问题)。这两条路的完整工程形态都在 06-采样.md;v2 的 adaptive sampling(按各服务实际流量动态算速率,v2 已转正、不再是实验标注)也归那一章讲。
一个统一视角:概念是怎么咬合的
最后把本章零件串成一条生产流水线,后面章节都是在某一段上做深:
SDK 生成: SpanContext(traceID/spanID/flags) --传播--> traceparent/baggage
↓
决策: sampler(head) 决定 flags.sampled ——决定数据量
↓
导出: OTLP 4317/4318 → Jaeger(Collector 角色) → 存储(memory/badger/ES/...)
↓
物化: spanmetrics → RED 指标; 服务级父子 → 依赖图
↓
消费: UI(16686)/gRPC(16685) 查询 ——时钟偏移在这里显形读图的正确姿势是把它当"故障定位地图":trace 缺失在"决策"行找(采样)、断链在"传播"行找(ctx/header)、服务名乱在"生成"行找(resource)、时间乱序在"消费"行找(时钟)。反过来,“物化"这一行也在提醒 capacity 思维:依赖图和 SPM 都是从同一份原始 span 上长出来的二级资产,原始数据被采样砍得太狠时,这两张视图会跟着一起失真——采样决策是全链路的,不是存储一个部门的事。
时钟偏移:跨机拼接的天然误差
trace 树是把不同机器的本地时钟读数拼到一起画出来的。A 机调 B 机,B 机时钟比 A 快 50ms,UI 上就出现"子 span 比父 span 早开始"甚至跨服务时间轴倒挂。这不是 Jaeger 的 bug,是物理事实:它只能照实绘制各机器上报的时钟。
处理顺序也应该是:
- 先修时钟:全集群统一 NTP/chrony,这是可观测性地基(容器场景注意宿主机同步、虚拟机注意 hypervisor 时钟)。修好时钟,90% 的"乱序"消失。顺带一提,时区不参与这个问题——span 时间戳是 epoch 纳秒/微秒,与时区无关;乱序只由"两台钟不一样"引起。
- 再考虑展示层补偿:v2 提供
jaeger_query.max_clock_skew_adjust配置项,默认 0s 即关闭——官方默认态度是"不猜、照实画”。打开后查询端会尝试调整偏移,适合确实无法统一时钟的存量环境,但要理解这是视觉修补不是数据修复。
还有一个容易忽略的下游效应:时钟偏差会污染 duration 的感知。瀑布图里各 span 的时长是自己机器上单调测的、不受偏移影响,但"两个 span 之间的空隙"(网络时间、排队时间的推断)全靠跨机比较——偏移大的环境里,这类推断整体不可信,别拿它做容量规划输入。
一个判断技巧:如果整条链路的 span 彼此都错位同样的量(同一台机器),多半是该机时钟问题;如果只有跨某个服务的边错位,才是那对机器的相对偏移。09-性能与运维.md 有排障补充。
给一个数字例子把失真算明白:A 机调 B 机,真实情况是 A 的 client span 覆盖 [t0, t0+100ms],B 的 server span 覆盖 [t0+5ms, t0+95ms](10ms 网络往返、90ms 处理)。B 机时钟快 60ms,则 B 上报的 server span 起始变成 t0+65ms——瀑布图里"子请求比父请求晚出发 65ms"还说得过去;反过来若 B 慢 60ms,server span 起始成了 t0-55ms,视觉上"子 span 比父 span 还早开始 55ms",整个时间轴像被压弯。同一份数据,符号相反、症状完全不同,所以看乱序先问"偏了多少、朝哪边",往往能直接反推是哪台机器的钟快或慢。
依赖图:trace 的副产品
调用拓扑不是手动维护的清单,而是 trace 数据的聚合结果。聚合规则简单到可以口述:对每个 span,取它自己的 serviceName 作为 callee;顺着 parentSpanID 找到父 span 的 serviceName 作为 caller;两个名字不同,就是一条跨服务调用边,计数加一(父不在本批数据里时,退而用 references 里的远端父信息兜底)。UI 的 Dependencies 页背后是 /api/dependencies(参数 endTs/lookback,返回 (caller, callee, count) 边列表;官方标注 Internal,程序化对接更推荐 gRPC QueryService 里的对应方法)。
这个"简单规则"也解释了为什么离线作业必不可少:逐条查询时现算意味着每查一次全扫一遍原始 span,spark/Flink 作业把"按时间窗聚合"摊销成持续的小任务,查询侧只读结果表。
关键的生产提醒(官方 deployment 文档明言):all-in-one 的内存图能实时算,但真实部署里依赖图数据需要离线任务写入存储——spark-dependencies 批作业或 Flink 流作业持续把 span 中的服务级父子关系聚合后存回去。UI 依赖页空白,先查这个任务有没有跑,而不是查 Jaeger 本身。作业部署方式见 03-部署与Kubernetes.md,存储侧的依赖写入见 04-存储后端.md。
为什么值得为此跑批作业:这条 (caller, callee, count) 边列表是调用量的实测值,比人工维护的架构文档诚实得多——下线了一个服务,图上那条边两周后自然消失;新人写的"不该有的依赖"第一次灰度就现形。count 本身还能当告警信号:某条边的调用量突增,往往是循环调用或重试风暴的早期症状,这比等 P99 恶化早知道一步。
SPM:从 trace 里长出 RED 指标
Jaeger UI 的 Service Performance Monitoring 页(每服务的请求率/错误率/时长分位)不是另一个系统——它是 trace 数据的实时衍生:管线里挂 OTel 生态的 spanmetrics connector,把每个 span 折算成两个指标:
- counter
calls_total:调用次数;错误判定条件是 span status 等于STATUS_CODE_ERROR。注意语义:OTel 里"未显式设置状态"是STATUS_CODE_ERROR之外的 UNSET/OK,不会被计成错误——代码捕获异常但不设 error status,SPM 错误率就是平的。这是"明明在报错错误率却是 0"的标准答案。 - histogram
duration:时延分布,默认 17 个桶。
这些指标进 Prometheus 后即可用 PromQL 拼 RED 视图(Jaeger UI 内置展示)。形状大致如下(示意,标签集以实际部署为准):
# 每秒请求数,按服务
sum by (service_name) (rate(calls_total[5m]))
# 错误率
sum by (service_name) (rate(calls_total{status_code="STATUS_CODE_ERROR"}[5m]))
/ sum by (service_name) (rate(calls_total[5m]))
# P95 时延(histogram 的 17 桶决定了分位的精度上限)
histogram_quantile(0.95, sum by (le, service_name) (rate(duration_bucket[5m])))它体现了一个值得记住的思想:trace 是比 metrics 更细的原始事实,metrics 可以是它的物化视图——但反过来 trace 不能替代 metrics(采样、成本、聚合粒度都不合适;采样掉的 span 不会进 calls_total 的"全量"里,SPM 的口径取决于它在管线里的挂载位置,见 06-采样.md 的口径讨论)。三者怎么分工互跳,见 08-可观测性联动.md;SPM 的部署开关与指标暴露见 04-存储后端.md 与 09-性能与运维.md。
核心概念速查
| 概念 | 一句话 | v2 里的对应物 |
|---|---|---|
| Trace | 同一 traceID 的 span 树 | ES jaeger-span 按 traceID 聚合 |
| SpanContext | traceID + spanID + flags | traceparent 头的内容 |
| attributes/tags | 请求级键值 | ES tags 数组 + tag.*(ignore_above 256) |
| events | span 内时间点,如 exception | ES logs(nested) |
| links / references | 跨 trace/非父子关联 | refType(follows_from 等),UI 跳转 |
| Resource | 进程级描述,含 service.name | process.tag.*;UI 聚合维度 |
| 传播格式 | 跨进程搬 SpanContext | traceparent/tracestate/baggage(W3C) |
| flags | sampled/debug 位 | traceparent 末段 00/01/02 |
| operationName | 低基数的操作名 | UI 搜索维度之一,别放 ID |
| head sampling | 入口决定整条采不采 | SDK ParentBased + remote_sampling(默认 0.001) |
| tail sampling | trace 结束后按条件留 | Collector 管线 processor |
| 依赖图 | trace 的服务级边聚合 | Dependencies 页;生产需 spark/Flink 写入 |
| SPM | trace 物化出的 RED 指标 | spanmetrics:calls_total + duration |
| 时钟偏移 | 跨机时钟差导致的乱序 | NTP 优先;max_clock_skew_adjust 默认关 |
| 状态码 | span 结局 | STATUS_CODE_ERROR 才进 SPM 错误率 |
| 断链 | ctx/header 丢一环 | 症状:服务总在当"孤立的根" |
这一表就是排障入口清单:UI 上看到任何怪现象,先在这里找到对应的概念行,再去对应章节做深。
最后给一个总纲式的实战建议:埋点质量决定整个体系的上限——ctx 贯穿、header 透传、service.name 规范、异常设 status,这四件事做好了,UI 里的图自然可信;做不好,后面所有部署与调优都是在流沙上盖楼。
动手埋点前,用三个自问收束这一章:
- 我的每个出站调用,有没有把 ctx 递进去?(决定链路连不连)
- 我的服务在 UI 下拉里叫什么,是我定的还是
unknown_service?(决定数据归谁) - 我的采样默认值(SDK 全采、Jaeger 千分之一)是显式决策还是凑巧?(决定你看得见多少)
三问都有明确答案后,直接进 05-Go客户端埋点.md 把概念落成代码;部署与存储的取舍在 02 到 04 章等你。