想象你从网上买了一台冰箱。商家发货后,包裹要经过揽收站、分拨中心、干线运输、末端网点,最后才到你家。你随时打开手机,能看到每一条“扫描记录”:几点到了哪个站、上一站是哪里、停留了多久。把这些记录按“上一站”关系拼起来,就是包裹的完整旅程图。在微服务世界里,一次下单请求就像这个包裹,要穿过网关、订单、支付、库存好几个服务。Span 就是每个服务留下的“扫描记录”,记录这一次处理的起止时间和上下文归属;Baggage 则是贴在包裹上的“共享便签”,写着客户编号、是否加急这类关键信息,让沿途任何服务扫一眼就能看到,不必反复打电话回总部求证。这篇文章就把这套机制讲透,并教你如何用它做跨服务调试——当客户的“快递”卡住时,一眼找出卡在了哪一站。

岗位关联

掌握 Span 与 Baggage,是进入可观测性与 SRE 领域的必修课。你将能在成百上千个微服务中精准定位慢请求与故障,成为团队里那个“一眼看穿链路”的排查专家。

目录
  1. 可视化演示
  2. Span 基本概念
  3. Baggage 机制
  4. Context 传播
  5. 链路树构建
  6. 跨服务调试实践
  7. 性能影响评估
  8. 优化方案
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示 Span 与 Baggage 在分布式追踪中的作用:Span 构建父子调用链,Baggage 键值对跨服务传播,上下文注入与提取,支撑跨服务端到端调试。

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

Span 父子关系 根 Span 构建子 Span 调用链 Parent-Child 树 每个操作一次执行,trace_id 关联全链路

Span 基本概念

快递包裹每到一个中转站,工作人员都会“哔”地刷一下条码,系统里立刻多出一条扫描记录:单号是什么、到站时间、上一站是哪、经手人是哪个班组。这条记录就是物流旅程的最小单位。把它搬到微服务世界:一次下单请求(包裹)穿过网关、订单、支付等多个服务(中转站),每个服务为这次处理生成一条“扫描记录”——这条记录在可观测性技术里就叫 Span。

Span 的核心是“一次命名操作的时间片段”。比如“调用支付接口”“查询库存”“渲染订单页”,每次操作都对应一个 Span。每个 Span 都携带身份信息:trace_id 相当于旅程单号,标识这是哪一次请求的旅程;span_id 是这条记录自己的编号;parent_span_id 则像扫描记录里的“上一站”,标出调用自己的那个 Span。有了这三个编号,所有零散记录才能被拼回一棵树。

Span 还记录起止时间戳和业务属性(attributes),例如 HTTP 状态码、错误信息、数据库表名。时间戳相减就得到这次处理的耗时——这是后续性能排查最基础的数据。注意,Span 是“本地记录”,它只会安静地待在生成它的服务里,不会自己跑去别的地方。

Span 记录得很勤快,但单看一条没有意义。就像单张扫描记录看不出包裹走了多远,必须把所有记录按“上一站”关系接起来。这正是下一节要讲的 Baggage 与链路树要做的事——不过在那之前,还有一个问题:中转站之间怎么知道“上一站”是谁?答案藏在请求传递的上下文里,我们先用便签把关键信息带上车。

Span=(trace_id, span_id, parent_span_id, tstart, tend, attributes)\text{Span} = (\text{trace\_id},\ \text{span\_id},\ \text{parent\_span\_id},\ t_{\text{start}},\ t_{\text{end}},\ \text{attributes})
一个 Span 就是一条“带身份标签的扫描记录”,身份标签决定了它属于哪次旅程、被谁调用、花掉了多少时间。
  1. trace\_id 旅程单号标识整条调用链属于哪一次用户请求,同一次请求的所有 Span 共享同一个值,是链路检索的主键。
    trace_id: 一次请求的全局唯一旅程编号\text{trace\_id}:\ \text{一次请求的全局唯一旅程编号}
  2. span\_id 本条记录号每个 Span 自己的唯一编号,由生成它的服务本地分配。
    span_id: 单条扫描记录的局部编号\text{span\_id}:\ \text{单条扫描记录的局部编号}
  3. parent\_span\_id 上一站编号记录“谁调用了我”,即父 Span 的 span\_id;旅程起点的根 Span 此值为空。
    parent_span_id=父 Span 的 span_id\text{parent\_span\_id} = \text{父 Span 的 span\_id}
  4. 起止时间与耗时记录处理开始与结束时刻,两者相减得到本次操作耗时。
    duration=tend−tstart\text{duration} = t_{\text{end}} - t_{\text{start}}

Baggage 机制

物流公司会在包裹侧面贴一张透明便签,上面写着“客户编号 9527”“加急件,勿压”。任何中转站不需要打电话回总部,扫一眼便签就能拿到这些关键信息。这张便签的分布式追踪版本叫 Baggage:一组随请求传播的键值对,跟着请求头走遍沿途每一个服务。

Baggage 的结构非常朴素,就是一组 key=value 的集合,例如 user_id=88、order_id=9527、debug=on。它通过 HTTP 头的形式在服务间传递(如 baggage 头),任何中间服务都可以读取、新增或修改其中的条目,然后继续传给下游。这跟 Span attributes 有本质区别:attributes 是“贴在本地扫描记录上的备注”,只有生成它的服务能看;Baggage 是“贴在包裹上的便签”,全程可见可传。

Baggage 的生命周期与当前请求绑定:请求进来时被创建,调用下游时随上下文传递,请求结束便签就作废,不会跨请求永久共享。正因为全程可见可改,安全边界必须划清——账号密码、身份证号、支付密钥这类敏感信息绝不该放进 Baggage,否则等于把保险柜密码写在了快递单上。

Baggage 解决了“跨服务共享线索”的问题,但它怎么搭上请求这趟车的?便签不能自己飞,必须有工作人员在交接时把它贴到包裹上。这个“交接动作”,就是分布式追踪里的上下文注入与提取。

Baggage={(k1,v1), (k2,v2), …, (kn,vn)},header: k1=v1, k2=v2, …\text{Baggage} = \{ (k_1, v_1),\ (k_2, v_2),\ \ldots,\ (k_n, v_n) \},\qquad \text{header:}\ k_1\text{=}v_1\text{, } k_2\text{=}v_2\text{, } \ldots
Baggage 就是贴包裹的共享便签:一组键值对随请求跨服务传播,生命周期与请求相同,敏感数据勿入。
  1. 键值对集合Baggage 本质是一个无序的键值对集合,每个键唯一,值为字符串。
    {(k1,v1),(k2,v2),…,(kn,vn)}\{(k_1, v_1), (k_2, v_2), \ldots, (k_n, v_n)\}
  2. 序列化为请求头调用下游前,集合被序列化进请求头,多个成员间用逗号分隔。
    k1=v1, k2=v2, …k_1\text{=}v_1\text{, } k_2\text{=}v_2\text{, } \ldots
  3. 逐跳传递每个服务读取请求头后,把 Baggage 整体复制进自己的上下文,继续传给下一个服务,直到请求结束。

Context 传播

包裹在路上经常要“换车”:从干线货车卸下来,装进末端三轮车。交接时,司机必须办理“过账手续”——把旅程单号、上一站记录单号抄写到新车清单上,否则这车包裹就“失联”了。在分布式系统里,服务 A 调用服务 B 之前做的注入(inject),就是这个过账手续:把当前旅程的 trace_id 和 span_id 写进 HTTP 请求头。

下游服务 B 收到请求后,要做相反的提取(extract):从请求头里读出上游传过来的上下文,然后以自己为“当前记录”,创建新的子 Span。提取出的父 Span 编号会成为子 Span 的 parent_span_id——正是靠这个动作,“上一站”关系才得以延续。如果某个服务忘了注入或提取,链路就会在这里断掉,后面的 Span 会变成一棵“孤儿树”。

这套上下文在标准里叫 TraceContext,通常包含三要素:trace_id(旅程号)、span_id(当前记录号)、trace_flags(标记位,例如是否采样)。W3C 为此制定了 Trace Context 标准,定义了 traceparent 请求头来传递这三个要素,baggage 请求头传递共享便签。有了统一标准,不同语言、不同供应商的追踪系统才能互相衔接。

有了注入和提取,每个服务都记录下“我是被谁调用的”。当这些记录汇聚到追踪后端时,就该做最后一步拼图——按父子关系把它们还原成一棵完整的旅程树。

inject:outgoing_headers←(trace_id, current_span_id),extract:(trace_id, parent_span_id)←incoming_headers\text{inject:}\quad \text{outgoing\_headers} \leftarrow (\text{trace\_id},\ \text{current\_span\_id}),\qquad\text{extract:}\quad (\text{trace\_id},\ \text{parent\_span\_id}) \leftarrow \text{incoming\_headers}
注入是把旅程号写进请求头递给下游,提取是下游从请求头读回旅程号并挂到自己名下,一进一出保证链路不断。
  1. 上游注入服务 A 发起调用前,把当前 trace_id 与 span_id 封装进请求头。
    outgoing_headers←(trace_id, current_span_id)\text{outgoing\_headers} \leftarrow (\text{trace\_id},\ \text{current\_span\_id})
  2. 下游提取服务 B 收到请求,从请求头还原出上游的 trace_id 与父 Span 编号。
    (trace_id, parent_span_id)←incoming_headers(\text{trace\_id},\ \text{parent\_span\_id}) \leftarrow \text{incoming\_headers}
  3. 生成子 SpanB 以提取结果创建自己的 Span,父 ID 指向 A 的 Span,旅程延续。

链路树构建

包裹走完 30 个中转站,系统里就有 30 条扫描记录。要还原旅程,不能按时间平铺,而要按“上一站”关系连接:哈尔滨中心说上一站是长春,长春说上一站是沈阳……一路追到起点,就得到一棵树。链路树(Trace Tree)的构建逻辑完全一样:所有 Span 上报后,追踪后端按 parent_span_id 建立起父子映射。

树必须有根:一个 Span 如果 parent_span_id 为空,它就是这棵树的根节点,通常是入口服务(如 API 网关)生成的第一个 Span。根 Span 之下,每个父 Span 可以挂多个子 Span——当服务并发调用多个下游时,每个下游各生成一个子 Span,它们的 parent_span_id 都指向同一个父 Span,于是在树上形成分支。这棵树的形态恰恰还原了真实的调用拓扑:谁调了谁、哪些调用是并行的、哪条路径最深。

采样策略在这时发挥作用:高并发下如果每条请求都全量建树,存储会爆炸。因此追踪系统通常按采样率决定“哪些旅程值得完整落库”,例如每 100 条请求只完整保存 1 棵链路树。采样发生在 Span 创建或上报阶段,但它不影响 Span 的父子关系构建,只影响“这棵树要不要存下来”。

树建好了,调试就有了地图。但地图怎么用?下一节我们把“快递卡住”的客户投诉场景搬进来,看看 Span 和 Baggage 如何联手定位问题。

Trace=Tree(root),children(S)={S′∣S′.parent_span_id=S.span_id}\text{Trace} = \text{Tree}(\text{root}), \qquad \text{children}(S) = \{ S' \mid S'.\text{parent\_span\_id} = S.\text{span\_id} \}
链路树就是把每个 Span 视作节点、按 parent\_span\_id 连边得到的调用关系树,根 Span 是树的起点。
  1. 根节点判定parent_span_id 为空的 Span 成为根,代表整次请求的入口。
    root={S∣S.parent_span_id=∅}\text{root} = \{ S \mid S.\text{parent\_span\_id} = \emptyset \}
  2. 父子映射对每个 Span,按“父 ID 等于谁”把它挂到对应父节点下。
    children(S)={S′∣S′.parent_span_id=S.span_id}\text{children}(S) = \{ S' \mid S'.\text{parent\_span\_id} = S.\text{span\_id} \}
  3. 递归还原从根出发沿 children 递归展开,得到完整树形拓扑。

跨服务调试实践

客户打电话投诉:“包裹卡了三天没动!”客服打开旅程系统,输入单号,整棵旅程树立刻展开——原来包裹在“支付中转站”停留了 2.9 天。这就是跨服务调试的第一步:链路检索。线上报障时,运维拿到请求的 trace_id,在追踪系统里一查,整条调用链的 Span 树按时间顺序铺开,从网关到数据库一目了然。

第二步是瓶颈定位。端到端延迟等于链路上关键 Span 耗时之和,但更重要的是找出“最长的那一段”:逐个查看每个 Span 的 duration,耗时最长的服务就是瓶颈所在。比如一次下单耗时 1.2 秒,其中“库存预占”服务占了 950 毫秒,优化目标自然锁定在这个服务上,而不是盲目扩容。

第三步是错误回溯。当某个 Span 携带了错误状态码或异常信息,沿着它的父链路向上追溯,就能还原“失败是从哪个下游引起的”。此时 Baggage 是绝佳配角:往里放一个 debug=on 标记,在低采样率的环境下也能让命中标记的请求强制全量采样并输出详细埋点;放入 user_id,则让客服与排查人员能按用户关联到同一请求的全部日志。这就是调试标记透传——便签上写一个“加急”,全链路都会认真对待。

调试实践越熟练,越能体会“每一笔观测都有成本”。可观测性不是免费的午餐——每次注入、每条 Span、每张便签都在消耗 CPU 与带宽。下面就来算算这笔账。

Ttotal=∑S∈chainduration(S),Sbottleneck=arg⁡max⁡S∈chainduration(S)T_{\text{total}} = \sum_{S \in \text{chain}} \text{duration}(S), \qquad S_{\text{bottleneck}} = \mathop{\arg\max}_{S \in \text{chain}} \text{duration}(S)
总耗时是把关键链路上的 Span 耗时逐个相加,瓶颈则是其中耗时最长的那个 Span——找到它,就找到了优化靶心。
  1. 链路分解把端到端延迟拆成链路上每个 Span 的耗时。
    Ttotal=∑S∈chainduration(S)T_{\text{total}} = \sum_{S \in \text{chain}} \text{duration}(S)
  2. 瓶颈定位在所有 Span 中找出耗时最大者。
    Sbottleneck=arg⁡max⁡S∈chainduration(S)S_{\text{bottleneck}} = \mathop{\arg\max}_{S \in \text{chain}} \text{duration}(S)

性能影响评估

每个中转站多刷一次码、多贴一张便签,都要花时间费人工。如果便签上密密麻麻贴了 1000 个字段,包裹要经过 30 个站,就是 3 万次复制。分布式追踪的埋点开销与此同理,主要来自三方面:一是生成 Span 时的 CPU 消耗(分配 ID、记录时间戳、序列化属性);二是请求头体积增大带来的网络传输开销(每次下游调用都携带 traceparent 与 baggage);三是追踪后端的存储与查询成本。

采样率是控制成本最关键的旋钮。设一次请求平均产生 $N$ 个 Span,采样率为 $p$,每个 Span 落库约 $b$ 字节,则每条请求的追踪存储成本约为 $N \times p \times b$。把 $p$ 从 100% 降到 10%,成本直接降一个量级——但也意味着 90% 的旅程记录被丢弃。

Baggage 还有一个隐蔽的“放大效应”:在高扇出场景下,一个服务并发调用 20 个下游,完整 Baggage 就被复制 20 份;如果每个 Baggage 有 8KB,一次扇出就要传递 160KB 的冗余头信息。因此工程上要监控两个指标:采样率是否符合预期,以及链路完整率(成功建树的比例),防止采样或 Baggage 过大导致链路断裂。

账算清楚了,优化方案就水到渠成:要么减少 $N$(少埋点),要么降低 $p$(聪明地采样),要么压缩 $b$(给便签瘦身)。下一节给出具体的工程组合拳。

Cost=N×p×b,p=NsampledNtotal\text{Cost} = N \times p \times b, \qquad p = \frac{N_{\text{sampled}}}{N_{\text{total}}}
追踪成本 = 总 Span 数 × 采样率 × 单条存储字节数;采样率就是被保留的 Span 占全部 Span 的比例。
  1. 总数与采样率$N$ 是请求产生的全部 Span 数,$p$ 是采样率。
    p=NsampledNtotalp = \frac{N_{\text{sampled}}}{N_{\text{total}}}
  2. 单位成本$b$ 是每个 Span 落库的平均字节数,含索引与属性。
    Cost=N×p×b\text{Cost} = N \times p \times b

优化方案

精明的物流公司不会把每一笔扫描都存档十年:普通件抽样留档,加急件全量留存,便签上只印必要字段。分布式追踪的优化思路完全一致,核心是“分层采样 + 限制便签”。

第一招是采样策略分层调优。头部采样在请求入口按比例决定整棵链路存不存,简单但对慢请求不友好;尾部采样等请求结束后再根据结果(如是否失败、是否超时)决定是否保留,能精准捞住异常;实践中更常用优先级采样:对支付、退款等关键业务打上标记,强制 100% 采样,普通查询类请求按 10% 采样。这相当于给加急包裹贴“必扫”标签。

第二招是 Baggage 瘦身。限制键的数量(例如最多 10 个)与总大小(例如 8KB 上限),只保留跨服务调试必需的最小集合:user_id、debug 标记、灰度标记等;其余信息一律用 Span attributes 留在本地。同时避免在高扇出服务上追加过大的键值对,防止放大效应失控。

第三招是异步上报。Span 生成后先写进内存缓冲队列,由独立线程批量、异步地发送给追踪后端,业务线程完全不被上报阻塞。这就像中转站先把扫描单堆在筐里,再由专人定时整车拉走,而不是每扫一单就派人跑一趟总部。结合预算约束,最优采样率可以这样估算:

从 Span 的记录、Baggage 的便签,到注入提取的交接、链路树的还原,再到调试实践与成本优化——这套机制环环相扣。就像一张快递旅程单让物流全程透明,Span 与 Baggage 让分布式系统的每一次调用都清晰可查、可追、可优化。

p∗=min⁡(1, B预算N×b)p^{*} = \min\left(1,\ \frac{B_{\text{预算}}}{N \times b}\right)
在存储预算 $B$ 固定时,最优采样率 = 预算 ÷(总 Span 数 × 单条字节数),上限为 1——预算越紧,采样越狠。
  1. 预算约束$B_{\text{预算}}$ 是每请求可承受的追踪存储上限。
    B预算N×b\frac{B_{\text{预算}}}{N \times b}
  2. 封顶取小采样率不能超过 1,与 1 取较小值得到最优采样率。
    p∗=min⁡(1, B预算N×b)p^{*} = \min\left(1,\ \frac{B_{\text{预算}}}{N \times b}\right)

AI 应用场景

大促订单卡顿排查

阶段:线上问题定位与根因分析(Trace 检索 + 瓶颈定位)

解决的问题:传统排障靠逐台机器 grep 日志,跨服务时无法串起因果,一个“下单转圈”问题往往要几个团队碰头排查数小时。用 Span 链路树可以把整条调用链按时间轴展开,直接锁定耗时最长的服务。

具体案例:大促期间用户反馈“提交订单一直转圈”。SRE 从网关日志拿到 trace_id,在追踪系统检索出完整链路树,发现总耗时 1.2 秒中 950 毫秒集中在“库存预占”服务的数据库查询 Span 上;同时 Baggage 里带有的 user_id=88 帮助客服在日志平台一秒关联到同一请求的明细埋点,最终定位为索引缺失。

灰度发布与链路染色

阶段:变更验证(Baggage 调试标记透传)

解决的问题:灰度流量与正式流量混杂在同一套日志和指标系统里,难以区分某个报错到底来自新版本还是旧版本,导致发布决策犹豫不决。

具体案例:发布平台在入口网关把 is_gray=true 写入 Baggage,全链路日志与指标系统都按这个键染色;当灰度请求出现异常时,再通过 Baggage 注入 debug=on 标记,命中标记的请求强制全量采样并输出详细 Span,无需重新发布即可复现问题。

发布前后性能回归对比

阶段:性能评估(Span 耗时聚合分析)

解决的问题:版本上线后才发现接口 P99 延迟劣化,事后难以定位是哪个服务、哪次改动引入的回归。Span 的 duration 与 attributes 天然适合做分层聚合对比。

具体案例:APM 平台按服务名聚合各 Span 的耗时分布,新版本发布后自动与上一版本逐服务对比 P99 曲线,发现“推荐服务”新增了一段 30ms 的串行调用。依据 Span 的属性标签定位到具体代码路径,回滚后指标恢复。

Python 演示

python114 行
import timeimport uuid  class Span:    """一次服务处理的扫描记录 (类似快递中转站的扫描单)"""     def __init__(self, trace_id, parent_sid, service_name):        self.span_id = uuid.uuid4().hex[:8]        self.trace_id = trace_id        self.parent_sid = parent_sid        self.service_name = service_name        self.start_ns = time.time_ns()        self.end_ns = None     def finish(self):        self.end_ns = time.time_ns()     def duration_ms(self):        return (self.end_ns - self.start_ns) / 1e6  class Baggage:    """随请求传播的键值便签 (key=value 集合)"""     def __init__(self):        self._items = {}     def set(self, key, value):        self._items[key] = value     def get(self, key):        return self._items.get(key)     def snapshot(self):        return dict(self._items)  class TraceStore:    """旅程单收集处: 存放所有 Span 并还原链路树"""     def __init__(self):        self.spans = []     def record(self, span):        self.spans.append(span)     def build_tree(self):        kids = {}        root = None        for s in self.spans:            if s.parent_sid is None:                root = s            else:                kids.setdefault(s.parent_sid, []).append(s)        return root, kids     def print_chain(self):        root, kids = self.build_tree()         def walk(span, level):            pad = "  " * level            print("%s[%s] span=%s parent=%s %.2fms" %                  (pad, span.service_name, span.span_id,                   span.parent_sid, span.duration_ms()))            for c in kids.get(span.span_id, []):                walk(c, level + 1)         walk(root, 0)  def slow(ms):    time.sleep(ms)  def handle_order(store, baggage, trace_id, parent_sid):    span = Span(trace_id, parent_sid, "订单服务")    slow(0.005)    span.finish()    store.record(span)    child = handle_pay(store, baggage, trace_id, span.span_id)    return span, child  def handle_pay(store, baggage, trace_id, parent_sid):    span = Span(trace_id, parent_sid, "支付服务")    if baggage.get("premium") == "1":        slow(0.002)    else:        slow(0.020)    span.finish()    store.record(span)    return span  def main():    store = TraceStore()    baggage = Baggage()    baggage.set("user_id", "88")    baggage.set("premium", "1")     trace_id = uuid.uuid4().hex[:16]    root = Span(trace_id, None, "网关")    slow(0.003)    root.finish()    store.record(root)     handle_order(store, baggage, trace_id, root.span_id)    print("Trace %s 完成, 链路树如下:" % trace_id)    store.print_chain()  if __name__ == "__main__":    main()
目的:用 80 行代码模拟微服务链路——Span 记录每次服务调用的起止时间与父子关系,Baggage 携带 user_id 与 premium 便签跨服务传递,TraceStore 按 parent_span_id 还原并打印整棵链路树。
  1. self.parent_sid = parent_sid记录“上一站”编号,这是链路树建树的关键。
  2. baggage.set("premium", "1")在入口贴便签,下游支付服务据此选择不同处理路径。
  3. handle_order(store, baggage, trace_id, span.span_id)传递 trace_id 与当前 span_id,模拟上下文注入。
  4. store.record(span)每个服务处理完就把 Span 上报到收集处,模拟异步采集。
  5. build_tree()按 parent_sid 分组,父 ID 为空的 Span 成为根,实现链路树还原。
  6. walk(root, 0)从根递归打印,树形缩进直观展示父子层级。

常见误区

误区既然 Baggage 会自动跟着请求走,我就把所有上下文都塞进去:user_id、商品 ID、页面来源、实验分组、日志级别……反正以后排查都用得上。

正解Baggage 与包裹便签一样,每经过一个服务、每发起一次下游调用都会被整体复制和传递。字段越多,头部越大,在高扇出场景下开销成倍放大(调用次数 × 便签体积)。应只放跨服务调试与路由必需的关键字段(如 user_id、debug 标记),并把总量限制在几 KB 以内,其余信息改用 Span attributes 留在本地。

为何常见:“传播”听起来像免费的光缆,新手很容易忽略复制放大带来的成本。

误区我在 Span 上打了 attributes,下游服务应该也能自动看到这些标签吧?不然链路信息怎么完整?

正解Span 是每个服务本地生成的“扫描记录”,它的 attributes 只属于这一条记录,不会跟随请求传播。真正能跨服务传递的是 Baggage。就像中转站电脑里的备注只存在于本地,不会跟着包裹走;要跟着走,必须写在包裹便签(Baggage)上。

为何常见:Span attributes 与 Baggage 都是“附加信息”,概念相邻,边界很容易混淆。

误区所有 Span 都有同一个 trace_id,按 trace_id 查出来排个序就是完整链路,根本不用管 parent_span_id。

正解只有按 parent_span_id 才能还原调用树:哪个服务调了哪个、哪些调用是并行的、哪条分支最深。只按 trace_id 平铺得到的是一张列表,既分不清父子层级,也算不出“某个分支的最长耗时”,瓶颈定位无从谈起。

为何常见:在追踪系统里第一眼看到的是 trace_id 列表,容易误以为列表顺序就是调用顺序。

误区采样率设成 0.1% 能省一大笔存储和带宽,慢请求多采样几次总能碰上。

正解低采样率会系统性丢失“长尾慢请求”——它们占比小,却恰恰是最需要排查的问题。更合理的做法是优先级采样:支付、退款等关键链路强制全采,普通查询按比例采样,再用尾部采样兜底保留失败样本。

为何常见:算账时只看到平均存储成本,忽略了罕见故障的排查价值。

误区Baggage 设置一次,之后所有请求都会带着它,就像全局变量一样方便。

正解Baggage 绑定在当前请求的传播上下文上,生命周期随请求结束而终止。它可以传给同一请求的下游服务,但不会自动跨请求、跨用户共享。想持久化业务属性,应显式写入数据库或消息体。

为何常见:key-value 的形式让人联想到全局配置或会话变量,忽略了“随请求生灭”的本质。

面试题

Q1. Span 在分布式追踪中承担的核心职责是什么?

单选题 · easy

  • A.记录单个服务的一段处理时间与上下文← 正确答案
  • B.存储用户的账号密码等业务数据
  • C.负责把请求消息持久化到数据库
  • D.直接替代日志系统和监控告警

答案:A。Span 是“一次服务处理的扫描记录”,记录起止时间、trace_id、span_id、parent_span_id 与属性,是链路的最小单元。它既不存业务凭证(B),也不负责持久化消息(C),更不能替代日志与监控(D)——它只服务于可观测性中的链路追踪。

Q2. 同一条 Trace 里的多个 Span 靠什么关联成树?

单选题 · easy

  • A.相同的服务名与主机 IP 地址
  • B.相同的起始时间戳与结束时间
  • C.相同的 trace_id 与 parent_span_id← 正确答案
  • D.相同的数据库表结构与字段名

答案:C。trace_id 保证“同一次旅程”,parent_span_id 记录“上一站是谁”,二者结合才能把 Span 还原成树。服务名与 IP(A)只说明在哪台机器,时间戳(B)不能表达调用关系,表结构(D)与链路构建无关。

Q3. W3C Trace Context 标准中,traceparent 头一般用来传递什么?

单选题 · easy

  • A.trace_id 与当前 span_id 等上下文← 正确答案
  • B.用户的全部购物车商品列表
  • C.服务的配置文件内容与密钥
  • D.数据库中的业务数据字典

答案:A。traceparent 是“过账手续单”,传递 trace_id、span_id 与 trace_flags,让下游服务接入同一链路。购物车(B)、配置密钥(C)、数据字典(D)都属于业务数据,与链路上下文无关,更不该放进请求头。

Q4. Baggage 与 Span attributes 最本质的区别是什么?

单选题 · mid

  • A.Baggage 只能存数字,attributes 只能存字符串
  • B.attributes 会自动透传到下游所有服务
  • C.Baggage 会随请求跨服务传播,attributes 仅本地可见← 正确答案
  • D.Baggage 创建后不可修改,attributes 可修改

答案:C。Baggage 是“共享便签”,随请求头逐跳传播;Span attributes 是“本地扫描备注”,只属于生成它的服务。A 的类型限制不存在,B 说反了(attributes 不传播),D 说反了(Baggage 中间服务可修改)。

Q5. 关于根 Span(root span)的 parent_span_id,正确的是?

单选题 · mid

  • A.值为空或不存在,表示旅程起点← 正确答案
  • B.值等于自身的 span_id
  • C.值等于整个 Trace 的 trace_id
  • D.值随机生成且毫无意义

答案:A。根 Span 是旅程的第一站,没有“上一站”,所以 parent_span_id 为空。B 会造成自环,C 混淆了 trace_id 与 span_id 两种编号,D 违背了父 ID 用于建树的语义。

Q6. 服务 B 在收到请求时,如何接入已有链路?

单选题 · mid

  • A.提取请求头中的上下文并创建子 Span← 正确答案
  • B.丢弃所有请求头以避免链路膨胀
  • C.生成全新的 trace_id 并独自建链
  • D.仅读取响应体中的业务字段

答案:A。B 应执行“提取”,从请求头读回 trace_id 与父 Span 编号,再创建子 Span 挂到树上。B 丢弃头会导致链路断裂,C 另起 trace_id 会变成孤儿链,D 读响应体与链路接入无关。

Q7. 某个服务并发调用 5 个下游,父 Span 会得到几个子 Span?

单选题 · hard

  • A.5 个子 Span,每个携带相同 parent_span_id← 正确答案
  • B.5 个子 Span,每个携带不同 trace_id
  • C.1 个子 Span,汇总全部下游耗时
  • D.0 个子 Span,下游必须另起链路

答案:A。每次下游调用都会提取同一父上下文并各自生成子 Span,因此父 Span 有 5 个子 Span,parent_span_id 都指向父 Span,形成并行分支。B 错在 trace_id 应相同,C/D 不符合“一调用一 Span”的模型。

Q8. 下列哪些做法能有效降低追踪开销?

多选题 · mid

  • A.为高价值请求配置更高的采样概率← 正确答案
  • B.限制 Baggage 的键数量与总大小← 正确答案
  • C.对每个请求都强制全量记录
  • D.将 Span 上报改为异步批量发送← 正确答案

答案:ABD。优先级采样(A)、便签瘦身(B)、异步上报(D)是三大优化手段。C 全量记录会让成本失控,与降低开销的目标相反。

Q9. 使用 Baggage 进行跨服务调试时,合理的做法有?

多选题 · mid

  • A.放入 user_id 便于按用户关联链路← 正确答案
  • B.放入 debug=on 标记以开启详细埋点← 正确答案
  • C.放入账号密码等敏感凭据
  • D.控制字段规模防止头部膨胀← 正确答案

答案:ABD。user_id 与调试标记正是 Baggage 的典型用途,控制字段规模能抑制放大效应。C 违反安全边界——Baggage 全程可见可改,放敏感凭据等于把密码写在快递单上。

Q10. 关于 Baggage 的放大效应与生命周期,正确的有?

多选题 · extreme

  • A.高扇出场景下 Baggage 会被反复复制← 正确答案
  • B.Baggage 生命周期随当前请求结束而结束← 正确答案
  • C.Baggage 会自动跨请求永久共享
  • D.每次下游调用都会携带完整 Baggage← 正确答案

答案:ABD。高扇出时每次下游调用都整体复制 Baggage,放大成本随扇出数线性增长(A、D);Baggage 绑定当前请求上下文,请求结束即失效(B)。C 错误,Baggage 不会跨请求自动共享,它并非全局变量。