笔者还并没有实际做个链路追踪的项目,但是比较偶然的机会接触到了一些信息,于是产生好奇,一路“带着误解”修正对这方面的理解。
这篇文章记录了一次从 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 时:
- 编译期(AST / SSA)
- 判断哪些函数可以安全插桩
- 确保 context 没断
- 运行时
- 在关键点创建 span
- 把 span 放进 context
- 子调用自动继承
- 后端
- 按 trace_id 聚合
- 用 parent_span_id 还原树结构