用好中间件是每一个开发人员的根本功,一个专业的开发人员,寻求的不仅是中间件的日常运用,还要探究这背面的规划初衷和底层逻辑,从而确保咱们的体系运转愈加安稳,让开发作业愈加高效。结合这一主题,本文从一次线上告警问题出发,经过第一时间定位问题的根本原因,从而引出Google Dapper与MTrace(美团内部自研)这类分布式链路追寻体系的规划思维和完结途径,再回到问题本质深化@Async的源码剖析底层的异步逻辑和完结特色,并给出MTrace跨线程传递失效的原因和处理计划,终究梳理现在干流的分布式盯梢体系的现状,并结合开发人员日常运用中间件的场景提出一些考虑和总结。

1. 问题布景和考虑

1.1 问题布景

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

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

在机器中能够清楚的发现“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是怎么在大型分布式环境下完结分布式链路追寻。咱们先来看看下图一个完好的分布式恳求:

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

用户发送一个恳求到前端A,然后恳求分发到两个不同的中间层服务B和C,服务B在处理完恳求后将成果回来,一起服务C需求持续调用后端服务D和E再将处理后的恳求成果进行回来,终究由前端A汇总来呼运用户的这次恳求。

回忆这次完好的恳求咱们不难发现,要想直观牢靠的追寻多项服务的分布式恳求,咱们最重视的是每组客户端和服务端之间的恳求呼应以及呼应耗时,因而,Google Dapper采纳对每一个恳求和呼应设置标识符和时间戳的办法完结链路追寻,根据这一规划思维的根本追寻树模型如下图所示:

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

追寻树模型由span组成,其间每个span包含span name、span id、parent id和trace id,进一步剖析盯梢树模型中各个span之间的调用联系能够发现,其间没有parent id且span id为1代表根服务调用,span id越小代表服务在调用链的进程中离根服务就越近,将模型中各个相对独立的span联系在一起就构成了一次完好的链路调用记载,咱们再持续深化看看span内部的细节信息:

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

除了最根本的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是怎么完结异步的:

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

从类图中咱们能够直观地看到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中进行传递和保存,那么当异步办法切换线程的时分,就会呈现下图中上下文信息传递丢掉的问题:

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

下面咱们探究一下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来保存需求在线程间进行传递的变量,大致流程咱们能够参阅下面给出的时序图剖析:

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

过程能够总结为:① 装修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数据时,选用浅复制有丢掉数据的危险。终究,咱们能够依据下表归纳比照:

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

考虑到TransmittableThreadLocal并非规范的Java API,而是第三方库供给的,存在与其它库的兼容性问题,无形中添加了代码的复杂性和运用难度。因而,MTrace挑选自界说完结的TransmissibleThreadLocal类能够便利地在跨线程和跨服务的状况下传递追寻信息,通明自动完结一切异步履行上下文的可定制、规范化的捕捉传递,使得整个盯梢信息愈加完好和精确。

2.3.2 Mtrace的跨线程传递计划

这一问题MTrace其完成已供给处理计划,首要的规划思路是在子线程初始化Runnable目标的时分首要会去父线程的ThreadLocal中拿到保存的trace信息,然后作为参数传递给子线程,子线程在初始化的时分设置trace信息来避免丢掉。下面咱们看看具体完结。

父线程新建使命时捕捉一切TransmissibleThreadLocal中的变量信息,如下图所示:

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

子线程履行使命时仿制父线程捕捉的TransmissibleThreadLocal变量信息,并回来备份的TransmissibleThreadLocal变量信息,如下图所示:

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

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

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

这种计划能够处理跨线程传递上下文丢掉的问题,可是需求代码层面的开发会添加开发人员的作业量,关于一个分布式追寻体系而言并不是最优解:

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的类图:

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

能够发现,其承继了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个中心组件构成,如下图所示:

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

  • Collector:收集器组件,它首要用于处理从外部体系发送过来的盯梢信息,将这些信息转换为Zipkin内部处理的Span格式,以支撑后续的存储、剖析、展现等功用。
  • Storage:存储组件,它首要对处理收集器接收到的盯梢信息,默许会将这些信息存储起来,一起支撑修正存储战略。
  • API:API组件,它首要用来供给外部拜访接口,比方给客户端展现盯梢信息,或是外接体系拜访以完结监控等。
  • UI:UI组件,根据API组件完结的上层运用,经过UI组件用户能够便利而有直观地查询和剖析盯梢信息。

当用户发起一次调用的时分,Zipkin的客户端会在进口处先记载这次恳求相关的trace信息,然后在调用链路上传递trace信息并履行实践的事务流程,为避免追寻体系发送推迟与发送失利导致用户体系的推迟与中断,选用异步的办法发送trace信息给Zipkin Collector,Zipkin Server在收到trace信息后,将其存储起来。随后Zipkin的Web UI会经过 API拜访的办法从存储中将trace信息提取出来剖析并展现。

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

终究,咱们看看Zipkin的跨线程传递计划的优缺点:在单个线程的调用中Zipkin经过界说一个ThreadLocal<TraceContext> local来完结在整个线程履行进程中获取相同的Trace值,可是当新起一个线程的时分ThreadLocal就会失效,关于这种场景,Zipkin关于不提交线程池的场景供给InheritableThreadLocal<TraceContext>来处理父子线程trace信息传递丢掉的问题。

而关于@Async的运用场景,Zipkin供给CurrentTraceContext类首要获取父线程的trace信息,然后将trace信息仿制到子线程来,其根本思路和上文MTrace的一致,可是需求代码开发,具有较强的侵入性。

4.2 SkyWalking

SkyWalking是Apache基金会下面的一个开源的运用程序功用监控体系,供给了一种简洁的办法来清晰地观测云原生和根据容器的分布式体系。具有支撑多种言语探针;微内核+插件的架构;存储、集群办理和运用插件集合都能够自由挑选;支撑告警;优异的可视化效果的特色。其首要由4个中心组件构成,如下图所示:

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

  • 探针:根据不同的来源可能是不一样的,但作用都是收集数据,将数据格式化为 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信息,这样一来每个用户只需求依据自己的需用界说阻拦点,就能够完结对一些模块实施分布式追寻。

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

终究,咱们总结一下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部分组成:

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

  • 署理:署理是鹰眼的数据收集组件,经过署理能够收集运用程序的功用目标、日志、反常信息等数据,并将其传输到鹰眼的存储和剖析组件中。署理支撑多种协议,如HTTP、Dubbo、RocketMQ、Kafka等,能够满意不同场景下的数据收集需求。
  • 存储:存储是鹰眼的数据存储组件,担任存储署理收集的数据,并供给高可用、高功用、高牢靠的数据存储服务。存储支撑多种存储引擎,如HBase、Elasticsearch、TiDB等,能够依据实践状况进行挑选和装备。
  • 剖析:剖析是鹰眼的数据剖析组件,担任对署理收集的数据进行实时剖析和处理,并生成相应的监控目标和功用陈述。剖析支撑多种剖析引擎,如Apache Flink、Apache Spark等,能够依据实践状况进行挑选和装备。
  • 可视化:可视化是鹰眼的数据展现组件,担任将剖析产生的监控目标和功用陈述以图形化的办法展现出来,以便用户能够直观地了解体系的运转状况和功用目标。
  • 告警:告警是鹰眼的告警组件,担任依据用户的装备进行反常检测和告警,及时发现和处理体系的反常状况,避免体系呈现故障。

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

EagleEye选用了StreamLib实时流式处理技能提升流核算功用,对收集的数据进行实时剖析和处理,当监控一个电商网站时,能够实时地剖析用户拜访的日志数据,并依据剖析成果来优化网站的功用和用户体会;参阅Apache Flink的Snapshot优化完全度算法来确保监控体系确定性;为了满意不同的个性化需求,把一些可复用的逻辑变成了“积木块”,让用户按照自己的需求,拼装流核算的pipeline。

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

终究总结一下EagleEye的跨线程传递计划优缺点:EagleEye的处理思路和大多数分布式追寻体系一致,都是经过javaagent的办法修正线程池的完结,从而子线程能够获取到父线程到trace信息,不同于SkyWalking这种开源体系选用的字节码增强,EagleEye大多数场景是内部运用,所以选用直接编码的办法,保护和功用消耗方面也是十分有优势的,但扩展性和敞开性并不是十分友爱。

5. 总结

本文意在从日常作业中一个很细微的问题出发,探究剖析背面的规划思维和底层原因,首要触及以下方面:

  • 抓住问题本质:在事务体系报警中抓住问题的中心代码并测验再次复现问题,找到真正出问题的模块。
  • 深化了解规划思维:在查阅公司中间件的产品文档的基础上再持续追根溯源,学习业内领先者最开端的分布式链路追寻体系的规划思维和完结途径。
  • 结合实践问题提出疑问:结合了解到的分布式链路追寻体系的完结流程和规划思维,回归到一开端咱们要处理的TraceId丢掉状况剖析是在什么环节呈现问题。
  • 阅读源码找到底层逻辑:从@Async注解、SimpleAsyncTaskExecutor和ThreadLocal类源码进行层层追寻,剖析底层真正的完结逻辑和特色。
  • 比照剖析找到处理计划:剖析为什么Mtrace的跨线程传递计划“失效”了,找到原因供给处理计划并总结其他分布式追寻体系。

从本文能够看出,中间件的呈现不仅为咱们保护体系的安稳供给有力的支撑,还现已为运用中可能发生的问题供给了更高效的处理计划,作为开发人员在享受这一极大便利的一起,还是要沉下心来认真考虑其间的完结逻辑和运用场景,假如只是一味的垂头运用囫囵吞枣,那么在一些特定问题上往往会显得十分被动,无法发挥中间件真正的价值,甚至在没有中间件支撑时无法高效的处理问题。

本文作者

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

参阅资料

  • [1] Dapper, a Large-Scale Distributed Systems Tracing Infrastructure
  • [2] ThreadLocal
  • [3] Annotation Interface Async
  • [4] SkyWalking 8 官方中文文档
  • [5] Zipkin Architecture
  • [6] 阿里巴巴鹰眼技能解密

| 在美团大众号菜单栏对话框回复【2022年货】、【2021年货】、【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可检查美团技能团队历年技能文章合集。

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

| 本文系美团技能团队出品,著作权归属美团。欢迎出于共享和沟通等非商业目的转载或运用本文内容,敬请注明“内容转载自美团技能团队”。本文未经许可,不得进行商业性转载或许运用。任何商用行为,请发送邮件至tech@meituan.com请求授权。