链路追踪的作用
当系统架构从单机转变为微服务后,我们的一次后端请求,可能历经了多个服务才最终响应到客户端。如果请求按照预期正确响应还好,万一在调用链的某一环节出现了问题,排查起来是很麻烦的。但是如果有链路追踪的话,就容易很多了。
可以通过链路埋点,记录请求链中所有重要的步骤,例如与哪些数据库做了交互,调用了哪些下游服务,下游服务又与哪些数据库做了交互,又调用了哪些下游服务...
下图是 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 搜索。结果如下
可以看到我们刚刚在代码中的 Tag 和 Log 都记录在链路上了
线程传递 Span
Span 之间是可以建立父子关系的,使用
Span parent= tracer.buildSpan("say-hello").start();
tracer.buildSpan("son")
.asChildOf(parent)
.start();
效果如图
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 的链路埋点,在没有什么特殊需求的时候,我们可以直接使用这些代码。具体参考