在分布式追踪体系中,Span 链接类型决定了调用链的还原方式。父子链接与跟随链接虽然都记录两个 Span 之间的关联,但其时序语义完全不同。Ruby 生态中的 OpenTelemetry SDK 通过上下文传播和 add_link 接口暴露了这一能力,但官方实现并没有把链接类型设计成强制枚举,而是把约束留给埋点代码。本文基于网络 API 请求追踪场景,说明如何用 Ruby 定义链接类型,并避免把父子与跟随关系混用。

为什么 Span 链接要区分父子与跟随
Span 是分布式追踪中的基本单元,一个 Trace 由多个 Span 组成。当一次网络 API 请求进入系统,入口 Span 开始计时,在调用下游服务、访问数据库、发送消息时生成新的 Span。仅仅把这些 Span 收集起来还不够,必须知道它们之间的依赖顺序,才能在追踪 UI 上生成调用图和时间轴。父子链接是其中最强的一种关联,它直接表达了同步调用栈的层级关系。
父子链接语义在经典追踪模型中对应 ChildOf 引用。父 Span 发起一个同步调用后,会阻塞等待子 Span 结束,子 Span 的结束时间一定落在父 Span 的生命周期内。例如一个 Rails 控制器调用第三方身份验证 API,如果代码中执行 HTTP 客户端并等待响应,那么这次 API 调用 Span 就是控制器的子 Span。它的延迟会直接累加到父 Span 上,异常也会向上传播。这种关系适合错误定位,因为父子层级与同步调用栈天然一致。
跟随链接对应 FollowsFrom 引用,它表示因果关系但不等同于生命周期包含。父 Span 触发一个异步任务后立即继续执行,不会等待任务完成。例如 API 请求创建了一个订单,并投递一条消息到队列,由后台 worker 处理发货。worker 中的 Span 可以标记为跟随订单创建 Span。它的结束时间可能比父 Span 更晚,父 Span 不能因为后台任务的耗时而被拉长。若把跟随链接误写成父子,火焰图会把后台处理时间算进同步请求里,造成 P99 延迟虚高。
网络 API 请求中还常见扇出调用。一个入口 Span 同时发起多个 HTTP 请求且不要求全部成功,这些子请求可以继续用父子关系,如果代码等待所有响应;也可以用跟随关系,如果发出后只记录不等待。区分标准不是服务间是否有网络调用,而是调用方是否阻塞等待结果。这个标准在 Ruby 埋点前需要反复确认。
使用 Ruby 定义链接类型
Ruby 中 OpenTelemetry SDK 的 Span 对象提供了上下文和 add_link 方法。对于父子关系,不用手动调用 add_link,而是把父 Span 的上下文传给 start_span 的 with_parent 选项。下面的示例创建一个入口 Span,并在其中创建同步的子 Span。
require 'opentelemetry'
tracer = OpenTelemetry.tracer_provider.tracer('billing_api')
parent_span = tracer.start_span('POST /checkout')
parent_context = OpenTelemetry::Trace.context_with_span(parent_span)
child_span = tracer.start_span('charge_card', with_parent: parent_context)
begin
charge_result = Gateway.charge(order)
child_span.set_attribute('http.status_code', 200)
child_span.finish
rescue => e
child_span.record_exception(e)
child_span.status = OpenTelemetry::Trace::Status.error('gateway failed')
raise
ensure
parent_span.finish
end
跟随链接需要手动创建 Span 并添加 link。先创建表示异步任务的 Span,然后把它的 SpanContext 添加到父 Span 的链接列表。为了明确语义,可以定义一个模块保存链接类型常量,并通过属性传给追踪后端。
module SpanLinkType
PARENT_CHILD = 'parent_child'
FOLLOWS_FROM = 'follows_from'
end
async_span = tracer.start_span('order_shipping_worker')
parent_span.add_link(
async_span.context,
attributes: { 'link.type' => SpanLinkType::FOLLOWS_FROM }
)
async_span.finish
parent_span.finish
SpanLinkType 这种自定义常量在 Ruby 侧不是强制约束,后端系统需要配合读取 link.type 属性才能正确绘图。如果追踪后端不支持属性维度,至少要在 Span 名称上体现异步语义,例如用 async. 前缀或 _worker 后缀作为团队约定。OpenTelemetry 协议本身有 link 数组,但语义是 unspecified,因此 Ruby 端的清晰命名是团队协作的关键。
如果项目曾用过 OpenTracing 兼容层,可能会看到 child_of 和 follows_from 两个显式方法。迁移到 OpenTelemetry 后,这两个概念合并到 with_parent 和 add_link 中。这种 API 差异容易让 Ruby 开发者困惑:with_parent 创建的是强父子关系,add_link 只表达弱关联;是否把它解释为跟随,由属性或上下文决定。因此不要在 add_link 后写注释说这是父子链接,那会误导后续维护者。
父子与跟随在追踪可视化中的表现
在 Jaeger、Zipkin、Grafana Tempo 等追踪后端中,父子 Span 通常以嵌套层级和甘特图形式展示。子 Span 横向起点在父 Span 开始之后,终点在父 Span 结束之前。如果看到子 Span 的横条超出父 Span 右边界,说明数据写入有误或存在时钟偏差。跟随链接一般不会形成嵌套,而是作为一条带箭头的虚线或独立分支出现,点击父 Span 可以看到关联的异步 Span。这种视觉差异能帮助开发者快速判断一个调用是否是阻塞型。
错误地把跟随即时任务写成父子会产生两个明显问题。第一,父 Span 时长被异步处理时间拖长,原本 50ms 的 API 请求显示为几秒甚至几分钟。第二,后台 worker 的异常会错误地沿着父子关系传播,导致 API 请求标记为失败,但实际请求已经成功返回。反过来,若把同步的数据库查询写成跟随链接,主流程的慢查询会被隐藏在独立分支里,火焰图上看不到它阻塞了响应,排查问题时会走弯路。
选择类型时有一个简单判断:调用线程是否等待结果。Ruby 中如果代码里有 .get、.value 等待 Future,或者直接调用 HTTP 客户端读取 response body,就属于父子。如果使用 Thread.new、Sidekiq.perform_async、bunny.publish 后立刻返回,只把 SpanContext 传给下游,就属于跟随。对于网络 API 来说,重试操作也需要留意:第一次同步调用是父子,后续后台重试如果脱离请求生命周期,应新建 Trace 或使用跟随链接,否则重试会污染原始 Trace。
链路追踪的价值不只在有数据,而在 Span 之间的关系是否真实。Ruby 的灵活性让开发者可以自定义链接类型,但也要求埋点前理解父子与跟随的差异。建议团队在埋点规范中写明:同步调用用 with_parent,异步触发用 add_link 并附带 link.type 属性;禁止在异步回调中复用父 Span 上下文作为 with_parent。这样追踪后端才能呈现准确的网络 API 调用拓扑,避免延迟与错误归属混乱。