分布式链路追踪
导读
在微服务架构时代,系统从单体应用演变为分布式系统后,一个请求往往跨越多个服务、多个模块,甚至多个数据中心。这带来了调试和监控的巨大挑战:如何快速定位问题?如何追踪请求的全链路?这就是链路追踪(Distributed Tracing)的价值所在。
本文将聚焦traceId(追踪ID)的核心概念,结合Spring Cloud Sleuth工具,从理论到实践,带你一步步实现微服务链路追踪。特别加入自定义RequestLogFilter(基于OncePerRequestFilter),打印请求头、参数、响应体和traceId,实现“日志即追踪”。无论你是Spring Boot开发者,还是运维工程师,这篇博客都能帮你解决“日志洪水”和“问题难定位”的痛点。预计阅读时间15分钟,文末有完整代码示例,欢迎star收藏!
关键词:Spring Cloud Sleuth, traceId, 链路追踪, 微服务监控, Zipkin, RequestLogFilter
一、traceId 与链路追踪基础概念
1.1 什么是traceId?
- traceId:Trace ID,全称为追踪ID,是分布式系统中为每个请求分配的唯一标识符。它像一张“通行证”,跟随请求在整个服务链路中传播,帮助你追踪从入口到出口的全过程。
- 为什么需要traceId?在微服务中,一个用户请求可能涉及10+服务(如网关 → 用户服务 → 订单服务 → 支付服务)。日志分散在不同文件中,如何关联?traceId就是“桥梁”,让日志变成可追踪的链路。
- 示例:一个traceId如
84b1c8e5d0a2f4b6c7e8d9f0a1b2c3d4,128位长度(避免碰撞),在日志中输出如[traceId: 84b1c8e5...] INFO UserService: 处理用户登录。
1.2 链路追踪(Distributed Tracing)概述
- 定义:记录请求在分布式系统中的传播路径、延迟、错误等指标,形成可视化“瀑布图”。
- 核心组件:
- Trace:整个请求的全局追踪(traceId标识)。
- Span:Trace内的子段(如服务A到B的调用),每个Span有spanId。
- 工具:Jaeger、Zipkin、SkyWalking等。Spring生态首选Sleuth + Zipkin。
- 益处:
- 问题诊断:慢查询?哪个服务瓶颈?traceId一搜全知道。
- 性能优化:可视化延迟分布,优化热点路径。
- 合规审计:追踪敏感操作全链路。
小结:traceId是链路追踪的“心脏”,Sleuth是Spring Boot的“开箱即用”实现。
二、Spring Cloud Sleuth 快速上手
2.1 环境准备
- 技术栈:Spring Boot 3.5.6 + Spring Cloud 2023.0.3 + Maven。
- 项目结构:微服务示例(用户服务 + 订单服务),用Feign调用。
添加依赖
在pom.xml中引入Sleuth(自动管理Micrometer桥接):
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<!-- 可选:Zipkin导出 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
</dependencies>
- 注意:Spring Boot 3.x中Sleuth依赖Micrometer Tracing,无需额外bridge-brave(BOM自动)。
2.2 配置Sleuth
在application.yml中启用:
spring:
sleuth:
enabled: true
web:
client:
enabled: true # Feign传播traceId
filter:
enabled: true # Servlet注入MDC
sampler:
probability: 1.0 # 开发全采样
trace-id128: true # 128位traceId
management:
tracing:
sampling:
probability: 1.0 # Micrometer采样
zipkin:
base-url: http://localhost:9411 # Zipkin地址
- 采样率:开发1.0(全追踪),生产0.1(10%采样,防性能开销)。
2.3 日志集成(Logback)
修改logback-spring.xml,注入traceId:
<property name="CONSOLE_LOG_PATTERN"
value="[${spring.application.name}:${spring.cloud.client.ip-address}:${server.port}] [%X{traceId}] %d{yyyy-MM-dd HH:mm:ss.SSS} %p %t %c{1}: %m%n"/>
- 效果:日志自动带
[traceId: xxx] INFO UserService: ...。
三、实践:自定义RequestLogFilter打印traceId与请求详情
为了将traceId融入业务日志,我们实现一个RequestLogFilter(继承OncePerRequestFilter),捕获请求头、参数、响应体,并高亮traceId。结合RequestWrapper/ResponseWrapper包装HTTP流,实现body读取。
3.1 FilterConfig(注册Filter)
在com.pig4cloud.pig.common.log.filter包下创建配置类:
package com.pig4cloud.pig.common.log.filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @ClassName FilterConfig
* @Description 过滤器配置类
* @Author brain
* @Date 2025/11/12
*/
@Configuration
public class FilterConfig {
/**
* 日志打印过滤器
* 注册 RequestLogFilter
* @return 过滤器注册配置
*/
@Bean
public FilterRegistrationBean<RequestLogFilter> requestLogFilter() {
FilterRegistrationBean<RequestLogFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestLogFilter());
registrationBean.addUrlPatterns("/*"); // 设置需要过滤的 URL 模式,根据需求配置
registrationBean.setOrder(-50); // 后于Sleuth (-100),MDC已填充
registrationBean.setName("requestLogFilter");
return registrationBean;
}
}
- order=-50:确保Sleuth先填充MDC。
3.2 RequestLogFilter(核心实现)
package com.pig4cloud.pig.common.log.filter;
import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pig.common.log.filter.dto.RequestWrapper;
import com.pig4cloud.pig.common.log.filter.dto.ResponseWrapper;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.*;
/**
* @ClassName RequestLogFilter
* @Description 打印请求与响应日志(集成traceId)
* @Author brain
* @Date 2025/11/12
*/
@Slf4j
public class RequestLogFilter extends OncePerRequestFilter {
/**
* 不打印日志的请求
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return StrUtil.containsAny(request.getRequestURI(), "/swagger", "/static", "/favicon.ico", "/actuator", "/springfox");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String url = Objects.requireNonNull(request).getRequestURI();
String method = Objects.requireNonNull(request).getMethod();
// 包装请求
RequestWrapper requestWrapper = new RequestWrapper(request);
StringBuilder requestParam = new StringBuilder();
BufferedReader bufferedReader = requestWrapper.getReader();
String line;
while ((line = bufferedReader.readLine()) != null) {
requestParam.append(line);
}
// 包装响应
ResponseWrapper responseWrapper = new ResponseWrapper(response);
// 构建完整参数(GET用query,POST用body,脱敏)
StringBuilder fullParams = new StringBuilder();
String bodyParam = requestParam.toString().trim();
if ("GET".equalsIgnoreCase(method) || StrUtil.isBlank(bodyParam)) {
// GET: 递归解析query params
fullParams.append("Query Params: ");
Map<String, String[]> paramMap = request.getParameterMap();
if (!paramMap.isEmpty()) {
paramMap.forEach((key, values) -> {
if (values != null && values.length > 0) {
String valueStr = String.join(",", values); // 数组转逗号字符串
fullParams.append(key).append("=").append(valueStr).append(", ");
}
});
fullParams.delete(fullParams.length() - 2, fullParams.length()); // 去尾部", "
} else {
fullParams.append("None");
}
} else {
// POST/PUT: 用body
fullParams.append("Body: ").append(bodyParam);
}
// 脱敏敏感字段
String paramsStr = fullParams.toString().replaceAll("(?i)(password|token|authorization)=[^,\\s]+", "$1=***");
// 获取traceId(Sleuth自动填充MDC)
String traceId = MDC.get("traceId");
if (StrUtil.isBlank(traceId)) {
traceId = java.util.UUID.randomUUID().toString().replace("-", ""); // Fallback
}
// 构成一条长日志,避免并发下日志错乱
StringBuilder reqLog = new StringBuilder(320);
// 日志参数
List<Object> reqArgs = new ArrayList<>();
reqLog.append("\n==================== 请求开始 ====================\n");
// 打印路由
reqLog.append("===> {}: {}\n");
reqArgs.add(method);
reqArgs.add(url);
// 请求参数
reqLog.append(" 【请求参数】 {}\n");
reqArgs.add(paramsStr);
// 打印请求头
Enumeration<String> headers = request.getHeaderNames();
while (headers.hasMoreElements()) {
String headerName = headers.nextElement();
if ("request-id".equals(headerName)) continue;
String headerValue = request.getHeader(headerName);
reqLog.append(" 【请求头】 {}: {}\n");
reqArgs.add(headerName);
reqArgs.add(headerValue);
}
String requestId = request.getHeader("request-id");
if (StrUtil.isNotBlank(requestId)) {
reqLog.append(" 【请求头】 {}: {}\n");
reqArgs.add("request-id");
reqArgs.add(requestId);
}
// traceId
reqLog.append(" 【请求头】 {}: {}\n");
reqArgs.add("traceId");
reqArgs.add(traceId);
reqLog.append("==================== 请求结束 ====================\n");
// 打印执行时间
long startNs = System.currentTimeMillis();
log.info(reqLog.toString(), reqArgs.toArray());
// 继续过滤链
filterChain.doFilter(requestWrapper, responseWrapper);
// 响应日志
StringBuilder respLog = new StringBuilder(220);
List<Object> respArgs = new ArrayList<>();
respLog.append("\n==================== 响应开始 ====================\n");
long tookMs = System.currentTimeMillis() - startNs;
respLog.append("<=== {}: {} ({} ms)\n");
respArgs.add(method);
respArgs.add(url);
respArgs.add(tookMs);
respLog.append(" 【响应报文】 {}\n");
// 获取响应内容类型
String contentType = responseWrapper.getContentType();
String resp = contentType;
if (!(MediaType.APPLICATION_OCTET_STREAM_VALUE.equals(contentType) ||
"application/octet-stream;charset=UTF-8".equals(contentType))) {
byte[] data = responseWrapper.getResponseData();
if (data.length > 1024) {
resp = new String(data, 0, 1024).replace("\r\n", "") + "... [truncated]";
} else {
resp = new String(data).replace("\r\n", "");
}
}
respArgs.add(resp);
// 打印响应日志
respLog.append("==================== 响应结束 ====================\n");
log.info(respLog.toString(), respArgs.toArray());
// 确保输出流刷新并关闭
ServletOutputStream os = response.getOutputStream();
os.write(responseWrapper.getResponseData());
os.flush();
os.close();
}
}
- 关键:
MDC.get("traceId")自动获取Sleuth生成的ID;参数解析支持GET query/POST body,脱敏token等。
3.3 RequestWrapper & ResponseWrapper(HTTP流包装)
用于捕获body(GET无body,POST有JSON)。
RequestWrapper.java(com.pig4cloud.pig.common.log.filter.dto):
package com.pig4cloud.pig.common.log.filter.dto;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.springframework.util.StreamUtils;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 请求包装器,捕获body
*/
public class RequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public RequestWrapper(HttpServletRequest request) throws IOException {
super(request);
// 读取body
body = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new CachedServletInputStream(body);
}
@Override
public BufferedReader getReader() throws IOException {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body);
return new BufferedReader(new InputStreamReader(byteArrayInputStream, StandardCharsets.UTF_8));
}
private static class CachedServletInputStream extends ServletInputStream {
private final ByteArrayInputStream cachedBody;
public CachedServletInputStream(byte[] cachedBody) {
this.cachedBody = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
return cachedBody.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return cachedBody.read();
}
}
}
ResponseWrapper.java(同包):
package com.pig4cloud.pig.common.log.filter.dto;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 响应包装器
*/
public class ResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private final ServletOutputStream out = new WrapperOutputStream(buffer);
private final PrintWriter writer = new PrintWriter(out);
public ResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public ServletOutputStream getOutputStream() throws IOException {
return out;
}
@Override
public PrintWriter getWriter() {
return writer;
}
@Override
public void flushBuffer() throws IOException {
if (out != null) out.flush();
if (writer != null) writer.flush();
}
public byte[] getResponseData() throws IOException {
flushBuffer();
return buffer.toByteArray();
}
private static class WrapperOutputStream extends ServletOutputStream {
private final ByteArrayOutputStream bos;
public WrapperOutputStream(ByteArrayOutputStream buffer) {
bos = buffer;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setWriteListener(WriteListener writeListener) {
// 忽略
}
@Override
public void write(int b) throws IOException {
bos.write(b);
}
@Override
public void write(byte[] b) throws IOException {
bos.write(b, 0, b.length);
}
}
}
3.4 测试效果
启动服务,访问/oaEmployeeContracts/page?contractName=正式¤t=1&size=10:
[traceId: 84b1c8e5d0a2f4b6c7e8d9f0a1b2c3d4] INFO RequestLogFilter:
==================== 请求开始 ====================
===> GET: /oaEmployeeContracts/page
【请求参数】 Query Params: contractName=合同, current=1, size=10
【请求头】 authorization: Bearer eyJ...
【请求头】 traceId: 84b1c8e5d0a2f4b6c7e8d9f0a1b2c3d4
==================== 请求结束 ====================
[traceId: 84b1c8e5d0a2f4b6c7e8d9f0a1b2c3d4] INFO RequestLogFilter:
==================== 响应开始 ====================
<=== GET: /oaEmployeeContracts/page (399 ms)
【响应报文】 {"code":0,"data":{...}}
==================== 响应结束 ====================
- traceId一致:Filter + 业务日志 + Feign调用,全链路追踪。
四、Zipkin可视化链路
4.1 启动Zipkin
Docker一键:
docker run -d -p 9411:9411 openzipkin/zipkin
- 访问localhost:9411,搜索traceId查看瀑布图(延迟分布、错误率)。
4.2 高级配置
- 排除路径:
spring.sleuth.web.skipPattern: (health|info)(避开/actuator)。 - 自定义采样:基于URL/头采样(e.g.,
/api/pay/*全采样)。 - 集成ELK:Logstash解析traceId,Kibana聚合查询。
五、常见问题与优化
- traceId null?检查
spring.sleuth.enabled=true+ 依赖BOM;Filter order=-50。 - 性能影响?采样0.1 + 异步日志(Logback AsyncAppender)。
- 多环境?Nacos动态配置采样率。
- 扩展:结合SkyWalking(无侵入)或Jaeger(OpenTelemetry)。
总结:traceId + Sleuth是微服务追踪的“瑞士军刀”,从日志关联到可视化,一劳永逸。实践后,你的系统将从“日志地狱”变“追踪天堂”!
参考:
欢迎评论区讨论:你的链路追踪实践经验?点赞+收藏,关注更新更多微服务干货!
GitHub源码: 完整项目示例(含Filter代码)。
标签:Spring Boot, Spring Cloud, Sleuth, traceId, 链路追踪, 微服务, Zipkin, 分布式系统, 监控, RequestLogFilter
版权声明:本文原创,转载请注明出处。
(完)

944

被折叠的 条评论
为什么被折叠?



