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 作为媒介传递。

相关推荐
柏油1 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。1 小时前
使用Django框架表单
后端·python·django
白泽talk1 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师2 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫2 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04122 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色2 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack2 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定3 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端