做线上问题排查的兄弟应该都经历过这种痛苦:接口报了个错,你想把这条请求完整的前因后果翻出来,结果发现网关入口日志有、业务逻辑日志有、调用下游服务的日志也有,但它们之间没有任何一个共同字段能串起来,只能靠时间戳在几千万条日志里硬猜。SpringBoot 项目里这种“日志全链路追踪”的缺失,往往是线上定位慢的最大元凶。我在实际治理日志过程中,最常用、性价比最高的一招就是用 MDC + TraceId 这套轻量方案:通过 SLF4J 的 MDC 把一次请求的 TraceId 注入线程上下文,再配合日志模板占位符输出,让同一请求贯穿整条调用链的所有日志片段。这篇文章会把接入方法、异步线程的坑、跨服务传递细节完整复盘一遍,适合那些暂时不想引入重量级链路追踪平台、只想把日志快速串起来的后端同学。
1. 为什么单靠时间戳拼日志会把人逼疯
先还原一下真实的排查场景。你负责一个订单服务,用户反馈“下单偶尔失败”,报错日志里能看到一个 NullPointerException,但问题是这个异常只保留了堆栈,没有带任何请求维度信息。你想知道:当前这笔订单是谁的、前端传了什么参数、有没有调库存服务、扣减结果是什么。
于是你去日志平台搜索异常关键字,搜出来几百条一模一样的 NPE。你按时间筛选“最近5分钟”,发现这些异常来自不同用户的请求,日志交织在一起,根本分不清哪几条日志属于同一个请求。你切到库存服务的日志,想把时间对齐看看有没有关联调用,结果又发现两个服务日志时间存在毫秒级偏差,肉眼对齐几乎不可行。
这种痛苦本质上不是日志格式问题,而是日志缺少“一次请求的统一标识”。
1.1 日志系统的核心痛点:上下文不互通
单体时代这个问题不突出。一个请求进来,执行过程基本分布在同一个 JVM 的同一个线程里,从头到尾日志按时间顺序落盘。你只要打开一个文件,从入口 ExceptionHandler 往下翻,基本能还原现场。但进入微服务和异步化之后,一次请求的执行路径至少会跨越三个维度:
- 线程维度:业务逻辑从 Tomcat 工作线程提交到线程池,日志打点落在另一个线程;
- 服务维度:服务 A 通过 HTTP 调用服务 B,服务 B 再调用服务 C,日志落在各自进程;
- 异步维度:请求触发消息推送后立即返回,真正处理放在 MQ 消费线程,和原请求线程已经毫无关系。
这三个维度一旦交叉,单靠时间戳和线程名根本拼不回去。
1.2 为什么是 MDC + TraceId,而不是直接上链路平台
很多团队一提“全链路追踪”,第一反应是要不要上 SkyWalking 或者 Zipkin。但这里有一个很现实的问题:重量级链路追踪平台解决的不只是日志关联,还包括调用拓扑、Span 耗时、异常聚合、采样分析。这些能力很强,但部署维护成本、探针侵入风险、学习门槛也是真实存在的。如果核心诉求只是“线上日志能按请求搜出来,快速定位问题”,那么更务实的路径是先落一套基于日志上下文的轻量方案。
这套轻量方案的核心就两个角色:MDC 和 TraceId。
MDC 的全称是 Mapped Diagnostic Context,翻译过来是“映射诊断上下文”。它本质上是日志框架提供的一个线程级上下文容器,你可以往里面塞 key-value,然后在日志格式里通过占位符动态取出。Spring Boot 默认集成了 SLF4J + Logback,天然支持 MDC。
TraceId 是“链路追踪标识”,代表一次完整业务请求的唯一编号。从用户请求进入系统的第一道门开始生成,后续无论这个请求在内部如何跳转、如何跨服务,都要带着这同一个编号打日志。
一句话概括这套方案的思路:在请求入口为每个请求分配 TraceId 放入 MDC,在日志输出时打印出来,在跨线程、跨服务的边界负责传递,最后在请求结束时清理。 它不解决调用拓扑可视化的问题,但能把 90% 的线上日志排查问题解决掉,而且实现成本极低,全部改造在一个公共模块内完成,业务代码几乎零侵入。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 入口 Filter 的三个关键动作:取头、放 MDC、finally 清理
在 SpringBoot 项目里,最自然的陷阱就是:全链路日志的第一环必须是一个入口过滤器,确保“每个请求进来都能拿到 TraceId”。这里不能放在业务 Service 里做,也不能放在 Controller 层做,因为一旦 Controller 或 Service 抛出异常,你根本来不及预处理这块逻辑。
建议直接基于 OncePerRequestFilter 实现一个 TraceIdFilter,放进公共模块。它要做的事情总结成三个动作。
2.1 拿到 TraceId:优先透传上游,没有则生成
先看代码,再解释为什么这样写。
java复制@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String TRACE_ID_MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Map<String, String> previousContext = MDC.getCopyOfContextMap();
String traceId = request.getHeader(TRACE_ID_HEADER);
if (!StringUtils.hasText(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
