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