Skip to content

技术精华-只会用Skywalking?教你如何自定义分布式链路id

微服务架构在一个请求涉及到几十个服务的调用也是很常见的,然后当这个请求出现问题进行排查时,如果排查哪些服务调用是在同一个请求中,这是非常困难的,而这时,跟踪和监控跨多个服务的请求的重要性就体现出来了。这就是分布式链路ID(Distributed Tracing ID)发挥作用的地方。

分布式链路ID的作用

  1. 跟踪请求流程:在一个由多个微服务组成的系统中,一个外部请求可能需要通过多个服务才能完成。分布式链路ID允许我们将这个请求经过的所有服务连接起来,形成一个完整的链路图,从而使我们能够追踪请求的整个流程。
  2. 性能监控:通过分析请求链路中各个环节的处理时间,我们可以识别出系统的性能瓶颈,为性能优化提供有力的数据支持。
  3. 故障定位:在发生错误或异常时,分布式链路ID可以帮助快速定位问题发生的服务和位置,加速故障排除和修复过程。
  4. 审计和安全:在需要审计请求或进行安全分析时,链路ID为每个请求提供了一个独一无二的标识,有助于追踪和分析潜在的安全问题。

在微服务体系下,服务之间的调用有时会特别复杂,当出现问题后就很难排查,针对这种问题,目前都会在请求开始端,例如Nginx或者业务网关Gateway/Zuul生成一个全局id然后传递下去,这样根据这个id就会将整个链路连起来

SKywalking这种APM的监控系统,直接用这个不就能监控到调用链路了吗?首先来说,使用SKywalking确实可以实现这个功能,但杀鸡焉用牛刀啊!

SKywalking的性能消耗其实并不低,它的原理是使用字节码增强生成代理类,然后在本地内存中进行数据的汇总,接着使用Grpc的传输协议到控制台中。

就这么一套下来,对cpu和内存其实都是有压力的,另外而言其实并不是每个链路都要图形化的显示。但每个链路调用确实都需要链路id来串联起来。

所以我们自己来设计出链路id的功能,这样不会怎么影响性能,也能实现这个核心功能

设计思路

设计一个有效的分布式链路追踪系统需要考虑以下几个关键点:

  1. 唯一性:每个请求都应该被赋予一个唯一的链路ID,确保在整个分布式系统中的唯一性,通常可以通过生成UUID或结合时间戳和一些其他信息来实现。
  2. 传播机制:当请求在微服务之间传递时,链路ID也需要跟随请求一起传递。这通常通过HTTP请求的头部信息实现,每个服务在接收到请求时读取链路ID,处理完毕后再将其加入到对下游服务的请求中。
  3. 轻量级:链路追踪系统的设计应尽可能轻量,以减少对系统性能的影响。这意味着在生成、传递和存储链路ID时需要尽量减少资源消耗。
  4. 数据收集和分析:设计一个中心化的数据收集系统来聚合和分析跨服务的链路数据是非常重要的。这个系统需要能够处理大量的数据,并支持复杂的查询,以便于快速定位问题和生成性能报告。

实现思路

首先这个链路id是公共参数,不能影响主业务,所以一般在网关层生成后会将id放入Request请求头中传递下去。

接受在每个业务服务会执行一个过滤器,此过滤器从Request头部取出id放入日志配置Slf4j的MDC作用域中,然后在Logback/Log4j2的配置中配置id的输出,如下

<!--输出控制台的配置-->
<Console name="Console" target="SYSTEM_OUT">
    <!-- 输出日志的格式 -->
    <PatternLayout pattern="[test-service] [%X{traceId}] %d{yyyy-MM-dd HH:mm:ss} %5p %c{1}:%L - %m%n"/>
</Console>

在实际的项目中,这个id是由nginx来生成的,如果请求没有经过nginx,那么gateway来生成然后传递到下一个业务服务中,然后再依次的传递下去,然而在传递过程中会发生各种各样的问题,让我们通过流程图来更清晰的理解整个结构

Feign的传递

微服务之间的调用常见的用Feign,比如A调用B服务,默认Feign并不会将A服务请求头中的id自动的传递给B服务的请求头中,所以需要我们额外配置

java
public class FeignRequestInterceptor implements RequestInterceptor {
    
    @Override
    public void apply(final RequestTemplate template) {
        try {
            RequestAttributes ra = RequestContextHolder.getRequestAttributes();
            if (ra != null) {
                ServletRequestAttributes sra = (ServletRequestAttributes) ra;
                HttpServletRequest request = sra.getRequest();
                String traceId = request.getHeader(TRACE_ID);
                String code = request.getHeader(CODE);
                //将traceId传递到下一个服务中
                template.header(TRACE_ID,traceId);
                //将code传递到下一个服务中
                template.header(CODE,code);
            }
        }catch (Exception e) {
            log.error("FeignRequestInterceptor apply error",e);
        }
    }
}

使用

添加依赖

java
<dependency>
    <groupId>com.example</groupId>
    <artifactId>damai-service-component</artifactId>
    <version>${revision}</version>
</dependency>

使用线程池的问题

Request的作用域其实就是个ThreadLocal,还有就是日志中的MDC本质其实也是个ThreadLocal,又或者有其他的数据需要放到ThreadLocal中,而ThreadLocal和线程是绑定的,这就导致了在线程池中是获取不到ThreadLocal中的数据的,ThreadLocal可以做到线程隔离原理是在每个线程存在一个Map,key是ThreadLocal对象本身,value是值。但在线程池情况下就无法传递参数了

Request的错误解决

通常服务中的request类型为HttpServletRequest,范围是跟线程绑定的。目前网上通常的说法是这样的:

  1. 先从主线程中获得request。RequestAttributes ra = RequestContextHolder.getRequestAttributes();
  2. 然后在子线程再重新设置进去。RequestContextHolder.setRequestAttributes(requestAttributes);

其实仔细看看RequestContextHolder的原理就知道这样做是存在隐患的。

java
private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
			new NamedThreadLocal<>("Request attributes");

private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
			new NamedInheritableThreadLocal<>("Request context");

public static RequestAttributes getRequestAttributes() {
	RequestAttributes attributes = requestAttributesHolder.get();
	if (attributes == null) {
		attributes = inheritableRequestAttributesHolder.get();
	}
	return attributes;
}

public static void setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable) {
	if (attributes == null) {
		resetRequestAttributes();
	}
	else {
		if (inheritable) {
			inheritableRequestAttributesHolder.set(attributes);
			requestAttributesHolder.remove();
		}
		else {
			requestAttributesHolder.set(attributes);
			inheritableRequestAttributesHolder.remove();
		}
	}
}

可以看出本质上还是通过ThreadLocalInheritableThreadLocal来实现父子线程公用一个request。这样做是没问题,前提是多线程或线程池中使用的是future这种获得异步结果阻塞式的操作,因为这样父线程会等待子线程执行完后,再清除掉request的内容。

如果使用线程的start或者线程池的execute,那么父线程开启子线程后会继续执行父线程后续业务然后清除掉request的内容,所以这时只要子线程的任务耗时一点就会可能导致request内容获取不到。

到这里,我们清楚了只是单纯的操作request还是不能从根本上解决问题,依旧要从数据入手,怎么让链路id能传递下去。而阿里提供的TransmittableThreadLocal确实可以解决。关于ThreadLocalInheritableThreadLocalTransmittableThreadLocal的介绍,本人有详解的讲解,小伙伴可跳转到相关文档查看

但其实阿里的这个其实挺笨重的,如果只是传递几个参数完全可以自己对线程池进行定制,实现这个功能。也能在简历上增加自己的亮点,所以来自己实现。

思路其实和TransmittableThreadLocal差不多,但要精简许多,就是每次在线程执行前,先将主线程中的数据传递到子线程中,子线程再获取一个副本,当子线程任务执行完后,再将刚才的副本设置回去。

MDC 和 BaseParameterHolder 获取当前线程信息的原理

为什么可以直接获取当前线程信息

  1. MDC(Mapped Diagnostic Context)

    • MDC.getCopyOfContextMap():MDC 内部使用 ThreadLocal 存储上下文信息
    • 每个线程都有自己的 MDC 上下文副本,通过 ThreadLocal 实现线程隔离
    • 调用时自动返回当前线程的 MDC 上下文映射
  2. BaseParameterHolder

    • [BaseParameterHolder.getParameterMap()](file://D:\Java_projects\damai_new\damai-common\src\main\java\com\damai\threadlocal\BaseParameterHolder.java#L40-L46):同样基于 ThreadLocal 实现
    • 维护当前线程的参数映射,确保线程间数据隔离

preprocess 方法工作原理

java
private static Map<String,Map<String,String>> preprocess(final Map<String, String> parentMdcContext, final Map<String, String> parentHoldContext){
    // 1. 保存当前线程(子线程)的原始上下文
    Map<String, String> holdContext = BaseParameterHolder.getParameterMap();
    Map<String, String> mdcContext = MDC.getCopyOfContextMap();
    
    // 2. 将父线程的上下文设置到当前线程(子线程)
    if (parentMdcContext == null) {
        MDC.clear();  // 清除子线程的MDC
    } else {
        MDC.setContextMap(parentMdcContext);  // 设置父线程的MDC上下文
    }
    if (parentHoldContext == null) {
        BaseParameterHolder.removeParameterMap();  // 清除子线程的参数映射
    } else {
        BaseParameterHolder.setParameterMap(parentHoldContext);  // 设置父线程的参数映射
    }
    
    // 3. 返回原始上下文,用于后续恢复
    map.put("holdContext",holdContext);
    map.put("mdcContext",mdcContext);
    return map;
}

传递机制

  • 在子线程执行业务逻辑前,先将父线程的上下文信息(parentMdcContextparentHoldContext)设置到子线程中
  • 同时保存子线程原有的上下文信息,以便后续恢复

postProcess 方法工作原理

java
private static void postProcess(Map<String, String> mdcContext, Map<String, String> holdContext){
    // 恢复子线程原有的MDC上下文
    if (mdcContext == null) {
        MDC.clear();
    } else {
        MDC.setContextMap(mdcContext);
    }
    // 恢复子线程原有的参数映射
    if (holdContext == null) {
        BaseParameterHolder.removeParameterMap();
    } else {
        BaseParameterHolder.setParameterMap(holdContext);
    }
}

恢复机制

  • 任务执行完成后,将子线程的上下文恢复为执行前的状态
  • 确保子线程不会影响其他任务的上下文环境

整体传递流程

  1. 捕获:在父线程中通过 [getContextForTask()](file://D:\Java_projects\damai_new\damai-thread-pool-framework\src\main\java\com\damai\base\BaseThreadPool.java#L44-L46) 和 [getContextForHold()](file://D:\Java_projects\damai_new\damai-thread-pool-framework\src\main\java\com\damai\base\BaseThreadPool.java#L53-L55) 捕获当前上下文
  2. 设置:在子线程中通过 [preprocess()](file://D:\Java_projects\damai_new\damai-thread-pool-framework\src\main\java\com\damai\base\BaseThreadPool.java#L104-L121) 将父线程上下文设置到子线程
  3. 执行:子线程执行业务逻辑,使用父线程的上下文信息
  4. 恢复:通过 [postProcess()](file://D:\Java_projects\damai_new\damai-thread-pool-framework\src\main\java\com\damai\base\BaseThreadPool.java#L128-L139) 将子线程上下文恢复为执行前状态

这种机制确保了父子线程间的上下文传递,同时保证了线程池中线程复用时的上下文隔离。

TransmittableThreadLocal 的核心原理详解

1. 捕获(Capture)
●目的:在任务提交到线程池时,记录主线程的上下文值(如用户ID、Trace ID等),并保存为快照。
●实现方式:
○调用 Transmitter.capture() 方法,遍历当前线程中所有的 TransmittableThreadLocal 实例。
○将每个 TransmittableThreadLocal 的当前值复制到一个快照对象(如 Map<TransmittableThreadLocal, Object>)中。
●关键点:
○捕获的时机是任务提交到线程池时,而不是线程创建时。
○快照保存的是主线程的上下文状态,与线程池中线程的当前状态无关。
2. 传递(Transmit)
●目的:将主线程的上下文快照传递到线程池的工作线程中,并在任务执行前恢复到工作线程。
●实现方式:
○使用 装饰器模式(如 TtlRunnable 或 TtlCallable)包装原始任务。
○在 TtlRunnable.run() 方法中:
ⅰ恢复主线程的上下文:调用 Transmitter.replay(capturedSnapshot),将主线程的上下文值注入到工作线程的 TransmittableThreadLocal 中。
ⅱ执行原始任务:调用 runnable.run(),此时工作线程可以访问主线程的上下文值。
3. 恢复(Restore)
●目的:任务执行完成后,清理工作线程的上下文,避免污染后续任务。
●实现方式:
○在 TtlRunnable.run() 的 finally 块中调用 Transmitter.restore(backup)。
○backup 是工作线程在任务执行前的原始上下文状态(通过 Transmitter.replay() 返回)。
○将工作线程的上下文恢复为 backup,确保后续任务不受当前任务的影响。
![[Pasted image 20260113203324.png]] ​

  • 2