想象你是一位快递公司的运单调度员。每天有成千上万个包裹从仓库发出,要经过分拣、干线运输、中转场、末端网点,才能到达用户手中。当一个用户打来电话抱怨“我的包裹卡了三天没动”,你要如何快速定位快递卡在了哪个环节?方法很简单——每个包裹都带一个唯一的运单号,每到一站就扫码记录。这,就是物流界的“分布式追踪”。 在微服务架构里,一个用户请求(包裹)会穿越网关、认证、订单、支付、库存等多个服务(中转站)。如果请求变慢或报错,与其在各台服务器日志里大海捞针,不如给这个请求也打一个“运单号”(Trace ID),让它在每个服务里留下“扫码记录”(Span)。把这些记录串起来,就得到一条完整的调用链(Trace)。Jaeger 和 Tempo 就是两套成熟的“运单查询平台”,让你像查快递一样排查线上问题。本文就用这个“快递物流”的比喻,带你从零看懂分布式追踪的每一块拼图。
分布式追踪是云原生可观测性三支柱中最能直接还原“一次请求全貌”的技术。掌握 Jaeger/Tempo 的架构与原理,是 SRE、全栈工程师与云原生架构师定位线上故障、优化调用链、建设追踪平台的必备核心能力。
可视化演示
以Span/Trace数据模型展示一次请求的完整调用链,演示采样策略的成本控制,以及traceparent头跨服务传播的过程,可切换三种模式观察。
本节配有交互动效(进入视口后自动加载,约 3KB)。
链路模型:Span与Trace Trace是完整请求,由多个Span组成调用树 每个Span含操作名、起止时间与父Span引用 TraceId 全局唯一,SpanId 链内唯一 同一TraceId串联前后端全部调用。
追踪基础概念
先回到快递的比喻。假设你在淘宝下单,快递公司给包裹贴了一张运单号。此后包裹每经过一个中转站,工作人员就扫码一次,录入“到达时间、离开时间、当前站点”。当运单号被查询时,你就看到一条完整的轨迹:杭州揽收 → 上海分拣 → 北京配送。这条轨迹,就是物流界的 Trace;而其中每一站的扫码记录,就是一个 Span。
在微服务架构中,前端请求到达网关后,会依次调用认证、订单、支付、库存等服务。分布式追踪系统为这条请求分配一个全局唯一的 Trace ID(运单号),每进入一个服务就创建一个 Span(扫码记录),记录服务名、操作名、开始时间、耗时,以及它的“上一站”是谁(父 Span ID)。将这些 Span 按照父子关系串起来,就得到一棵“追踪树”。
一棵追踪树可以用数学形式描述:它由一个 Span 集合和一组父子边构成。根节点对应入口请求,叶子节点对应最底层的数据库或缓存调用。
1. S 集合:表示一条 Trace 中所有工作单元记录的集合,每个元素是一个 Span。
2. E 边集合:表示 Span 之间父子调用关系的集合,一条边说明“谁是调用方、谁是被调用方”。
3. T 整体:由 S 和 E 共同构成一棵有向树,描述一次请求从入口到出口的完整路径。
所以,当用户说“下单接口好慢”时,你只要在追踪系统里输入 Trace ID,就能像查快递一样,看到请求在哪个服务停留最久、哪一段调用失败。这就是分布式追踪提供的第一个核心价值:全链路可见。
数据采集原理
快递员不会凭记忆在终点站复述包裹走过哪些站,而是每到一站就扫码录入。对应到代码世界,这个“扫码”动作就是埋点。当今的主流标准是 OpenTelemetry:它提供 SDK,可以在 HTTP 请求进来时自动创建 Span,也会在调用下游服务时自动把追踪信息“粘”在出站请求上。
请求从服务 A 调到服务 B,仅仅在 A 里创建 Span 是不够的,B 必须知道“自己属于哪条链路、A 的 Span 是谁”。这就是上下文传播(Context Propagation)。物流界的做法是随包裹携带一张“交接单”,上面写着运单号、上一站编号、时间戳;微服务界的做法是在 HTTP Header 或消息队列的 Metadata 中携带同样的信息。
1. traceID:整条链路的全局唯一标识,类似快递运单号,贯穿所有服务。
2. spanID:当前工作单元的唯一标识,相当于本站点扫码产生的流水号。
3. parentSpanID:指向父 Span 的指针,让服务 B 能把自己挂到树上正确的位置。
实际落地时,OpenTelemetry 的自动埋点已经覆盖了主流框架(Spring Boot、Gin、Express 等),它会在 RPC 调用的 Header 中写入 W3C 标准的 traceparent 字段。就像快递公司统一了“交接单”格式,各站点不用协商也知道怎么读。上下文传播是整棵追踪树能够“从根长到叶”的血管。
Jaeger架构
如果说分布式追踪是一套“快递运单系统”,那么 Jaeger 就是一套典型的“总部集中式”平台。它的架构可以拆成三个角色:快递员手里的扫码枪(Agent)、网点的分拣调度中心(Collector)、以及供客户查询运单轨迹的客服查询台(Query)。
Jaeger 最初由 Uber 开源,现为 CNCF 毕业项目。应用服务通过 SDK 把 Span 上报给本机 Agent(UDP 协议),Agent 做轻量校验后批量转发给 Collector;Collector 负责大规模接收、验证、索引并写入存储后端(支持 Cassandra、Elasticsearch、Badger 等);Query 服务提供 Web UI 和 API,让你按 Trace ID、服务名、标签检索追踪数据。这样的分层设计使各组件可以独立水平扩展——“扫码枪”不会成为“分拣中心”的瓶颈。
从请求产生到查询结果返回,整个链路会产生如下端到端延迟:
1. L_sdk:应用 SDK 在本地生成、缓冲 Span 的耗时,通常只有几微秒。
2. L_agent:Agent 将批量数据通过 UDP/HTTP 转发给 Collector 的网络传输时间。
3. L_collector:Collector 做解包、校验、索引并写存储的处理时间。
4. L_query:查询时从存储读取并渲染结果的耗时。
回到快递比喻:扫码枪只负责“扫”,网点分拣中心负责“理”,查询台负责“答”。这种分工让 Jaeger 可以在每层按流量独立扩容,是生产环境中最常用的追踪后端之一。
Tempo架构
与 Jaeger 的“中央分拣”思路不同,Grafana Tempo 更像一家“极简物流公司”:快递员只在前端登记一个运单号,然后把包裹整个扔进仓库,查询时才按运单号去翻货。它的设计哲学是“追踪数据就是大 blob,不做深度索引,只做按 ID 查找”。
Tempo 的核心组件只有四个:Distributor(分发器)、Ingester(预写器)、Querier(查询器)和对象存储后端(S3、GCS、MinIO 等)。请求到达后,Distributor 按 traceID 哈希分片,把数据交给 Ingester 缓冲在内存中,定期刷成 block 写入对象存储。查询时,Querier 先查内存索引或近期 block,再回溯对象存储中的归档数据。
1. idx_recent:由 Ingester 维护的近期 Trace 索引,覆盖最近数小时的数据。
2. blobs_archive:对象存储中长期归档的 block 集合,适合海量冷数据。
3. Lookup(·):先查索引定位 blockID,再到对应 block 中取出完整 Trace 数据。
Tempo 的选择与 Grafana 生态深度绑定:追踪数据可以直接在 Grafana 中与日志、指标联动。就像你查一年前的快递,虽然要去大仓库翻,但仓库租金便宜,长期保存也不心疼。Tempo 的“按 ID 直查”模型非常适合海量低成本追踪归档。
存储与检索
快递公司每天产生几亿条运单记录,不可能每条都放在快递柜里,更不可能全部无条件永久保存。存储设计决定了追踪系统的成本、查询速度和数据保留能力。分布式追踪的存储策略同样需要在“收入成本”与“查询体验”之间做取舍。
规划存储容量时,有五个关键变量:每秒请求量(QPS)、每条 Trace 的平均 Span 数量、单个 Span 序列化后的平均字节数、数据保留天数、以及采样率。它们相乘,就是一天的存储量。
1. QPS:入口服务的每秒请求数,直接决定产生多少条 Trace。
2. spans̄:每条 Trace 平均包含的 Span 数量,取决于调用链深度(微服务链路越长数值越大)。
3. sizē:单个 Span 序列化后的平均字节数,包含属性标签、时间戳等。
4. 86400 与 r:一天的秒数;r 为采样率(0~1),二者共同决定实际落库总量。
检索设计上,Jaeger 和 Tempo 都按 traceID 建主索引;若要按服务名/标签搜索,则需要额外的二级索引或依赖外部系统。理解存储公式后你就知道:无脑提高采样率,等于把所有包裹都永久存在市中心仓库,查询只会越来越慢、账单越来越贵。
采样策略
质检部门不可能对每个快递都开箱检查,但也不能什么都不查。分布式追踪面对同样的困境:如果每条请求都全量上报,存储和带宽成本会呈爆炸式增长;如果随机丢弃,又可能恰好丢掉最重要的错误链路。所以需要采样策略。
最简单的方案是头部采样:在请求入口(根 Span 处)按固定概率决定整条链路是否上报。优点是开销极小,缺点是决策“盲人摸象”——不知道请求最终成不成功就直接丢弃,可能漏掉错误链路。更聪明的做法是尾部采样:请求结束后,由收集端根据结果(错误标志、延迟超标、自定义标签)决定哪些 Trace 值得保留。
1. w_i:第 i 条 Trace 的权重,由错误、延迟、标签等特征加权计算。
2. Σ w_j:采样窗口内所有候选 Trace 的权重之和,用于归一化概率。
3. 比值:权重占比越高,该链路被保留的概率越大,从而实现“重点链路全保留、普通链路低概率保留”。
像质检员优先检查投诉多的包裹一样,尾部采样把宝贵的存储资源集中在“最值得复盘”的链路上。生产实践中,头部采样控制总量,尾部采样兜底异常,两者通常搭配使用。
性能优化
快递高峰期,总部要临时增开分拣口、甩挂运输、优先派送时效件。追踪系统在高流量下同样面临“吞吐扛不住、查询乌龟速”的挑战。性能优化的目标,就是在不丢失关键数据的前提下,让上报和查询都快起来。
批量和异步是关键。SDK 端不要每产生一个 Span 就立刻同步发送,而是先在内存缓冲、批量压缩后再上报,大幅减少网络包数量。查询侧则引入并行化:把大查询拆成多个子查询并行执行,最后聚合结果。实际测算优化效果时,延迟分为不可并行的串行部分和可并行的部分:
1. D_serial:无法并行必须顺序执行的耗时,如单次网络 RTT、磁盘寻址。
2. D_parallel:可拆分成子任务并行处理部分的耗时,如扫描多个 block。
3. K:并行度,即同时参与查询的协程/线程/机器数量。
4. D_opt:优化后的总延迟;K 越大,并行部分被摊薄得越狠。
在实际部署中,Jaeger 和 Tempo 都支持分区存储:近期热点数据放在 SSD 上,历史归档数据滚动到对象存储。这就像快递公司把热销区包裹放在最近的网点,把过季库存送到郊区仓库——热得快、冷得省。
实践落地
有了追踪平台,运维团队就像装上了“全链路监控大屏”的快递老板:用户投诉派送慢,你能直接看到是揽收慢、干线堵还是派送员绕路。在真实业务中,分布式追踪的落地场景已经非常成熟。
最直接的场景是根因定位:告警系统发现订单接口 P99 延迟飙到 2 秒,工程师打开 Grafana/Tempo 搜出几十条慢 Trace,瀑布图一眼锁定了“库存服务”对某个 Redis 分片的读耗时异常——顺着 Trace 直接找到大 Key 热区。第二个场景是 SLO 统计:分布式追踪天然记录每次请求的真实耗时,可以据此计算服务等级目标的达成率。
1. 指示函数 1[·]:当第 i 次请求的耗时小于等于阈值时计 1,否则计 0。
2. 求和:对所有请求的指示函数值求和,得到满足阈值的请求总数。
3. N 与百分比:N 为总请求数,比值乘以 100% 转化为百分比。
第三个场景是容量规划:基于追踪数据统计出各服务调用频率、依赖深度、平均耗时,结合预估 QPS,用存储公式和延迟模型推算所需实例数与大促资源预算。追踪不是炫技,而是让“看得见、查得清、改得准”成为日常——像管理快递网络一样管理你的微服务。
AI 应用场景
微服务调用链故障定位
阶段:在线诊断与根因分析
解决的问题:传统排查需要运维工程师登录多台服务器逐行翻日志,靠经验猜测慢在哪个环节,耗时从分钟到小时不等。接入 Jaeger/Tempo 后,输入一个 Trace ID,秒级画出调用链瀑布图,直接定位到耗时异常或报错的服务节点。
具体案例:某电商平台晚高峰订单接口 P99 延迟从 200ms 飙升至 2s。SRE 通过 Tempo 检索该接口的慢 Trace,发现“库存服务”对某个 Redis 分片的读延迟异常,进一步定位到大 Key 热区。修复后 P99 回落至 180ms。
基于追踪数据的容量规划
阶段:负载建模与容量预测
解决的问题:大促前扩容靠经验“拍脑袋”:到底加多少实例?加少了扛不住,加多了浪费钱。追踪数据提供了每次请求经过的服务清单与耗时,可以精确统计每个服务被调用的频率、依赖深度和平均处理时间,从而推算出目标 QPS 下的资源需求。
具体案例:某支付团队在双 11 前基于 Jaeger 数据做容量测算:支付网关的核心链路当前 6 个实例扛 5 万 QPS,平均耗时 85ms。按大促预估 10 万 QPS 计算,需要扩容至 12 实例;同时发现“风控服务”存在 30% 的重复调用,优化后只需 10 实例。
服务质量 SLO 度量与告警
阶段:数据聚合与合规评估
解决的问题:对外承诺“下单接口 99.9% 可用”,但缺少请求级延迟数据来量化达成情况。追踪系统天然记录每个请求的真实耗时,是计算 SLO 达成率最可靠的数据源;与告警平台联动,可自动生成工单并附上慢 Trace。
具体案例:某互联网公司以“下单接口 P99 < 800ms”为 SLO,每天从追踪数据自动统计达标率;若连续 5 分钟跌破 99.5%,自动创建告警工单并附带最近的慢 Trace 链接,值班工程师点开即可看到完整调用链。
Python 演示
import timeimport randomimport json class Span: """单个追踪跨度,好比快递中转站的一次扫码记录""" def __init__(self, span_id, parent_id, service, operation, start, duration): self.span_id = span_id self.parent_id = parent_id self.service = service self.operation = operation self.start = start self.duration = duration def to_dict(self): return { "span_id": self.span_id, "parent_id": self.parent_id, "service": self.service, "operation": self.operation, "start": round(self.start, 4), "duration": self.duration, } class Tracer: """模拟 OpenTelemetry Tracer:为一次请求生成一条 Trace""" def __init__(self, service): self.service = service self.trace_id = str(random.randint(10**15, 10**16)) self.spans = [] def start_span(self, operation, parent_id=None): span_id = str(random.randint(10**8, 10**9)) span = Span(span_id, parent_id, self.service, operation, time.time(), 0) self.spans.append(span) return span def end_span(self, span, duration): span.duration = duration def export(self): return { "trace_id": self.trace_id, "spans": [s.to_dict() for s in self.spans], } def simulate_request(): """模拟一次下单请求:网关 -> 订单服务 -> 支付服务""" tracer = Tracer(service="api-gateway") root = tracer.start_span("POST /api/order") time.sleep(0.001) order_span = tracer.start_span("order.create", parent_id=root.span_id) time.sleep(0.002) pay_span = tracer.start_span("pay.deduct", parent_id=order_span.span_id) time.sleep(0.003) tracer.end_span(pay_span, duration=0.004) tracer.end_span(order_span, duration=0.006) tracer.end_span(root, duration=0.010) return tracer.export() if __name__ == "__main__": traces = [simulate_request() for _ in range(3)] print("===== 模拟分布式追踪导出 =====") for i, trace in enumerate(traces): print(f"Trace {i + 1}: {trace['trace_id']}") for s in trace["spans"]: print(f" Span {s['span_id']} parent={s['parent_id']} " f"{s['service']}@{s['operation']} {s['duration']}s") print(json.dumps(trace, ensure_ascii=False))
class Span:定义单个追踪跨度,对应快递中转站的一次扫码记录。tracer.trace_id = str(random.randint(...))为整条链路生成全局唯一运单号。tracer.start_span("order.create", parent_id=root.span_id)在子服务中创建 Span 并挂到父 Span 下。tracer.end_span(pay_span, duration=0.004)记录耗时,相当于扫码后填上离开时间。tracer.export()把整条 Trace 导出为 JSON,模拟上报给 Jaeger/Tempo。
常见误区
误区我刚搭好 Jaeger,为了不漏掉任何线上问题,把采样率直接配成 1.0(100%),这样每条请求都有完整链路数据,排查时心里踏实。
正解100% 采样意味着每个请求、每个 Span 都要序列化、传输、存储。在高 QPS 场景下,存储成本随 QPS × 链路深度线性暴涨,查询也会因为数据量过大而变慢;同时 SDK 全量上报会占用应用线程和带宽,可能拖慢业务本身。生产环境通常取 5%~20% 基础采样率,再配合尾部采样补采错误链路。
为何常见:人们直觉上认为“数据越全越安全”,却忽略了追踪数据是有成本的,而且采样器和存储设计本来就是配套的取舍问题。
误区Jaeger 和 Tempo 都是分布式追踪后端,功能重叠,公司引入其中一个就够了,同时部署纯属重复建设。
正解两者可以共存甚至互补。Jaeger 自带完整 UI 和丰富存储后端,适合快速启动、中小规模场景;Tempo 与 Grafana 生态深度集成,对象存储成本低,适合海量归档。实践中常见方案是:通过 OpenTelemetry 统一出口,上游双写到两套系统,或先 Jaeger 后迁移 Tempo。
为何常见:二者确实在“追踪后端”这一能力上有重叠,容易让人误解为排他性竞争,忽略了架构理念和成本模型的差异。
误区只要接入了分布式追踪,再复杂的线上故障都能靠一个 Trace ID 查到根因,日志和指标都不太需要了。
正解Trace 只回答“请求经过哪些服务、各花了多久、哪段报错”,但崩溃现场的业务日志、CPU/内存指标、数据库慢查询等仍然需要日志和指标体系配合。可观测性三支柱各司其职:日志回答“发生了什么”,指标回答“有多严重”,追踪回答“在哪条链路上发生”。
为何常见:追踪在定位慢请求时的体验太直观,容易让人高估它的能力边界,忽略了其他信号源的不可替代性。
误区我们的服务之间都用消息队列异步通信,不走 HTTP Header,所以不用做 OpenTelemetry 的上下文传播,追不到也正常。
正解消息队列场景同样需要传播 Trace Context,只是载体从 HTTP Header 变成了消息 Header(如 Kafka Headers)或 gRPC Metadata。OpenTelemetry 对主流 MQ 客户端提供了自动注入与提取,生产者发送时写入 traceparent,消费者接收时读取并挂到同一棵树上。
为何常见:大家接触上下文传播的示例大多是 HTTP 的 traceparent 头,容易把“一种载体”误当成“唯一方式”。
面试题
Q1. 可观测性三支柱中,分布式追踪主要回答什么问题?
- A.系统每秒处理多少请求
- B.一次请求完整经过哪些服务← 正确答案
- C.服务器磁盘剩余空间多少
- D.代码库有多少行注释内容
答案:B。分布式追踪回答的是“一次请求的完整路径与各阶段耗时”,对应快递追踪的“包裹走过哪些中转站”。A 属于指标监控范畴,C 属于基础设施监控,D 与可观测性无关。
Q2. 一条 Trace 由多个 Span 组成,Span 之间的结构关系是?
- A.兄弟节点构成的链表
- B.父子节点构成的树形结构← 正确答案
- C.双向循环的图状关联
- D.毫无关联的孤岛式结构
答案:B。Span 通过 parentSpanID 形成层级关系,整条 Trace 是一棵有向树。同一层级的 Span 可能互为兄弟,但整体结构是树,不是链表、循环图或孤岛。
Q3. 下列哪个字段用于将子 Span 挂到父 Span 上?
- A.traceID 全局链路标识
- B.parentSpanID 父级定位标识← 正确答案
- C.serviceName 服务归属标记
- D.operationName 操作名称标记
答案:B。parentSpanID 指向父 Span 的唯一 ID,是新 Span 接入追踪树的关键。traceID 标识整条链路而非层级关系;serviceName 和 operationName 只是描述性元数据,不参与树结构构建。
Q4. Jaeger 组件中,负责接收应用侧 Span 并转发给 Collector 的是?
- A.Agent 轻量代理组件← 正确答案
- B.Query 查询服务入口
- C.Ingester 数据写入组件
- D.Distributor 分发调度模块
答案:A。Jaeger Agent 以 DaemonSet 方式部署在业务节点旁,通过 UDP 接收应用上报的 Span 并批量转发给 Collector。Query 负责查询展示,Ingester 与 Distributor 是 Tempo 的组件,与 Jaeger 无关。
Q5. 头部采样(Head-based Sampling)的采样决策发生在什么时候?
- A.请求结束后由后台异步决定
- B.根 Span 创建时一次性决策← 正确答案
- C.每个 Span 分别独立随机决策
- D.查询 Trace 时动态临时决策
答案:B。头部采样在请求入口(根 Span 创建时)按概率决定整条链路是否采样,并把决策结果通过上下文传播给所有子 Span。它不需要等待请求结果,开销小但可能漏掉错误请求。
Q6. Grafana Tempo 存储追踪数据主要依赖什么后端?
- A.对象存储服务如 S3← 正确答案
- B.单节点的本地 SQLite
- C.无持久化能力的 Redis
- D.进程内的临时内存队列
答案:A。Tempo 的设计核心是利用对象存储(S3、GCS、MinIO)保存全量 Trace 数据,Ingester 先把数据缓冲到内存或 SSD,随后刷成 block 落盘对象存储。SQLite、Redis 或纯内存队列在成本和持久性上都不适合海量追踪数据。
Q7. 日存储量公式估算中,QPS 变为原来 2 倍且采样率降为原来一半时,存储量如何变化?
- A.变为原来存储的四倍
- B.变为原来存储的两倍
- C.保持原有存储量不变← 正确答案
- D.缩减为原来存储的一半
答案:C。存储量与 QPS 成正比、与采样率成正比。QPS × 2、采样率 × 0.5,两者相乘恰好抵消,所以日存储量不变。这提醒我们:调低采样率可以直接对冲流量增长带来的存储压力。
Q8. 下列哪些属于常见的分布式追踪系统?
- A.Jaeger 分布式追踪系统← 正确答案
- B.Grafana Tempo 追踪平台← 正确答案
- C.Prometheus 指标监控系统
- D.Zipkin 开源追踪库成果← 正确答案
答案:ABD。Jaeger、Tempo、Zipkin 都是成熟的分布式追踪后端,属于可观测性三支柱中的 Traces 支柱。Prometheus 是指标监控系统,属于 Metrics 支柱,不负责追踪调用链。
Q9. 关于追踪上下文传播,下列哪些说法正确?
- A.可借助 HTTP Header 传递← 正确答案
- B.只能使用 UDP 协议传递
- C.消息队列场景可透传← 正确答案
- D.必须将 ID 写入数据库
答案:AC。HTTP Header(如 W3C traceparent)是最常见的传播载体,消息队列场景则通过消息 Header 透传上下文。UDP 只用于 Jaeger Agent 上报,不负责服务间传播;把 ID 写入数据库既慢又不必要。
Q10. 下列哪些方案适合“重点保留错误和慢请求链路,同时控制存储成本”?
- A.尾部采样保留异常链路← 正确答案
- B.头部采样随机抽检异常
- C.错误标记触发动态补采← 正确答案
- D.永久关闭追踪采样功能
答案:AC。尾部采样在请求结束后根据错误/延迟权重决定是否保留,天然聚焦异常链路;错误标记触发动态补采(如 OpenTelemetry 的 Tail Sampling Processor 按错误标签决策)也是成熟做法。头部采样是随机盲抽,可能漏掉异常;关闭采样则彻底失去追踪能力。