在调用链的绘制过程中,调用链上下文的传递非常值得关注。各个节点在获取上层上下文后生成新的上下文并向后传递。在传递过程中,上下文一旦丢失或出现异常就会导致调用链数据缺失,甚至可能会发生断裂。
本文主要讲述UAV中调用链上下文传递过程中的部分实现细节。
前言
在调用链的实现中,主要存在以下几种调用链上下文的传递方式:
- 请求处理前到请求处理后的上下文传递;
- 各个客户端调用间的上下文传递;
- 各个服务间调用时的上下文传递。
在这三种情况中,上下文传递过程中所传递的信息以及遇到的问题会有所不同。
- 在请求处理前后的上下文传递过程中,需要传递的信息一般包括traceID、 spanID、请求开始的时间以及部分请求参数等。相关代码可能会因为异步执行导致上下文面临异步线程传递的问题。
- 在客户端调用间及服务间调用中,需要传递的上下文信息一般只包括traceID和spanID。但客户端调用之间的上下文传递可能会遇到跨线程池传递的问题,服务间调用则存在跨应用传递的问题。
因此,我们把今天所讲的上下文传递划分为以下四种场景进行分析:
- 在同一线程内传递
- 跨线程池传递
- 异步线程传递
- 跨应用传递
为了更好地阐述这四种场景,我们假设存在以下业务调用过程:
假设某次请求首先进入服务A,在服务A的业务代码中发起了一次JDBC请求,访问了一次数据源;然后又通过httpClient(同步,异步)发起了一次http访问并返回相应结果。
数字表示所在点存在调用链上下文信息的获取。在大多数的相邻点之间都会涉及到调用链上下文的传递。
例如,从2点到3点就是请求前和请求后的上下文传递,从3点到4点就是两次客户端调用间的上下文传递,从4点到5点就是服务间的上下文传递。下面我们将在不同的场景下说明各点之间的上下文传递过程。
一、在同一线程内的上下文传递
这种场景比较常见,也是最简单的场景。
假设上述模拟流程中全部为同步操作,业务代码中不涉及任何的线程池(数据库连接池不影响)及异步操作,那么服务A中调用链的相关代码均会在同一个线程中执行。
说到这里,想必大家都会想到使用ThreadLocal便可以解决。使用ThreadLocal的确可以解决同线程中的参数共享传递问题。在UAV中,一般两次客户端调用之间的上下文传递都直接使用ThreadLocal(其实并不是原生的ThreadLocal,后文会有所介绍),传递过程如下:
但是很多时候,业务代码中经常会涉及到异步或者提交线程池的操作,此时单单使用ThreadLocal便无法满足相应的需求。下面我们就来讨论有关含有线程池操作和异步请求的上下文传递问题。
二、跨线程池的上下文传递
首次我们来看一下跨线程池上下文传递问题。
假设上述的业务场景中在进行JDBC操作时,当前线程仅负责将JDBC操作提交到线程池中,那么此时上下文信息从1点传递到2点就会遇到跨线程池的问题,此时使用ThreadLocal无法上下文信息的传递。
当然有的同学可能会说用InheritableThreadLocal。但是提交线程和线程池线程本身并不存在父子关系,因此InheritableThreadLocal也是无法完成跨线程池的上下文传递的。
为了解决这个问题,我们使用了阿里开源的跨线程池的ThreadLocal组件:transmittable-thread-local(以下简称TTL,具体的实现方式有兴趣的同学可以去了解下
https://github.com/alibaba/transmittable-thread-local)。
通过该组件可以增强ThreadLocal的功能实现跨线程池的传递。以下是github中TTL的使用示例:
TransmittableThreadLocal<String> parent =newTransmittableThreadLocal<String>();
parent.set("value-set-in-parent");
Runnable task =new Task("1");
// 额外的处理,生成修饰了的对象ttlRunnable
Runnable ttlRunnable = TtlRunnable.get(task);
executorService.submit(ttlRunnable);
// Task中可以读取,值是"value-set-in-parent"
String value = parent.get();
可以看到,想要TTL起作用,就需要将业务代码中的runnable更换为TtlRunnable。为了实现对业务代码的零入侵,我们借助javaagent机制增加了一个针对ThreadPoolExecutor等一些Eexecutor的ClassFileTransformer,将提交到线程池中的Runnable和Callable包装成相应的TtlRunnable和TtlCallable,这样就实现了在不修改业务代码的情况下完成跨线程池的上下文传递。
另外,由于TTL具备ThreadLocal的所有特性,因此UAV的上下文传递过程中用到的ThreadLocal均是TTL。
三、异步线程中上下文传递
看完上面的跨线程池操作,我们再来看一下异步线程的问题。
假设在上述模拟场景中,我们使用异步HttpClient发送了一个异步的Http请求。由于是异步操作,4点的代码和7点的代码(这里7点的上下文是从4点中获取的属于请求前后的上下文获取场景)实际上会在不同的线程中执行,导致7点无法获取4点放入ThreadLocal中的上下文数据,进而导致调用链的数据丢失。
为了解决这个问题,在UAV中我们同时使用了字节码改写和动态代理技术。关键在于目标劫持函数的选择,需要能够获取到异步线程的回调对象。
下面以异步HttpClient为例介绍UAV中异步线程上下文的传递过程。
在异步HttpClient中,我们劫持的是InternalHttpAsyncClient类的execute()方法,该方法声明如下:
一般情况下,异步的使用方式为传入一个callback接口对象,在callback中实现相应的异步逻辑;或者使用返回的Future接口对象的get()方法实现一种异步转同步的操作。
为了能够在相应的地方获取到调用链的上下文,我们首先通过改写字节码的方式,在方法执行前生成调用链的上下文信息;然后对FutureCallback接口做动态代理,同时将生成的上下文信息传入到代理对象中,并替换原来的callback对象。
这样当异步请求返回调用callback接口时,实际上拿到的是我们的代理对象,此时也就完成了异步线程中上下文的传递过程,具体过程如下:
为了支持通过get()方法的异步转同步操作,在这里我们也对返回的Future接口做了动态代理来完成上下文的传递。
四、跨应用上下文传递
说完应用内的上下文传递过程,我们来看一下跨应用的上下文传递问题。
跨应用的场景也是比较常见的。在这种场景下,上下文传递的思路一般是将上下文的信息按照一定的协议反序列化,然后放入到请求的传输报文中;在下游服务中劫持请求,获取其中的信息完成上下文的传递。在整个处理过程中,不对应用报文解析造成任何影响。
常见的传输协议中如HTTP协议,Dubbo的RPC协议,RocketMQ的MQ协议等。这些协议一般会含有类似于头信息的结构,用于表示本次请求的额外信息。
我们恰好可以利用这一点,将上下文信息放入其中传输给下游服务,完成上下文的传递。
下面我们仍然以异步HttpClient来介绍UAV跨应用上下文的传递过程。
之前我们说过,在异步HttpClient中,我们劫持的是execute()方法。在这个方法中,我们可以拿到HttpAsyncRequestProducer接口对象,具体接口如下:
通过其中的generateRequest()方法,我们就可以拿到本次请求将要发送的request对象,利用request的setHeader()方法,将调用链的上下文信息放入Header中传入下游。
这里的上下文一般比较简单,基本上都是由traceID和spanID的字符串构成,传输成本也不高。
至于下游服务中如何解析该上下文,实际上之前的调用链系列中有谈到,就是借助UAV的中间件增强框架(MOF),在服务端劫持请求对应的request对象,然后直接从其头信息中获取即可。
其他的RPC或者MQ等协议,在UAV中均是采用这种方式完成,只是具体的API和劫持点有所不同。
例如,Dubbo远程调用过程中使用是其中的RpcContext,而RocketMQ则是放入到了msg的UserProperty中。感兴趣的同学可以到UAVStack(
https://github.com/uavorg/uavstack)中查看相关的源码。
总结
了解这些上下文的传递过程后,大家便可以基于调用链实现更为强大的功能。UAV中,调用链和日志关联功能就是通过劫持日志输入部分的相关代码,获取调用链上下文,然后将traceID输出到业务日志中来实现的。
大家也可以自己在业务代码中尝试获取调用链的上下文,将业务数据与调用链数据打通,方便数据统计和问题排查。
作者:朱文强
宜信技术学院