自实现分布式链路追踪 方案&实践

前言:

排查问题是程序员的基本能力也是必须要会的,在开发环境,我们可以debug,但是一旦到了服务器上,就很难debug了,最有效的方式就是通过日志揪出bug,而一次请求的日志如果没有一个唯一的链路标识(我们下边称他为traceId),单靠程序员人工分析的话,费时费力,尤其是请求量高频的接口,更是雪上加霜,排查问题效率大打折扣,作为程序员,低效的方式是忍不了的!!!本文我将用一次实战演练,来演示常用框架/中间件/多服务 之间如何传递traceId

本文大概有如下内容:

  • 链路追踪简述和自实现思路
  • 单服务内如何实现链路id的输出
  • 垮服务调用时,实现链路id传递的各种方式( 包含http(openFeign,httpClient restTemplate)、rpc(motan、 dubbo)、mq(RocketMq) )
  • 异步调用时,如何解决log4j2自带的ThreadLocal丢失链路id问题
  • 起4个服务,进行调用,观察链路追踪的效果

1、链路追踪实现简述

所谓链路追踪,就是为了 把整个请求链路从头到尾串起来,不管调用链路有多深,多复杂,只要将一次链路完整无误的串联起来,就是合格的链路追踪功能

业界不乏skywalking zipkin 等等链路追踪方面牛逼的框架,但是我们为了更轻量更灵活可控同时也是抱着学习心态,所以自己来实现链路追踪。

首先想实现链路追踪,有两点是核心,实现了这两点,问题也就不大了

  1. traceId 如何在本地 (或者说单服务内) 传递
  2. 分布式环境中,traceId如何跨服务/中间件 传递

2、单体服务 的链路追踪

首先我们先讲下单服务内的链路传递

作为java开发,最常用的就是slf4j来实现打印日志的功能(但是slf4j并不没有实现逻辑,因为 slf4j整个的定义是一个日志门面,该包中并无具体的实现,实现都是在 比如:logback log4j2等等日志实现框架中)

slf4j的门面不仅给我们提供了打印日志的功能,还提供了 org.slf4j.MDC 类, 该类的作用大概如下:

映射诊断上下文(Mapped Diagnostic Context,简称MDC)是一种工具,用于区分不同来源的交错日志输出。当服务器几乎同时处理多个客户机时,日志输出通常是交错的。MDC是基于每个线程进行管理的

上边这个官方解释,最重要的一句话就是 MDC是基于每个线程进行管理的

上边这个太官方,说下我个人对MDC的理解:

他是一个日志的扩展,扩展的目的就是给 每个线程 输出的日志打上一个标记(一个线程只有一个标记且不能重复一般使用uuid即可),这样我们在查看日志时候,就可以根据这个标记来区分调用链路

ps: 当然了, 光往MDC中设置当前线程的链路id也是不行的你还得在log4j2.xml文件中,设置占位符,这样最终输出的日志才会带链路信息。

从代码层面看下 MDC做了啥:

  1. MDC类中通过一个 MDCAdapter实例
  2. 调用MDCAdapter的put get remove clear等方法。
  3. 而put get remove clear 具体的实现是不同厂商来做的 比如我常用的log4j2包中就实现了 MDCAdapter 接口,实现在 org.apache.logging.slf4j.Log4jMDCAdapter类中
  4. Log4jMDCAdapter类中使用了一个 ThreadContext来执行put get remove clear逻辑,
  5. 而ThreadContext 中又是一个 ThreadContextMap ThreadContextMap是一个接口 ,有不同的实现
  6. 其中默认的是 DefaultThreadContextMap 该类中维护了一个 ThreadLocal<Map<String, String>> localMap 类型的成员变量 其中map中的k,v 就是你调用MDC.put(k,v); 时传入的k v
  7. 最终你调用MDC.put(k,v);时候传入的k和v会被放倒 localMap这个ThreadLocal中去。
  8. 在你给某个线程设置了key,value后 ,log4j2在打印日志时候,将会去log4j2.xml文件中找 占位符等于 key的,然后用value把占位符替换,从而打印输出了value(也就是traceId),至此实现了单服务内的链路追踪。

把代码流程梳理下大概如下:

MDC(类)
	MDCAdapter(接口)
		Log4jMDCAdapter(实现类之一)
			ThreadContext(类)
				ThreadContextMap(接口)
					DefaultThreadContextMap
                                                //(MDC的几个操作都是往这个里边存取数据)
						ThreadLocal<Map<String, String>> localMap;
                                                

2.1、如何使用MDC来输出(单服务内的)链路信息?

  1. 往MDC中设置key value
  2. 在log4j2.xml 中设置占位符(注意:占位符名称要和MDC中的key一致)
  3. 接着我们简单看下效果:(可以看到,每一个线程,都有一个唯一的链路id )

上边我们说过 MDC最终设置的key,value 是放到 DefaultThreadContextMap类中的 ThreadLocal<Map<String, String>> localMap 这个里边的,也就是说 里边的Map<String,String>是某个线程本地副本(不懂线程本地副本的可以回顾下ThreadLocal的知识),有了这个知识基础,我们就不难理解为什么输出的日志是每个线程都有唯一的traceId了。

举个例子来说就是:

  • 线程t1 往MDC中设置的 key,value ,当log4j2打印t1的日志时,会找到当前(t1)线程设置的value,来把 log4j2.xml 中的占位符替换
  • 线程t2 往MDC中设置 key,value ,当log4j2打印t2的日志时,会找到当前(t2)线程设置的value,来把 log4j2.xml 中的占位符替换

ok,从上边可以了解到,单服务内通过slf4j提供的MDC功能,可以实现 某服务内 的链路追踪。

我们都知道,所谓微服务,都是由各个小的单服务组合起来的,通过上边的描述,我们知道单服务内如何打印链路了,那么微服务间不管怎么调用,只要保证在请求最开始(一般是网关)生成一个链路id,在后续的调用中将这个链路id一层层传递下去,整个完整的调用链路追踪也就实现了。在下边小节,我们就看看traceId是怎么垮服务传递的。


3、跨服务 的链路追踪实现简述

垮服务间调用,本质上就是各个服务之间进行通信的过程,一般情况下,微服务间常用通信方式有如下几种:(这里我们仅从应用层来看,如果从传输层看的话 1和2 都是tcp)

  1. rpc
  2. http
  3. 消息队列(mq)
  4. 其他可能我不知道的

在日常开发中我常用的 :

http 框架有apache的 httpclientspringcloud的 openfeign, spring包中的 resttemplateokhttp(okhttp一般安卓用的比较多)。

rpc 框架的话一般是dubbomotan 其他的本人没怎么用过。

mq 的话我用过kafka rabbitmq rocketmq

下边,我搭建了四个微服务,分别是

网关服务 study-gateway

微服务消费服务 study-consumer

后台管理服务 study-admin

订单服务 study-order

在这里我的 调用链路 大概如下图所示:如上图所示的链路,该次请求会经过 网关openFeign异步调用httpClient调用,mq 这几个组件,而如何在这几个组件 调用前传递traceId 和 调用后设置traceId 成为垮服务进行链路追踪的关键。ps: dubbo和motan的我们就不演示了,会直接给出传递方案。

铺垫了那么多,我们下边直接了当点不再啰嗦!

3.1、在请求最前边(study-gateway)添加过滤器

在网关过滤器中生成并设置traceId到MDC(此刻网关服务的日志中将会打印traceId) ,同时通过header传递到下游服务。简单看下网关配置:

3.2、在路由目标 服务(study-consumer)中添加过滤器(为了从请求头获取链路id)

3.3、在study-consumer服务添加feign拦截器(因为consumer要通过feign调用study-admin服务)

3.4、在study-admin中添加过滤器(为了从请求头获取链路id)

3.5、在httpClient工具中添加拦截器(对外调用时候往header设置链路id)

3.6、在resttemplate中添加拦截器(对外调用时候往header设置链路id)

注册拦截器到RestTemplate实例:

3.7、在RocketMq发送前消费前添加钩子

发送前添加钩子:消费前添加钩子:

分别往生产和消费实例对象中设置钩子:

3.8、用ttl解决异步调用存在的问题(在这里我们也一并说了不再啰嗦)

上边我们也说了,MDC底层 DefaultThreadContextMap 是用 ThreadLocal 来保存的链路信息,而ThreadLocal是同一个线程,才会有相同的副本数据,而当我们在项目中使用线程池时候,主线程和子线程肯定是不一样的,那么这种情况下就得考虑如何将主线程的值传递给子线程,让子线程也能记录traceId,从而保证 链路不会断!

值的一说的是jdk也想到了这个问题,提供了一个InheritableThreadLocal类,但是这个类并不适用于链路追踪场景,因为在异步调用场景下,是要保证每一次请求,都要将主线程的traceId传递给子线程,而 InheritableThreadLocal只能是第一次时候传递,或者说他不是每次都传递给子线程更贴切,下边看下官方的描述:

InheritableThreadLocal存在的问题:官方原话: 使用InheritableThreadLocal时(ThreadContext 可能并不总是自动传递给工作线程)

由于线程池的复用机制,所以第n次请求时,线程池中线程所打印出的链路id,还是上次或者是上n次的链路id(我试验了确实如此),而我们真实希望是,线程池中线程打印的链路id保持和当前主线程中的链路id一致,换句话说: 我们需要的是 任务提交给线程池时的链路id传递到 任务执行时

既然InheritableThreadLocal不满足需求,那么怎么办呢?看下边:

在log4j2中,他底层是通过spi机制提供了 对 ThreadContextMap接口的扩展能力 ,不了解的可以去看看官网,而正好阿里开源了一个这个小框架 ttlttl-thread-context-map ,ttl-thread-context-map 可以解决线程间的传递丢失问题(他内部也是使用的TransmittableThreadLocal也就是ttl来存储MDC 的key和value)。ttl-thread-context-map 依赖java的spi机制,依靠spi机制,让log4j2 在启动加载时,用log4j2.component.properties中 log4j2.threadContextMap这个key 对应的 value作为ThreadContextMap 接口的实现(也就是替换掉DefaultThreadContextMap这个默认实现),从而实现了线程间传递的功能。对ttl和ttl-thread-context-map不熟悉的可以跳的github 讲的很详细很清楚。

TtlThreadContextMap内部使用TransmittableThreadLocal来存储MDC的key,valuespi配置:

而我们使用阿里这个工具也很简单首先maven引入(注意版本 不清楚的去maven库看看)

<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>transmittable-thread-local</artifactId>
   <version>2.14.2</version>
</dependency>
<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>log4j2-ttl-thread-context-map</artifactId>
   <exclusions>
      <exclusion>
         <groupId>org.apache.logging.log4j</groupId>
         <artifactId>log4j-api</artifactId>
      </exclusion>
   </exclusions>
   <version>1.4.0</version>
   <scope>runtime</scope>
</dependency>

在引入这个后,我什么也没配,如果我使用jdk的 ThreadPoolExecutor 或者spring的 ThreadPoolTaskExecutor,都是可以实现链路传递的,但是我使用 CompletableFuture的话,第一次请求的链路是对的,当第二次请求时候,CompletableFuture线程池中的打印链路信息还是第一次的,这个问题github上有说明,作者让使用javaagent来解决,果然在我配置javaagent后,CompletableFuture 的链路信息每次都是正确的。

在idea 的 VM options 中配置:

-javaagent:/Users/hzz/.m2/repository/com/alibaba/transmittable-thread-local/2.14.2/transmittable-thread-local-2.14.2.jar

即可解决 CompletableFuture的链路id传递问题(这里我们最好是agent这样对代码无侵入,如果你使用TtlRunable修饰Runable的话 对代码侵入比较多,维护起来也比较麻烦)

4、跨服务+异步 链路追踪效果演示

最后我们将四个服务启动,看下整体效果:

postman调用:

study-gateway

study-consumer

study-admintcpdump抓包看看第三方的请求头中是否含有了链路id:

study-order

上边几张截图可以看到下边这个调用链 通过链路id 可以完美的串起来了!

至此,垮服务进行链路追踪完成!

最后我将TraceIdUtil代码粘出来。

package com.xzll.common.util;

import com.xzll.common.constant.StudyConstant;
import jodd.util.StringUtil;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;

import javax.servlet.http.HttpServletRequest;
import java.util.UUID;

/**
 * @Author: hzz
 * @Date: 2023/2/26 15:19:36
 * @Description:
 */
public class TraceIdUtil {

   public static final String REGEX = "-";

   /**
    * 从header和参数中获取traceId
    * 从网关传入数据
    *
    * @param request  HttpServletRequest
    * @return traceId
    */
   public static String getTraceIdByRequest(HttpServletRequest request) {
      String traceId = request.getParameter(StudyConstant.TraceConstant.TRACE_ID);
      if (StringUtils.isBlank(traceId)) {
         traceId = request.getHeader(StudyConstant.TraceConstant.TRACE_ID);
      }
      return traceId;
   }

   public static String getTraceIdByLocal() {
      return MDC.get(StudyConstant.TraceConstant.TRACE_ID);
   }

   /**
    * 传递traceId至MDC
    *
    * @param traceId  链路id
    */
   public static void setTraceId(String traceId) {
      if (StringUtil.isNotBlank(traceId)) {
         MDC.put(StudyConstant.TraceConstant.TRACE_ID, traceId);
      }
   }

   /**
    * 构建traceId
    * @return
    */
   public static String buildTraceId() {
      return UUID.randomUUID().toString().replaceAll(REGEX, StringUtils.EMPTY);
   }

   /**
    * 清理traceId
    */
   public static void cleanTraceId() {
      MDC.clear();
   }
}

5、结尾

由于我的项目中没有使用motan和dubbo所以无法演示rpc调用,但是我接触的项目有,这里不粘完整代码了,直接给出答案,在motan中也是支持配置过滤器的 ,在调用前,通过过滤器往request的attachment中设置traceId来将traceId传递给服务提供者,在服务提供者中也可以添加过滤器,此时从attachment属性中取出traceId通过MDC.put(key,value)来将traceId设置进本服务。达到链路传递的效果。同理dubbo框架也是类似做法。

ps:

  1. 由于时间原因,我的四个服务中,有多处冗余代码,比如过滤器,这些类似公共的都可以抽出来搞一个starter或者是导入bean等等方式,来减少重复代码。
  2. 由于本文重点讲述使用方面的东西,原理方面的不做过多解散,关于ttl和 log4j2-ttl-thread-context-map ,可以看看github的资料,已经讲的超级细了。另外如果像知道更多细节,需要从slf4j和log4j2的源码入手,相信会有更多的收获。你会发现每天使用的slf4j log4j2 里边居然这么多值的学习的地方!!!
全部评论

相关推荐

04-13 11:21
已编辑
北京航空航天大学 Java
年份:2026月份:2月面试轮次:三面岗位:中间件研发/SRE专家难度:⭐⭐⭐⭐⭐面试回顾:“设计一个用于RocketMQ/Kafka的消息轨迹追踪与全链路诊断平台。目标:1)能对每秒百万级的消息生产/消费进行无侵入、低开销的轨迹采集;2)能还原任意一条消息的完整生命周期(从哪个Producer、经过哪些Topic/Queue、被哪个Consumer消费、处理成功/失败、耗时多久);3)当出现消息堆积、重复消费或丢失时,能快速定位瓶颈或异常节点。给出架构设计、数据采集方案、存储与查询引擎选型。”💡&nbsp;解析:这是一道“可观测性”领域的顶尖难题,将消息中间件与分布式追踪深度结合。它要求超越简单的监控报警,构建一个能进行事后复杂调查的“病历系统”,是SRE和中间件团队的核心能力。设计思路:应用业务场景:这是保障抖音电商下单、支付、库存扣减等核心链路最终一致性的生命线。当用户支付成功但订单未更新时,运维人员可以凭借支付中心发出的消息ID,在这个平台中快速查明:消息是否发出?是否成功存储到Broker?库存服务是否已消费?消费耗时多久?是否抛出了异常?从而在几分钟内定位是网络问题、代码BUG还是数据库故障。核心考点:分布式追踪原理(OpenTracing,&nbsp;OpenTelemetry)消息中间件(RocketMQ/Kafka)的客户端与Broker端原理海量日志/时序数据处理架构(ELK/EFK,&nbsp;ClickHouse)流式计算(Flink)在可观测性场景的应用低性能损耗的埋点设计与异步编程实践(避坑指南):采样率控制:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;全量采集在洪峰期可能压垮系统。必须支持动态采样(如1%采样率),并在发生错误时(如消费失败)自动提升该链路的采样率为100%,确保问题可被追踪。上下文传递:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;traceId必须在整个异步消息链路中传递,包括线程池切换、异步回调、跨服务RPC调用,否则链路会断裂。存储成本:&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;轨迹数据量巨大,必须设计清晰的生命周期策略(热数据ES,温数据ClickHouse,冷数据归档到对象存储)。🚨&nbsp;趋势押题预测预测名称:基于消息轨迹的智能根因分析与自愈系统押题题目:“在上述轨迹追踪平台的基础上,设计一个智能根因分析与自愈系统。要求:1)系统能自动分析消息堆积、延迟增高的故障,通过关联&nbsp;metrics、trace、log&nbsp;数据,自动定位到具体的服务、代码方法或基础设施层(如网络、磁盘);2)在识别出已知模式(如某数据库慢查询导致消费阻塞)后,能自动执行预案(如扩容、重启消费者、流量调度);3)生成可读的故障分析报告。阐述如何实现多源数据关联、根因分析算法,以及安全自动化的边界。”押题依据:公开招聘需求:在BOSS直聘和拉勾网上,字节跳动2026年发布的“SRE”、“可观测性引擎研发”岗位中,超过70%&nbsp;的JD明确要求“有AIOps、智能运维、根因分析项目经验”或“熟悉OpenTelemetry标准”。这标志着运维正从“监控告警”向“智能诊断”演进。行业技术风向:**CNCF(云原生计算基金会)**&nbsp;在2025年的年度报告中,将“AIOps”和“可观测性”列为增长最快的两大技术领域。KubeCon&nbsp;2025&nbsp;上有多个议题专注于“Using&nbsp;eBPF&nbsp;and&nbsp;ML&nbsp;for&nbsp;Root&nbsp;Cause&nbsp;Analysis”。开源项目动态:SkyWalking、Elastic&nbsp;APM&nbsp;等主流APM项目在2025年均增加了机器学习检测异常的插件或集成。这证明智能分析已成为可观测性工具演进的下一站。官方技术发声:&nbsp;&nbsp;&nbsp;&nbsp;火山引擎在2026年初的“云原生日”活动中,发布了“可观测性套件”的升级,重点宣传了其“智能诊断”功能,表明这是字节对外的技术产品方向,必然驱动内部技术栈对齐和人才要求。押题逻辑理由:当前面试题考察的是构建可观测性的“数据采集与查询”能力,这是基础。而行业公开的技术趋势(CNCF报告)、人才市场的明确需求(招聘JD)、以及字节自身对外的产品发布(火山引擎智能诊断),三者共同且强烈地指向了下一个技术制高点:利用已收集的海量可观测性数据,通过算法实现自动、精准的故障定位与自愈。面试官通过此题,能筛选出不仅会搭建系统,更能思考如何让系统产生“智能”、直接赋能业务稳定性的顶尖候选人。押此题,是基于公开的招聘要求、行业共识与公司产品路线图的强关联推导。核心考点:AIOOps基本理念、多源数据关联分析、时间序列异常检测算法、故障模式库、自动化运维的安全边界。适配岗位:&nbsp;&nbsp;&nbsp;&nbsp;SRE专家、可观测性平台架构师、中间件研发。押中概率:&nbsp;&nbsp;&nbsp;&nbsp;【80%】&nbsp;(行业明确趋势+招聘需求显性化+内部技术产品化)//&nbsp;【代码示例】基于简单规则的根因模式识别器(概念示例)@Componentpublic&nbsp;class&nbsp;RootCauseAnalyzer&nbsp;{@Autowiredprivate&nbsp;MetricService&nbsp;metricService;@Autowiredprivate&nbsp;TraceService&nbsp;traceService;@Autowiredprivate&nbsp;IncidentRepository&nbsp;incidentRepo;public&nbsp;Optional&lt;Diagnosis&gt;&nbsp;analyze(Alert&nbsp;alert)&nbsp;{//&nbsp;1.&nbsp;获取关联时段内的多维数据Instant&nbsp;windowStart&nbsp;=&nbsp;alert.getFireTime().minusSeconds(300);Instant&nbsp;windowEnd&nbsp;=&nbsp;alert.getFireTime();//&nbsp;获取相关服务的延迟、错误率指标Map&lt;String,&nbsp;Double&gt;&nbsp;latencySpike&nbsp;=&nbsp;metricService.getTopNSpikes(&quot;service_latency&quot;,&nbsp;windowStart,&nbsp;windowEnd,&nbsp;5);//&nbsp;获取慢Trace样本List&lt;SlowTrace&gt;&nbsp;slowTraces&nbsp;=&nbsp;traceService.getSlowTraces(windowStart,&nbsp;windowEnd,&nbsp;10);//&nbsp;获取错误日志聚合List&lt;ErrorPattern&gt;&nbsp;errorPatterns&nbsp;=&nbsp;logService.getErrorPatterns(windowStart,&nbsp;windowEnd);//&nbsp;2.&nbsp;应用规则进行模式匹配&nbsp;(此处为简化示例,实际可能使用决策树或图算法)//&nbsp;规则A:&nbsp;如果某个服务S延迟飙升,且其下游依赖DB的慢查询比例同时飙升for&nbsp;(String&nbsp;spikedService&nbsp;:&nbsp;latencySpike.keySet())&nbsp;{List&lt;String&gt;&nbsp;downstreamDBs&nbsp;=&nbsp;getDownstreamResources(spikedService,&nbsp;&quot;DB&quot;);for&nbsp;(String&nbsp;db&nbsp;:&nbsp;downstreamDBs)&nbsp;{if&nbsp;(metricService.isSpiked(db&nbsp;+&nbsp;&quot;_query_duration&quot;,&nbsp;windowStart,&nbsp;windowEnd))&nbsp;{//&nbsp;匹配到“数据库慢查询导致服务延迟”模式return&nbsp;Optional.of(new&nbsp;Diagnosis(&quot;DB_PERF_ISSUE&quot;,String.format(&quot;服务[%s]延迟由数据库[%s]慢查询导致&quot;,&nbsp;spikedService,&nbsp;db),List.of(new&nbsp;Action(&quot;SCALE_DB&quot;,&nbsp;db),&nbsp;new&nbsp;Action(&quot;RESTART_CONSUMER&quot;,&nbsp;spikedService))));}}}//&nbsp;规则B:&nbsp;如果错误日志中频繁出现“ConnectionTimeout”,且对应主机网络指标异常//&nbsp;...&nbsp;其他规则return&nbsp;Optional.empty();&nbsp;//&nbsp;无法自动诊断}}宝子们,字节跳动真题和押题预测都给你们整理好了,赶紧【关注】评论、收藏起来好好准备,祝大家都能顺利上岸!💪~~~关注/评论区:接好运~~~~~~上岸~!
查看2道真题和解析
点赞 评论 收藏
分享
评论
2
5
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务