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