Opentracing 链路追踪实战

链路追踪的作用

当系统架构从单机转变为微服务后,我们的一次后端请求,可能历经了多个服务才最终响应到客户端。如果请求按照预期正确响应还好,万一在调用链的某一环节出现了问题,排查起来是很麻烦的。但是如果有链路追踪的话,就容易很多了。

可以通过链路埋点,记录请求链中所有重要的步骤,例如与哪些数据库做了交互,调用了哪些下游服务,下游服务又与哪些数据库做了交互,又调用了哪些下游服务...

下图是 jaeger UI 为例,每次链路追踪都会产生一个唯一的 TraceId,通过该 Id 可以查看请求链路的状态

  • 下游服务可以正常访问时

    下游服务可以正常访问
  • RestTemplate 无法访问下游服务时

    无法访问下游服务时

当有了链路追踪之后。我们可以清楚的看到问题出在哪。

什么是 Opentracing

Opentracing 制定了一套链路追踪的 API 规范,支持多种编程语言。虽然OpenTracing不是一个标准规范,但现在大多数链路跟踪系统都在尽量兼容OpenTracing

使用 Opentracing 时,还需要集成实现该规范的链路追踪系统,例如我们的项目正在使用 Jaeger,本文也同样以 Jaeger 为例

Opentracing 核心接口

  • Tracer:用于创建 Span,以及跨进程链路追踪

  • Span:Tracer 中的基本单元,上图中每一个蓝色或者黄色的块都是一个 Span。Span 包含的属性有 tag,log,SpanContext,BaggageItem

  • SpanContext:Span 上下文,用于跨进程传递 Span,以及数据共享

  • ScopeManager:用于获取当前上下文中 Span,或者将 Span 设置到当前上下文

  • Scope:与 ScopeManager 配合使用,我们在后面的例子中会说明

快速开始

首先,我们先部署一个 jaeger 服务。关于 jaeger 服务的更多细节,这里不多说了,各位读者可以自行去 jaeger 官网阅读

执行如下命令,启动 jaeger 服务
docker run -d --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 5775:5775/udp -p 6831:6831/udp -p 6832:6832/udp -p 5778:5778 -p 16686:16686 -p 14268:14268 -p 14250:14250 -p 9411:9411 jaegertracing/all-in-one:1.23

引入 maven 依赖



    com.google.guava
    guava
    30.1.1-jre



    io.opentracing
    opentracing-api
    0.33.0



    io.jaegertracing
    jaeger-client
    1.6.0

记录一个简单的链路

import com.google.common.collect.ImmutableMap;
import io.jaegertracing.Configuration;
import io.jaegertracing.internal.JaegerTracer;
import io.opentracing.Span;
import io.opentracing.Tracer;

import java.time.LocalDateTime;

public class GettingStarter {

    public static void main(String[] args) {
        // 指定服务名,初始化 Tracer
        Tracer tracer = initTracer("starter-service");
        // 指定 Span 的 operationName
        Span span = tracer.buildSpan("")
                // 指定当前 Span 的 Tag, key value 格式
                .withTag("env", "local")
                .start();

        span.setTag("system", "windows");

        // log 也是 key value 格式,默认 key 为 event
        span.log("create first Span");
        // 传入一个 Map
        span.log(ImmutableMap.of("currentTime", LocalDateTime.now().toString()));

        // 输出当前 traceId
        System.out.println(span.context().toTraceId());

        // 结束并上报 span
        span.finish();
    }

    public static JaegerTracer initTracer(String service) {
        Configuration.SamplerConfiguration samplerConfig = Configuration.SamplerConfiguration.fromEnv().withType("const").withParam(1);
        Configuration.ReporterConfiguration reporterConfig = Configuration.ReporterConfiguration.fromEnv().withLogSpans(true);
        Configuration config = new Configuration(service).withSampler(samplerConfig).withReporter(reporterConfig);
        return config.getTracer();
    }

}

jaeger UI 查询链路,访问 http://localhost:16686/search,右上角输入 traceId 搜索。结果如下

image.png

可以看到我们刚刚在代码中的 Tag 和 Log 都记录在链路上了

线程传递 Span

Span 之间是可以建立父子关系的,使用

Span parent= tracer.buildSpan("say-hello").start();
tracer.buildSpan("son")
                .asChildOf(parent)
                .start();

效果如图

image.png

parent Span 肯定不能一直在调用栈中传递下去,这对集成 opentracing 的程序来说侵入性太大了。回顾一下上面我们提到了一个 ScopeManager ,来看下其核心方法

  • activate(Span span):用于将当前 Span Set 到当前上下文中。上图中的注释也给出了该方法如何使用。返回值 Scope 字面翻译为范围 / 跨度。稍后我们找一个实现类具体分析一下

  • activeSpan():这个方法很好理解,从当前上下文中 Get Span

ThreadLocalScopeManager

Jaeger 中默认的 ScopeManager,该类由 opentracing 提供。

public class ThreadLocalScopeManager implements ScopeManager {
    final ThreadLocal tlsScope = new ThreadLocal();

    @Override
    public Scope activate(Span span) {
        return new ThreadLocalScope(this, span);
    }

    @Override
    public Span activeSpan() {
        ThreadLocalScope scope = tlsScope.get();
        return scope == null ? null : scope.span();
    }
}
public class ThreadLocalScope implements Scope {
    private final ThreadLocalScopeManager scopeManager;
    private final Span wrapped;
    private final ThreadLocalScope toRestore;

    ThreadLocalScope(ThreadLocalScopeManager scopeManager, Span wrapped) {
        this.scopeManager = scopeManager;
        this.wrapped = wrapped;
        this.toRestore = scopeManager.tlsScope.get();
        scopeManager.tlsScope.set(this);
    }

    @Override
    public void close() {
        if (scopeManager.tlsScope.get() != this) {
            // This shouldn't happen if users call methods in the expected order. Bail out.
            return;
        }

        scopeManager.tlsScope.set(toRestore);
    }

    Span span() {
        return wrapped;
    }
}

ThreadLocalScopeManager#activate:直接调用了new ThreadLocalScope。在 ThreadLocalScope 构造方法中,首先将从 ThreadLocal 中获取到目前上下文中的 ThreadLocalScope 赋值给 toRestore,然后将 this (ThreadLocalScope 对象) set 到 ThreadLocal。

ThreadLocalScope 中存储着当前的 Span。后续的代码如果想要获取 Span,只需要调用 ScopeManager#activeSpan 就可以(ScopeManager 可以在 Tracer 对象中拿到)

在执行 close 时(Scope 继承了 Closeable),将之前的 Span 重新放回到上下文中

线程传递 Span 演示

Span parentSpan = tracer.buildSpan("parentSpan").start();

try (Scope scope = tracer.activateSpan(parentSpan)) {
    xxxMethod();
} finally {
    parentSpan.finish();
}

public void xxxMethod() {
    // 这里并不需要手动从 ScopeManager 中取出上下文中的 Span
    // start 方法中已经做了
    // 如果 ScopeManager.activeSpan() != null 会自动调用 asChildOf
    tracer.buildSpan("sonSpan").start();
}

链路中数据共享

如果需要和链路的下游共享某些数据,使用如下方法

// 写
span.setBaggageItem("key", "value");

// 读
span.getBaggageItem("key");

只要保证在同一条链路中,即使下游 Span 在不同的进程,依然可以通过 getBaggageItem 读到数据

跨进程链路追踪

opentracing 中提供了实现跨进程追踪的规范

Tracer 接口中提供了如下两个方法

  • inject:将 SpanContext 注入到 Carrier 中,传递给下游的其他进程

  • extract:下游进程从 Carrier 中抽取出 SpanContext,用于创建下游的 Child Span

简单举个例子解释下,例如我们使用 Http 协议访问下游服务,inject 可以将 SpanContext 注入到 HttpHeaders 中。
下游服务再从 HttpHeaders 中按照链路中的约定取出有特殊标识的 header 来构建 SpanContext。这样一来就实现了链路的跨进程

再回到代码层面,通过接口方法的声明我们可以看出来,Format 决定了 Carrier 的类型。下面来看看实际代码中如何实现

跨进程链路追踪演示

public class TracingRestTemplateInterceptor implements ClientHttpRequestInterceptor {
    private static final String SPAN_URI = "uri";

    private final Tracer tracer;

    public TracingRestTemplateInterceptor(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        ClientHttpResponse httpResponse;
        // 为当前 RestTemplate 调用,创建一个 Span
        Span span = tracer.buildSpan("RestTemplate-RPC")
                .withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_CLIENT)
                .withTag(SPAN_URI, request.getURI().toString())
                .start();
        // 将当前 SpanContext 注入到 HttpHeaders
        tracer.inject(span.context(), Format.Builtin.HTTP_HEADERS,
                new HttpHeadersCarrier(request.getHeaders()));

        try (Scope scope = tracer.activateSpan(span)) {
            httpResponse = execution.execute(request, body);
        } catch (Exception ex) {
            TracingError.handle(span, ex);
            throw ex;
        } finally {
            span.finish();
        }
        return httpResponse;
    }

}

TracingRestTemplateInterceptor 实现了 RestTemplate 的拦截器,用于在 Http 调用之前,将 SpanContext 注入到 HttpHeaders 中。

Format.Builtin.HTTP_HEADERS 决定了当前的 Carrier 类型必须 TextMap (源码中可以看到,这里我没有列出)

public class HttpHeadersCarrier implements TextMap {
    private final HttpHeaders httpHeaders;

    public HttpHeadersCarrier(HttpHeaders httpHeaders)  {
        this.httpHeaders = httpHeaders;
    }

    @Override
    public void put(String key, String value) {
        httpHeaders.add(key, value);
    }

    @Override
    public Iterator> iterator() {
        throw new UnsupportedOperationException("Should be used only with tracer#inject()");
    }
}

tracer.inject 内部会调用 TextMap 的 put 方法,这样就将 SpanContext 注入到 HttpHeaders 了。

下面再来看看下游怎么写

public class TracingFilter implements Filter {
    private final Tracer tracer;

    public TracingFilter(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        // 通过 HttpHeader 构建 SpanContext
        SpanContext extractedContext = tracer.extract(Format.Builtin.HTTP_HEADERS,
                new HttpServletRequestExtractAdapter(httpRequest));

        String operationName = httpRequest.getMethod() + ":" + httpRequest.getRequestURI();
        Span span = tracer.buildSpan(operationName)
                .asChildOf(extractedContext)
                .withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER)
                .start();

        httpResponse.setHeader("TraceId", span.context().toTraceId());

        try (Scope scope = tracer.activateSpan(span)) {
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception ex) {
            TracingError.handle(span, ex);
            throw ex;
        } finally {
            span.finish();
        }
    }
}

TracingFilter 实现了 Servlet Filter,每次请求访问到服务器时创建 Span,如果可以抽取到 SpanContext,则创建的是 Child Span

public class HttpServletRequestExtractAdapter implements TextMap {
    private final IdentityHashMap headers;

    public HttpServletRequestExtractAdapter(HttpServletRequest httpServletRequest) {
        headers = servletHeadersToMap(httpServletRequest);
    }

    @Override
    public Iterator> iterator() {
        return headers.entrySet().iterator();
    }

    @Override
    public void put(String key, String value) {
        throw new UnsupportedOperationException("This class should be used only with Tracer.inject()!");
    }

    private IdentityHashMap servletHeadersToMap(HttpServletRequest httpServletRequest) {
        IdentityHashMap headersResult = new IdentityHashMap<>();

        Enumeration headerNamesIt = httpServletRequest.getHeaderNames();
        while (headerNamesIt.hasMoreElements()) {
            String headerName = headerNamesIt.nextElement();

            Enumeration valuesIt = httpServletRequest.getHeaders(headerName);
            while (valuesIt.hasMoreElements()) {
                // IdentityHashMap 判断两个 Key 相等的条件为 k1 == k2
                // 为了让两个相同的字符串同时存在,必须使用 new String
                headersResult.put(new String(headerName), valuesIt.nextElement());
            }

        }

        return headersResult;
    }

}

tracer.extract 内部会调用 HttpServletRequestExtractAdapter iterator 方法用于构建 SpanContext

如果你看完了这些还是对于跨进程链路追踪有疑惑的,可以下载一下我写的 Demo,通过 Debug 来更进一步了解

https://github.com/TavenYin/taven-springcloud-learning/tree/master/jaeger-mutilserver

Demo 中的代码参考了 opentracing 的实现,做了相应的简化,诸位可以放心食用

实际使用

opentracing 已经实现了一些常用 api 的链路埋点,在没有什么特殊需求的时候,我们可以直接使用这些代码。具体参考

你可能感兴趣的:(Opentracing 链路追踪实战)