-
链路追踪的基本原理
- 追踪(trace):Trace 表示一次完整的分布式请求生命周期,它是一个全局上下文,包含了整个调用链所有经过的服务节点和调用路径。例如,用户发起一个请求,从前端服务到后端数据库的多次跨服务调用构成一个 Trace。
- 跨度(Span):Span 是 Trace 中的一个基本单元,表示一次具体的操作或调用。一个 Trace 由多个 Span 组成,按时间和因果关系连接在一起。Span 内有描述操作的名称 span name、记录操作的开始时间和持续时间、Trace ID、当前 Span ID、父 Span ID(构建调用层级关系)等信息。
原理:为每个操作或调用记录一个跨度,一个请求内的所有跨度共享一个 trace id。通过 trace id,便可重建分布式系统服务间调用的因果关系。链路追踪(Trace)是由若干具有顺序、层级关系的跨度组成一棵追踪树(Trace Tree),核心是在服务调用过程中收集 trace 和 span 信息,并汇总生成追踪树结构。
- Span之间存在层级关系,形成调用树
-
数据采集
目前,追踪系统的主流实现有俩种,具体如下:
-
基于日志的追踪:直接将 Trace、Span 等信息输出到应用日志中,然后采集所有节点的日志汇聚到一起,再根据全局日志重建完整的调用链拓扑。这种方式的优点是没有网络开销、应用侵入性小、性能影响低;但其缺点是,业务调用与日志归集不是同时完成的,有可能业务调用已经结束,但日志归集不及时,导致追踪失真。
-
基于服务的追踪:通过某些手段给目标应用注入追踪探针(Probe),然后通过探针收集服务调用信息并发送给链路追踪系统。探针通常被视为一个嵌入目标服务的小型微服务系统,具备服务注册、心跳检测等功能,并使用专用的协议将监控到的调用信息通过独立的 HTTP 或 RPC 请求发送给追踪系统。
- 以 SkyWalking 的 Java 追踪探针为例,它实现的原理是将需要注入的类文件(追踪逻辑代码)转换成字节码,然后通过拦截器注入到正在运行的应用程序中。比起基于日志实现的追踪,基于服务的追踪在资源消耗和侵入性(但对业务工程师基本无感知)上有所增加,但其精确性和稳定性更高。现在,基于服务的追踪是目前最为常见的实现方式。
-
zipkin 的实现原理
-
zipkin 的核心数据模型
-
Sleuth实现原理分析
原理
Sleuth 会把跟踪数据 (appname、traceId、spanId、exportable) 添加到 MDC ,MDC 的实现实际是将需要记录到日志的信息设置到当前线程的上下文(ThreadContext)
核心机制
- 请求入口:拦截器注入 Trace 信息
- Trace 信息注入到 MDC,链路信息写入线程上下文
MDC.put("traceId", traceId);
MDC.put("spanId", spanId);
- Trace 信息跨线程传播
MDC 是线程本地(ThreadLocal)变量,不能支持线程切换(如异步任务、线程池)。Sleuth 提供了如下能力来支持:
- 对
Runnable、Callable、ExecutorService等进行了包装;
- 使用
TraceRunnable、TraceCallable等将 Trace 信息复制到新线程上下文中; - 保证 traceId 在异步任务、线程池中的上下文仍能被正确使用
-
Trace 信息跨服务传播(HTTP Header)
- Sleuth 在调用其他服务(如使用 RestTemplate、FeignClient)时,会自动在请求头中注入 trace 信息
-
自实现链路追踪坑点错误场景分析
自定义线程池
@Component
public class DemoService {
@Autowired
private Tracer tracer;
ExecutorService executor = Executors.newFixedThreadPool(1); // 未封装
public void runAsync() {
for (int i = 0; i < 3; i++) {
TraceContext traceContext = tracer.currentSpan().context();
executor.submit(() -> {
// Sleuth 的 traceId 没有传播过来
log.info("traceId: {}", tracer.currentSpan() == null ? "null" : t racer.currentSpan().context().traceIdString());
});
}
}
}
错误原因:
ThreadLocal的set/remove的上下文传递模式 在使用线程池等异步执行组件的情况下就失效了
失效原因
- ThreadLocal 是通过Thread.threadLocals实现的一个 Map(ThreadLocalMap),在线程池中线程是复用
- ThreadLocal 没有跨线程传递机制。除非自己封装任务,显式传递上下文。
- InheritableThreadLocal 是ThreadLocal的一个子类,只能在 新创建的 线程 时 将父线程的上下文拷贝一份
解决方案(知识扩展)
使用 TransmittableThreadLocal (TTL)
-
TransmittableThreadLocal 的核心方法
在与captured(抓取)/replay(回放)/restore(恢复)
Demo
执行结果
使用TransmittableThreadLocalTtlExecutors.getTtlExecutorService(executorService)装饰线程池之后,在每次调用任务的时,都会将当前的主线程的TransmittableThreadLocal数据copy到子线程里面,执行完成后,再清除掉。同时子线程里面的修改回到主线程时其实并没有生效
源码分析
public final class TtlRunnable implements Runnable {
private final Runnable runnable;
private final AtomicReference<Object> capturedRef;
private final boolean releaseTtlValueReferenceAfterRun;
// 构造时 capture 主线程上下文
private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
this.runnable = runnable;
// capturedRef 存的是主线程中所有 TransmittableThreadLocal 的快照(Map结构)
this.capturedRef = new AtomicReference<>(TtlTransmittee.capture());
this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
}
public static TtlRunnable get(Runnable runnable) {
return new TtlRunnable(runnable, true);
}
@Override
public void run() {
// 1从主线程快照中获取 captured 上下文
Object captured = capturedRef.get();
// 防止重复运行
if (captured == null || (releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null))) {
throw new IllegalStateException("TTL value reference is released after run!");
}
// 2备份当前子线程的 TTL 上下文(可能已有设置)
Object backup = TtlTransmittee.replay(captured);
try {
// 3执行异步任务(此时 TTL 已是主线程上下文)
runnable.run();
} finally {
// 4 恢复原子线程上下文,防止污染线程池
TtlTransmittee.restore(backup);
}
}
}
capture :捕获主线程中所有的 TransmittableThreadLocal 的值
replay :设置从主线程捕获到的上下文
restore :恢复这个线程原本的上下文
参考学习