引言:分布式日志追踪的痛点
线上系统出了问题,用户投诉接口响应慢,你登录到各个服务器查看日志,却发现日志信息杂乱无章,根本无法追踪一个请求从入口到出口的完整路径?或者一个请求跨越了多个微服务,每个服务都打印了自己的日志,但你无法将这些日志关联起来?
这就是分布式系统日志追踪的经典难题。传统的日志记录方式已经无法满足微服务架构的需求。今天我们就来聊聊如何用SpringBoot + ELK + MDC构建一个完整的分布式日志追踪体系,让你能够快速定位跨服务调用链问题。
原文链接
为什么需要分布式日志追踪?
先说说为什么分布式系统需要日志追踪。
想象一下,你是一家电商公司的后端工程师。用户下单流程涉及订单服务、库存服务、支付服务、物流服务等多个微服务。如果用户反馈下单失败,你该如何排查?
在传统方式下,你可能需要:
- 登录订单服务服务器,查看订单服务日志
- 登录库存服务服务器,查看库存服务日志
- 登录支付服务服务器,查看支付服务日志
- 登录物流服务服务器,查看物流服务日志
然后手动拼接这些日志,试图还原请求的完整路径。这不仅效率低下,而且容易出错。
分布式日志追踪就是为了解决这个问题而生的,它能够:
- 关联跨服务请求:通过唯一标识追踪整个调用链
- 可视化调用路径:清晰展示服务间的调用关系
- 快速定位问题:一目了然地看到问题出现在哪个环节
技术选型:为什么选择这些技术?
ELK Stack:日志处理的黄金组合
ELK是Elasticsearch、Logstash、Kibana的缩写,是日志处理的事实标准:
- Elasticsearch:分布式搜索引擎,用于存储和检索日志
- Logstash:日志收集和处理工具
- Kibana:可视化界面,用于日志分析和展示
MDC:日志上下文传递的关键
MDC(Mapped Diagnostic Context)是Logback/Log4j提供的功能,可以在日志中添加上下文信息:
- 线程安全:基于ThreadLocal实现
- 灵活扩展:可以添加任意键值对
- 自动传递:日志输出时自动包含MDC信息
SpringBoot:快速集成的桥梁
SpringBoot提供了:
- 自动配置:快速集成各种组件
- 拦截器支持:便于在请求入口统一处理
- AOP支持:便于在方法级别添加日志
系统架构设计
我们的日志追踪体系主要包括以下几个模块:
- 请求标识生成:在请求入口生成唯一追踪ID
- MDC上下文管理:在请求处理过程中维护日志上下文
- 跨服务传递:将追踪ID传递到下游服务
- 日志格式化:统一日志格式,包含追踪信息
- 日志收集:收集日志到ELK系统
- 日志展示:通过Kibana进行可视化展示
核心实现思路
1. 请求追踪ID生成
首先创建一个请求追踪ID生成器:
java
@Component
public class TraceIdGenerator {
public String generateTraceId() {
// 生成全局唯一追踪ID
return UUID.randomUUID().toString().replace("-", "");
}
}
2. MDC上下文管理
创建MDC上下文管理器:
java
public class TraceContext {
private static final String TRACE_ID = "traceId";
private static final String SPAN_ID = "spanId";
private static final String SERVICE_NAME = "serviceName";
public static void setTraceId(String traceId) {
MDC.put(TRACE_ID, traceId);
}
public static void setSpanId(String spanId) {
MDC.put(SPAN_ID, spanId);
}
public static void setServiceName(String serviceName) {
MDC.put(SERVICE_NAME, serviceName);
}
public static String getTraceId() {
return MDC.get(TRACE_ID);
}
public static void clear() {
MDC.remove(TRACE_ID);
MDC.remove(SPAN_ID);
MDC.remove(SERVICE_NAME);
}
}
3. 请求拦截器
创建拦截器在请求入口统一处理:
java
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Autowired
private TraceIdGenerator traceIdGenerator;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 从请求头获取追踪ID,如果不存在则生成新的
String traceId = request.getHeader("X-Trace-Id");
if (StringUtils.isEmpty(traceId)) {
traceId = traceIdGenerator.generateTraceId();
}
// 设置MDC上下文
TraceContext.setTraceId(traceId);
TraceContext.setSpanId(generateSpanId());
TraceContext.setServiceName(getServiceName());
// 将追踪ID添加到响应头,便于前端追踪
response.setHeader("X-Trace-Id", traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 清理MDC上下文
TraceContext.clear();
}
private String generateSpanId() {
return System.currentTimeMillis() + "-" + Thread.currentThread().getId();
}
private String getServiceName() {
return "order-service"; // 实际项目中可以从配置获取
}
}
4. 日志配置
配置logback-spring.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<loggerName/>
<message/>
<mdc/>
<arguments/>
</providers>
</encoder>
</appender>
</springProfile>
<springProfile name="prod">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<logLevel/>
<loggerName/>
<message/>
<mdc/>
<arguments/>
</providers>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
5. 跨服务调用追踪
在服务间调用时传递追踪ID:
java
@Component
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList(new TraceRestInterceptor()));
return restTemplate;
}
public static class TraceRestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// 将当前追踪ID添加到请求头
String traceId = TraceContext.getTraceId();
if (traceId != null) {
request.getHeaders().add("X-Trace-Id", traceId);
}
return execution.execute(request, body);
}
}
}
6. Feign客户端追踪
如果是使用Feign进行服务调用:
java
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
String traceId = TraceContext.getTraceId();
if (traceId != null) {
requestTemplate.header("X-Trace-Id", traceId);
}
};
}
}
高级特性实现
1. 自定义日志注解
创建一个自定义注解来自动记录方法执行时间:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TraceLog {
String value() default "";
boolean includeParams() default false;
boolean includeResult() default false;
}
创建AOP切面来处理注解:
java
@Aspect
@Component
public class TraceLogAspect {
private static final Logger logger = LoggerFactory.getLogger(TraceLogAspect.class);
@Around("@annotation(traceLog)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint, TraceLog traceLog) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
if (traceLog.includeParams()) {
logger.info("开始执行方法: {}.{}, 参数: {}",
className, methodName, Arrays.toString(joinPoint.getArgs()));
} else {
logger.info("开始执行方法: {}.{}", className, methodName);
}
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
logger.info("方法执行完成: {}.{}, 耗时: {}ms",
className, methodName, executionTime);
if (traceLog.includeResult()) {
logger.info("方法返回结果: {}", result);
}
return result;
} catch (Exception e) {
logger.error("方法执行异常: {}.{}", className, methodName, e);
throw e;
}
}
}
2. 异步调用追踪
在异步方法中保持追踪上下文:
java
@Component
public class AsyncTraceExecutor {
@Async
public CompletableFuture<String> processAsync(String data) {
// 从当前线程获取追踪ID并传递到异步线程
String traceId = TraceContext.getTraceId();
return CompletableFuture.supplyAsync(() -> {
// 在异步线程中设置追踪ID
TraceContext.setTraceId(traceId);
try {
// 业务逻辑
return processBusinessLogic(data);
} finally {
// 清理异步线程的追踪上下文
TraceContext.clear();
}
});
}
}
ELK配置
Logstash配置
创建logstash.conf:
input {
file {
path => "/path/to/your/logs/*.log"
start_position => "beginning"
codec => json
}
}
filter {
if [message] =~ /ERROR/ {
mutate {
add_tag => [ "error" ]
}
}
if [message] =~ /WARN/ {
mutate {
add_tag => [ "warning" ]
}
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
index => "application-logs-%{+YYYY.MM.dd}"
}
}
Kibana配置
在Kibana中创建索引模式,设置traceId字段为可搜索字段,这样就可以通过traceId来追踪整个调用链。
最佳实践
1. 日志级别管理
java
@Service
public class OrderService {
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
public Order createOrder(OrderRequest request) {
String traceId = TraceContext.getTraceId();
logger.info("开始创建订单, traceId: {}, userId: {}, amount: {}",
traceId, request.getUserId(), request.getAmount());
try {
Order order = processOrder(request);
logger.info("订单创建成功, traceId: {}, orderId: {}", traceId, order.getId());
return order;
} catch (Exception e) {
logger.error("订单创建失败, traceId: {}, error: {}", traceId, e.getMessage(), e);
throw e;
}
}
}
2. 敏感信息脱敏
java
public class LogUtils {
public static String maskSensitiveInfo(String data) {
if (data == null) return data;
// 脱敏手机号
data = data.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
// 脱敏邮箱
data = data.replaceAll("(\\w{2})\\w+@(\\w+)", "$1***@$2");
return data;
}
}
3. 性能考虑
- 异步日志:使用异步Appender减少日志对业务的影响
- 日志级别:生产环境避免DEBUG级别日志
- 日志轮转:合理配置日志轮转策略,避免磁盘空间不足
监控与告警
1. 日志指标收集
java
@Component
public class LogMetricsCollector {
private final MeterRegistry meterRegistry;
public void recordLogEvent(String level, String service) {
Counter.builder("log_events_total")
.tag("level", level)
.tag("service", service)
.register(meterRegistry)
.increment();
}
}
2. 异常告警
通过Kibana的告警功能,设置异常日志告警规则,及时发现问题。
总结
通过SpringBoot + ELK + MDC的组合,我们可以构建一个完整的分布式日志追踪体系。关键在于:
- 统一标识:使用TraceId关联整个调用链
- 上下文传递:通过MDC在日志中传递上下文信息
- 跨服务传递:通过HTTP头将追踪信息传递到下游服务
- 统一格式:使用结构化日志便于ELK处理
- 可视化展示:通过Kibana进行日志分析和问题定位
记住,日志追踪不是一次性的工作,而是一个持续优化的过程。掌握了这些技巧,你就能快速定位分布式系统中的问题,告别日志排查的烦恼。