笔者还并没有实际做个链路追踪的项目,但是比较偶然的机会接触到了一些信息,于是产生好奇,一路“带着误解”修正对这方面的理解。

这篇文章记录了一次从 Java 字节码注入 tracing 出发,一路走到 Go context + span 模型 的理解过程。


一、起点:字节码注入的调用链分析

问题的起点很直接:

微服务链路追踪里,基于字节码注入的调用链分析到底是什么原理?

在 Java 里,这件事其实很“优雅”:

  • JVM 在类加载时可以被拦截
  • 通过 javaagent + Instrumentation
  • 在方法入口 / 出口插入 tracing 代码

效果是:

public String queryOrder(String id) {
    Span span = tracer.start("queryOrder");
    try {
        return service.query(id);
    } finally {
        span.finish();
    }
}

代码没改,但 JVM 加了逻辑,这就是所谓的无侵入 tracing。

二、问题转折:那 Go 怎么办?

Go 没有 JVM,也没有字节码注入,那怎么做 tracing?

答案一下子变得“没那么优雅”:
Go 没有字节码,没有 ClassLoader,不支持运行时改函数体。
| 问题 | Java | Go |
| —– | ———– | ————— |
| 运行时注入 | ✔️ | ❌ |
| 编译期插桩 | 少 | ✔️改源码再编译 AST |
| 框架拦截 | ✔️ | ✔️(主流,依赖上层框架) |
| 完全无侵入 | ✔️ | ❌ |
| 上下文传播 | ThreadLocal | context.Context |

框架拦截一般很好入手,也取决于项目的实际情况,于是笔者更关注AST插桩,

.go 源码
 ↓(AST 分析)
插入 tracing 代码
 ↓
go build

// 原始代码
func QueryOrder(ctx context.Context, id string) {
    svc.Query(ctx, id)
}
// 模拟插桩后
func QueryOrder(ctx context.Context, id string) {
    ctx, span := trace.Start(ctx, "QueryOrder") // 插桩工具自动根据提供的AST插桩规则添加;AST还会判断这里插 tracing 会不会断链
    defer span.End()
    svc.Query(ctx, id)
}

三、关键问题出现:链路的形成

当理解到这里时,一个非常自然的疑问出现了:context 在 tracing 里到底承载了什么?

context
  └── 当前 span(current span)

链路是如何“长出来的”:

// 每次创建 span 时发生的事情
parent := SpanFromContext(ctx)
span := NewSpan(parent)
ctx = context.WithValue(ctx, span)

// 子 span 在创建那一刻,就已经知道父是谁了
ctx, spanA := Start(ctx, "A")
ctx, spanB := Start(ctx, "B")
ctx, spanC := Start(ctx, "C")

// 那 tracing 链路是怎么“还原”的?
{
  "trace_id": "abc",
  "span_id": "b",
  "parent_span_id": "a"
}
// 按 trace_id 分组,根据 parent_span_id 建树,就能还原出完整链路

Go 的 tracing 不是在“追踪 context”,而是利用 context 已经存在的调用关系;每个 span 在创建时确定父子关系,context 只负责传播当前 span,最终由后端系统还原完整调用链。当在 Go 中做 tracing 时:

  1. 编译期(AST / SSA)
    • 判断哪些函数可以安全插桩
    • 确保 context 没断
  2. 运行时
    • 在关键点创建 span
    • 把 span 放进 context
    • 子调用自动继承
  3. 后端
    • 按 trace_id 聚合
    • 用 parent_span_id 还原树结构

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !