一句话总结
分布式追踪将"一次请求流经的所有服务"拼接为一条带时间线的调用链,专门应对微服务架构中"无法看到全局"的难题;其核心机制由三部分组成:Span(每一调用跳),Trace ID(贯穿全链路的标识),Context 传播(使 Trace ID 跨服务传递),而 OpenTelemetry 是将这一切标准化的统一规范,避免绑定特定厂商.
前置回顾
trace_id 在第 04,05 篇中反复出现,但尚未解释其来源以及如何贯穿多个服务.本篇将揭晓这一机制.这也是第 01 篇排障主线中**追踪 = "定位在哪一环节"**的关键--当指标显示"下单延迟异常",是追踪指出"延迟发生在第几个调用跳".本篇是阶段四的开篇,也是全系列中概念密度最高的一篇.
指标和日志的共同盲区:无法看到"请求的旅程"
回到凌晨的慢请求场景.此时已有指标(知道下单 P99 为 2 秒),也有各服务的日志.但排查在下一步陷入停滞:
下单请求经过:网关 → 订单 → 库存 → 支付 → 优惠券 → 通知.指标告诉我们整条链路总耗时 2 秒,日志告诉我们每个服务各自产生了若干日志.但无法回答以下问题:
- 这 2 秒是如何分摊到 6 个服务上的?
- 是支付慢,还是库存慢,还是串行调用本身就该耗时这么久?
- 哪些调用是并行的,哪些是串行的?
指标是"各服务独立的聚合数据",日志是"各服务独立的事件片段",两者都没有"这一次请求的全局时间线".
问题的本质:指标和日志均以"服务"为中心组织,而一次请求是"横跨多个服务"的.将视角从"每个服务发生了什么"转换为"这一次请求经历了什么"--这就是分布式追踪(Distributed Tracing).
指标和日志如同每个驿站各自的值班记录(该站今天接待了多少人,发生了什么事).
追踪如同对某一位旅客的全程跟踪记录:几点到达哪个驿站,在每个驿站停留多久,哪段路程是并行抄近道的--一条完整旅程的时间线,而非各驿站的独立流水账.
核心模型:Span 与 Trace
追踪的数据模型仅包含两个核心概念.
- Span(跨度):一次操作的耗时记录--例如"订单服务处理本次请求""一次数据库查询""一次对支付服务的调用".每个 Span 包含:名称,开始/结束时间(即耗时),以及一组属性(Attributes,如 http.status_code).Span 是追踪的基本单元.
- Trace(追踪):一次完整请求中所有 Span 的集合,通过父子关系构成一棵树.整棵树共享同一个 Trace ID.
Span 之间为父子嵌套关系:网关的 Span 是根,它"包含"订单服务的 Span,订单服务的 Span 又"包含"它调用库存,支付的 Span...绘制出来即经典的瀑布图(Waterfall):
一次下单请求的 Trace(横轴为时间,缩进表示父子调用关系)
|
从瀑布图可以立即判断:2 秒中 1820ms 消耗在支付服务,而支付服务的时间几乎全部花在重试第三方 API.这正是第 01 篇所述"追踪定位'在哪个环节'"的体现.瀑布图还揭示了串行与并行关系--库存,优惠券,通知是依次串行的(它们的时间段不重叠).
Span 可承载的信息
每个 Span 不仅包含耗时,还可附带丰富的信息:属性(键值对,如 http.method=POST,db.statement=SELECT...),事件(Span 生命周期中的时间点,如"开始重试"),状态(成功/失败).
因此追踪不仅告诉我们"哪个调用跳慢",还能告诉我们"该调用跳在执行哪条 SQL,返回了什么状态码".它比指标更细粒度,又比日志更结构化,处于两者之间.
核心机制:Context 如何跨服务传播
现在进入追踪的核心难点:网关生成了 Trace ID = a1b2c3,但订单服务,支付服务运行在不同的进程,不同的机器上,它们如何知道"自己属于 a1b2c3 这次追踪"?如果每个服务各自生成独立的 ID,则无法将这些 Span 拼接成一条完整的链.
答案是 Context 传播(Context Propagation):将 Trace ID 和当前 Span ID,随服务间的调用一起传递到下游.对于 HTTP 调用,即将其放入特定的请求头中:
|
|
第 04 篇指出"每条日志携带 trace_id 才能串联三支柱",第 05 篇使用 trace_id 在 Loki 中拉取一次请求的全部日志.那个 trace_id,正是此处通过 Context 传播一路传递的同一个值.
追踪系统通过传播它来串联 Span,日志库读取它并将其写入每条日志--于是 Span 和日志共享同一个 trace_id,三支柱的关联(第 08 篇)才得以实现.这条连接线的物理实现,就是 Context 传播机制.
传播中断,追踪即断裂
Context 传播是追踪中最脆弱的环节.任何一处未将请求头传递到下游,链路即在该处断裂为两截:例如手写的 HTTP 客户端遗漏了 traceparent 的透传,使用了不支持传播的旧版中间件,跨消息队列时未在消息中附带 context 信息.
在排查"追踪为何不完整"时,绝大多数情况是某一跳的传播丢失了.下文介绍的自动埋点能够解决大部分此类问题.
埋点:Span 的生成方式
Span 不会凭空产生,需要在代码中"埋点(Instrumentation)"--告知系统"此处开始一个 Span,此处结束".有两种方式:
| 自动埋点(Auto) | 手动埋点(Manual) | |
|---|---|---|
| 做法 | 使用 SDK/Agent 自动拦截常见框架(HTTP 框架,gRPC,数据库驱动) | 在业务代码中手动开始/结束 Span |
| 覆盖范围 | 框架层面的调用(接收请求,发出请求,查询数据库)自动生成 Span 并传播 Context | 框架看不到的关键业务逻辑(如"计算优惠""风控检查") |
| 成本 | 几乎不需要代码改动 | 需要编写代码 |
实践建议:自动埋点覆盖基础链路,手动埋点补充关键逻辑
推荐做法是先部署自动埋点--它以极低成本覆盖了 80% 的工作:HTTP/gRPC 调用的 Span,Context 跨服务传播(解决上述"传播中断"问题),数据库查询的 Span,全部自动完成.
再通过手动埋点为框架无法覆盖但业务上关心的逻辑补充 Span(例如"本次下单中,风控检查耗时多少").绝大多数团队仅靠自动埋点即可运行一套可用的追踪系统.
OpenTelemetry:统一标准,消除厂商绑定
上面讨论的 Span,Trace,传播,埋点,在早期各追踪系统(Jaeger,Zipkin,各商业 APM)各自实现一套--使用某厂商的 SDK 进行埋点,即被该厂商绑定,更换后端需要重新埋点.**OpenTelemetry(简称 OTel)**的出现即为了解决这一问题:
OTel 是一套厂商中立的开放标准 + SDK,定义了"如何描述 Span,如何传播 Context,如何导出数据".使用 OTel 进行一次埋点,数据可发送至任意支持 OTel 的后端(Jaeger,Tempo,商业 APM...),更换后端无需修改埋点代码.
OTel 目前是该领域的事实标准(CNCF 项目,活跃度仅次于 Kubernetes).它的另一个重要组件是 Collector:
|
Collector 是一个独立部署的中间层:服务将遥测数据(追踪,乃至指标,日志)发送给它,由它统一执行批处理,采样,脱敏,格式转换,分发到各个后端.其优势在于应用仅需与 Collector 通信,后端的任何变更均与应用无关--又一次解耦思想的体现(对照第 05 篇 stdout,第 03 篇 Pull 模型).
OTel 不限于追踪
虽然 OTel 因追踪而广为人知,但其目标是统一三大支柱的数据采集:Traces,Metrics,Logs 均被纳入同一套标准和 SDK.这正是对第 01 篇"三支柱割裂"之痛的回应--用一套标准采集三类信号,它们天然共享 trace_id,资源属性等,关联(第 08 篇)便水到渠成.
掌握 OTel,相当于获得了打通三支柱的统一工具.
承上启下
本篇阐述了追踪的原理:Span 组成 Trace,Context 传播串联链路,OTel 标准化采集.但要真正投入使用,还需解决两个工程问题:**海量请求中,全部追踪存储成本过高,应采样哪些?以及追踪数据存储于何处,如何查询,如何与指标和日志联动?**下一篇将使用 Jaeger 和 Tempo 来回答这些问题,届时将看到第 04 篇"保留异常,稀释正常"的采样策略在追踪领域的应用.
快速回顾
- 追踪解决的盲区:指标和日志均以"服务"为中心组织,无法呈现"一次请求横跨多服务的完整时间线"
- Span = 一次操作的耗时记录(基本单元),可附带属性/事件/状态;Trace = 一次请求所有 Span 组成的树,共享一个 Trace ID,可视化为瀑布图
- Context 传播:将 trace_id + span_id 随调用(如 HTTP
traceparent请求头)传递给下游,下游据此挂接到同一条 trace--这就是 trace_id 那根"连接线"的物理来源,任何一处中断即导致链路断裂 - 埋点:自动埋点(拦截框架,无需代码改动,同时解决传播问题)覆盖基础链路 + 手动埋点补充关键业务逻辑
- OpenTelemetry:厂商中立的统一标准 + SDK,一次埋点可发送至任意后端,消除厂商绑定;Collector 作为接收/处理/分发的解耦中间层
- OTel 统一三支柱的采集标准,为第 08 篇的关联能力奠定基础
Trace ID 与 Span ID 有何区别?
Trace ID 标识"整次请求",全链路所有 Span 共享同一个,用于将它们归为一组.Span ID 标识"单个操作",每个 Span 各不相同.父子关系通过"我的 parent span id = 上游的 span id"建立.类比:Trace ID 如同快递单号(整个运输过程唯一),Span ID 如同途中每个中转站的扫描记录(每站一条,但均属于同一单号).日志中通常两者均携带:trace_id 用于关联整次请求,span_id 用于精确定位到具体是哪一个调用跳产生的日志.
追踪是否会对应用性能造成显著影响?埋点是否存在不可忽视的性能开销?
开销存在但通常很小,且可控.生成 Span,传播 Context 为轻量级内存操作;真正的成本在数据导出(发送至后端),而这通过异步批量发送(SDK/Collector 积累一批后再发送)和采样(第 07 篇:并非每个请求都完整记录)来摊薄.绝大多数场景下,追踪带来的性能影响在个位数百分比甚至更低,与其带来的排障能力提升相比是值得的.真正需要关注的问题不是性能开销,而是采样策略不当导致的存储成本--这恰是下一篇的主题.
动手练习
- 配置自动埋点:使用 OpenTelemetry 的语言 SDK,为一个简单的"A 服务调用 B 服务"的 demo 配置自动埋点,在控制台导出器中查看生成的 Span.
- 验证 Context 传播:观察 A 调用 B 的 HTTP 请求头,找到
traceparent,确认 Trace ID 确实被传递--亲眼验证 Context 传播机制. - 手动创建 Span:在 A 服务的一段业务逻辑中手动创建一个 Span,设置若干属性,观察它如何嵌套进自动生成的 Span 树中.
- 复现传播中断:在 A 调用 B 时故意移除 traceparent 请求头(不透传),观察 B 的 Span 是否脱离原 trace,变为一条新 trace--复现"传播中断"的故障场景.
- 接入 Jaeger:将 demo 的导出目标从控制台改为本地 Jaeger(下一篇),在 Jaeger UI 中查看完整的瀑布图.