写在前面
说实话,排查线上Bug最痛苦的不是问题本身,而是面对一台机器上成千上万条日志,根本分不清哪条是哪次请求的。我记得有次用户反馈"下单失败了",我查日志查到凌晨3点,眼睛都看花了才找到那条报错------就因为没有TraceId。后来我们全链路接入了TraceId,排查问题的效率提升了不止10倍。这篇文章把我实战中的完整方案,包括踩过的坑,全部整理出来。

文章目录
-
- 一、为什么需要日志链路追踪?
-
- [1.1 一个真实的排查噩梦](#1.1 一个真实的排查噩梦)
- [1.2 生活类比:快递单号](#1.2 生活类比:快递单号)
- [1.3 链路追踪的核心价值](#1.3 链路追踪的核心价值)
- 二、MDC原理
-
- [2.1 MDC是什么?](#2.1 MDC是什么?)
- [2.2 MDC与ThreadLocal的关系](#2.2 MDC与ThreadLocal的关系)
- [2.3 日志框架集成](#2.3 日志框架集成)
- [2.4 日志输出效果](#2.4 日志输出效果)
- 三、TraceId生成与传递
-
- [3.1 网关层生成TraceId](#3.1 网关层生成TraceId)
- [3.2 TraceId在HTTP Header中透传](#3.2 TraceId在HTTP Header中透传)
- [3.3 Feign客户端透传TraceId](#3.3 Feign客户端透传TraceId)
- [3.4 下游服务接收TraceId](#3.4 下游服务接收TraceId)
- [3.5 跨线程传递:线程池+MDC](#3.5 跨线程传递:线程池+MDC)
- 四、TraceId集成日志与ELK
-
- [4.1 Logback完整配置](#4.1 Logback完整配置)
- [4.2 ELK按TraceId搜索](#4.2 ELK按TraceId搜索)
- [4.3 与SkyWalking/Pinpoint关联](#4.3 与SkyWalking/Pinpoint关联)
- 五、进阶:链路追踪信息扩展
-
- [5.1 MDC里除了TraceId还能放什么?](#5.1 MDC里除了TraceId还能放什么?)
- [5.2 全链路SpanId](#5.2 全链路SpanId)
- [5.3 日志采样策略](#5.3 日志采样策略)
- 六、踩坑指南
- 七、问题与解答
- 八、面试高频考点汇总
- 九、模拟面试官提问
- 十、互动话题
- 十一、参考资料
一、为什么需要日志链路追踪?
1.1 一个真实的排查噩梦
用户反馈:"我下单失败了,钱扣了没订单。"
你打开服务器,看到日志是这样的:
2024-01-10 09:23:15 INFO [http-nio-8080-exec-5] c.o.s.OrderService - 创建订单开始
2024-01-10 09:23:15 INFO [http-nio-8080-exec-3] c.o.s.PayService - 调用支付接口
2024-01-10 09:23:16 ERROR [http-nio-8080-exec-7] c.o.s.OrderService - 库存不足
2024-01-10 09:23:16 INFO [http-nio-8080-exec-5] c.o.s.OrderService - 订单创建成功
2024-01-10 09:23:17 ERROR [http-nio-8080-exec-3] c.o.s.PayService - 支付超时
2024-01-10 09:23:17 INFO [http-nio-8080-exec-8] c.o.s.UserService - 查询用户信息
你告诉我,哪几条日志属于同一个请求?
根本分不清。 如果有10台机器、每台上千条日志,排查一个Bug能花3小时。
1.2 生活类比:快递单号
想象你寄了个快递:
- 没有单号:你只能打电话问"我的包裹到哪了",对方问你"什么包裹?谁的?"------完全没法查。
- 有了单号:每个中转站扫描一次,你输入单号就能看到完整路径:"已揽收→到达A转运中心→到达B转运中心→派送中→已签收"。
TraceId就是这个"快递单号"。 一次请求生成一个唯一的TraceId,沿途每个服务、每个方法都把TraceId打在日志里,你就能完整串起这条请求的"一生"。
1.3 链路追踪的核心价值
| 价值 | 具体表现 |
|---|---|
| 排查问题效率提升10倍 | 按TraceId一秒定位所有相关日志 |
| 请求全链路可视化 | 网关→A服务→B服务→数据库,完整路径 |
| 性能瓶颈定位 | 看每个节点的耗时,慢在哪里一目了然 |
| 故障影响范围评估 | 一个下游故障影响了哪些上游请求 |
二、MDC原理
2.1 MDC是什么?
MDC(Mapped Diagnostic Context),翻译过来叫"映射诊断上下文"。
说白了,它就是一个线程级别的Map ,底层用的是ThreadLocal。你可以在当前线程里存一些键值对,日志框架在输出日志的时候自动把这些值取出来打到日志里。
java
// 核心API就三个
MDC.put("traceId", "abc123"); // 放入值
String traceId = MDC.get("traceId"); // 取出值
MDC.clear(); // 清理当前线程的所有值
2.2 MDC与ThreadLocal的关系
Thread对象
└── ThreadLocalMap
└── MDC的ThreadLocal
├── traceId = abc123
├── userId = 10086
└── requestUri = /api/order/create
关键点 :MDC的数据是和线程绑定的。线程A的MDC,线程B访问不到。这也带来一个问题------异步线程里MDC会丢失,后面会讲怎么解决。
2.3 日志框架集成
Logback通过%X{key}占位符来读取MDC中的值。
xml
<!-- logback-spring.xml -->
<configuration>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- %X{traceId} 就是读取MDC中traceId的值 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 文件输出,JSON格式方便ELK解析 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/app/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/app/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<!-- LogstashEncoder会自动把MDC的所有key打到json里 -->
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
2.4 日志输出效果
配置了TraceId后,日志变成了这样:
2024-01-10 09:23:15.123 [http-nio-8080-exec-5] [a1b2c3d4e5f6] INFO c.o.s.OrderService - 创建订单开始, userId=10086
2024-01-10 09:23:15.234 [http-nio-8080-exec-5] [a1b2c3d4e5f6] INFO c.o.s.PayService - 调用支付网关, amount=199.00
2024-01-10 09:23:15.456 [http-nio-8080-exec-5] [a1b2c3d4e5f6] INFO c.o.s.OrderService - 订单创建成功, orderId=888888
看到没,同一个请求的三条日志,[a1b2c3d4e5f6]这个TraceId把她们串起来了!
三、TraceId生成与传递
3.1 网关层生成TraceId
TraceId最好在最外层生成,一般是网关(Spring Cloud Gateway)或者Nginx。这样整个链路从头到位都有一个统一的标识。
java
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
/**
* Spring Cloud Gateway全局过滤器
* 在所有请求的入口生成TraceId
*/
@Slf4j
@Component
public class TraceIdGatewayFilter implements GlobalFilter, Ordered {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String TRACE_ID_MDC_KEY = "traceId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER);
// 如果请求头里没有traceId,就生成一个
if (traceId == null || traceId.isEmpty()) {
traceId = generateTraceId();
}
// 把traceId放入MDC(注意:WebFlux是异步的,需要用Reactor的Context传递)
final String finalTraceId = traceId;
ServerHttpRequest request = exchange.getRequest().mutate()
.header(TRACE_ID_HEADER, finalTraceId)
.build();
return chain.filter(exchange.mutate().request(request).build())
.doFinally(signalType -> MDC.remove(TRACE_ID_MDC_KEY));
}
/**
* 生成TraceId,UUID vs Snowflake?后面踩坑篇细说
*/
private String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "");
}
@Override
public int getOrder() {
// 最高优先级,确保最先执行
return Ordered.HIGHEST_PRECEDENCE;
}
}
3.2 TraceId在HTTP Header中透传
服务A调用服务B时,必须把TraceId放在请求头里带过去。如果你用RestTemplate:
java
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* RestTemplate拦截器:把TraceId透传到下游服务
*/
@Component
public class TraceIdRestTemplateInterceptor implements ClientHttpRequestInterceptor {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = MDC.get("traceId");
if (traceId != null) {
// 关键:把当前线程的traceId放入请求头
HttpHeaders headers = request.getHeaders();
headers.add(TRACE_ID_HEADER, traceId);
}
return execution.execute(request, body);
}
}
3.3 Feign客户端透传TraceId
如果你用Feign调用下游服务:
java
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Feign全局配置:透传TraceId
*/
@Configuration
public class FeignTraceIdConfig {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
@Bean
public RequestInterceptor traceIdInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
String traceId = MDC.get("traceId");
if (traceId != null) {
template.header(TRACE_ID_HEADER, traceId);
}
}
};
}
}
3.4 下游服务接收TraceId
下游服务(被调用方)需要把请求头里的TraceId放进自己的MDC:
java
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* TraceId接收过滤器
* 每个微服务都要配一个,从请求头中读取TraceId并放入MDC
*/
@Component
public class TraceIdReceiveFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String TRACE_ID_MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId != null && !traceId.isEmpty()) {
MDC.put(TRACE_ID_MDC_KEY, traceId);
} else {
// 兜底:如果没有上游传过来的TraceId,自己生成一个
MDC.put(TRACE_ID_MDC_KEY, generateTraceId());
}
try {
filterChain.doFilter(request, response);
} finally {
// 这个坑我踩过!一定要清理MDC,否则线程复用会导致traceId串了
MDC.clear();
}
}
private String generateTraceId() {
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}
3.5 跨线程传递:线程池+MDC
这个坑我踩过!你把任务丢进线程池,MDC里的TraceId就丢了,因为线程池里的线程不是当前线程。
解决方案:包装Runnable/Callable,提交任务前把MDC复制过去,执行完再清理。
java
import org.slf4j.MDC;
import java.util.Map;
import java.util.concurrent.*;
/**
* MDC线程池包装器
* 支持跨线程传递TraceId的线程池
*/
public class MdcThreadPoolExecutor extends ThreadPoolExecutor {
public MdcThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
@Override
public void execute(Runnable command) {
// 提交任务时,把当前线程的MDC上下文复制一份
Map<String, String> contextMap = MDC.getCopyOfContextMap();
super.execute(new MdcRunnable(command, contextMap));
}
/**
* MDC Runnable包装类
*/
public static class MdcRunnable implements Runnable {
private final Runnable delegate;
private final Map<String, String> contextMap;
public MdcRunnable(Runnable delegate, Map<String, String> contextMap) {
this.delegate = delegate;
this.contextMap = contextMap;
}
@Override
public void run() {
// 任务执行前,把MDC上下文设置到当前线程
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
delegate.run();
} finally {
// 任务执行完,一定要清理!
MDC.clear();
}
}
}
}
使用方式:
java
// 用MdcThreadPoolExecutor替代普通线程池
ExecutorService executor = new MdcThreadPoolExecutor(
4, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100)
);
// 提交任务,TraceId会自动传递
executor.submit(() -> {
log.info("异步任务执行,traceId还在!");
// 日志输出:... [a1b2c3d4e5f6] INFO ... - 异步任务执行,traceId还在!
});
四、TraceId集成日志与ELK
4.1 Logback完整配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 彩色控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%yellow(%d{yyyy-MM-dd HH:mm:ss.SSS}) %green([%thread]) %highlight([%X{traceId}]) %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>
<!-- JSON格式文件输出,用于ELK收集 -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/app/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/app/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<loggerName/>
<message/>
<mdc/>
<threadName/>
<stackTrace/>
</providers>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="JSON_FILE"/>
</root>
</configuration>
4.2 ELK按TraceId搜索
JSON格式的日志打到Elasticsearch后,Kibana里可以直接搜:
mdc.traceId: a1b2c3d4e5f6
一条搜索语句,就能把这个TraceId对应的所有服务的所有日志全找出来。这就是TraceId的魔力。
4.3 与SkyWalking/Pinpoint关联
如果你用了SkyWalking或Pinpoint,它们也有自己的TraceId。建议把SkyWalking的TraceId和MDC的TraceId打通:
java
import org.apache.skywalking.apm.toolkit.trace.TraceContext;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* SkyWalking TraceId与MDC打通
*/
@Component
public class SkyWalkingTraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 获取SkyWalking的TraceId
String swTraceId = TraceContext.traceId();
// 如果SkyWalking有TraceId,优先用它(更规范)
if (swTraceId != null && !"N/A".equals(swTraceId)) {
MDC.put("traceId", swTraceId);
} else {
// 兜底:用请求头里的或自己生成
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null) {
traceId = generateTraceId();
}
MDC.put("traceId", traceId);
}
try {
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
}
private String generateTraceId() {
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}
五、进阶:链路追踪信息扩展
5.1 MDC里除了TraceId还能放什么?
java
// 在过滤器里一次性放入所有上下文信息
MDC.put("traceId", traceId);
MDC.put("userId", String.valueOf(userId));
MDC.put("requestUri", request.getRequestURI());
MDC.put("requestMethod", request.getMethod());
MDC.put("clientIp", getClientIp(request));
日志输出就会变成:
2024-01-10 09:23:15.123 [http-nio-8080-exec-5] [a1b2c3d4] INFO c.o.s.OrderService - 创建订单开始
同时JSON日志里会有完整的字段:
json
{
"timestamp": "2024-01-10T09:23:15.123+08:00",
"level": "INFO",
"logger": "c.o.s.OrderService",
"message": "创建订单开始",
"mdc": {
"traceId": "a1b2c3d4",
"userId": "10086",
"requestUri": "/api/order/create",
"requestMethod": "POST",
"clientIp": "192.168.1.100"
},
"thread": "http-nio-8080-exec-5"
}
5.2 全链路SpanId
TraceId标识一次请求,SpanId标识请求内的某个具体调用。比如一个请求调用了3个下游服务,每个调用就是一个Span:
Trace: a1b2c3d4
├─ Span 1.1: Gateway接收请求
├─ Span 1.2: OrderService处理
│ ├─ Span 1.2.1: 查询用户信息 (50ms)
│ ├─ Span 1.2.2: 扣减库存 (100ms)
│ └─ Span 1.2.3: 调用支付服务 (300ms) ← 瓶颈在这里!
└─ Span 1.3: Gateway返回响应
SkyWalking会自动生成SpanId。如果你只用MDC做简单日志追踪,一般TraceId就够了。
5.3 日志采样策略
全量采集日志压力太大,可以按策略采样:
| 采样策略 | 适用场景 |
|---|---|
| 100%采样 | 核心业务链路、排查期 |
| 按比例采样(如10%) | 日常流量大的接口 |
| 错误全采+成功按比例 | 平衡成本和问题发现 |
| 按用户白名单 | VIP用户全链路追踪 |
六、踩坑指南
坑1:MDC的ThreadLocal在异步线程中丢失
这个坑我踩过!主线程MDC里有traceId,一提交到线程池就没了。必须用我上面写的
MdcThreadPoolExecutor包装一下,或者手动MDC.getCopyOfContextMap()+MDC.setContextMap()。
坑2:TraceId在MQ消费端的传递MQ消费是一个新线程(或者线程池里的线程),消息头里如果没有带TraceId,消费端就断了链路。解决方案:生产者在消息属性里塞入TraceId,消费者收到后取出来放到MDC。
java// 生产者 Message msg = new Message("topic", "tag", "body".getBytes()); msg.putUserProperty("traceId", MDC.get("traceId")); // 消费者 String traceId = msg.getUserProperty("traceId"); MDC.put("traceId", traceId);
坑3:线程池复用导致MDC未清理
这个坑最隐蔽!如果
finally里没调MDC.clear(),线程池里的线程下次被复用时,MDC里还残留着上次的traceId,导致日志串了。我见过有人排查Bug,发现日志里一个请求有两套traceId,就是因为这个。
坑4:TraceId长度与性能UUID是36位(带横线),去掉横线32位。每行日志都带32个字符,日志量大了对存储和传输都有影响。追求性能可以用Snowflake生成18位的Long型ID,或者自己实现更短的随机字符串。不过说实话,32位UUID在现代硬件上影响微乎其微,简单稳定优先。
七、问题与解答
Q1:MDC和普通的ThreadLocal有什么区别?
MDC底层就是ThreadLocal,但它封装了一层标准API,并且和日志框架(Logback/Log4j2/Log4j)深度集成,可以通过%X{key}占位符直接输出到日志。你当然可以直接用ThreadLocal存traceId,但日志框架不认识你的ThreadLocal,MDC是行业标准方案。
Q2:WebFlux响应式编程中MDC还能用吗?
WebFlux的线程模型和传统Servlet不一样,一个请求可能在不同线程上执行。这种情况下MDC的ThreadLocal机制会失效。解决方案是用Reactor的Context来传递,或者引入context-propagation库。这是响应式编程的一个痛点,如果团队对WebFlux不熟,建议传统MVC项目先用MDC方案。
Q3:如果下游系统是老系统,不支持接收TraceId怎么办?
这是个现实问题。有几种应对:
- 推动下游改造,加个Filter读请求头很简单
- 在接口文档里约定
X-Trace-Id透传 - 实在透传不了的,至少在本系统内部打好TraceId,排查时能缩小范围
- 通过日志时间戳+请求参数辅助定位(效率低但比没有强)
八、面试高频考点汇总
考点1:MDC是什么?底层原理是什么?
MDC(Mapped Diagnostic Context)是日志框架提供的线程级诊断上下文工具,底层基于ThreadLocal实现。通过MDC.put(key, value)在当前线程存储键值对,日志框架通过%X{key}占位符输出。典型应用是存储TraceId实现全链路日志追踪。
考点2:TraceId如何在微服务之间传递?
- 网关层生成 TraceId,放入HTTP请求头(如
X-Trace-Id) - 调用方通过RestTemplate拦截器或Feign拦截器,从MDC读取TraceId放入请求头
- 被调用方通过Filter从请求头读取TraceId,放入自己的MDC
- 跨线程场景需要用包装类(如MdcRunnable)复制MDC上下文
考点3:线程池里TraceId丢失怎么解决?
因为MDC基于ThreadLocal,线程池的线程和提交任务的线程不是同一个。解决方案是包装Runnable/Callable:
java
public class MdcRunnable implements Runnable {
private final Runnable delegate;
private final Map<String, String> contextMap;
@Override
public void run() {
MDC.setContextMap(contextMap);
try {
delegate.run();
} finally {
MDC.clear();
}
}
}
考点4:为什么每次请求结束后要调用MDC.clear()?
Tomcat等Web容器使用线程池处理请求,线程会被复用。如果不清除MDC,下一次请求复用这个线程时,MDC里还残留着上一次的数据,导致traceId串了、日志混乱。
考点5:TraceId和SkyWalking的TraceId是什么关系?
两者都是用于链路追踪的标识,但实现层级不同:
- MDC TraceId:日志层面的手动/半自动方案,轻量、简单、成本低
- SkyWalking TraceId:APM层面的全自动方案,功能强大但引入额外组件
实际项目中两者可以共存,甚至把SkyWalking的TraceId同步到MDC中,实现日志和APM的打通。
九、模拟面试官提问
场景题1:你们项目的日志链路追踪是怎么做的?
参考答案:
我们用的是MDC + TraceId的方案。具体流程:
- Spring Cloud Gateway层通过GlobalFilter生成TraceId,放入HTTP Header
- 每个微服务通过OncePerRequestFilter读取Header里的TraceId,放入MDC
- RestTemplate和Feign都配置了拦截器,自动把MDC里的TraceId透传到下游
- 线程池用自定义的MdcThreadPoolExecutor,保证异步任务里TraceId不丢
- 日志输出JSON格式,通过Filebeat收集到ELK,Kibana里按traceId搜索
- 同时也集成了SkyWalking,把SkyWalking的TraceId同步到MDC里
这套方案跑下来,排查问题的效率比之前提升了至少10倍。
场景题2:用户反馈"下单失败",你怎么快速定位问题?
参考答案:
- 先问用户要订单号或者操作时间
- 在Kibana里搜:
mdc.userId:10086 AND message:"下单",找到对应的traceId- 用traceId搜全链路日志:
mdc.traceId:a1b2c3d4- 按时间顺序看日志,定位到哪一步报错:是参数校验失败?库存不足?支付超时?还是下游服务异常?
- 如果是下游服务问题,继续用traceId去下游服务的日志里查
- 整个过程基本在5分钟内能定位到根因
场景题3:如果异步线程里的任务报错了,怎么关联到原始请求?
参考答案:
这就体现了MdcThreadPoolExecutor的价值。提交异步任务时,主线程的MDC上下文会被复制到任务线程里。所以异步任务里的日志也会带原始traceId。排查时直接用同一个traceId搜索就行,主线程和异步任务的日志能完整串起来。
场景题4:设计一个高并发场景下的日志追踪方案,要求对性能影响最小。
参考答案:
- TraceId生成:用Snowflake替代UUID,减少长度和生成开销
- 日志采样:非核心接口按10%比例采样,错误日志100%采集
- 异步日志 :Logback配置
<appender>为异步模式,避免日志IO阻塞业务线程- 避免过度扩展MDC:MDC里只放最关键的字段(traceId、userId),不要什么都塞
- 批量收集:Filebeat/Fluentd批量发送日志到ES,减少网络开销
- 日志级别动态调整:生产环境默认WARN/ERROR,排查时临时开启DEBUG
场景题5:MQ消费端怎么保证TraceId不丢?
参考答案:
- 生产者在发送消息时,把traceId放到消息的属性(UserProperty)里
- 消费者监听消息后,先从消息属性里取出traceId
- 把traceId放入当前消费线程的MDC
- 执行业务逻辑,日志自动带上traceId
- 消费完成后,finally里清理MDC
- 如果消费端还要调用下游服务,通过Feign/RestTemplate拦截器继续透传
关键是MQ作为异步中间件,要主动承担起TraceId的"接力"工作。
十、互动话题
你们项目现在是怎么排查线上问题的?是手动一台台机器搜grep,还是已经接入了TraceId或者SkyWalking?如果让你从零搭建一套日志链路追踪方案,你觉得最大的阻力会是什么?欢迎在评论区交流。
十一、参考资料
如果这篇文章对你有帮助,欢迎点赞收藏!关注我,持续输出Java后端实战经验。