一次「找回」TraceId的问题分析与过程思考

2023-10-27

用好中间件是每一个开发人员的基本功,一个专业的开发人员,追求的不仅是中间件的日常使用,还要探究这背后的设计初衷和底层逻辑,进而保证我们的系统运行更加稳定,让开发工作更加高效。

结合这一主题,本文从一次线上告警问题出发,通过第一时间定位问题的根本原因,进而引出yu(美团内部自研)这类分布式链路追踪系统的设计思想和实现途径,再回到问题本质深入@Async的源码分析底层的异步逻辑和实现特点,并给出MTrace跨线程传递失效的原因和解决方案,最后梳理目前主流的分布式跟踪系统的现状,并结合开发人员日常使用中间件的场景提出一些思考和总结。

  • 1. 问题背景和思考

    • 1.1 问题背景

    • 1.2 问题复现和思考

  • 2. 深度分析

    • 2.1 MTrace与Google Dapper

    • 2.2 @Async的异步过程追溯

    • 2.3. “丢失”TraceId的原因

  • 3. 解决方案

  • 4. 其他方案对比

    • 4.1 Zipkin

    • 4.2 SkyWalking

    • 4.3 EagleEye

  • 5. 总结

 1. 问题背景和思考 

| 1.1 问题背景

在一次排查线上告警的过程中,突然发现一个链路信息有点不同寻常(这里仅展示测试复现的内容):

a82166c4c6d8a49ff19d57360bc05080.png

在机器中可以清楚的发现“2022-08-02 19:26:34.952 DXMsgRemoteService ”这一行日志信息并没有携带TraceId,导致调用链路信息戛然而止,无法追踪当时的调用情况。

| 1.2 问题复现和思考

在处理完线上告警后,我们开始分析“丢失”的TraceId到底去了哪里?首先在代码中定位TraceId没有追踪到的部分,发现问题出现在一个@Async注解下的方法,删除无关的业务信息代码,并增加MTrace埋点方法后的复现代码如下:

@SpringBootTest
@RunWith(SpringRunner.class)
@EnableAsync
public class DemoServiceTest extends TestCase {
 @Resource
  private DemoService demoService;
 @Test
  public void testTestAsy() {
  Tracer.serverRecv("test");
  String mainThreadName = Thread.currentThread().getName();
  long mainThreadId = Thread.currentThread().getId();
  System.out.println("------We got main thread: "+ mainThreadName + " - " +  mainThreadId + "  Trace Id: " + Tracer.id() + "----------");
  demoService.testAsy();
 }
}
@Component
public class DemoService {
 @Async
  public void testAsy(){
  String asyThreadName = Thread.currentThread().getName();
  long asyThreadId = Thread.currentThread().getId();
  System.out.println("======Async====");
  System.out.println("------We got asy thread: "+ asyThreadName + " - " +  asyThreadId + "  Trace Id: " + Tracer.id() + "----------");
 }
}

运行这段代码后,我们看看控制台实际的输出结果:

------We got main thread: main - 1  Trace Id: -5292097998940230785----------
======Async====
------We got asy thread: SimpleAsyncTaskExecutor-1 - 630  Trace Id: null----------

至此我们可以发现TraceId是在@Async异步传递的过程中发生丢失现象,明白了造成这一现象的原因后,我们开始思考:

  • MTrace(美团内部自研的分布式链路追踪系统)这类分布式链路追踪系统是如何设计的?

  • @Async异步方法是如何实现的?

  • InheritableThreadLocal、TransmittableThreadLocal和TransmissibleThreadLocal有什么区别?

  • 为什么MTrace的跨线程传递方案“失效”了?

  • 如何解决@Async场景下“弄丢”TraceId的问题?

  • 目前有哪些分布式链路追踪系统?它们又是如何解决跨线程传递问题的?

2. 深度分析 

| 2.1 MTrace与Google Dapper

MTrace是美团参考Google Dapper对服务间调用链信息收集和整理的分布式链路追踪系统,目的是帮助开发人员分析系统各项性能和快速排查告警问题。要想了解MTrace是如何设计分布式链路追踪系统的,首先看看Google Dapper是如何在大型分布式环境下实现分布式链路追踪。我们先来看看下图一个完整的分布式请求:

ad443dd5cb4c2fa26c7f2c23a3f1108a.png

用户发送一个请求到前端A,然后请求分发到两个不同的中间层服务B和C,服务B在处理完请求后将结果返回,同时服务C需要继续调用后端服务D和E再将处理后的请求结果进行返回,最后由前端A汇总来响应用户的这次请求。

回顾这次完整的请求我们不难发现,要想直观可靠的追踪多项服务的分布式请求,我们最关注的是每组客户端和服务端之间的请求响应以及响应耗时,因此,Google Dapper采取对每一个请求和响应设置标识符和时间戳的方式实现链路追踪,基于这一设计思想的基本追踪树模型如下图所示:

810716d9323b43f9d3d405dfd47026cc.png

追踪树模型由span组成,其中每个span包含span name、span id、parent id和trace id,进一步分析跟踪树模型中各个span之间的调用关系可以发现,其中没有parent id且span id为1代表根服务调用,span id越小代表服务在调用链的过程中离根服务就越近,将模型中各个相对独立的span联系在一起就构成了一次完整的链路调用记录,我们再继续深入看看span内部的细节信息:

b9b0638304f56685f59b4aca00a527f5.png

除了最基本的span name、span id和parent id之外,Annotations扮演着重要的角色,Annotations包括<Strat>、Client Send、Server Recv、Server Send、Client Recv和<End>这些注解,记录了RPC请求中Client发送请求到Server的处理响应时间戳信息,其中foo注解代表可以自定义的业务数据,这些也会一并记录到span中,提供给开发人员记录业务信息;在这当中有64位整数构成的trace id作为全局的唯一标识存储在span中。

至此我们已经了解到,Google Dapper主要是在每个请求中配置span信息来实现对分布式系统的追踪,那么又是用什么方式在分布式请求中植入这些追踪信息呢?

为满足低损耗、应用透明和大范围部署的设计目标,Google Dapper支持应用开发者依赖于少量通用组件库,实现几乎零投入的成本对分布式链路进行追踪,当一个服务线程在链路中调用其他服务之前,会在ThreadLocal中保存本次跟踪的上下文信息,主要包括一些轻量级且易复制的信息(类似spand id和trace id),当服务线程收到响应之后,应用开发者可以通过回调函数进行服务信息日志打印。

MTrace是美团参考Google Dapper的设计思路并结合自身业务进行了改进和完善后的自研产品,具体的实现流程这里就不再赘述了,我们重点看看MTrace做了哪些改进:

  • 在美团的各个中间件中埋点,来采集发生调用的调用时长和调用结果等信息,埋点的上下文主要包括传递信息、调用信息、机器相关信息和自定义信息,各个调用链路之间有一个全局且唯一的变量TraceId来记录一次完整的调用情况和追踪数据。

  • 在网络间的数据传递中,MTrace主要传递使用UUID异或生成的TraceId和表示层级和前后关系的SpanId,支持批量压缩上报、TraceId做聚合和SpanId构建形态。

  • 目前,产品已经覆盖到RPC服务、HTTP服务、MySQL、Cache缓存和MQ,基本实现了全覆盖。

  • MTrace支持跨线程传递和代理来优化埋点方式,减轻开发人员的使用成本。

| 2.2 @Async的异步过程追溯

从Spring3开始提供了@Async注解,该注解的使用需要注意以下几点:

  1. 需要在配置类上增加@EnableAsync注解;

  2. @Async注解可以标记一个异步执行的方法,也可以用来标记一个类表明该类的所有方法都是异步执行;

  3. 可以在@Async中自定义执行器。

我们以@EnableAsync为入口开始分析异步过程,除了基本的配置方法外,我们重点关注下配置类AsyncConfigurationSelector的内部逻辑,由于默认条件下我们使用JDK接口代理,这里重点看看ProxyAsyncConfiguration类的代码逻辑:

@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {
 @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
  Assert.notNull(this.enableAsync, "@EnableAsync annotation metadata was not injected");
  //新建一个异步注解bean后置处理器
  AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
  //如果@EnableAsync注解中有自定义annotation配置则进行设置
  Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
  if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
   bpp.setAsyncAnnotationType(customAsyncAnnotation);
  }
  if (this.executor != null) {
   //设置线程处理器
   bpp.setExecutor(this.executor);
  }
  if (this.exceptionHandler != null) {
   //设置异常处理器
   bpp.setExceptionHandler(this.exceptionHandler);
  }
  //设置是否需要创建CGLIB子类代理,默认为false
  bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
  //设置异步注解bean处理器应该遵循的执行顺序,默认最低的优先级
  bpp.setOrder(this.enableAsync.<Integer>getNumber("order"));
  return bpp;
 }
}

ProxyAsyncConfiguration继承了父类AbstractAsyncConfiguration的方法,重点定义了一个AsyncAnnotationBeanPostProcessor的异步注解bean后置处理器。看到这里我们可以知道,@Async主要是通过后置处理器生成一个代理对象来实现异步的执行逻辑,接下来我们重点关注AsyncAnnotationBeanPostProcessor是如何实现异步的:

24088b5387fef995af938fed691bf34d.png

从类图中我们可以直观地看到AsyncAnnotationBeanPostProcessor同时实现了BeanFactoryAware的接口,因此我们进入setBeanFactory()方法,可以看到对AsyncAnnotationAdvisor异步注解切面进行了构造,再接着进入AsyncAnnotationAdvisor的buildAdvice()方法中可以看AsyncExecutionInterceptor类,再看类图发现AsyncExecutionInterceptor实现了MethodInterceptor接口,而MethodInterceptor是AOP中切入点的处理器,对于interceptor类型的对象,处理器中最终被调用的是invoke方法,所以我们重点看看invoke的代码逻辑:

public Object invoke(final MethodInvocation invocation) throws Throwable {
 Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
 Method specificMethod = ClassUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
 final Method userDeclaredMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
  //首先获取到一个线程池
 AsyncTaskExecutor executor = determineAsyncExecutor(userDeclaredMethod);
 if (executor == null) {
  throw new IllegalStateException("No executor specified and no default executor set on AsyncExecutionInterceptor either");
 }
  //封装Callable对象到线程池执行
 Callable<Object> task = () -> {
  try {
   Object result = invocation.proceed();
   if (result instanceof Future) {
    return ((Future<?>) result).get();
   }
  }
  catch (ExecutionException ex) {
   handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments());
  }
  catch (Throwable ex) {
   handleError(ex, userDeclaredMethod, invocation.getArguments());
  }
  return null;
 };
  //任务提交到线程池
 return doSubmit(task, executor, invocation.getMethod().getReturnType());
}

我们再接着看看@Async用了什么线程池,重点关注determineAsyncExecutor方法中getExecutorQualifier指定获取的默认线程池是哪一个:

@Override
@Nullable
protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
 Executor defaultExecutor = super.getDefaultExecutor(beanFactory);   
 return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor()); //其中默认线程池是SimpleAsyncTaskExecutor
}

至此,我们了解到在未指定线程池的情况下调用被标记为@Async的方法时,Spring会自动创建SimpleAsyncTaskExecutor线程池来执行该方法,从而完成异步执行过程。

| 2.3. “丢失”TraceId的原因

回顾我们之前对MTrace的学习和了解,TraceId等信息是在ThreadLocal中进行传递和保存,那么当异步方法切换线程的时候,就会出现下图中上下文信息传递丢失的问题:

e166ed3cf86c8d2e73d04018931e811c.png

下面我们探究一下ThreadLocal有哪些跨线程传递方案?MTrace又提供哪些跨线程传递方案?SimpleAsyncTaskExecutor又有什么不一样?逐步找到“丢失”TraceId的原因。

2.3.1 InheritableThreadLocal、TransmittableThreadLocal和TransmissibleThreadLocal

在前面的分析中,我们发现跨线程场景下上下文信息是保存在ThreadLocal中发生丢失,那么我们接下来看看ThreadLocal的特点及其延伸出来的类,是否可以解决这一问题:

  • ThreadLocal主要是为每个ThreadLocal对象创建一个ThreadLocalMap来保存对象和线程中的值的映射关系。当创建一个ThreadLocal对象时会调用get()或set()方法,在当前线程的中查找这个ThreadLocal对象对应的Entry对象,如果存在,就获取或设置Entry中的值;否则,在ThreadLocalMap中创建一个新的Entry对象。ThreadLocal类的实例被多个线程共享,每个线程都拥有自己的ThreadLocalMap对象,存储着自己线程中的所有ThreadLocal对象的键值对。ThreadLocal的实现比较简单,但需要注意的是,如果使用不当,可能会出现内存泄漏问题,因为ThreadLocalMap中的Entry对象并不会自动删除。

  • InheritableThreadLocal的实现方式和ThreadLocal类似,但不同之处在于,当一个线程创建子线程时会调用init()方法:

private void init(ThreadGroup g, Runnable target, String name,long stackSize, AccessControlContext acc,Boolean inheritThreadLocals) {
 if (inheritThreadLocals && parent.inheritableThreadLocals != null)
  //拷贝父线程的变量
 this.inheritableThreadLocals =ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); 
 this.stackSize = stackSize;
 tid = nextThreadID();
}

这意味着子线程可以访问父线程中的InheritableThreadLocal实例,而且在子线程中调用set()方法时,会在子线程自己的inheritableThreadLocals字段中创建一个新的Entry对象,而不会影响父线程中的Entry对象。同时,根据源码我们也可以看到Thread的init()方法是在线程构造方法中拷贝的,在线程复用的线程池中是没有办法使用的。

  • TransmittableThreadLocal是阿里巴巴提供的解决跨线程传递上下文的InheritableThreadLocal子类,引入了holder来保存需要在线程间进行传递的变量,大致流程我们可以参考下面给出的时序图分析:

31fa965e55ed7bc6925122ee0a89c5ee.png

步骤可以总结为:① 装饰Runnable,将主线程的TTL传入到TtlRunnable的构造方法中;② 将子线程的TTL的值进行备份,将主线程的TTL设置到子线程中(value是对象引用,可能存在线程安全问题);③ 执行子线程逻辑;④ 删除子线程新增的TTL,将备份还原重新设置到子线程的TTL中,从而保证了ThreadLocal的值在多线程环境下的传递性。

TransmittableThreadLocal虽然解决了InheritableThreadLocal的继承问题,但是由于需要在序列化和反序列化时对ThreadLocalMap进行处理,会增加对象创建和序列化的成本,并且需要支持的序列化框架较少,不够灵活。

  • TransmissibleThreadLocal是继承了InheritableThreadLocal类并重写了get()、set()和remove()方法,TransmissibleThreadLocal的实现方式和TransmittableThreadLocal类似,主要的执行逻辑在Transmitter的capture()方法复制holder中的变量,replay()方法过滤非父线程的holder的变量,restore()来恢复经过replay()过滤后holder的变量:

public class TransmissibleThreadLocal<T> extends InheritableThreadLocal<T> {
 public static class Transmitter {
  public static Object capture() {
   Map<TransmissibleThreadLocal<?>, Object> captured = new HashMap<TransmissibleThreadLocal<?>, Object>();
      //获取所有存储在holder中的变量
   for (TransmissibleThreadLocal<?> threadLocal : holder.get().keySet()) { 
    captured.put(threadLocal, threadLocal.copyValue());
   }
   return captured;
  }
  public static Object replay(Object captured) {
   @SuppressWarnings("unchecked")
   Map<TransmissibleThreadLocal<?>, Object> capturedMap = (Map<TransmissibleThreadLocal<?>, Object>) captured;
   Map<TransmissibleThreadLocal<?>, Object> backup = new HashMap<TransmissibleThreadLocal<?>, Object>();
   for (Iterator<? extends Map.Entry<TransmissibleThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator();iterator.hasNext(); ) {
    Map.Entry<TransmissibleThreadLocal<?>, ?> next = iterator.next();
    TransmissibleThreadLocal<?> threadLocal = next.getKey();
    // backup
    backup.put(threadLocal, threadLocal.get());
    // clear the TTL value only in captured
    // avoid extra TTL value in captured, when run task.
        //过滤非传递的变量
    if (!capturedMap.containsKey(threadLocal)) { 
     iterator.remove();
     threadLocal.superRemove();
    }
   }
   // set value to captured TTL
   for (Map.Entry<TransmissibleThreadLocal<?>, Object> entry : capturedMap.entrySet()) {
    @SuppressWarnings("unchecked")
    TransmissibleThreadLocal<Object> threadLocal = (TransmissibleThreadLocal<Object>) entry.getKey();
    threadLocal.set(entry.getValue());
   }
   // call beforeExecute callback
   doExecuteCallback(true);
   return backup;
  }
  public static void restore(Object backup) {
   @SuppressWarnings("unchecked")
   Map<TransmissibleThreadLocal<?>, Object> backupMap = (Map<TransmissibleThreadLocal<?>, Object>) backup;
   // call afterExecute callback
   doExecuteCallback(false);
   for (Iterator<? extends Map.Entry<TransmissibleThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator();
                       iterator.hasNext(); ) {
    Map.Entry<TransmissibleThreadLocal<?>, ?> next = iterator.next();
    TransmissibleThreadLocal<?> threadLocal = next.getKey();
    // clear the TTL value only in backup
    // avoid the extra value of backup after restore
    if (!backupMap.containsKey(threadLocal)) { 
     iterator.remove();
     threadLocal.superRemove();
    }
   }
   // restore TTL value
   for (Map.Entry<TransmissibleThreadLocal<?>, Object> entry : backupMap.entrySet()) {
    @SuppressWarnings("unchecked")
    TransmissibleThreadLocal<Object> threadLocal = (TransmissibleThreadLocal<Object>) entry.getKey();
    threadLocal.set(entry.getValue());
   }
  }
 }
}

TransmissibleThreadLocal不但可以解决跨线程的传递问题,还能保证子线程和主线程之间的隔离,但是目前跨线程拷贝span数据时,采用浅拷贝有丢失数据的风险。最后,我们可以根据下表综合对比:

f8497570de9445a8facb4da6de52b0dd.png

考虑到TransmittableThreadLocal并非标准的Java API,而是第三方库提供的,存在与其它库的兼容性问题,无形中增加了代码的复杂性和使用难度。因此,MTrace选择自定义实现的TransmissibleThreadLocal类可以方便地在跨线程和跨服务的情况下传递追踪信息,透明自动完成所有异步执行上下文的可定制、规范化的捕捉传递,使得整个跟踪信息更加完整和准确。

2.3.2 Mtrace的跨线程传递方案

这一问题MTrace其实已经提供解决方案,主要的设计思路是在子线程初始化Runnable对象的时候首先会去父线程的ThreadLocal中拿到保存的trace信息,然后作为参数传递给子线程,子线程在初始化的时候设置trace信息来避免丢失。下面我们看看具体实现。

父线程新建任务时捕捉所有TransmissibleThreadLocal中的变量信息,如下图所示:

4caa554bd7853a5df7416f318ad6a21f.png

子线程执行任务时复制父线程捕捉的TransmissibleThreadLocal变量信息,并返回备份的TransmissibleThreadLocal变量信息,如下图所示:

2e085b8201a16f92fec0018a4d1c99ce.png

在子线程执行完业务流程后会恢复之前备份的TransmissibleThreadLocal变量信息,如下图所示:

a0f5a22e1125b744066d3e471db294bd.png

这种方案可以解决跨线程传递上下文丢失的问题,但是需要代码层面的开发会增加开发人员的工作量,对于一个分布式追踪系统而言并不是最优解:

TraceRunnable command = new TraceRunnable(runnable);
newThread(command).start();
executorService.execute(command);

因此,MTrace同时提供无侵入方式的javaagent&instrument技术,可以简单理解成一个类加载时的AOP功能,只要在JVM参数添加javaagent的配置,不需要修饰Runnable或是线程池的代码,就可以在启动时增强完成跨线程传递问题。

回归到本次的问题中来,目前使用的MDP本身就已经集成了MTrace-agent的模式,但是为什么还是会“弄丢”TraceId呢?查看MTrace的ThreadPoolTransformer类和ForkJoinPoolTransformer类我们可以知道,MTrace修改了ThreadPoolExecutor类、ScheduledThreadPoolExecutor类和ForkJoinTask类的字节码,顺着这个思路我们再看看@Async用到的SimpleAsyncTaskExecutor线程池是怎么一回事。

2.3.3 SimpleAsyncTaskExecutor是怎么一回事

我们先深入SimpleAsyncTaskExecutor的代码中,看看执行逻辑:

public class SimpleAsyncTaskExecutor extends CustomizableThreadCreator implements AsyncListenableTaskExecutor, Serializable {
 private ThreadFactory threadFactory;
 public void execute(Runnable task, long startTimeout) {
  Assert.notNull(task, "Runnable must not be null");
    //isThrottleActive是否开启限流(默认concurrencyLimit=-1,不开启限流)
  if(this.isThrottleActive() && startTimeout > 0L) {  
   this.concurrencyThrottle.beforeAccess();
   this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
   this.concurrencyThrottle.beforeAccess();
   this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
   this.concurrencyThrottle.beforeAccess();
   this.doExecute(new SimpleAsyncTaskExecutor.ConcurrencyThrottlingRunnable(task));
  } else {
   this.doExecute(task);
  }
 }
 protected void doExecute(Runnable task) {
    //没有线程工厂的话默认创建线程
  Thread thread = this.threadFactory != null?this.threadFactory.newThread(task):this.createThread(task);  
  thread.start();
 }
 public Thread createThread(Runnable runnable) {
    //和线程池不同,每次都是创建新的线程
  Thread thread = new Thread(getThreadGroup(), runnable, nextThreadName());
  thread.setPriority(getThreadPriority());
  thread.setDaemon(isDaemon());
  return thread;
 }
}

看到这里我们可以得出以下几个特性:

  • SimpleAsyncTaskExecutor每次执行提交给它的任务时,会启动新的线程,并不是严格意义上的线程池,达不到线程复用的功能。

  • 允许开发者控制并发线程的上限(concurrencyLimit)起到一定的资源节流作用,但默认concurrencyLimit取值为-1,即不启用资源节流,有引发内存泄漏的风险。

  • 阿里技术编码规约要求用ThreadPoolExecutor的方式来创建线程池,规避资源耗尽的风险。

结合之前说过的MTrace线程池代理模型,我们继续再来看看SimpleAsyncTaskExecutor的类图:

bfa9befe0e3aa8af9c2d27ff97e9bca0.png

可以发现,其继承了spring的TaskExecutor接口,其实质是java.util.concurrent.Executor,结合我们这次“丢失”的TraceId问题来看,我们已经找到了Mtrace的跨线程传递方案“失效”的原因:虽然MTrace已经通过javaagent&instrument技术可以完成Trace信息跨线程传递,但是目前只覆盖到ThreadPoolExecutor类、ScheduledThreadPoolExecutor类和ForkJoinTask类的字节码,而@Async在未指定线程池的情况下默认会启用SimpleAsyncTaskExecutor,其本质是java.util.concurrent.Executor没有被覆盖到,就会造成ThreadLocal中的get方法获取信息为空,导致最终TraceId传递丢失。

 3. 解决方案 

实际上@Async支持我们使用自定义的线程池,可以手动自定义Configuration来配置ThreadPoolExecutor线程池,然后在注解里面指定bean的名称,就可以切换到对应的线程池去,可以看看下面的代码:

@Configuration
public class ThreadPoolConfig {
 @Bean("taskExecutor")
     public Executor taskExecutor() {
  ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
  //设置线程池参数信息
  taskExecutor.setCorePoolSize(10);
  taskExecutor.setMaxPoolSize(50);
  taskExecutor.setQueueCapacity(200);
  taskExecutor.setKeepAliveSeconds(60);
  taskExecutor.setThreadNamePrefix("myExecutor--");
  taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
  taskExecutor.setAwaitTerminationSeconds(60);
  taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
  taskExecutor.initialize();
  return taskExecutor;
 }
}

然后在注解中标注这个线程池:

@SpringBootTest
@RunWith(SpringRunner.class)
@EnableAsync
public class DemoServiceTest extends TestCase {
 @Resource
   private DemoService demoService;
 @Test
   public void testTestAsy() {
  Tracer.serverRecv("test");
  String mainThreadName = Thread.currentThread().getName();
  long mainThreadId = Thread.currentThread().getId();
  System.out.println("------We got main thread: "+ mainThreadName + " - " +  mainThreadId + "  Trace Id: " + Tracer.id() + "----------");
  demoService.testAsy();
 }
}
@Component
public class DemoService {
 @Async("taskExecutor")
   public void testAsy(){
  String asyThreadName = Thread.currentThread().getName();
  long asyThreadId = Thread.currentThread().getId();
  System.out.println("======Async====");
  System.out.println("------We got asy thread: "+ asyThreadName + " - " +  asyThreadId + "  Trace Id: " + Tracer.id() + "----------");
 }
}

看看输出台的打印:

------We got main thread: main - 1  Trace Id: -3495543588231940494----------
======Async====
------We got asy thread: SimpleAsyncTaskExecutor-1 - 658  Trace Id: 3495543588231940494----------

最终,我们可以通过这一方式“找回”在@Async注解下跨线程传递而“丢失”的TraceId。

 4. 其他方案对比 

分布式追踪系统从诞生之际到有实质性的突破,很大程度受到Google Dapper的影响,目前常见的分布式追踪系统有Twitter的Zipkin、SkyWalking、阿里的EagleEye、PinPoint和美团的MTrace等,这些大多都是基于Google Dapper的设计思想,考虑到设计思路和架构特点,我们重点介绍Zipkin、SkyWalking和EagleEye的基本框架和跨线程解决方案(以下内容主要来源于官网及作者总结,仅供参考,不构成技术建议)。

| 4.1 Zipkin

Zipkin是由Twitter公司贡献开发的一款开源的分布式追踪系统,官方提供有基于Finagle框架(Scala语言)的接口,而其他框架的接口由社区贡献,目前可以支持Java、Python、Ruby和C#等主流开发语言和框架,其主要功能是聚集来自各个异构系统的实时监控数据。主要由4个核心组件构成,如下图所示:

d7a052fa2d6a5793d553972092299e71.png

  • Collector:收集器组件,它主要用于处理从外部系统发送过来的跟踪信息,将这些信息转换为Zipkin内部处理的Span格式,以支持后续的存储、分析、展示等功能。

  • Storage:存储组件,它主要对处理收集器接收到的跟踪信息,默认会将这些信息存储起来,同时支持修改存储策略。

  • API:API组件,它主要用来提供外部访问接口,比如给客户端展示跟踪信息,或是外接系统访问以实现监控等。

  • UI:UI组件,基于API组件实现的上层应用,通过UI组件用户可以方便而有直观地查询和分析跟踪信息。

当用户发起一次调用的时候,Zipkin的客户端会在入口处先记录这次请求相关的trace信息,然后在调用链路上传递trace信息并执行实际的业务流程,为防止追踪系统发送延迟与发送失败导致用户系统的延迟与中断,采用异步的方式发送trace信息给Zipkin Collector,Zipkin Server在收到trace信息后,将其存储起来。随后Zipkin的Web UI会通过 API访问的方式从存储中将trace信息提取出来分析并展示。

c0a03d76dafd08f01af9c7d00541b0eb.png

最后,我们看看Zipkin的跨线程传递方案的优缺点:在单个线程的调用中Zipkin通过定义一个ThreadLocal<TraceContext> local来完成在整个线程执行过程中获取相同的Trace值,但是当新起一个线程的时候ThreadLocal就会失效,对于这种场景,Zipkin对于不提交线程池的场景提供InheritableThreadLocal<TraceContext>来解决父子线程trace信息传递丢失的问题。

而对于@Async的使用场景,Zipkin提供CurrentTraceContext类首先获取父线程的trace信息,然后将trace信息复制到子线程来,其基本思路和上文MTrace的一致,但是需要代码开发,具有较强的侵入性。

| 4.2 SkyWalking

SkyWalking是Apache基金会下面的一个开源的应用程序性能监控系统,提供了一种简便的方式来清晰地观测云原生和基于容器的分布式系统。具有支持多种语言探针;微内核+插件的架构;存储、集群管理和使用插件集合都可以自由选择;支持告警;优秀的可视化效果的特点。其主要由4个核心组件构成,如下图所示:

c3076a23fd14bf382bfca726c83819d2.png

  • 探针:基于不同的来源可能是不一样的,但作用都是收集数据,将数据格式化为 SkyWalking适用的格式。

  • 平台后端:支持数据聚合,数据分析以及驱动数据流从探针到用户界面的流程。分析包括Skywalking原生追踪和性能指标以及第三方来源,包括Istio、Envoy telemetry、Zipkin追踪格式化等。

  • 存储:通过开放的插件化的接口存放SkyWalking数据。用户可以选择一个既有的存储系统,如ElasticSearch、H2或MySQL集群(Sharding-Sphere管理),也可以指定选择实现一个存储系统。

  • UI :一个基于接口高度定制化的Web系统,用户可以可视化查看和管理SkyWalking数据。

SkyWalking的工作原理和Zipkin类似,但是相比较于Zipkin接入系统的方式,SkyWalking使用了插件化+javaagent 的形式来实现:通过虚拟机提供的用于修改代码的接口来动态加入打点的代码,如通过javaagent premain来修改Java 类,在系统运行时操作代码,让用户可以在不需要修改代码的情况下进行链路追踪,对业务的代码无侵入性,同时使用字节码操作技术(Byte-Buddy)和AOP概念来实现拦截追踪上下文的trace信息,这样一来每个用户只需要根据自己的需用定义拦截点,就可以实现对一些模块实施分布式追踪。

69e361c4562385b6433748ef22995546.png

最后,我们总结一下SkyWalking的跨线程传递方案的优缺点:和主流的分布式追踪系统类似,SkyWalking也是借助ThreadLocal来存储上下文信息,当遇到跨线程传输时也面临传递丢失的场景,针对这一问题SkyWalking会在父线程调用ContextManager.capture()将trace信息保存到一个ContextSnapshot的实例中并返回,ContextSnapshott则被附加到任务对象的特定属性中,那么当子线程处理任务对象的时会先取出ContextSnapshott对象,将其作为入参调用ContextManager.continued(contextSnapshot)来保存到子线程中。

整体思路其实和主流的分布式追踪系统的相似,SkyWalking目前只针对带有@TraceCrossThread注解的Callable、Runnable和Supplier这三种接口的实现类进行增强拦截,通过使用xxxWrapper.of的包装方式,避免开发者需要大的代码改动。

| 4.3 EagleEye

EagleEye阿里巴巴开源的应用性能监控工具,提供了多维度、实时、自动化的应用性能监控和分析能力。它可以帮助开发人员实时监控应用程序的性能指标、日志、异常信息等,并提供相应的性能分析和报告,帮助开发人员快速定位和解决问题。主要由以下5部分组成:

ded1aa7a92d837b61520642126a5ce0d.png

  • 代理:代理是鹰眼的数据采集组件,通过代理可以采集应用程序的性能指标、日志、异常信息等数据,并将其传输到鹰眼的存储和分析组件中。代理支持多种协议,如HTTP、Dubbo、RocketMQ、Kafka等,能够满足不同场景下的数据采集需求。

  • 存储:存储是鹰眼的数据存储组件,负责存储代理采集的数据,并提供高可用、高性能、高可靠的数据存储服务。存储支持多种存储引擎,如HBase、Elasticsearch、TiDB等,可以根据实际情况进行选择和配置。

  • 分析:分析是鹰眼的数据分析组件,负责对代理采集的数据进行实时分析和处理,并生成相应的监控指标和性能报告。分析支持多种分析引擎,如Apache Flink、Apache Spark等,可以根据实际情况进行选择和配置。

  • 可视化:可视化是鹰眼的数据展示组件,负责将分析产生的监控指标和性能报告以图形化的方式展示出来,以便用户能够直观地了解系统的运行状态和性能指标。

  • 告警:告警是鹰眼的告警组件,负责根据用户的配置进行异常检测和告警,及时发现和处理系统的异常情况,防止系统出现故障。

不同于SkyWalking的开源社区,EagleEye重点面向阿里内部环境开发,针对海量实时监控的痛点,对底层的流计算、多维时序指标与交互体系等进行了大量优化,同时引入了时序检测、根因分析、业务链路特征等技术,将问题发现与定位由被动转为主动。

EagleEye采用了StreamLib实时流式处理技术提升流计算性能,对采集的数据进行实时分析和处理,当监控一个电商网站时,可以实时地分析用户访问的日志数据,并根据分析结果来优化网站的性能和用户体验;参考Apache Flink的Snapshot优化齐全度算法来保证监控系统确定性;为了满足不同的个性化需求,把一些可复用的逻辑变成了“积木块”,让用户按照自己的需求,拼装流计算的pipeline。

151943426b4e63df1816ab0ea5ccd858.png

最后总结一下EagleEye的跨线程传递方案优缺点:EagleEye的解决思路和大多数分布式追踪系统一致,都是通过javaagent的方式修改线程池的实现,进而子线程可以获取到父线程到trace信息,不同于SkyWalking这种开源系统采用的字节码增强,EagleEye大多数场景是内部使用,所以采用直接编码的方式,维护和性能消耗方面也是非常有优势的,但扩展性和开放性并不是非常友好。

5. 总结 

本文意在从日常工作中一个很细微的问题出发,探究分析背后的设计思想和底层原因,主要涉及以下方面:

  • 抓住问题本质:在业务系统报警中抓住问题的核心代码并尝试再次复现问题,找到真正出问题的模块。

  • 深入理解设计思想:在查阅公司中间件的产品文档的基础上再继续追根溯源,学习业内领先者最开始的分布式链路追踪系统的设计思想和实现途径。

  • 结合实际问题提出疑问:结合了解到的分布式链路追踪系统的实现流程和设计思想,回归到一开始我们要解决的TraceId丢失情况分析是在什么环节出现问题。

  • 阅读源码找到底层逻辑:从@Async注解、SimpleAsyncTaskExecutor和ThreadLocal类源码进行层层追踪,分析底层真正的实现逻辑和特点。

  • 对比分析找到解决方案:分析为什么Mtrace的跨线程传递方案“失效”了,找到原因提供解决方案并总结其他分布式追踪系统。

从本文可以看出,中间件的出现不仅为我们维护系统的稳定提供有力的支持,还已经为使用中可能发生的问题提供了更高效的解决方案,作为开发人员在享受这一极大便利的同时,还是要沉下心来认真思考其中的实现逻辑和使用场景,如果只是一味的低头使用不求甚解,那么在一些特定问题上往往会显得十分被动,无法发挥中间件真正的价值,甚至在没有中间件支撑时无法高效的解决问题。

 6. 本文作者 

李祯,美团到店事业群/充电宝业务部工程师。

 7. 参考资料 

[1] Dapper, a Large-Scale Distributed Systems Tracing Infrastructure

[2] ThreadLocal

[3] Annotation Interface Async

[4] SkyWalking 8 官方中文文档

[5] Zipkin Architecture

[6] 阿里巴巴鹰眼技术解密

----------  END  ----------

 推荐阅读 

  | Netty堆外内存泄露排查与总结

  | 疑案追踪:Spring Boot内存泄露排查记

  | 海盗中间件:美团服务体验平台对接业务数据的最佳实践

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

一次「找回」TraceId的问题分析与过程思考 的相关文章

  • 如何使用固定数量的工作线程实现简单线程

    我正在寻找最简单 最直接的方法来实现以下内容 主程序实例化worker 执行任务的线程 Only n任务可以同时运行 When n已达到 不再有工人 开始直到计数 正在运行的线程回落到下方n 我觉得Executors newFixedThr
  • Java 读取大文本文件时出现 OutOfMemoryError

    我是 Java 新手 正在读取非常大的文件 需要一些帮助来理解问题并解决它 我们有一些遗留代码 必须对其进行优化才能正常运行 文件大小仅在 10mb 到 10gb 之间变化 只有当文件开始大小超过 800mb 时才会出现启动问题 Input
  • 在 Java 中从 SOAPMessage 获取原始 XML

    我已经在 J AX WS 中设置了 SOAP WebServiceProvider 但我无法弄清楚如何从 SOAPMessage 或任何 Node 对象获取原始 XML 下面是我现在获得的代码示例 以及我试图获取 XML 的位置 WebSe
  • 项目缺少所需的注释处理库

    我的 Eclipse IDE 突然在问题视图中显示 xxxx 项目缺少所需的注释处理库 xxxx M2 REPO 中的一些旧 jar 我用谷歌搜索 没有找到任何答案 为什么我的项目使用旧的 jar 以及错误来自哪里 To remove th
  • 迁移到Java 9或更高版本时是否需要切换到模块?

    我们目前正在从 Java 8 迁移到 Java 11 但是 升级我们的服务并没有我们预期的那么痛苦 我们基本上只需要更改我们的版本号build gradle文件和服务都顺利启动并运行 我们升级了库以及使用这些库的 微 服务 到目前为止没有问
  • javax.persistence.RollbackException:提交事务时出错],根本原因是 java.lang.StackOverflowError:null

    我有一个使用 Spring Data REST 框架的 Spring Boot API 从 spring boot starter parent 2 1 0 RELEASE 继承的依赖项 我正在尝试执行 PUT 或 PATCH 请求来更新实
  • 具有 CRUD 功能的基于 Spring Web 的管理工具

    在 PHP Symfony 世界里有一个工具叫 Sonata Adminhttps sonata project org https sonata project org 基于 AdminLTE 模板 这是一款一体化管理工具 具有登录 菜单
  • OpenNLP 与斯坦福 CoreNLP

    我一直在对这两个包进行一些比较 但不确定该往哪个方向走 我简单地寻找的是 命名实体识别 人 地点 组织等 性别识别 一个不错的训练 API 据我所知 OpenNLP 和斯坦福 CoreNLP 提供了非常相似的功能 然而 Stanford C
  • 使用 JAX-WS 的 WebLogic 中没有模式导入的单个 WSDL

    如何使用 JAX WS 配置由 WebLogic 10 3 6 生成的 Web 服务 以将对象架构包含在单个 WSDL 文件声明 而不是导入声明 中 示例代码 界面 import javax ejb Local Local public i
  • 需要正则表达式帮助

    我正在尝试替换两次或多次出现的 br like br br br 标签与两个一起 br br 具有以下模式 Pattern brTagPattern Pattern compile lt s br s s gt s 2 Pattern CA
  • 在 Java 中创建 T 的新实例

    在C 中 我们可以定义一个泛型class A
  • Java 中的 ExecuteUpdate sql 语句不起作用

    我正在学习如何将 SQL 与 Java 结合使用 我已成功安装 JDBC 驱动程序 并且能够从数据库读取记录并将其打印在屏幕上 我的问题发生在尝试执行更新或插入语句时 没有任何反应 这是我的代码 问题所在的方法 public static
  • 更改 JComboBox 中滚动条的大小

    有谁知道如何手动更改 jComboBox 中的滚动条大小 我已经尝试了一大堆东西 但没有任何效果 好吧 我明白了 您可以实现 PopUpMenuListener 并使用它 public void popupMenuWillBecomeVis
  • 是否有最新的 Facebook Java SDK? [关闭]

    Closed 这个问题正在寻求书籍 工具 软件库等的推荐 不满足堆栈溢出指南 help closed questions 目前不接受答案 好像没找到最近更新的 如果没有 是否有一个好的 Java 库来执行与 Facebook 的 API 交
  • 从 Android 访问云存储

    我一直无法找到任何有关如何从 Android 应用程序使用云存储的具体文档 我确实遇到过这个客户端库 https cloud google com storage docs reference libraries然而 Google Clou
  • 改变 Java 中凯撒移位的方向

    用户可以通过选择 1 向左或 2 向右移动字母来选择向左或向右移动 左边工作正常 右边不行 现在它显示了完全相同的循环 但我已经改变了所有 and 以不同的方式进行标记 最终我总是得到奇怪的字符 如何让程序将字符向相反方向移动 如果用户输入
  • JAXB 编组器无参数默认构造函数

    我想从 java 库中编组一个 java 对象 当使用 JAXB marschaller 编组 java 对象时 我遇到了一个问题 A 类没有无参数默认构造函数 我使用Java Decompiler来检查类的实现 它是这样的 public
  • Java String ReplaceAll 方法给出非法重复错误?

    我有一个字符串 当我尝试运行时replaceAll方法 我收到这个奇怪的错误 String str something op str str replaceAll o n it works fine str str replaceAll n
  • 在 Freemarker 模板中检查 Spring 安全角色和记录的用户名

    有谁知道 freemarker 标签来检查 freemarker 文件中的 spring 安全角色和用户名 我从网络上的几个资源中发现以下代码将打印登录的用户名 但它没有打印用户名 而是打印 登录为
  • @Embeddable 中的 @GenerateValue

    我已将实体的 id 分离到一个单独的 Embeddable 类中 该实体如下 Entity Table name users public class Users EmbeddedId private Users pk id private

随机推荐

  • Win10编译64位curl(支持https)

    前期准备 1 安装NASM 官网https www nasm us 安装完成需要设置环境变量PATH 2 安装ActivePerl Download Install Perl ActiveState 3 下载openssl zlib cur
  • 矩阵乘法(C语言)

    Description 矩阵乘法是线性代数中最基本的运算之一 给定三个矩阵 请编写程序判断 是否成立 Input 输入包含多组数据 每组数据的格式如下 第一行包括两个整数p和q 表示矩阵A的大小 后继p行 每行有q个整数 表示矩阵A的元素内
  • 在VMware15.5上安装Ubuntu16.04(18.04)的具体流程及可能出现的问题(保姆级)

    在VMware15 5上安装Ubuntu16 04 18 04 的具体流程及可能出现的问题 保姆级 用镜像安装时已经下载好了两个可用的Ubuntu选项分别是Ubuntu16 04和Ubuntu18 04两个版本 下载时注意与两个版本兼容的R
  • 查看字符对应utf-8编码

    查看字符对应utf 8编码 http www mytju com classcode tools encode utf8 asp
  • 第十二章 微服务核⼼组件之⽹关

    1 什么是微服务的 关和应 场景 什么是 关 API Gateway 是系统的唯 对外的 介于客户端和服务器端之间的中间层 处理 业务功能提供路由请求 鉴权 监控 缓存 限流等功能 统 接 智能路由 AB测试 灰度测试 负载均衡 容灾处理
  • python pymssql_python pymssql — pymssql模块使用指南

    前言 最近在学习python 发现好像没有对pymssql的详细说明 于是乎把官方文档学习一遍 重要部分做个归档 方便自己以后查阅 pymssql是python用来连接Microsoft SQL Server的一个工具库 package 其
  • T5、RoBERTa、悟道·天鹰、紫东太初、CPM作者谈基础模型前沿技术丨大模型科研、创业避坑指南...

    导读 大语言模型日益火爆 学者们的研究方向是指明灯 那么相关大模型重要项目的主要贡献者怎么看 6月9日的北京智源大会 基础模型前沿技术 论坛邀请了T5 RoBERTa 悟道 天鹰 紫东太初 CPM等重要模型工作作者出席 图注 五位嘉宾现场讨
  • Deeplearning4j 实战 (19):基于胶囊网络(Capsule Network)的手写体数字识别

    Eclipse Deeplearning4j GitChat课程 https gitbook cn gitchat column 5bfb6741ae0e5f436e35cd9f Eclipse Deeplearning4j 系列博客 ht
  • 阿里云盘开启公测,这些“暗号”助你云盘容量扩容2.5T(内含10+兑换码)

    关注ITValue 看企业级最新鲜 最价值报道 上次发了阿里云盘内测福利码之后 不少盆友后台哭诉 名额有限 没领到内测福利 不过很快啊 今天 阿里云盘 正式开启了公测 这次我又收集了一些 码 他们分别是 上云上阿里云 阿里云购爆款 达摩院招
  • 大数据毕设 - 大数据二手房数据分析与可视化(python 爬虫)

    文章目录 1 前言 1 课题背景 2 实现效果 2 1 二手房基本信息可视化分析 2 2 二手房房屋属性可视化分析 3 数据采集 3 1 链家网网站结构分析 3 2 网络爬虫程序关键问题说明 4 数据清洗 4 1 原始数据主要需要清洗的部分
  • 菜鸟入门之一:在Ubuntu18.04下利用VS code编写C语言的配置

    出于学 zhuang 习 bi 开始接触linux 所以尝试在电脑上安装了ubuntu 不想一发不可收拾 逐渐被Linux的魅力所征服 作为一名ITboy自然首先想到的是如何解决写代码的问题 由于Linux水平还处于菜鸟水准 所以什么利用v
  • Centos中ifcfg-ens33文件参数解释

    DEVICE 接口名 设备 网卡 USERCTL yes no 非root用户是否可以控制该设备 BOOTPROTO IP的配置方法 none static bootp dhcp 引导时不使用协议 静态分配IP BOOTP协议 DHCP协议
  • java JSONObject转换为String格式

    在使用微信支付时 需将从前台接收的JSONObeject 格式数据转换为String类型 其具体的转换过程如下 JSONObject jsonObject JSONObject parseObject XmltoJsonUtil xml2J
  • NS元胞自动机模型--python实现

    实现 coding utf 8 NS模型 场景 周期型边界 道路长度 cell 1000个元胞 车辆初始分布为均匀分布 初始速度 v0 vmax 5 随机慢化概率 p 0 1 仿真时步为2000时步 从500时步开始采样 1表示元胞 其他值
  • echarts仪表盘颜色渐变

    echarts仪表盘背景颜色渐变 echarts仪表盘背景颜色渐变 offset设置偏移量 代码 option tooltip formatter a br b c toolbox feature restore saveAsImage s
  • ASP.NET MVC Controller与Areas下面的Controller同名的解决办法

    问题重现 当项目下 Controller HomeController cs时 人在创建一个域Test 之后在建一个同名的HomeController Areas Test Controller HomeController cs 运行报错
  • 软件测试面试题1

    1 问 软件测试的原则 答 软件测试的八个原则 山鬼谣弋痕夕的博客 CSDN博客 软件测试的八个原则 所有测试的标准都是建立在用户需求之上 始终保持 质量第一 的觉悟 当时间和质量冲突时 时间要服从质量 需求阶段应定义清楚产品的质量标准 软
  • Homebrew命令

    安装软件 brew install appname 卸载软件 brew uninstall appname brew remove appname 查看以安装软件 brew list 查看软件相关信息 brew info appname 查
  • 数据挖掘——基于sklearn包的分类算法小结

    目录 一 分类算法简介 二 KNN算法 三 贝叶斯分类算法 四 决策树算法 五 随机森林算法 六 SVM算法 一 分类算法简介 1 概念 1 1 监督学习 Supervised Learning 从给定标注 训练集有给出明确的因变量Y 的训
  • 一次「找回」TraceId的问题分析与过程思考

    用好中间件是每一个开发人员的基本功 一个专业的开发人员 追求的不仅是中间件的日常使用 还要探究这背后的设计初衷和底层逻辑 进而保证我们的系统运行更加稳定 让开发工作更加高效 结合这一主题 本文从一次线上告警问题出发 通过第一时间定位问题的根