Spring Cloud Sleuth:traceId 与链路追踪详解,从入门到实践(含自定义Filter日志打印)

导读

在微服务架构时代,系统从单体应用演变为分布式系统后,一个请求往往跨越多个服务、多个模块,甚至多个数据中心。这带来了调试和监控的巨大挑战:如何快速定位问题?如何追踪请求的全链路?这就是链路追踪(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=正式&current=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

版权声明:本文原创,转载请注明出处。

(完)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wáng bēn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值