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 依赖

<!-- guava 依赖,与 jaeger 无关 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>

<dependency>
    <groupId>io.opentracing</groupId>
    <artifactId>opentracing-api</artifactId>
    <version>0.33.0</version>
</dependency>

<dependency>
    <groupId>io.jaegertracing</groupId>
    <artifactId>jaeger-client</artifactId>
    <version>1.6.0</version>
</dependency>

记录一个简单的链路

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<ThreadLocalScope> tlsScope = new ThreadLocal<ThreadLocalScope>();

    @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<Map.Entry<String, String>> 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<String, String> headers;

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

    @Override
    public Iterator<Map.Entry<String, String>> 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<String, String> servletHeadersToMap(HttpServletRequest httpServletRequest) {
        IdentityHashMap<String, String> headersResult = new IdentityHashMap<>();

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

            Enumeration<String> 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 的链路埋点,在没有什么特殊需求的时候,我们可以直接使用这些代码。具体参考

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容