想象你是一家大型电商公司的技术负责人,名叫老周。你们的系统早已不是“一台服务器跑所有业务”的单体时代,而是拆成了上百个微服务:用户登录、商品浏览、下单、支付、库存扣减……每一次你在 App 上点“立即购买”,背后就是一场几十个服务接力传递数据的“接力赛”。过去系统出问题时,你像在黑夜里找一根断掉的保险丝,无从下手。 好在有一个叫“分布式追踪”的工具,它给每一次用户请求贴上一张“运单号”(Trace ID),每个服务处理请求时都记录下“快递员到过哪里、做了什么、花了多久”(Span)。这些记录汇总起来,就成了一张完整的“物流轨迹图”。而 Jaeger、Zipkin、Tempo,就是三家处理这些运单信息的“快递分拣中心”——它们都能帮你还原接力赛的全程,但每家公司的仓库设计、查件方式、运营成本却天差地别。选对了,海量链路数据轻松驾驭;选错了,你会在存储账单和慢查询里苦不堪言。这篇文章,就是带你逛完这三家“分拣中心”,听一听三位主管各自怎么说。

岗位关联

对 AI 从业者而言,可观测性是排查分布式 AI 系统(如模型推理链路、特征管道)故障的基石。掌握三大追踪后端的选型逻辑,让你在搭建 AI 服务监控体系时少走弯路,快速定位性能瓶颈。

目录
  1. 可视化演示
  2. 三大后端初识
  3. 存储架构对比
  4. 查询机制对比
  5. 部署与性能
  6. 选型决策指南
  7. 迁移与落地
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示Jaeger Agent Collector Query Storage组件链路、索引型与非索引型存储架构对查询的影响、以及Jaeger Zipkin Tempo三大后端在存储与查询能力的对比。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Jaeger四组件链路 Agent接收Span批量转发Collector Collector写入Storage Query查询 Agt→Coll→Stor→Qry 组件分层支撑全链路追踪

三大后端初识

在天工市的物流行业,有三家风格迥异的快递分拣中心。第一家是“城市邮局”Zipkin,2012 年由 Twitter 开源,用 Java 写成,是追踪界的元老——像一位资深的邮政局长,规矩多、流程稳,很多老牌企业(如阿里的鹰眼早期)都受过它的启发。第二家是“极速快运”Jaeger,由 Uber 在 2017 年捐给 CNCF,用 Go 语言打造,主打高性能和云原生,像一支装备精良的机动分队。第三家是“云仓一体”Tempo,由 Grafana Labs 在 2021 年推出,背靠 Grafana 全家桶,像一座直接连通云厂商大仓库的智能物流中心。

这三家的共同目标完全一致:接收来自各微服务的 Span(追踪片段),按照 Trace ID 把它们拼成完整的调用链,并提供查询界面。但它们面对同一个问题时——“如何在海量运单中快速找到想要的那张?”——给出了截然不同的答案。

要理解答案的差异,先得明白一个核心概念:Span 是链路追踪的最小单元,记录了一次跨服务调用的名称、耗时、父级关系等元数据;Trace 则是若干 Span 按树形结构组成的完整请求轨迹。如果把一次用户请求比作一趟“接力赛”,Trace 就是整场比赛的录像,Span 则是每个运动员单独佩戴的 GPS 记录仪。

理解了 Span 的数据结构,就好比看懂了快递面单上的每一个格子。接下来,我们走进三家分拣中心的“仓库重地”,看看它们各自的存放哲学。

Trace=⋃i=1nSpani  ,Spani={traceId,spanId,parentSpanId,serviceName,operationName,duration,tags}\text{Trace} = \bigcup_{i=1}^{n} \text{Span}_i \;,\quad \text{Span}_i = \{ \text{traceId}, \text{spanId}, \text{parentSpanId}, \text{serviceName}, \text{operationName}, \text{duration}, \text{tags} \}
一个 Trace 由多个 Span 组成;每个 Span 记录着它在整棵调用树中的位置关系(parentSpanId)以及它们属于哪个服务、执行了什么操作、耗时多久、附加了哪些标签。
  1. Trace 的组成Trace 是所有 Span 的并集,表示一次完整请求的全部追踪数据。
    Trace=⋃i=1nSpani\displaystyle \text{Trace} = \bigcup_{i=1}^{n} \text{Span}_i
  2. Span 的字段结构每个 Span 包含 traceId(运单号)、spanId(本环节编号)、parentSpanId(上一环节编号)等字段——正是这个 parentSpanId,把散落的 GPS 记录串成了一棵树。
    Span={traceId,spanId,parentSpanId,serviceName,operationName,duration,tags}\displaystyle \text{Span} = \{ \text{traceId}, \text{spanId}, \text{parentSpanId}, \text{serviceName}, \text{operationName}, \text{duration}, \text{tags} \}

存储架构对比

老周跟着 Zipkin 的邮政局长走进仓库,看到一排排整齐的货架——这是 Elasticsearch 集群。每一个 Span 被当作一份 JSON 文档存入 ES,靠倒排索引撑起查询。这种方式像在传统仓库里给每件包裹建立“索引卡片”,查询灵活,但索引的建立和维护要吃掉大量磁盘和内存。“老牌邮局”的这套玩法虽然成熟,运维 ES 集群却像伺候一位挑剔的老爷。

Jaeger 的仓库则提供了两种货架可选:支持 Cassandra 和 Elasticsearch。Cassandra 是列式存储,擅长海量写入,适合超大规模场景;ES 则与 Zipkin 类似,便于多维检索。Jaeger 让客户按需选择,就像快运公司让你选“走陆运还是空运”。但请注意,Cassandra 运维门槛高,ES 又在高基数标签下容易臃肿——两种选择各有隐形成本。

Tempo 的“仓库”最另类——它几乎没有自己的索引,直接把 Span 数据压缩后丢进对象存储(S3、GCS 或阿里云 OSS)。这就像一家不建货架的仓库,把所有包裹按“运单号”直接堆进成本极低的云仓。它不做复杂的索引,想要检索 Trace 时,用 Trace ID 算出对象存储的路径,直接取出对应文件。

存储架构的分野,直接决定了查询时的体验。接下来我们看看,三家分拣中心的“查件柜台”分别怎么工作。

CTCO(Tempo)≈Cstorage(S3)+Ccompute(查询时)  ,CTCO(Zipkin)≈Cstorage(ES)+Cindex(常驻)C_{\text{TCO}}(Tempo) \approx C_{\text{storage}}(S3) + C_{\text{compute}}(\text{查询时}) \;,\quad C_{\text{TCO}}(Zipkin) \approx C_{\text{storage}}(ES) + C_{\text{index}}(\text{常驻})
Tempo 的总拥有成本集中在低频查询的计算上,而 Zipkin 的成本大头在 ES 集群常驻的索引开销。这也是为什么 Tempo 在大数据量下“越跑越便宜”。
  1. Tempo 的成本公式Tempo 的账单主要是对象存储费用(便宜)+ 查询时按需拉起计算资源的费用(用多少付多少)。
    CTCO(Tempo)≈Cstorage(S3)+Ccompute(查询时)\displaystyle C_{\text{TCO}}(Tempo) \approx C_{\text{storage}}(S3) + C_{\text{compute}}(\text{查询时})
  2. Zipkin 的成本公式Zipkin 的 ES 集群必须 7×24 小时运行,索引常驻内存,因此成本是“存储 + 常驻索引”。
    CTCO(Zipkin)≈Cstorage(ES)+Cindex(常驻)\displaystyle C_{\text{TCO}}(Zipkin) \approx C_{\text{storage}}(ES) + C_{\text{index}}(\text{常驻})

查询机制对比

在 Zipkin 的查件柜台,工作人员会先问你:“寄件服务叫什么?收件操作叫什么?”然后对着 ES 的索引卡片逐一翻找。它的查询模型是“服务名 + 操作名 + 时间范围”的组合检索,能够找到符合条件的所有 Trace。但问题在于,当数据量大时,这种“翻卡片”的方式响应变慢;而且 Zipkin 早期不支持标签(Tags)深度过滤,定位一个“失败支付”具体发生在哪一跳,往往要人工逐个排查。

Jaeger 的查询柜台明显更先进。它允许你用标签直接筛选,比如 error=true 或者 http.status_code=500,然后基于这些 KV 组合快速定位目标链路。这相当于在快递面单上做了“多层索引卡”,既支持服务维度检索,也支持标签维度过滤。代价就是每写入一个 Span,都要同步更新这些标签索引,产生“写放大”。

Tempo 的取件方式干脆利落——柜台只问一个问题:“运单号(Trace ID)是多少?”你只要给出 Trace ID,它直接去对象存储里精确取件,速度快得惊人。它不擅长做“条件模糊搜索”,但 Grafana 生态的 Tempo 通常搭配 Loki(日志)和 Prometheus(指标)一起工作:先用日志或指标锁定一个有问题的 Trace ID,再交给 Tempo 精确拉取完整链路。

查件方式的差异决定了各自的适用场景。那么这三家分拣中心在“日常运营”中,谁更省人省力?我们看看部署与性能这盘棋。

QTempo=O(hash(TraceID)→ObjectKey)=O(1)vsQZipkin=O(IndexScan)Q_{\text{Tempo}} = O(\text{hash}(\text{TraceID}) \rightarrow \text{ObjectKey}) = O(1) \quad\text{vs}\quad Q_{\text{Zipkin}} = O(\text{IndexScan})
Tempo 的查询是哈希直达 O(1) 的精确寻址,而 Zipkin 依赖索引扫描,数据量越大两者查询延迟差距越明显。
  1. Tempo 的哈希直达Tempo 对 Trace ID 做哈希,直接映射到对象存储中的文件路径,取件只需一次磁盘读取,复杂度 O(1)。
    QTempo=O(hash(TraceID)→ObjectKey)=O(1)\displaystyle Q_{\text{Tempo}} = O(\text{hash}(\text{TraceID}) \rightarrow \text{ObjectKey}) = O(1)
  2. Zipkin 的索引扫描Zipkin 需要扫描 ES 索引卡片,复杂度随索引规模增长,数据量大时延迟明显上升。
    QZipkin=O(IndexScan)\displaystyle Q_{\text{Zipkin}} = O(\text{IndexScan})

部署与性能

如果用一个词总结 Zipkin 的部署体验,那就是“重”。它需要你自建 ES 集群,生产环境至少 3 节点起步;用 Java 写的服务端内存占用高,启动慢,但社区资料多,出了问题网上到处有解药。对于没有专职 ES 运维的中小团队,这像养了一只食量巨大的看门犬。

Jaeger 的部署轻盈不少。官方提供 All-in-One 模式用于测试,生产环境可拆分为 collector、query、agent 三组件,每个都是无状态的 Go 二进制,内存占用远低于 Java 系。配合 Kubernetes Operator,一个 YAML 就能拉起整套系统。不过它的性能同样受存储后端制约——写入 Cassandra 时吞吐高,写入 ES 时则受索引瓶颈限制。

Tempo 的部署最“云里云气”。后端存储直接用云厂商的对象存储,无需维护任何索引集群。它的查询组件是可选的无状态微服务,按需伸缩。这种架构下,Tempo 几乎可以把运维成本降到最低,但代价是查询体验需要依赖 Grafana 生态的配合才能完整体现。

现在你心里大概有数了:没有绝对的优劣,只有匹配度的问题。那到底该选哪家?我们进入选型决策指南。

Swrite∼BatchSizetˉindex+storageIO  ,IndexAmplification=实际写入有效数据S_{\text{write}} \sim \frac{\text{BatchSize}}{\bar{t}_{\text{index}}} + \text{storageIO} \;,\quad \text{IndexAmplification} = \frac{\text{实际写入}}{\text{有效数据}}
链路数据的写入速度受索引构建时间影响,Zipkin/Jaeger 的索引写入放大效应明显高于 Tempo 的“零索引”模式。
  1. 写入吞吐公式写入吞吐 S 由批处理大小除以索引耗时加上存储 IO 决定,索引越重、吞吐越低。
    Swrite∼BatchSizetˉindex+storageIO\displaystyle S_{\text{write}} \sim \frac{\text{BatchSize}}{\bar{t}_{\text{index}}} + \text{storageIO}
  2. 索引放大系数索引放大 = 实际写入磁盘的数据量 / 有效链路数据量。Zipkin/Jaeger 通常大于 2,Tempo 接近 1。
    IndexAmplification=实际写入有效数据\displaystyle \text{IndexAmplification} = \frac{\text{实际写入}}{\text{有效数据}}

选型决策指南

老周把三家分拣中心的情况列成了一张表,开始按自己的条件做排除法。第一步看技术栈亲和性:如果团队已经是 Java 主导、ES 运维成熟,选 Zipkin 顺理成章;如果公司全面云原生化、Go 微服务为主,Jaeger 如鱼得水;如果已经重度拥抱 Grafana(Loki + Prometheus + Grafana),那 Tempo 是必然之选,因为它与 Grafana 的 UI 无缝集成。

第二步看规模与预算。小规模(日均 Trace 百万级以下)用 Zipkin 的 ES 方案最稳妥,开发资源好找;中大规模(日均千万级)且预算有限,Tempo 的对象存储路线能省下惊人的存储成本;追求极致查询性能与标签过滤能力、预算充足,Jaeger + Cassandra 是硬核选择。

第三步看团队运维能力。Zippkin 需要你懂 ES、Jaeger 需要你懂 Cassandra(如果选它)或 ES、Tempo 几乎不需要你懂存储运维,但要求你理解对象存储与 Grafana 生态。最后还要考虑可观测性整合度:Tempo 与 Prometheus 的“指标→链路”跳转体验业内有口皆碑,Zipkin 则相对封闭。

选型定下来只是开始。真正让老周睡不着觉的,是如何把正在运行的链路数据从旧系统搬到新系统,不能断、不能丢、不能影响业务。这就要说到迁移与落地了。

Score(S)=w1⋅Affinitystack+w2⋅(1−CostBudget)+w3⋅EcoIntegrate\text{Score}(S) = w_1 \cdot \text{Affinity}_{\text{stack}} + w_2 \cdot \left( 1 - \frac{\text{Cost}}{\text{Budget}} \right) + w_3 \cdot \text{EcoIntegrate}
选型打分 = 技术栈亲和性 × 权重 + 成本匹配度 × 权重 + 生态整合度 × 权重。权重由你所在团队的核心痛点决定。
  1. 技术栈亲和性得分Affinity 衡量后端与团队现有语言、基础设施的匹配程度——Java 团队配 Zipkin、Go 团队配 Jaeger 都是高分。
    Affinitystack\displaystyle \text{Affinity}_{\text{stack}}
  2. 成本匹配度得分成本占预算比例越低,该项得分越高,用来体现 Tempo 在超大规模下的成本优势。
    1−CostBudget\displaystyle 1 - \frac{\text{Cost}}{\text{Budget}}
  3. 生态整合度得分EcoIntegrate 衡量与现有监控体系(Prometheus、Loki、Grafana)联动的顺畅程度,Tempo 在此项常常拉满。
    EcoIntegrate\displaystyle \text{EcoIntegrate}

迁移与落地

老周的团队原本用的是 Zipkin,现在决定转向 Tempo 已降低存储成本。迁移第一步是协议兼容——庆幸的是,Zipkin 的客户端(如 Brave、Zipkin 官方库)可以通过 Zipkin 兼容 API 直接把 Span 数据发给 Jaeger 或 Tempo。Tempo 内置了 Zipkin API 的接收端点,这意味着老周根本不用改业务代码,只需要把数据上报的地址从 Zipkin 服务器指向 Tempo,老系统就能“无缝换轨”。

第二步是接入 OpenTelemetry(OTel)做统一收口。OTel 是一个与厂商无关的遥测标准,它的 SDK 支持导出到 Jaeger、Zipkin、Tempo 等所有主流后端。老周的做法是:在应用层统一埋点 OTel,由 OTel Collector 作为“总转运站”,按需把数据同时发给新旧两个后端做“双写”。双写期间对比两边数据完整性,验证新系统没问题后再逐步减少向旧系统的流量,直到完全切换。

最后一步是风险管控。老周在设计方案时特意加了一条“开关”:通过 OTel Collector 的动态配置,可以随时把新后端从数据流中摘除,避免新系统故障拖垮业务。同时,老周在 Tempo 上配置了采样策略——全量生产流量的 10% 双写,验证通过后再放大到 100%。这个过程,就像给高铁换轨,先把列车引到临时便道,确认无误后再拆除旧轨。

至此,老周对三家分拣中心从仓库到柜台再到换轨策略都有了全面认识。其实选型没有银弹,重要的是你把团队的技术栈、成本预算、运维能力和生态现状放在桌面上,对照三家特点做理性的匹配。

MigrationReadiness=双写一致性数据丢失率+1×回滚能力\text{MigrationReadiness} = \frac{\text{双写一致性}}{\text{数据丢失率} + 1} \times \text{回滚能力}
迁移就绪度 = 双写一致性 /(数据丢失率 + 1)× 回滚能力。双写一致、丢失率为零、随时可回滚,才是安全的切换。
  1. 双写一致性双写期间新旧后端的数据一致比例,通过定时比对 Trace ID 集合得出,越高越安全。
    双写一致性=∣Tracenew∩Traceold∣∣Traceold∣\displaystyle \text{双写一致性} = \frac{| \text{Trace}_{\text{new}} \cap \text{Trace}_{\text{old}} |}{| \text{Trace}_{\text{old}} |}
  2. 数据丢失率新系统未能接收到的 Trace 比例,目标为零;分母 +1 是为了防止除零并使“零丢失”得满分。
    数据丢失率=1−双写一致性\displaystyle \text{数据丢失率} = 1 - \text{双写一致性}
  3. 回滚能力通过 OTel Collector 动态切换配置,能在 5 分钟内切回旧系统的能力,是迁移的保险绳。
    回滚能力∈{0,1}\displaystyle \text{回滚能力} \in \{0, 1\}

AI 应用场景

场景一:AI 推理服务链路诊断

阶段:模型推理与在线服务监控

解决的问题:一个 AI 图片识别服务由“网关→鉴权→特征提取→模型推理→结果后处理”5 个微服务组成。传统方式中,模型推理变慢时,你只知道整体延迟升高,却不知瓶颈在哪一环。接入 Jaeger 后,每个服务的 Span 都记录耗时,用标签 model_version=v2 过滤出特定模型版本的所有链路,瞬间定位到“特征提取”环节耗时异常——原来是新版本的归一化逻辑引入了性能回退。

具体案例:某智能客服公司为每个会话请求生成 Trace ID,通过 Jaeger 的标签检索功能筛查 sentiment_model=bert-large 的链路数据,发现 GPU 服务在高并发下的排队时间激增,据此触发弹性扩容策略。

场景二:AI 数据管道可观测性

阶段:数据处理与特征工程

解决的问题:离线特征管道每天处理 TB 级数据,管道由 Spark 任务、特征存储写入、模型训练触发等多个阶段组成。使用 Tempo 后,每个阶段作为 Span 上报,Trace ID 与数据分片 ID 绑定。当某天的特征数据质量异常时,数据工程师从 Grafana 看板上发现异常指标,点击跳转到 Tempo 拉取对应分片的完整处理链路,快速定位是“写入特征库超时”还是“上游数据源延迟”。

具体案例:某推荐系统团队用 Tempo 追踪“用户行为日志→特征拼接→模型打分”的批处理链路,配合 Loki 日志跳转,将平均故障排查时间从 2 小时缩短到 15 分钟。

场景三:AI Agent 多工具调用追踪

阶段:大模型应用编排

解决的问题:一个 AI Agent 在回答问题时可能依次调用搜索工具、代码解释器、知识库检索等多个外部服务。每个工具调用就是一个 Span,Trace 完整还原 Agent 的思考与动作序列。使用 Zipkin 兼容 API 上报数据到 Tempo,可以在 Grafana 上按 Trace ID 回放 Agent 的全部工具调用时间线,找出哪个外部 API 拖慢了整体响应。

具体案例:某创业公司的 AI 编程助手接入 OTel SDK 后,所有插件调用链路自动上报至 Tempo,工程师通过链路火焰图观察到“代码检索服务”的 p99 延迟高达 8 秒,据此为向量数据库增加了缓存层。

Python 演示

python96 行
"""简化模拟:三大追踪后端的 Span 存储与查询对比"""import hashlibimport timefrom dataclasses import dataclass, fieldfrom typing import Dict, List, Optional  @dataclassclass Span:    trace_id: str    span_id: str    parent_span_id: Optional[str]    service_name: str    operation_name: str    duration_ms: float    tags: Dict[str, str] = field(default_factory=dict)  class ZipkinBackend:    """老牌邮局:全量索引,查询靠服务名+操作名"""    name = "Zipkin"     def __init__(self):        self.spans: List[Span] = []  # 模拟 ES 文档库        self.index: Dict[tuple, list] = {}  # (service, operation) -> span 列表     def write(self, span: Span):        self.spans.append(span)        key = (span.service_name, span.operation_name)        self.index.setdefault(key, []).append(span)     def query(self, service_name: str, operation_name: str) -> List[Span]:        key = (service_name, operation_name)        return self.index.get(key, [])  class JaegerBackend:    """极速快运:在 Zipkin 基础上增加标签索引"""    name = "Jaeger"     def __init__(self):        self.spans: List[Span] = []        self.tag_index: Dict[str, Dict[str, list]] = {}  # tag_key -> tag_value -> spans     def write(self, span: Span):        self.spans.append(span)        for k, v in span.tags.items():            self.tag_index.setdefault(k, {}).setdefault(v, []).append(span)     def query(self, tag_key: str, tag_value: str) -> List[Span]:        return self.tag_index.get(tag_key, {}).get(tag_value, [])  class TempoBackend:    """云仓一体:零索引,TraceID 哈希直达对象存储"""    name = "Tempo"     def __init__(self):        self.store: Dict[str, List[Span]] = {}  # object_key -> trace spans     def write(self, span: Span):        # TraceID 哈希计算对象存储路径        key = hashlib.md5(span.trace_id.encode()).hexdigest()[:16]        self.store.setdefault(key, []).append(span)     def query(self, trace_id: str) -> List[Span]:        key = hashlib.md5(trace_id.encode()).hexdigest()[:16]        return self.store.get(key, [])  def build_demo_trace() -> List[Span]:    """构造一条模拟链路:网关 -> 商品服务 -> 支付服务"""    return [        Span("abc123", "s1", None, "api-gateway", "POST /checkout", 105.0, {"http.status_code": "200"}),        Span("abc123", "s2", "s1", "product-svc", "getStock", 45.0, {"http.status_code": "200"}),        Span("abc123", "s3", "s1", "payment-svc", "charge", 58.0, {"error": "true", "http.status_code": "500"}),    ]  if __name__ == "__main__":    trace = build_demo_trace()    backends = [ZipkinBackend(), JaegerBackend(), TempoBackend()]    for backend in backends:        for span in trace:            backend.write(span)        print(f"--- {backend.name} ---")         if isinstance(backend, ZipkinBackend):            result = backend.query("payment-svc", "charge")            print(f"按服务+操作查询: {len(result)} 个 Span, 含错误标签: {result[0].tags}")        elif isinstance(backend, JaegerBackend):            result = backend.query("error", "true")            print(f"按错误标签查询: {len(result)} 个 Span, duration_ms={result[0].duration_ms}")        elif isinstance(backend, TempoBackend):            result = backend.query("abc123")            print(f"按 TraceID 精确取件: {len(result)} 个 Span, 耗时最长 {max(s.duration_ms for s in result)}ms")
目的:用三个类分别模拟 Zipkin、Jaeger、Tempo 的存储与查询逻辑,直观展示不同索引策略对查询方式的影响。
  1. key = (span.service_name, span.operation_name)Zipkin 建立“服务名+操作名”的联合索引卡片,查询必须提供这两个字段
  2. self.tag_index.setdefault(k, {}).setdefault(v, []).append(span)Jaeger 为每个标签键值对建立倒排索引,实现多维过滤
  3. hashlib.md5(span.trace_id.encode()).hexdigest()[:16]Tempo 用 Trace ID 的哈希值作为对象存储路径,实现 O(1) 直达取件
  4. if isinstance(backend, ZipkinBackend)不同后端的查询方式差异通过类型判断演示,体现选型对查询模式的决定性影响

常见误区

误区Tempo 按 Trace ID 查询,不能像 Jaeger 那样直接按标签搜索链路,所以它的性能全面落后于 Jaeger,不适合生产环境。

正解这混淆了“查询方式”与“查询性能”。Tempo 的 Trace ID 精确查询是 O(1) 的哈希寻址,比 Jaeger 的索引扫描更快。它只是不支持复杂的按标签搜索——这个短板在真实场景中往往被 Loki 和 Prometheus 补齐:先用日志和指标定位 Trace ID,再交给 Tempo 精准取件。就像你查快递不必翻遍仓库找“所有红色箱子”,只需报出运单号,仓库的自动化机械臂直接取货。

为何常见:初学者习惯用“能否条件搜索”来衡量后端能力强弱,而忽略了可观测性体系的整体配合。

误区Jaeger 是 CNCF 项目、Go 语言、性能好,所以任何场景都应该直接选 Jaeger,Zipkin 已经过时了。

正解Zipkin 成熟稳定、社区庞大、Java 生态兼容性好,在 ES 运维经验丰富的团队手中依然高效。选型的关键是匹配度——就像你选快递公司,寄同城急件可能选闪送(Jaeger),但寄偏远乡村可能邮政(Zipkin)反而更可靠。Jaeger 如果搭配 ES 存储,同样的索引放大问题依然存在;而 Zipkin 的 Brave 客户端在 Java 技术栈中至今仍是零侵入集成的典范。

为何常见:人们容易受“新项目优于老项目”的直觉影响,忽略存量系统、团队技能、运维成本等现实约束。

误区Tempo 零索引的设计让它存储成本极低,所以无脑选 Tempo 就是最佳省钱方案。

正解Tempo 把索引成本转移到了“查询时的计算成本”。低频的按需查询确实便宜,但如果你需要频繁做标签过滤分析(例如:按 error=true 快速扫描全部失败链路),Tempo 无能为力,只能靠 Loki 侧面绕过。这就好比省了货架钱(索引)的仓库,找货时必须全家齐上阵(大量计算资源)。在日均几百万级 Trace 的小规模下,Zipkin + ES 单机版反而更简单划算。

为何常见:将“零索引”简单等同于“零成本”,忽略了查询模式对总成本结构的影响。

误区从 Zipkin 切换到 Jaeger 或 Tempo 必须重写所有微服务的埋点代码,工程浩大风险高,因此宁愿留在旧系统。

正解三大后端之间并非孤立。Zipkin 兼容 API 允许 Zipkin 客户端的数据直接上报给 Jaeger 或 Tempo;更现代的做法是先用 OpenTelemetry SDK 统一埋点,再通过 OTel Collector 配置多路导出,实现“双写新旧系统”的灰度切换,业务代码完全不动。这就像你的手机号可以携号转网,换的是运营商,号码不变,联系人无感知。

为何常见:直觉上会认为后端存储不同、API 格式不同,必然要求改数据源,但忽略了网关层(OTel Collector)的存在意义。

误区Zipkin 和 Jaeger、Tempo 的追踪数据格式不互通,不能同时使用两个后端作数据比对。

正解Zipkin 的 Zipkin v2 JSON 和 OpenTelemetry 的 OTLP 格式在字段层面高度兼容(serviceName、operationName、tags、traceId 等语义一一对应),Jaeger 与 Tempo 都支持直接接收 Zipkin 格式的 span。因此“双写”比想象中的容易。真正的差异不在“能否收”,而在“收了之后怎么存、怎么查”——这正是本篇文章的核心观点。

为何常见:初学者从各自的官方文档入手,看到的是不同的 API 名称和配置参数,误以为底层数据模型也是彼此隔离的孤岛。

面试题

Q1. 分布式追踪中,一个 Trace 是由什么组成的?

单选题 · easy

  • A.多个日志事件的集合
  • B.多个 Span 按树形结构组成的完整请求轨迹← 正确答案
  • C.多个指标数据的聚合
  • D.单个服务的性能快照

答案:B。Trace 是若干 Span 按 parentSpanId 关系组成的树形结构,记录一次完整请求的全链路信息。Span 是追踪最小单元,多个 Span 通过父子关系拼接成 Trace。日志事件集合属于日志系统范畴,指标聚合是 Metrics 系统的职责,均不符合 Trace 定义。

Q2. 以下哪个后端使用 Go 语言开发并获得 CNCF 孵化?

单选题 · easy

  • A.Zipkin
  • B.Jaeger← 正确答案
  • C.Tempo
  • D.SkyWalking

答案:B。Jaeger 由 Uber 用 Go 语言开发,2017 年捐给 CNCF 并成为毕业项目。Zipkin 由 Twitter 用 Java 开发,Tempo 是 Grafana Labs 用 Go 开发但未走 CNCF 路线,SkyWalking 是 Apache 项目使用 Java 开发。

Q3. 关于 Tempo 的存储设计,以下描述正确的是?

单选题 · mid

  • A.必须依赖 Elasticsearch 提供索引能力
  • B.直接使用对象存储(S3/GCS/OSS),实现零索引← 正确答案
  • C.使用 Cassandra 存储全量索引
  • D.将数据存入内存数据库以实现高速查询

答案:B。Tempo 的核心特点是不建索引,直接把 Span 压缩后存入对象存储,查询时通过 Trace ID 哈希直达。ES 是 Zipkin 常用存储,Cassandra 是 Jaeger 可选存储,内存数据库不是任何主流后端的设计选择。

Q4. 在 Zipkin 的查询模型中,用户主要通过什么条件检索 Trace?

单选题 · mid

  • A.Trace ID 哈希取模
  • B.标签键值对深度过滤
  • C.服务名 + 操作名 + 时间范围← 正确答案
  • D.日志内容全文搜索

答案:C。Zipkin 的查询模型基于服务名(serviceName)加操作名(operationName)加时间范围的组合检索。标签深度过滤是 Jaeger 的强项,Trace ID 哈希直达是 Tempo 的方式,日志全文搜索属于 Loki 的职责范围。

Q5. 从 Zipkin 迁移到 Tempo 时,最省力的数据上报方案是?

单选题 · mid

  • A.重写所有微服务的埋点 SDK
  • B.使用 Tempo 内置的 Zipkin 兼容 API 接收端点← 正确答案
  • C.通过 Kafka 手工转发数据
  • D.放弃旧数据,直接全量重新上线

答案:B。Tempo 内置 Zipkin API 的接收端点,Zipkin 客户端无需改动代码即可把 Span 上报给 Tempo。重写 SDK 成本高且无必要,Kafka 转发属于多余环节,放弃数据则违背迁移的初衷。这也是 Tempo 吸引 Zipkin 存量用户的核心卖点。

Q6. 关于 Jaeger 与 Tempo 的查询性能对比,以下说法正确的是?

单选题 · hard

  • A.Tempo 支持按标签快速过滤,Jaeger 只能按 Trace ID 查
  • B.Tempo 的 Trace ID 精确查询是 O(1) 哈希寻址,快于 Jaeger 的索引扫描← 正确答案
  • C.Jaeger 因为使用 Go 语言一定比 Tempo 查询更快
  • D.两者查询性能完全相同,没有可比性

答案:B。Tempo 按 Trace ID 哈希算出对象存储路径直接取件,复杂度 O(1);Jaeger 需要走索引扫描加存储读取,链路更长。A 与事实相反(Jaeger 才支持标签过滤),C 是错误归因,D 无视了架构差异。

Q7. 在链路追踪系统中,“索引写入放大效应”指的是?

单选题 · hard

  • A.索引文件占用的磁盘空间逐年膨胀
  • B.每条 Span 数据在写入时还要同步更新多套索引,放大写入成本← 正确答案
  • C.查询变慢导致索引重建频率上升
  • D.索引数量越多历史数据越分散

答案:B。写入放大指每新增一条 Span,除存储原始数据外,还需同步更新服务索引、标签索引等多套倒排索引,真实写入的磁盘数据远超有效数据量。这是 Zipkin(ES 索引)与 Jaeger(标签索引)的共性代价,也是 Tempo 零索引设计的省钱根本。

Q8. 以下哪些因素应该纳入追踪后端选型的评估维度?

多选题 · mid

  • A.团队现有技术栈的亲和性← 正确答案
  • B.业务规模与存储预算约束← 正确答案
  • C.与现有监控体系(Grafana/Prometheus)的整合度← 正确答案
  • D.后端开发语言的执行效率

答案:ABC。选型评估应关注技术栈亲和性(A)、规模预算(B)和生态整合度(C)。后端语言执行效率仅是工程实现的细节,并非选型核心考量——用户关心的是功能、成本、运维与生态,而非后端自身用什么语言实现。

Q9. 关于 Tempo 的查询机制,以下描述正确的是哪些?

多选题 · mid

  • A.支持基于 Trace ID 的高效精确查询← 正确答案
  • B.不支持复杂的标签条件搜索← 正确答案
  • C.需要单独部署 Elasticsearch 作为查询索引
  • D.通常与 Loki 配合完成“日志定位 ID,Tempo 取链路”← 正确答案

答案:ABD。Tempo 的查询以 Trace ID 哈希直达为核心,不做标签索引所以不支持复杂条件搜索,同时它依赖对象存储而非 ES。生产落地时“先由 Loki 从日志中找到 Trace ID,再到 Tempo 拉全链路”是最常见的实践路径。

Q10. 关于 OpenTelemetry(OTel)在追踪迁移中的作用,以下说法正确的是?

多选题 · hard

  • A.OTel Collector 支持同时导出数据到新旧两套后端实现双写← 正确答案
  • B.OTel 的 OTLP 格式与 Zipkin v2 格式在字段语义上不兼容
  • C.OTel SDK 统一埋点后切换后端无需改动业务代码← 正确答案
  • D.OTel 只支持链路追踪,不支持日志与指标

答案:AC。OTel Collector 确实支持多 exporter 配置实现双写,SDK 埋点后更换后端只需改 Collector 配置。OTLP 与 Zipkin v2 的字段语义高度兼容(Jaeger/Tempo 均支持 Zipkin 格式)。OTel 是涵盖 traces、metrics、logs 的三大信号统一标准,并非只做链路追踪。