Dubbo3 分布式系统链路追踪 TraceID 落地实践

1. 问题背景

排查问题是程序员的基本能力也是必须要会的。

在开发环境,我们可以 debug,但是一旦到了服务器上,就很难 debug 了(当然你可以远程 debug -Xrunjdwp:transport=dt_socket,address=8001,server=y,suspend=n)。最有效的方式就是通过日志定位 bug,而一次请求的日志如果没有一个唯一的链路标识(TraceID), 单靠程序员人工分析的话,费时费力,尤其是请求量高频的接口,更是雪上加霜,排查问题效率大打折扣。

2. 解决思路

SpringBoot + Dubbo3 搭建的分布式系统,一次请求涉及到的链路不外乎两种:HTTP + RPC。

  • 单次 HTTP 请求之间:在最开始请求系统时候生成一个全局唯一的 TraceID,放在 Http 请求域中,系统接收到请求后,从请求域中取出这个 TraceID,调用方和服务方此时形成链路中的一节。
  • 单次 RPC 请求之间:在最开始请求系统时候生成一个全局唯一的 TraceID,放在 Dubbo RpcContext 中,消费者和提供者共享同一个 RpcContext,可以从中取出 TraceID,消费者和生产者形成链路中的一节。
  • 多次 HTTP/RPC 请求之间:在接收到上一次请求和发送下一次请求之间,我们需要引入类似 ThreadLocal 的机制,作为多次调用之间的线程存储的媒介传递 TraceID,从而将单次调用一节节串起来,形成一条链路。如果特殊场景想让异步线程也被监控,可以引入阿里的 TransmittableThreadLocal(TTL),当然一般实践中我们会使用 slf4j 为我们提供的 MDC(Mapped Diagnostic Context)映射诊断上下文工具。
  • HTTP 传递到 RPC 链路:上一次请求从 Http 请求域中获取 TraceID,要放入下一次请求的 RpcContext 中,跨越两个不同的上下文环境,我们一样需要引入线程副本存储媒介传递 TraceID,一般实践中我们会使用 slf4j 为我们提供的 MDC(Mapped Diagnostic Context)映射诊断上下文工具。

3. 理论深化

SpringBoot + Dubbo3 分布式环境下,单个服务中我们采用 MDC 作为 TraceID 的传递媒介,多个服务间 Http 调用我们采用 Request 请求域作为传递媒介,多个服务间 Rpc 调用我们采用 RpcContext 请求域作为传递媒介。

3.1. MDC(Mapped Diagnostic Context)

MDC ( Mapped Diagnostic Contexts ) 是一个线程安全的存放诊断日志的容器。

MDC 是 slf4j 提供的适配其他具体日志实现包的工具类,目前只有 logback 和 log4j 支持此功能。MDC 可以看成是一个与当前线程绑定的哈希表,可以往其中添加键值对。MDC 中包含的内容可以被同一线程中执行的代码所访问。当前线程的子线程会继承其父线程中的 MDC 的内容。当需要记录日志时,只需要从 MDC 中获取所需的信息即可。

Slf4j 的实现原则就是调用底层具体实现类,比如 logback, log4j 等包,而不会去实现具体的输出打印等操作,是典型的装饰器模式。我们翻阅源码,最重要的是这个 MDCAdapter 核心类。

java 复制代码
public interface MDCAdapter {

    public void put(String key, String val);

    public String get(String key);

    public void remove(String key);

    public void clear();

    public Map<String, String> getCopyOfContextMap();

    public void setContextMap(Map<String, String> contextMap);
}

Logback 使用的是 LogbackMDCAdapter,我们发现其实底层还是我们熟悉的 ThreadLocal 来实现的。关于 ThreadLocal 原理的深入解析,可以参考作者的文章 TransmittableThreadLocal 线程池内异步线程值传递解决方案

java 复制代码
public class LogbackMDCAdapter implements MDCAdapter {

    final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal<Map<String, String>>();

    private static final int WRITE_OPERATION = 1;
    private static final int MAP_COPY_OPERATION = 2;

    // keeps track of the last operation performed
    final ThreadLocal<Integer> lastOperation = new ThreadLocal<Integer>();

    ......
    
     /**
     * Get the current thread's MDC as a map. This method is intended to be used
     * internally.
     */
    public Map<String, String> getPropertyMap() {
        lastOperation.set(MAP_COPY_OPERATION);
        return copyOnThreadLocal.get();
    }

    /**
     * Returns the keys in the MDC as a {@link Set}. The returned value can be
     * null.
     */
    public Set<String> getKeys() {
        Map<String, String> map = getPropertyMap();

        if (map != null) {
            return map.keySet();
        } else {
            return null;
        }
    }

    /**
     * Return a copy of the current thread's context map. Returned value may be
     * null.
     */
    public Map<String, String> getCopyOfContextMap() {
        Map<String, String> hashMap = copyOnThreadLocal.get();
        if (hashMap == null) {
            return null;
        } else {
            return new HashMap<String, String>(hashMap);
        }
    }

    public void setContextMap(Map<String, String> contextMap) {
        lastOperation.set(WRITE_OPERATION);

        Map<String, String> newMap = Collections.synchronizedMap(new HashMap<String, String>());
        newMap.putAll(contextMap);

        // the newMap replaces the old one for serialisation's sake
        copyOnThreadLocal.set(newMap);
    }
    
    private Map<String, String> duplicateAndInsertNewMap(Map<String, String> oldMap) {
        Map<String, String> newMap = Collections.synchronizedMap(new HashMap<String, String>());
        if (oldMap != null) {
            // we don't want the parent thread modifying oldMap while we are
            // iterating over it
            synchronized (oldMap) {
                newMap.putAll(oldMap);
            }
        }

        copyOnThreadLocal.set(newMap);
        return newMap;
    }

}

这里的代码与 InheritableThreadLocal 原理非常类似。 从 lastOperation 和 copyOnThreadLocal 两个 ThreadLocal,结合 duplicateAndInsertNewMap() 方法可以看出,父线程的异步子线程也可以继承 Map 的内容信息,即我们的 TraceID 不会丢掉。

3.2. Dubbo3 的 RpcContext

Dubbo 中的 RpcContext 是一个 ThreadLocal 的临时状态记录器,当接收到 RPC 请求,或发起 RPC 请求时,RpcContext 的状态都会变化。比如:A调B,B调C,则B机器上,在B调C之前,RpcContext 记录的是A和B的信息,在B调C之后,RpcContext 记录的是B和C的信息。

Dubbo3 中,RpcContext 被拆分为四大模块(ServerContext、ClientAttachment、ServerAttachment 和 ServiceContext),它们分别承担了不同的指责:

  • ServiceContext:在 Dubbo 内部使用,用于传递调用链路上的参数信息,如 invoker 对象等;
  • ClientAttachment:在 Client 端使用,往 ClientAttachment 中写入的参数将被传递到 Server 端;
  • ServerAttachment:在 Server 端使用,从 ServerAttachment 中读取的参数是从 Client 中传递过来的;
  • ServerContext:在 Client 端和 Server 端使用,用于从 Server 端回传 Client 端使用,Server 端写入到 ServerContext 的参数在调用结束后可以在 Client 端的 ServerContext 获取到。

分布式链路追踪场景,其实现原理就是在全链路的上下文中维护一个 TraceID,Consumer 和 Provider 通过传递 TraceID 来连接一次 RPC 调用,分别上报日志后可以在追踪系统中串联并展示完整的调用流程,这样可以更方便地发现异常,定位问题。

4. 代码实践

试想一个业务场景,前端通过 Http 请求你的 Web 服务,Web 服务需要调用 Dubbo 的 Service 实现业务,此时我们需要全局埋入一个 TraceID 来实现全链路的监控。

rust 复制代码
Http N -> Dubbo A -> Dubbo B -> Dubbo C

对于 Http 的请求,Spring-Web 处理方式是引入一个拦截器,并且生成一个新的 TraceID 放入各自的媒介中:

java 复制代码
public interface TraceConst {  

    String TRACE_ID = "trace_id";  
  
}
java 复制代码
@Slf4j
public class HttpTraceInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String traceID = UUID.randomUUID().toString().replace("-", "");
        MDC.put(TraceConst.TRACE_ID, traceID);
        request.setAttribute(TraceConst.TRACE_ID, traceID);
        response.setHeader(TraceConst.TRACE_ID, traceID);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        MDC.clear();
    }

}
java 复制代码
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {

    @Bean
    public HttpTraceInterceptor httpTraceInterceptor() {
        return new HttpTraceInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(httpTraceInterceptor()).addPathPatterns("/**");
        super.addInterceptors(registry);
    }

}

对于 Dubbo 的 RPC 请求,我们分别在 消费者 Consumer 和 生产者 Provider 端做不同的处理,Dubbo 的扩展采用的 SPI 机制这里不再过多赘述,只给出代码:

建立 resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter 文件

java 复制代码
## 注意这里写自己的类目录
dubboProviderTraceFilter=com.odbpo.app.traces.DubboProviderTraceFilter  
dubboConsumerTraceFilter=com.odbpo.app.traces.DubboConsumerTraceFilter
JAVA 复制代码
@Slf4j
@Activate(group = CommonConstants.CONSUMER, order = 1)
public class DubboConsumerTraceFilter implements Filter, Filter.Listener {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {

        String traceID = MDC.get(TraceConst.TRACE_ID);
        if (StringUtils.isBlank(traceID)) {
            traceID = UUID.randomUUID().toString().replace("-", "");
        }

        MDC.put(TraceConst.TRACE_ID, traceID);
        RpcContext.getClientAttachment().setAttachment(TraceConst.TRACE_ID, traceID);

        String interfaceName = invoker.getInterface().getName();
        String methodName = invocation.getMethodName();
        String REQ_SERVICE = interfaceName + "." + methodName;
        String SOURCE_IP = RpcContext.getServiceContext().getLocalAddressString();

        Result result = null;
        try {
            result = invoker.invoke(invocation);
            if (result.hasException()) {
                log.error("Consumer服务业务异常: traceID=[{}] 调用方=[{}] 接口=[{}]",
                        traceID, SOURCE_IP, REQ_SERVICE, result.getException());
            }
        } catch (Exception ex) {
            log.error("Consumer服务系统异常: traceID=[{}] 调用方=[{}] 接口=[{}]",
                    traceID, SOURCE_IP, REQ_SERVICE, ex);
        } finally {
            MDC.clear();
        }
        return result;
    }

    @Override
    public void onResponse(Result appResponse, Invoker<?> invoker, Invocation invocation) {

    }

    @Override
    public void onError(Throwable t, Invoker<?> invoker, Invocation invocation) {

    }

}

我们根据上一节的原理,将 MDC 中取出的 TraceID 丢入 ClientAttachment 中在链路中传递。

注意:这里我们的 TraceID 是从 MDC 中取出的,而此时 MDC 中的 TraceID 是在 HttpTraceInterceptor 或者上一个 DubboProviderTraceFilter 中放入的,此时两个值必然相同;当然有种特殊情形 MDC 中的 TraceID 为空,比如定时器自发启动执行业务,并没有经过 Http 的拦截器,这时候我们就成了链路的起点,生成一个新的 TraceID 往后传递。

JAVA 复制代码
@Slf4j
@Activate(group = CommonConstants.PROVIDER, order = 1)
public class DubboProviderTraceFilter implements Filter, Filter.Listener {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {

        String traceID = RpcContext.getServerAttachment().getAttachment(TraceConst.TRACE_ID);
        if (StringUtils.isBlank(traceID)) {
            traceID = UUID.randomUUID().toString().replace("-", "");
        }

        MDC.put(TraceConst.TRACE_ID, traceID);

        String interfaceName = invoker.getInterface().getName();
        String methodName = invocation.getMethodName();
        String REQ_SERVICE = interfaceName + "." + methodName;
        String SOURCE_IP = RpcContext.getServiceContext().getRemoteAddressString();

        Result result = null;
        try {
            result = invoker.invoke(invocation);
            if (result.hasException()) {
                log.error("Provider服务业务异常: traceID=[{}] 调用方=[{}] 接口=[{}]",
                        traceID, SOURCE_IP, REQ_SERVICE, result.getException());
            }
        } catch (Exception ex) {
            log.error("Provider服务系统异常: traceID=[{}] 调用方=[{}] 接口=[{}]",
                    traceID, SOURCE_IP, REQ_SERVICE, ex);
            throw ex;
        } finally {
            MDC.clear();
        }
        return result;
    }

    @Override
    public void onResponse(Result appResponse, Invoker<?> invoker, Invocation invocation) {

    }

    @Override
    public void onError(Throwable t, Invoker<?> invoker, Invocation invocation) {

    }

}

注意 : DubboProviderTraceFilter 和 DubboConsumerTraceFilter 初看差不多,其实本质上是有很大差异的。我们这里的 TraceID 是从 ServerAttachment 中取出的,然后丢入 MDC 中;继续放入 MDC 中的目的最重要的一点是,当我们的调用链路为 A->B->C, 此时 B 既是生产者,又是下一个 RPC 请求的消费者,此时我们的 TraceID 需要用 MDC 作为媒介传递。

相关推荐
XMYX-033 分钟前
Spring Boot + Prometheus 实现应用监控(基于 Actuator 和 Micrometer)
spring boot·后端·prometheus
记得开心一点嘛2 小时前
使用MinIO搭建自己的分布式文件存储
分布式·spring cloud·minio
@yanyu6662 小时前
springboot实现查询学生
java·spring boot·后端
酷爱码3 小时前
Spring Boot项目中JSON解析库的深度解析与应用实践
spring boot·后端·json
纪元A梦3 小时前
分布式拜占庭容错算法——PBFT算法深度解析
java·分布式·算法
AI小智3 小时前
Google刀刃向内,开源“深度研究Agent”:Gemini 2.5 + LangGraph 打造搜索终结者!
后端
java干货4 小时前
虚拟线程与消息队列:Spring Boot 3.5 中异步架构的演进与选择
spring boot·后端·架构
一只叫煤球的猫4 小时前
MySQL 8.0 SQL优化黑科技,面试官都不一定知道!
后端·sql·mysql
写bug写bug5 小时前
如何正确地对接口进行防御式编程
java·后端·代码规范