微服务架构跑了好几年,大家最头疼的往往不是某个接口报错,而是报错之后根本不知道这个错到底从哪冒出来的。前端说调你后端超时了,你查自己服务日志发现一切正常,但下游服务到底发生了啥,你只能一个接一个地登服务器,翻日志,猜哪个时间点对应哪次请求。运气好,几分钟找到;运气不好,排查一整天。问题的真正根源,不是日志不够多,而是这些日志之间缺了一根“线”,把同一次业务请求在所有服务里的印记串起来。这根线,就是统一追踪标识。

一、为什么日志串不起来

先看个最普通的场景。用户下单,请求先到订单服务,订单服务要扣库存,于是调库存服务;扣完库存还要发消息,消息又触发积分服务。整个过程跨越三四个服务,每个服务都打印了日志,比如“收到下单请求”“扣减库存成功”“积分已发放”。单看每个服务的日志,都挺正常。可一旦用户说“我下单没成功”,你要还原整个链路,就麻烦了。每个服务都有自己的时间戳和请求日志,但没有公共的字段能告诉你:这条“扣减库存成功”到底是不是对应刚才那笔订单。于是你只能靠业务参数倒推,比如订单号,或者靠时间硬凑。订单号如果传了还好,但很多内部调用只传必要参数,甚至有时连订单号都没传全。时间更是不可靠,同一毫秒内可能有几十个请求。

这就是最核心的痛点:日志之间没有“关联键”。解决方案也简单,给每一次外部请求发一个全局唯一的ID,叫它traceId。traceId随着请求在整个链路里传递,每个服务在记录日志时都带上这个ID。这样,后续不管日志散落在哪个服务,只要用traceId去查,就能一次性拉出这条请求走过的所有路径。

二、统一追踪标识怎么设计

2.1 核心概念:traceId和spanId

traceId是全局追踪ID,用来标识一次完整的业务请求。从入口服务开始生成,比如一个16位的随机字符串。整个链路里所有服务都复用这个值。

spanId是每次调用的ID,用来标识一次服务间的调用关系。比如订单服务调库存服务,这次调用会有一个spanId,下一次订单服务调积分服务,又会生成另一个spanId。spanId一般用于观察每次调用的耗时和顺序,但如果只为了把日志串起来,纯粹用traceId就已经足够了。

为了让排查更顺手,通常在日志里同时打印traceId和spanId,另外再加一个parentSpanId,用于表示当前span的父级是谁,这样就能还原出调用树。不过大多数场景下,只要traceId一致,就能定位全链路,spanId是锦上添花。

2.2 传递方式:HTTP Header

RESTful服务之间走的基本都是HTTP,所以最自然的做法就是把traceId放在HTTP请求头里。比如自定义一个Header,名字叫X-Trace-Id。服务A发起HTTP调用服务B时,在请求里带上这个Header,服务B收到请求后,从Header里取出来,存到日志上下文里。这样服务B打印的所有日志都会自动带上traceId。

这里有个容易踩的坑:如果服务B没取出来,或者取名不一致,traceId就断了。所以统一约定Header名称非常关键,所有服务必须使用同一个名字。

三、实践:一套可落地的方案

下面用Java技术栈演示,基于Spring Boot框架。核心思路是写一个过滤器,在接收请求时提取或生成traceId,存入MDC(Mapped Diagnostic Context,日志上下文),同时写一个RestTemplate或OpenFeign的拦截器,在发起HTTP请求时把traceId写进Header。这样,整个链路就自动接上了。

3.1 技术栈

本示例使用Java 8 + Spring Boot 2.x,日志框架使用Logback。

3.2 第一步:定义常量类

先统一管理Header名称和MDC的key,避免到处写魔法值。

/**
 * 追踪标识常量
 */
public class TraceConstant {
    // HTTP Header中传递traceId的字段名
    public static final String TRACE_HEADER = "X-Trace-Id";
    // MDC中存储traceId的key
    public static final String TRACE_MDC_KEY = "traceId";
}

3.3 第二步:编写服务端过滤器

这个过滤器处理所有进入本服务的HTTP请求。如果请求头里带了traceId,说明是从上游服务传来的,直接提取出来;如果没有,则生成一个新的。然后把traceId放入MDC。在处理完请求后,清除MDC,防止线程复用导致串数据。

import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

/**
 * 服务端过滤器:负责提取或生成traceId,并写入MDC
 */
@Component
public class TraceIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpReq = (HttpServletRequest) request;
        // 尝试从Header中获取traceId
        String traceId = httpReq.getHeader(TraceConstant.TRACE_HEADER);
        // 如果上游没有传,就生成一个新的
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        // 将traceId放入MDC,这样日志里就能通过%X{traceId}打印了
        MDC.put(TraceConstant.TRACE_MDC_KEY, traceId);
        try {
            // 继续处理请求
            chain.doFilter(request, response);
        } finally {
            // 请求结束后必须清除,否则线程池复用会带上旧traceId
            MDC.remove(TraceConstant.TRACE_MDC_KEY);
        }
    }
}

3.4 第三步:编写客户端拦截器

当本服务作为调用方,去请求下游服务时,需要把当前MDC里的traceId写入请求头。这里以RestTemplate为例,通过实现ClientHttpRequestInterceptor来完成。

import org.slf4j.MDC;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;

import java.io.IOException;

/**
 * 客户端拦截器:发送HTTP请求时,自动携带traceId
 */
public class TraceIdClientInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {
        // 从MDC中获取当前请求的traceId
        String traceId = MDC.get(TraceConstant.TRACE_MDC_KEY);
        // 如果存在,则写入Header
        if (traceId != null && !traceId.isEmpty()) {
            request.getHeaders().set(TraceConstant.TRACE_HEADER, traceId);
        }
        // 继续执行调用
        return execution.execute(request, body);
    }
}

3.5 第四步:注册拦截器到RestTemplate

需要把上面的拦截器注册到RestTemplate实例中,这样所有通过它发出的请求都会自动带上traceId。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.web.client.RestTemplate;

import java.util.Collections;
import java.util.List;

/**
 * HTTP客户端配置
 */
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        // 把自定义拦截器加进去
        List<ClientHttpRequestInterceptor> interceptors = 
                Collections.singletonList(new TraceIdClientInterceptor());
        restTemplate.setInterceptors(interceptors);
        return restTemplate;
    }
}

3.6 第五步:配置日志格式

要让日志里出现traceId,需要在logback的配置文件中加上pattern。比如:

<!-- logback-spring.xml 部分配置 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <!-- 注意 %X{traceId} 是从MDC中取值的 -->
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n</pattern>
    </encoder>
</appender>

这样每次打印日志时,只要MDC里有traceId,就会自动追加在中括号里。比如:

2025-02-18 14:30:21.123 [http-nio-8080-exec-3] INFO  com.shop.order.controller.OrderController [e7f8a9d21c004a8e9b3f6d0c1a2b3c4d] - 用户创建订单成功

3.7 第七步:使用示例

假设订单服务调用库存服务。订单服务的Controller里通过RestTemplate调用库存接口:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;

/**
 * 订单控制器
 */
@RestController
public class OrderController {

    private static final Logger log = LoggerFactory.getLogger(OrderController.class);

    @Autowired
    private RestTemplate restTemplate;

    @PostMapping("/orders")
    public String createOrder(@RequestBody Map<String, Object> orderData) {
        // 业务日志:打印收到的请求
        log.info("收到创建订单请求,订单号:{}", orderData.get("orderNo"));

        // 模拟扣减库存,调用库存服务
        Map<String, Object> stockReq = new HashMap<>();
        stockReq.put("productId", orderData.get("productId"));
        stockReq.put("quantity", orderData.get("quantity"));

        // 注意:这行代码会自动携带traceId,不用手动处理
        String response = restTemplate.postForObject(
                "http://stock-service/inventory/deduct", stockReq, String.class);

        log.info("库存服务返回结果:{}", response);
        return "success";
    }
}

库存服务里的Controller,因为有了服务端过滤器,会自动把traceId从Header中取出来放入MDC,所以它的日志也会自动带上同一个traceId。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * 库存控制器
 */
@RestController
public class InventoryController {

    private static final Logger log = LoggerFactory.getLogger(InventoryController.class);

    @PostMapping("/inventory/deduct")
    public String deduct(@RequestBody Map<String, Object> requestBody) {
        // 这里不需要手动获取traceId,过滤器已经放入了MDC
        log.info("开始扣减库存,商品ID:{},数量:{}",
                requestBody.get("productId"), requestBody.get("quantity"));

        // 模拟扣减逻辑
        boolean success = true;
        log.info("扣减库存成功");

        return success ? "ok" : "fail";
    }
}

当用户发起一次创建订单请求时,订单服务的日志会打印出traceId,库存服务的日志也会打印出同样的traceId。这时你再去做全链路查询,只需要在日志系统里搜这个traceId,所有相关日志瞬间全出来了。

四、应用场景:什么时候最需要这种方案

4.1 跨服务排障

最直接的场景就是联调排错。比如上面下单的例子,前端反馈某个订单状态异常,你只要在日志系统里用traceId搜索,从订单服务到库存服务再到积分服务的一系列过程,每一步的输入输出、耗时、错误信息全都拉出来,不用再象以前那样来回跳转。

4.2 性能分析

如果你把每次HTTP调用的开始时间和结束时间也作为日志打出来,配合traceId,就能分析出整个链路中哪一环最慢。比如发现某个订单请求总共耗时800ms,其中库存服务占了600ms,那就知道瓶颈在库存服务。这种事情手动排查非常耗时,但有了traceId,统计起来非常方便。

4.3 业务日志和系统日志统一

很多服务除了业务日志,还有访问日志、异常日志、慢查询日志等。只要都打印了traceId,就能把这几种日志通过同一个ID关联起来。比如用户投诉某个功能很慢,你用traceId不仅能找到业务日志,还能看到同一时间点上的GC日志、IO等待等,辅助分析问题背后更深层的原因。

五、技术优缺点

5.1 优点

5.1.1 成本低

不用引入额外的框架或中间件。每个服务只需增加一个过滤器和一个拦截器,再修改一下日志pattern,总共几十行代码的事情。对于Java技术栈尤其简单,因为Spring Boot本身提供了完整的拦截机制。

5.1.2 侵入性小

业务代码里完全不需要手动传递traceId,也不需要显式调用任何API。MDC自动帮你把traceId塞进日志上下文,RestTemplate拦截器自动帮你把它传给下游。业务开发人员基本感知不到这个机制的存在。

5.1.3 通用性强

不仅仅适用于RESTful调用。任何基于HTTP的通信,只要你遵循同一个Header约定,不管是同步还是异步,也不管中间经过几层网关,都能传递。如果用了消息队列,还可以把traceId放到消息头里,生产者和消费者各自提取,照样能串起来。

5.2 缺点

5.2.1 只覆盖基于HTTP的同步调用

如果服务之间使用gRPC、Dubbo这类RPC框架,或者走消息队列,就需要额外适配。比如Dubbo有attachment,MQ有自定义消息头,每种协议都要单独写一套透传逻辑。

5.2.2 依赖人工约定

如果团队内部对Header名称没有统一,或者服务B没有按照约定提取,traceId就断了。尤其在老项目中,很多服务没有使用公共的过滤器,很难一次性全部改造完。

5.2.3 线程上下文容易丢

MDC基于ThreadLocal实现,如果代码里使用了异步线程池,比如@Async或者CompletableFuture,子线程默认拿不到父线程的MDC值。这时候要么手动传递,要么使用TransmittableThreadLocal等组件。这是个比较常见的坑,需要特别注意。

六、注意事项

6.1 保证Header名称全局唯一

不要用容易冲突的Header名,比如自定义的Token。建议统一使用X-Trace-Id或者X-Request-Id。最好写在公司内部的技术规范文档里,每个服务都必须遵守。

6.2 兼容无Header的情况

入口请求不一定都来自外部,比如定时任务、MQ消费者、内部测试工具。这些请求可能没有traceId,所以服务端过滤器一定要有兜底逻辑:如果取不到就自动生成一个新的,而不是直接把请求干掉。

6.3 清除MDC,防止内存泄漏和串数据

处理完请求后,必须调用MDC.remove()。因为线程是复用的,如果不清理,下一条请求在进入过滤器之前,日志里可能还带着上一条请求的traceId,导致日志串味。在finally块中处理是最稳妥的。

6.4 异步场景需要特殊处理

如果你在服务里用了线程池,比如异步发送邮件,你在异步线程里打印日志时,traceId可能为空。简单做法是在提交任务前,手动从MDC取得traceId,传给子线程,在子线程里再放入MDC。

import org.slf4j.MDC;

/**
 * 异步任务示例
 */
public class AsyncTaskUtil {

    public static void runInAsync(Runnable task) {
        // 获取当前线程的traceId
        String traceId = MDC.get(TraceConstant.TRACE_MDC_KEY);
        // 注意:这里用的是简化的写法,实际项目中建议配合线程池
        new Thread(() -> {
            if (traceId != null) {
                // 放入子线程的MDC
                MDC.put(TraceConstant.TRACE_MDC_KEY, traceId);
            }
            try {
                task.run();
            } finally {
                MDC.remove(TraceConstant.TRACE_MDC_KEY);
            }
        }).start();
    }
}

6.5 日志采样

在超高并发场景下,如果每个请求都全量打印日志,磁盘会承受巨大压力。可以考虑加上采样率,比如只记录耗时超过100ms的请求,或者随机采样10%的流量。但注意,采样率不宜过低,否则排查问题时又找不到日志了。

七、关联技术和进阶方向

如果不想自己手写这些拦截器,可以选用现成的解决方案。比如Spring Cloud Sleuth、Micrometer Tracing、OpenTelemetry。这些框架除了传递traceId,还能自动生成spanId,上报调用链数据到Zipkin、Jaeger等系统,提供图形化的链路界面。

特别是OpenTelemetry,它已经成为业界标准。它定义了统一的规范和SDK,支持多种语言、多种协议。如果你的系统准备长期演进,用OpenTelemetry是更高效的方向。不过它的学习成本也比纯手写要高很多,需要理解Span、Trace、Export等概念,还要部署采集器。对于小型团队或者老系统,手写简单的traceId透传仍然是最快捷的救火方案。

另外要注意,traceId不同于业务ID。业务ID比如订单号只能代表某个业务对象,而traceId代表一次完整的请求过程。一个订单号可能对应多次修改操作,每次操作都有不同的traceId。所以两者是互补关系,不能互相替代。

八、总结

日志串不起来这件事,绝不是某个服务写几行日志就能解决的。它是全局性、系统性的问题,必须从一次请求的入口开始,为它分配一个唯一标识,然后让这个标识沿着调用链自然传播。实现方式说难也难,说简单也简单。难在需要所有服务配合,简单在核心代码就那么几行。关键是要在团队内部建立规范,统一Header名称,统一日志格式,统一过滤器逻辑。

我们今天用Java和Spring Boot演示了一个可运行的方案。服务端通过过滤器提取traceId,客户端通过RestTemplate拦截器透传traceId,MDC负责把traceId注入日志。这套方案只用了三个核心类加一个配置,就能让所有基于HTTP的RESTful调用自动串联起来。排查问题时,你不再需要来回切换系统,只用一个traceId就能完整还原请求路径。无论你的系统规模多大,都建议尽早做了这件事。越早做,后面存储到日志中的数据越规范,排查故障时救你于水火的能力越强。