ElIauk

日志规范与 traceId 透传

日志先定规范 日志不是给“以后可能有人看”写的,而是给排障的人在三分钟内定位问题用的。一条合格的业务日志,至少包含时间、级别、线程、traceId、类名、关键业务标识和结果;错误日志还要带异常栈。只写 log.info("success"),出问题时等于没写。 级别要克制。ERROR 表示需要人介入

评论
日志规范与 traceId 透传

日志先定规范

日志不是给“以后可能有人看”写的,而是给排障的人在三分钟内定位问题用的。一条合格的业务日志,至少包含时间、级别、线程、traceId、类名、关键业务标识和结果;错误日志还要带异常栈。只写 log.info("success"),出问题时等于没写。

级别要克制。ERROR 表示需要人介入的失败,WARN 表示可恢复的异常或降级,INFO 记录状态变化和关键入参出参,DEBUG 留给排查时临时打开。把每个方法进出口都打成 INFO,QPS 一高日志量先把自己压垮;反过来,全用 ERROR 记业务异常,真正的故障会被淹没。

敏感信息必须过滤。手机号、身份证、银行卡、密码、token 不能进日志,必要时脱敏成 138****0000。请求体和响应体不要整体打印,尤其是登录、支付接口。日志框架的异步 appender 要设置有界队列,队列满时宁可按策略丢弃 DEBUG,也不要阻塞业务线程。

traceId 从入口到出口

一次请求可能经过网关、多个服务、线程池、MQ,排查时要靠 traceId 把散落的日志串起来。traceId 一般在最外层入口生成:网关或第一个收到的服务检查请求头里有没有,没有就生成一个,响应头回传,后续所有日志都带上。

Spring Boot 3.x 推荐用 Micrometer Tracing(Spring Cloud Sleuth 的继任者),它兼容 W3C 的 traceparent 标准,能和 OpenTelemetry、Brave 对接。如果项目还没上链路追踪,用 X-Trace-Id 加 MDC 也能满足基本需求。关键是统一 header 名称和格式,不要让网关叫 X-Request-Id、服务里又叫 traceId。

代码:过滤器、线程池与 HTTP 客户端

入口过滤器负责生成和清理 MDC:

@Component
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put(TRACE_ID, traceId);
        response.setHeader("X-Trace-Id", traceId);
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID);
        }
    }
}

finally 里的 MDC.remove 不能省。Tomcat 线程是复用的,不清掉的话,下一个请求可能带着上一个请求的 traceId,排查时会被引到完全错误的方向。

Logback 的输出格式里加上 MDC:

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-}] %logger{40} - %msg%n</pattern>
    </encoder>
</appender>

异步线程和线程池不会自动继承 MDC。用 TaskDecorator 在任务提交时复制上下文,执行完清理:

public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> context = MDC.getCopyOfContextMap();
        return () -> {
            if (context != null) {
                MDC.setContextMap(context);
            }
            try {
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

把它设置到 ThreadPoolTaskExecutor 上即可。注意复制的是提交时的快照,任务执行期间上游 MDC 变化不会同步,这符合 trace 的语义。

跨服务调用要透传 header。用 RestTemplate 时加拦截器:

@Bean
public RestTemplate restTemplate() {
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.getInterceptors().add((request, body, execution) -> {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            request.getHeaders().add("X-Trace-Id", traceId);
        }
        return execution.execute(request, body);
    });
    return restTemplate;
}

OpenFeign 用 RequestInterceptor,思路一样。MQ 消息则要把 traceId 放进消息头,消费端取出后重新放入 MDC,否则生产者和消费者的日志断成两截。

常见坑

1. 只在 Web 线程里放 MDC

@Async、CompletableFuture、定时任务、MQ 消费线程都拿不到 traceId,日志里全是空值。每个跨线程的入口都要显式复制上下文,不能指望框架自动传递。

2. 忘记清理导致串号

过滤器里 MDC.put 之后没有 remove,线程复用时会污染下一个请求。自定义线程池的 finally 里也要 MDC.clear(),不要只清某个 key。

3. 日志 pattern 写错

%X{traceId} 大小写敏感,MDC 里放的是 traceId,pattern 写成 %X{traceid} 就取不到。默认值写法是 %X{traceId:-},没有值时输出空字符串,而不是报错。

4. 直接拼接异常信息

log.error("处理失败: " + e.getMessage()) 只留下消息,没有堆栈;log.error("处理失败", e) 才会打印完整异常栈。不要把异常对象 toString() 后拼进字符串。

5. 异步 appender 丢日志

Logback 的 AsyncAppender 队列满了默认会丢弃 TRACE/DEBUG/INFO,严重时连 ERROR 都可能受影响。生产环境要关注 discardingThreshold 和队列容量,或者用 neverBlock=false 并配监控。

6. 信任客户端传来的 traceId

客户端可以随便传一个超长字符串或带换行的内容,造成日志注入或存储膨胀。入口要校验格式和长度,不合法就重新生成,不要直接拼进日志。

排查步骤

先验证 traceId 能不能贯穿一次请求:

curl -i -H 'X-Trace-Id: trace-test-001' http://localhost:8080/api/orders/1
grep 'trace-test-001' logs/app.log

响应头里应该回传同一个值,日志里每个相关线程都应该带这个值。如果没有,按“过滤器是否生效 → MDC 是否放入 → pattern 是否取对 → 线程是否切换”顺序排查。

异步链路单独验证:在 @Async 方法里打印 Thread.currentThread().getName() 和 MDC.get("traceId"),确认线程名变了、traceId 还在。跨服务调用则抓取下游日志,看请求头有没有带过去,下游过滤器有没有重新放入 MDC。

小结

traceId 透传的本质是上下文管理:入口生成、线程内放入、跨线程复制、跨服务透传、出口清理。任何一环缺失,日志就会断链。先把这五步做扎实,再考虑接入完整的链路追踪系统;否则工具上了,日志里还是没有可关联的标识。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者