日志先定规范
日志不是给“以后可能有人看”写的,而是给排障的人在三分钟内定位问题用的。一条合格的业务日志,至少包含时间、级别、线程、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 透传的本质是上下文管理:入口生成、线程内放入、跨线程复制、跨服务透传、出口清理。任何一环缺失,日志就会断链。先把这五步做扎实,再考虑接入完整的链路追踪系统;否则工具上了,日志里还是没有可关联的标识。
评论