- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 反馈说慢,却不知道是哪个服务
- 追踪、跨度与上下文传播的结构
- 日志和指标回答不了的问题
- 埋点 — 自动埋点的边界与必须补手动跨度的位置
- 采样 — 头部采样的陷阱与尾部采样的价值
- 跨度属性与异步边界 — 基数、队列、链接
- 实战 — 在追踪界面上到底要找什么
- 结语 — 追踪是回答「在哪里」的工具
引言 — 反馈说慢,却不知道是哪个服务
有人反馈结算页面要花 2 秒。打开仪表盘一看,网关的 p99 已经涨到 1.8 秒。它后面挂着十个服务,于是逐个打开各自的 p99 看。全都正常。
这种局面很常见。而且在这种局面下,指标在原理上给不出答案。「每个服务单独看都很快」和「依次穿过它们的那一次请求很慢」这两个事实并不矛盾。你需要的不是分服务的统计,而是单次请求的完整路径。
本文要讲的就是如何构建并读懂这条路径。
追踪、跨度与上下文传播的结构
追踪是共享同一个追踪 ID 的一棵跨度树。每个跨度都有名称、开始时刻、结束时刻、父跨度 ID、属性、事件和状态。此外它还有种类(kind),而这一点在实务里比想象中更重要。
- SERVER:接收并处理请求的一侧
- CLIENT:调用其他服务的一侧
- PRODUCER / CONSUMER:发送消息的一侧与接收消息的一侧
- INTERNAL:进程内部的逻辑区间
CLIENT 跨度与 SERVER 跨度之间的时间差,就是消耗在网络和排队上的时间。只有两个跨度都在,才能得到这个值。如果只埋了一侧,你就只能知道「这次调用花了很久」,而分不清原因是网络还是对端服务。
一条完整的追踪看起来是这样的。
Trace 4bf92f3577b34da6a3ce929d0e0e4736 共 1,847ms
├─ SERVER api-gateway POST /v1/orders 1847ms
│ ├─ CLIENT auth-svc GET /verify 38ms
│ ├─ CLIENT checkout POST /orders 1782ms
│ │ ├─ SERVER checkout POST /orders 1776ms
│ │ │ ├─ INTERNAL cart.validate 6ms
│ │ │ ├─ CLIENT inventory GET /stock 41ms
│ │ │ ├─ CLIENT pricing POST /quote 52ms
│ │ │ ├─ CLIENT db.query SELECT coupons WHERE ... 1612ms <-- 就是这里
│ │ │ └─ CLIENT payment POST /charge 64ms
│ │ └─ (网络 + 排队 6ms)
│ └─ INTERNAL response.serialize 9ms
上下文传播是构建这棵树的唯一机制。调用方把当前的追踪 ID 和自己的跨度 ID 放进请求头,接收方读出来,把它当作自己跨度的父级。标准请求头是 W3C 的 traceparent。
# 网关调用 checkout 时实际发出去的请求头
curl -sD - -o /dev/null http://checkout.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
# 00 版本
# 4bf92f3577b34da6a3ce929d0e0e4736 追踪 ID — 整个请求中保持一致
# 00f067aa0ba902b7 父跨度 ID — 每一跳调用都会变
# 01 采样标志 — 如果是 00,这条追踪不会被保存
传播断掉的位置基本上是固定的那几个。自动埋点包不住的自研 HTTP 客户端、把任务交给线程池或 worker 的代码、消息队列,以及第三方代理把请求头抹掉的情况。如果追踪短得反常,就从这四处开始看。
日志和指标回答不了的问题
把追踪的必要性解释成「因为它是第三种信号」,落地一定会失败。你得知道它究竟回答什么问题。
第一,单次请求把时间花在了哪里。指标是聚合值,无法还原单次请求的路径。服务 A 的 p99 和服务 B 的 p99 并不保证属于同一次请求。上面那条追踪里花掉 1,612ms 的优惠券查询,在那个数据库的平均查询时间仪表盘上绝对看不见。一天几百万次里这种请求只占 1 个百分点的话,平均值纹丝不动。
第二,重复调用问题。N+1 查询在指标上看不见,因为单条查询只要 4ms,很快。问题在于它在一次请求里执行了 340 次,而这只有数跨度才能看出来。
├─ SERVER order-api GET /v1/orders/A-99183 1421ms
│ ├─ CLIENT db.query SELECT * FROM orders WHERE id = ? 5ms
│ ├─ CLIENT db.query SELECT * FROM order_items WHERE oid = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ ... (同一条查询 340 次) ...
第三,条件路径。只在特定租户、特定功能开关、特定缓存未命中组合下才变慢的情况。日志只能给出每个服务的一个切面,而把这些切面按时间配对拼起来只是猜测。追踪把那个组合作为一个对象展示出来,并且能按属性过滤。
反过来,追踪回答不了什么也很清楚。从什么时候开始变差、有多少用户受影响,这些是指标的问题。那个跨度里收到了什么值、走了哪个分支,这是日志的问题。追踪回答「在哪里」,指标回答「什么时候、有多少」,日志回答「为什么」。
埋点 — 自动埋点的边界与必须补手动跨度的位置
起点是自动埋点。不改代码,I/O 边界上就会生出跨度。
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
OTEL_SERVICE_NAME=checkout-api \
OTEL_RESOURCE_ATTRIBUTES=service.version=1.42.3,deployment.environment=prod \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4318 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=1.0 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
parentbased_traceidratio 会原样沿用父跨度的决定,只有在没有父级时才按比例判断。这应该是默认值。如果每个服务各自独立做概率判定,追踪就会在中途被截断。
自动埋点给你的东西恰好只有一样。网络边界。对象是 HTTP 服务端与客户端、gRPC、数据库驱动、Redis 以及消息队列客户端。
自动埋点看不见的东西也只有一样。进程内部发生的一切。内存排序、模板渲染、加解密、序列化、锁等待、事件循环延迟,都不会体现为任何跨度。它们只会以父跨度的 self time,也就是子跨度没有占用的那段时间显现出来。
├─ SERVER report-api GET /v1/reports/monthly 3204ms
│ ├─ CLIENT db.query SELECT ... FROM ledger 412ms
│ └─ CLIENT s3.upload PUT report-2026-07.pdf 260ms
│ self time = 3204 - 412 - 260 = 2532ms <-- 未埋点的区间
一旦发现 self time 很大的跨度,就往里面补手动跨度。需要补的位置有五处。
- 循环与批处理边界 — 把迭代次数作为属性留下。
- 缓存查询与未命中路径 — 把是否命中作为属性留下,缓存性能就能在追踪里直接看到。
- 没有自动埋点的第三方 SDK 调用
- 锁等待、队列等待、连接池等待
- 序列化、压缩、图像处理这类 CPU 区间
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('checkout', '1.42.3')
export async function applyPromotions(cart, tenantId) {
return tracer.startActiveSpan('checkout.applyPromotions', async (span) => {
span.setAttribute('cart.item_count', cart.items.length)
span.setAttribute('promotion.engine', 'rules-v3')
span.setAttribute('tenant.id', tenantId)
try {
const cached = await rules.fromCache(tenantId)
span.setAttribute('promotion.cache_hit', Boolean(cached))
const result = await (cached ?? rules.compile(tenantId)).evaluate(cart)
span.setAttribute('promotion.rules_evaluated', result.evaluated)
span.setAttribute('promotion.matched_count', result.matched.length)
return result
} catch (err) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw err
} finally {
span.end()
}
})
}
跨度名必须是低基数的。不是 GET /v1/orders/A-99183,而是 GET /v1/orders/:id。后端会按跨度名分组,名字里一旦带上 ID,所有聚合视图就全塌了。具体的值要作为属性发送。
采样 — 头部采样的陷阱与尾部采样的价值
对大多数组织来说,全量保存在成本上说不通。问题在于丢掉什么。
头部采样在创建根跨度的那一刻,也就是什么都还没发生的时候就做决定。因为不知道结果,只能随机丢弃。而随机丢弃意味着:罕见事件会被按其罕见程度精确地丢掉。
python3 - <<'PY'
rate = 0.01 # 头部采样 1%
errors_per_hour = 5 # 想排查的错误的真实发生频率
kept = errors_per_hour * rate
print(f"被保留的错误追踪: 每小时 {kept:.2f} 条")
print(f"想看到 1 条平均要等 {1/kept:.0f} 小时")
PY
# 被保留的错误追踪: 每小时 0.05 条
# 想看到 1 条平均要等 20 小时
这就是头部采样的实际结局。需要排查的时候,那条追踪不在。而在的那些追踪全是正常请求,没有理由去看。
尾部采样会把跨度先缓存起来直到追踪完成,再看结果做决定。有错误就留,慢就留,其余只留一小部分。
# otel-collector-tailsampler.yaml
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow
type: latency
latency:
threshold_ms: 800
- name: keep-vip-tenants
type: string_attribute
string_attribute:
key: tenant.tier
values: [enterprise]
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 2
尾部采样不是免费的,这一点必须说清楚。它有三项成本。
第一,从 agent 到采集器的传输量并不会减少。因为所有跨度都得先到达采集器才能做判定。省下来的只是后端的存储与索引成本。采集器自身的 CPU 和网络反而会增加。
第二是内存。在 decision_wait 期间必须把所有跨度都攥在手里。
python3 - <<'PY'
traces_per_sec, wait_sec, spans, span_kb = 2000, 10, 12, 1.2
mb = traces_per_sec * wait_sec * spans * span_kb / 1024
print(f"缓冲内存大约 {mb:.0f} MB (留两倍余量就是 {mb*2:.0f} MB)")
PY
# 缓冲内存大约 281 MB (留两倍余量就是 563 MB)
第三,也是最容易出错的部分。同一条追踪的所有跨度必须到达同一个采集器实例。如果把采集器横向扩成多台,前面放一个普通负载均衡器,那么一条追踪的跨度就会散落到多个实例上,各自看着残缺的追踪做判定。结果就是被随机截断的追踪。解法是加一层按 trace ID 路由的网关。
# otel-collector-gateway.yaml — 在前端按 trace ID 路由
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: otel-tailsampler.observability.svc.cluster.local
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]
| 项目 | 头部采样 | 尾部采样 |
|---|---|---|
| 决定时点 | 创建根跨度时 | 追踪完成并经过 decision_wait 后 |
| 慢请求的保留 | 只能靠概率 | 按规则 100% |
| 错误追踪的保留 | 只能靠概率 | 按规则 100% |
| agent 与网络成本 | 按采样比例下降 | 没有下降 |
| 后端存储成本 | 下降 | 下降 |
| 采集器内存 | 可忽略 | 按每秒追踪数乘以等待时间缓冲 |
| 运维要求 | 无 | 必须按 trace ID 路由 |
| 配置位置 | SDK 环境变量 | 采集器处理器 |
实务上会把两者组合起来。流量特别大的服务先用头部采样压到 10~50 个百分点的水平,再在上面叠尾部采样,把错误和慢请求捞出来。在头部就已经丢掉的东西,尾部救不回来,所以原则上头部比例要在承受得起的前提下尽量调高。
跨度属性与异步边界 — 基数、队列、链接
「追踪里也要小心基数」这条建议只对了一半。跨度属性的高基数和指标那边不一样,不是爆炸问题。订单 ID、用户 ID、查询参数,本来就是为了放进追踪而存在的值。没有它们,追踪就变成一张没法过滤的图。
出问题的地方有两处。
第一,把跨度转换成指标的时候。用 spanmetrics 连接器从跨度生成 RED 指标时,你指定为维度的属性会直接变成指标标签。往里面塞用户 ID,时间序列就会爆炸。
connectors:
spanmetrics:
# 只有列在这里的才会变成指标标签 — 只放低基数的
dimensions:
- name: http.route
- name: http.request.method
- name: deployment.environment
histogram:
explicit:
buckets: [10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
第二,跨度本身的大小。OpenTelemetry SDK 默认会限制每个跨度的属性个数(默认 128),但对属性值的长度没有默认上限。把整个请求体塞进属性,一个跨度就能有几百 KB,传输和存储成本会原封不动地跟上来。有需要就显式设限。
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64 \
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
异步边界更棘手。在消息队列里,生产者的跨度早就结束了,消费者才开始动。直接沿用父子关系的话,父级已经结束,追踪的时间轴就会变得很怪,而批量消费者根本就没法表达。一次处理 100 条消息就有 100 个父级,可跨度只能有一个父级。
解法是链接。
import { propagation, context, trace, SpanKind } from '@opentelemetry/api'
// 生产者: 把上下文注入到消息头里
export async function publish(order) {
return tracer.startActiveSpan('orders.publish', { kind: SpanKind.PRODUCER }, async (span) => {
const headers = {}
propagation.inject(context.active(), headers)
await producer.send({ topic: 'orders', messages: [{ value: JSON.stringify(order), headers }] })
span.end()
})
}
// 批量消费者: 把每条消息的上下文作为链接而不是父级挂上去
export async function consumeBatch(messages) {
const links = messages
.map((m) => trace.getSpan(propagation.extract(context.active(), m.headers))?.spanContext())
.filter(Boolean)
.map((spanContext) => ({ context: spanContext }))
return tracer.startActiveSpan(
'orders.processBatch',
{ kind: SpanKind.CONSUMER, links },
async (span) => {
span.setAttribute('messaging.batch.message_count', messages.length)
await Promise.all(messages.map(handle))
span.end()
}
)
}
如果消费者一次只处理一条消息,用父子关系接起来也可以。只是队列等待时间一长,单条追踪的持续时间就会变成好几个小时,后端处理起来会很吃力。等待时间长的管道更实际的做法是用链接切开,再把队列等待时间作为属性记录下来。
实战 — 在追踪界面上到底要找什么
工具开着却不用,最大的原因就是没有排查顺序。下面这个顺序在大多数情况下都管用。
- 从指标开始。先确定是哪条路由、哪个时间段变差了。一上来就漫无目的地翻追踪,只会白白耗时间。
- 在服务图里找延迟或错误率上升的那条边。调用关系本身是否发生了变化,也能在这里看出来。
- 看那条路由、那个时间段里持续时间排在前列的追踪列表。大多数后端都提供按持续时间和属性过滤的查询。
- 打开一条追踪,找 self time 最大的跨度。不是最长的跨度,是 self time 最大的跨度。最长的那个通常是根跨度,没有信息量。
- 看那个跨度的属性。是缓存未命中,还是特定租户,重试了几次。
- 用同一个 trace ID 去查日志。「为什么」就出在这里。
这里需要一条纪律。不要只看一条慢追踪就下结论。一条可能只是偶然。要打开多条相同条件下的追踪确认共同模式,并和正常的追踪并排比较。好的后端会为此提供按跨度的持续时间分布或追踪对比视图。
OpenTelemetry 已经标准化的部分与尚未标准化的部分
这会影响引入决策,所以要知道边界在哪里。
已经标准化、可以放心依赖的部分是 API 与 SDK 的结构、OTLP 传输协议、基于 W3C 的上下文传播,以及采集器。只要埋点代码是用 OpenTelemetry API 写的,换后端时基本不需要改代码。HTTP 这类核心领域的语义约定也已经稳定。
而仍在变动的部分也存在。若干领域的语义约定还处在实验阶段或正在改名,各语言 SDK 的成熟度差异也很大。从跨度生成指标的方式、服务图的生成逻辑各家后端都不一样,查询追踪的语言也没有标准。依赖这一领域的仪表盘和告警,设计时就要当成是被绑定在后端上的。
结语 — 追踪是回答「在哪里」的工具
要记住的只有一句话。追踪是回答「单次请求把时间花在了哪里」的工具,而这个答案只有在上下文没有断、并且那条追踪没有被丢弃时才存在。
于是引入的顺序也就定下来了。先确认传播。如果追踪在服务边界上就断了,其他所有投入都毫无意义。接着看采样。如果只用头部采样,那么在需要排查的那一刻追踪就不在,所以要把尾部采样和 trace ID 路由一起引入。最后在 self time 大的区间补上手动跨度,把自动埋点留下的空白填上。
最先该做的事,是现在就在生产环境里打开一条追踪看看。如果有十个服务却只看得到三个跨度,那就是你下周要做的全部工作。
值得继续深入的资料。