对于很多基建不完备的中小型企业,很多时候业务服务都是单点模式,或者简单的分布式或者集群,除了存在数据孤岛问题,还存在严重的线上问题跟踪困难症结。举两个简单例子:
在一些小规模、低复杂度的服务体系中,通过上述介绍的日志搜索查看和分析方式,没有太大问题,也能够很快的分析出请求调用链和问题代码,但是仔细思考后,发现分析日志和调用链路的过程中会存在以下几个明显的问题:
对于同一业务标识,产生的日志大致如下:
// 请求A和B的日志交错输出(按时间戳排序)
[2023-08-15 15:00:00.001] [thread-1] INFO c.OrderController - 收到订单支付请求: ORD-123
[2023-08-15 15:00:00.001] [thread-2] INFO c.OrderController - 收到订单支付请求: ORD-123
[2023-08-15 15:00:00.002] [thread-1] INFO c.OrderService - 开始处理订单支付: ORD-123
[2023-08-15 15:00:00.003] [thread-2] INFO c.OrderService - 开始处理订单支付: ORD-123
[2023-08-15 15:00:00.003] [thread-1] DEBUG c.OrderDao - 查询订单数据: ORD-123
[2023-08-15 15:00:00.004] [thread-2] WARN c.OrderService - 等待订单锁: ORD-123
[2023-08-15 15:00:00.005] [thread-1] INFO c.OrderService - 获取分布式锁成功: ORD-123
[2023-08-15 15:00:00.007] [thread-1] DEBUG c.OrderDao - 更新订单状态: ORD-123 [CREATED → PAID]
[2023-08-15 15:00:00.009] [thread-1] INFO c.OrderService - 支付处理完成: ORD-123
[2023-08-15 15:00:00.010] [thread-1] INFO c.OrderService - 释放订单锁: ORD-123
[2023-08-15 15:00:00.011] [thread-2] INFO c.OrderService - 获取分布式锁成功: ORD-123
[2023-08-15 15:00:00.012] [thread-2] DEBUG c.OrderDao - 查询订单数据: ORD-123
[2023-08-15 15:00:00.013] [thread-2] INFO c.OrderService - 订单已支付,跳过处理: ORD-123
这样就造成根据业务标识进行日志跟踪和问题排查的时候造成很大困扰,需要筛选、分析出单一请求链路的日志信息。
MDC是日志框架(如 SLF4J、Logback、Log4j2)提供的一个线程绑定的上下文存储机制,用于在日志中附加额外的上下文信息(如请求 ID、用户 ID、链路追踪 ID 等)。它是一个在日志框架中使用的工具,主要用于在多线程环境中追踪和诊断日志。MDC允许开发人员在日志记录中关联特定线程的上下文信息,例如用户ID、请求ID、会话ID等,以便在日志中更方便地进行追踪和定位问题。
使用MDC的时候一般会在链路开始阶段执行MDC.put,然后在链路结束时执行MDC.clear,我们简单分析一下其实现原理:
public static void put(String key, String val) throws IllegalArgumentException {
if (key == null) {
throw new IllegalArgumentException("key parameter cannot be null");
}
if (mdcAdapter == null) {
throw new IllegalStateException("MDCAdapter cannot be null. See also " + NULL_MDCA_URL);
}
mdcAdapter.put(key, val);
}
mdcAdapter有多种实现,看一下LogbackMDCAdapter实现:
public void put(String key, String val) throws IllegalArgumentException {
if (key == null) {
throw new IllegalArgumentException("key cannot be null");
}
Map<String, String> oldMap = copyOnThreadLocal.get();
Integer lastOp = getAndSetLastOperation(WRITE_OPERATION);
if (wasLastOpReadOrNull(lastOp) || oldMap == null) {
Map<String, String> newMap = duplicateAndInsertNewMap(oldMap);
newMap.put(key, val);
} else {
oldMap.put(key, val);
}
}
可以看到MDC的put实际操作的是copyOnThreadLocal,而copyOnThreadLocal是一个ThreadLocal,是线程上下文。
链路结束时执行的clear操作实现如下:
public void clear() {
lastOperation.set(WRITE_OPERATION);
copyOnThreadLocal.remove();
}
与put操作类似,clear也会操作当前线程对应的ThreadLocal,执行了remove操作把上下文进行了清理。
也就是说通过MDC可以将我们想要记录的内容或者标识放入当前请求的处理线程,然后在后续逻辑中随时可以拿来使用,比如日志打印。
对于springboot服务,默认使用tomcat作为servlet容器启动服务,以http请求接收与处理为例,整体流程大致如下:
所有http请求的处理默认使用tomcat提供的线程池,对于java而言线程是基于native线程的封装,线程池维护线程的创建、闲置处理、销毁的流程,也就是说线程池管理的线程是可以复用的,也就是说不管是tomcat管理的线程池还是spring容器管理的线程池,在使用过程中或者说处理请求过程中会在业务处理的过程中产生和携带上下文信息,使用完成后清除上下文信息,归还线程到线程池并不会立刻销毁线程,这一点是非常重要的,避免线程级别的引用造成内存泄漏,以及对后续请求复用线程造成干扰。
对于MDC而言,本身操作的就是线程级别的ThreadLocal,所以如果要使用MDC做请求和线程级别的链路追踪,那么就需要选择一个合适的请求开始节点通过MDC塞入需要跟踪的上下文信息,然后在请求结束时(无论是正常结束还是异常结束)清除上下文信息即可。
对于请求处理链路的追踪并不能局限于http请求,以springboot服务为例,从请求起点或者异步线程独立执行作为基线,需要做链路追踪的切入点还是比较多的。
通过分析,我们尽可能能够覆盖绝大多数需要追踪的链路和切入点,满足后续分析日志和问题排查的诉求。
http请求是web服务中最普遍的请求,对于http请求,我们可以基于框架提供的拦截器进行请求拦截,在请求开始的时候通过MDC塞入线程上下文,然后在后续的处理链路中可以拿到上下文信息进行日志打印以及其他操作。
在Spring框架中,HandlerInterceptor(处理器拦截器)默认不会直接拦截WebSocket连接建立后的消息传输,但是会拦截WebSocket握手阶段的 HTTP 请求(即连接建立的初始 HTTP 请求)。请求升级协议到WebSocket后便不会再拦截。
websocket模块有自己的拦截器,比如HandshakeInterceptor,可以通过编写相关实现来嵌入MDC上下文。
定时任务也是业务开发中比较常用的技术,对于单体应用,可以使用spring自带调度器scheduler,而集群服务会使用中心化任务调度平台,比如xxl-job。
在一些负载查询场景,比如订单详情,会关联用户、地址、商品等信息,我个人比较喜欢空间换时间做法,主线程查询订单数据,然后并发线程查询其他关联内容,然后归并到统一返回结果中。
对于这种情况,主流程还是由工作线程完成,我会使用google的并发组件完成并发查询,然后结果集回写到主线程。但是不管是google还是其他并发组件,其底层核心逻辑还是线程池,那么类似情况,我们可以改造线程池即可。
@Async也是spring自带的模块,提供异步处理能力,和前边类似,异步处理的核心逻辑都是开启新的线程处理业务,@Async只是封装了复杂的线程操作而已,对于这种情况我们改造@Async的线程池,嵌入MDC上下文即可。
稍微复杂一点的业务都会引入消息服务,接收消息后会处理一些业务逻辑,也是一个完整的请求链路,对于这种情况,以Rocketmq为例,在监听器的处理逻辑入口嵌入MDC上下文即可完成整个链路的最终。
上一节分析了请求链路追踪的切入点,接着我们选择几个典型的切入点来实现链路追踪能力。
引入依赖(logback):
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
修改日志配置文件(logback-spring.xml):
<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="false">
<!--日志存储路径-->
<property name="log" value="/mnt/applogs/" />
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--输出格式化-->
<pattern>[%X{TRACE_ID}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
<!-- 按天生成日志文件 -->
<appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!--日志文件名-->
<FileNamePattern>${log}/%d{yyyy-MM-dd}.log</FileNamePattern>
<!--保留天数-->
<MaxHistory>30</MaxHistory>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%X{TRACE_ID}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
<!--日志文件最大的大小-->
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>10MB</MaxFileSize>
</triggeringPolicy>
</appender>
<!-- 日志输出级别 -->
<root level="INFO">
<appender-ref ref="console" />
<appender-ref ref="file" />
</root>
</configuration>
这里定义了TRACE_ID,也就是说请求处理开始的时候,需要通过MDC将TRACE_ID放入线程上下文,然后后续日志打印的时候会读取到TRACE_ID并打印到控制台以及日志文件。请求链路分析以及问题排查的时候就可以根据TRACE_ID来展开。
http的请求追踪,可以基于HandlerInterceptor来实现,实现HandlerInterceptor接口重写preHandle,afterCompletion逻辑,在请求开始时嵌入MDC逻辑,请求结束后返回调用之前请求线程的MDC上下文信息,避免造成线程污染。
编写拦截器:
public class TraceInterceptor implements HandlerInterceptor {
private static final String TRACE_ID = "TRACE_ID";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = UUID.randomUUID().toString().replace("-", "");
//外部调用如果传入了TRACE_ID,直接传递即可
if (StringUtils.isNotEmpty(request.getHeader("TRACE_ID"))){
traceId=request.getHeader("TRACE_ID");
}
MDC.put(TRACE_ID, traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) {
//请求处理完成,清理MDC信息
MDC.remove(TRACE_ID);
}
}
添加拦截器:
@Configuration
public class WebConfigurerAdapter implements WebMvcConfigurer {
@Bean
public TraceInterceptor traceInterceptor() {
return new TraceInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceInterceptor()).addPathPatterns("/**")
.excludePathPatterns(WHITELIST);
}
}
编写简单api并发送http请求可以看到日志内容:
这样对于http请求就可以基于MDC传入的上下文信息完成请求链路分析了。
基于HandlerInterceptor实现请求预处理和请求扫尾处理,对于afterCompletion方法一定会执行吗?假如业务逻辑处理出现异常了,afterCompletion还会执行吗?如果不执行的话线程中的MDC上下文就没有清除了,从而导致后续的线程污染、内存泄露等,这是非常危险的,搞清楚这一点也是非常重要的。
http请求处理以及拦截器拦截逻辑的执行都在DispatcherServlet的doDispatch实现:
// DispatcherServlet.java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// ... 获取 HandlerExecutionChain ...
try {
// 1. 执行 preHandle
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 2. 执行逻辑
// 3. 执行 postHandle
mappedHandler.applyPostHandle(processedRequest, response, mv);
//4.组装结果
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
} catch (Exception ex) {
// 捕获异常,触发 afterCompletion(传入异常)
triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
} catch (Throwable err) {
triggerAfterCompletion(processedRequest, response, mappedHandler,
new NestedServletException("Handler processing failed", err));
} finally {}
}
这里我们需要关注三个点:
这里拦截器预处理如果返回false则会调用triggerAfterCompletion,这里就是调用拦截器afterCompletion逻辑的地方:
从doDispatch方法的异常捕获,会执行triggerAfterCompletion方法,从而也会调用拦截器的afterCompletion方法清理MDC上下文。
对于业务请求正常处理完成,会调用processDispatchResult方法:
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {
boolean errorView = false;
//...省略...
if (mappedHandler != null) {
// Exception (if any) is already handled..
mappedHandler.triggerAfterCompletion(request, response, null);
}
}
这里也会调用triggerAfterCompletion方法执行拦截器的afterCompletion逻辑,从而清理MDC上下文。
也就是说任何一种场景拦截器的afterCompletion方法一定会被执行,所以把清理线程上的MDC上下文没有任何问题。
定时任务作为切入点的链路追踪实现,我们分为两种类型实现,分别是spring自带的任务调度scheduler和常用的xxl-job。
自带scheduler
spring自带的任务调度框架封装了复杂的调度逻辑,通过注解的方式使用调度能力,那么我们如果想要在调度任务中嵌入MDC链路追踪,那么需要从调度的线程池作为切入点改造。
对于spring自带Scheduler如果想要自定义线程池,一般做法是实现SchedulingConfigurer接口重写configureTasks方法实现:
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(){
//...改造执行逻辑...
};
taskScheduler.setPoolSize(10); // 设置线程池大小
taskScheduler.setThreadNamePrefix("scheduled-task-pool-"); // 设置线程名称前缀
taskScheduler.initialize();
taskRegistrar.setTaskScheduler(taskScheduler);
}
}
上述configureTasks方法中创建ThreadPoolTaskScheduler时,我们可以使用自己的实现,修改相关线程任务执行逻辑,在实行任务之前在线程中植入MDC上下文,然后执行完成进行清理。
// 包装任务的核心方法
private Runnable wrap(Runnable command) {
return () -> {
// 放置traceId
String traceId=UUID.randomUUID().toString().replace("-", "");
MDC.put("TRACE_ID", traceId);
try {
command.run();
} finally {
MDC.clear();
}
};
}
private <T> Callable<T> wrap(Callable<T> task) {
return () -> {
String traceId=UUID.randomUUID().toString().replace("-", "");
MDC.put("TRACE_ID", traceId);
try {
return task.call();
} finally {
MDC.clear();
}
};
}
这样在任务触发以及后续逻辑处理的时候都能从线程上下文中拿到MDC信息,从而实现调度任务执行逻辑链路追踪。
xxl-job调度
众所周知,应用集成xxl-job后,启动的时候会通过xxl-job内嵌的EmbedServer启动一个单独的web服务(默认监听端口9999):
这里我们可以改写EmbedServer的逻辑,在ThreadPoolExecutor工作线程池上做文章,改造工作线程池在执行逻辑上嵌入MDC上下文以及清除逻辑,不做这种方式要改造xxl-job源码,改造成本比较高,不推荐这种做法。
当然我们有更简单的解决方案,通过xxl-job来实现任务调度一般使用@XxlJob注解的方式,那么直接在@XxlJob注解标注的方法上注入MDC上下文以及MDC清理逻辑。
@XxlJob("demoJobHandler")
public void execute() {
// 生成 traceId
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("TRACE_ID", traceId); // 存入任务执行线程的 MDC
try {
log.info("开始执行任务,traceId={}", traceId);
// 业务逻辑...
} finally {
MDC.clear();
}
}
这样xxl-job-admin在触发调度的时候通过http协议调用业务服务的run接口触发任务执行,也实现了调度任务执行逻辑链路追踪。
很多服务都有异步、削峰填谷、广播通知的诉求,都会引入消息服务,消息消费由消息服务器推送消息或者业务服务主动拉取消息来完成业务操作,也是一个完整的请求链路,我们以Rocketmq为例分析一下消费消息的MDC请求链路追踪。
@Component
@RocketMQMessageListener(consumerGroup = MQConstant.xxx,
topic = "xxx", messageModel = MessageModel.CLUSTERING,
consumeThreadMax = 5)
public class TestMQReceiver implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("TRACE_ID", traceId);
try {
//...执行消息消费逻辑...
} catch (Throwable e) {
log.error("代收订单结果跟踪MQ消费异常", e);
} finally {
MDC.clear();
}
}
}
收到消息是向线程中塞入MDC信息,然后消息消费完成无论成功失败都清理掉MDC内容,这样就实现了消息消费的请求链路追踪能力。
市面上有很多免费开源的请求链路追踪框架比如SkyWalking、Zipkin,但是个人观点是通过MDC实现请求链路追踪与通过框架实现请求链路追踪本质上是殊途同归的,单体应用或者简单的服务拓扑,使用MDC更适合,降低了项目复杂度,并且降低了搭建链路追踪服务的人工成本和资源成本。当然对于一些大型应用或者比较复杂的服务架构,实用链路追踪的优势就比较明显了,并且框架还能提供QPS监控、请求链路自动分析处理、以及告警能力。如下是从一些典型的维度对比MDC与框架之间的异同和优劣:
MDC
链路追踪框架
MDC
链路追踪框架
MDC
链路追踪框架
本文分享自 PersistentCoder 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!