跨服务日志里 traceId 断了,排查时用这几种传参和存储方式把它接上
在一次线上事故里,我们花了四十分钟才定位到问题——不是因为逻辑复杂,而是因为跨了四个服务后,traceId 在第三个服务断了。日志里一堆独立的请求碎片,像拼图缺了角。后来复盘,问题出在两个环节:传参方式不一致,以及存储位置没约定好。
这篇文章就是那次踩坑后沉淀下来的方案。不聊概念,只讲在跨服务调用中,traceId 怎么生成、怎么传、怎么存、怎么查,以及遇到断层时怎么兜底。
traceId 的生成:别用随机串,也别让上游全权负责
traceId 的生成规则直接影响后续检索效率和存储成本。两个常见坑:一是用纯随机 UUID 导致日志检索时无法做前缀过滤,二是完全依赖上游传入,一旦上游没传或传错,整条链路就断了。
首条请求用雪花算法变体生成,下游用约定头接收,同时做兜底生成。
具体做法:在网关或首个服务处,用带时间戳前缀的 ID 生成器创建 traceId。我们用的是 SnowflakeId 的变体,取 41 位毫秒时间戳 + 10 位机器 ID + 12 位序列号,再转成 16 进制字符串,最终形如 1a2b3c4d5e6f。这样生成出来的 ID 天然按时间有序,在 ELK 里做前缀搜索时能利用索引分区裁剪,比纯随机 UUID 的检索速度快一个数量级。
下游服务从固定请求头里取,比如 X-Trace-Id。但绝对不能假设它一定存在。每个服务在接收到请求后,先检查该头是否为空或格式异常,如果是,立即用本机生成一个新的 traceId,并在日志里标记 trace_source=self_generated,这样在排查时一眼就能看出断点位置。
这个兜底逻辑要封装成公共库,不要在每个服务里重复写。我们团队的 Java 版本大概是这样:
public class TraceContext {
private static final String TRACE_HEADER = "X-Trace-Id";
private static final ThreadLocal<String> CURRENT_TRACE = new ThreadLocal<>();
public static void init(HttpServletRequest request) {
String incoming = request.getHeader(TRACE_HEADER);
if (incoming != null && incoming.matches("^[a-f0-9]{12,32}$")) {
CURRENT_TRACE.set(incoming);
} else {
String generated = SnowflakeIdGenerator.nextHex();
CURRENT_TRACE.set(generated);
MDC.put("trace_source", "self_generated");
}
MDC.put("traceId", CURRENT_TRACE.get());
}
public static String getTraceId() {
return CURRENT_TRACE.get();
}
public static void clear() {
CURRENT_TRACE.remove();
MDC.remove("traceId");
MDC.remove("trace_source");
}
}
要点就三个:验证格式、兜底生成、标记来源。标记来源这个字段在排查断层时是第一个要看的。
跨服务传参:别只依赖 HTTP Header,RPC 和 MQ 场景各有坑
HTTP 调用传 traceId 是直觉操作,放 Header 就行。但实际系统里,跨服务调用往往掺杂了 Dubbo、gRPC 这类 RPC 框架,以及 RocketMQ、Kafka 这类异步消息。每种通道的元数据传递机制不同,不统一处理就会断。
HTTP 和 RPC 用框架的隐式传参机制,MQ 放进消息头,定时任务从调度参数里取。
先说 HTTP。RestTemplate 或 Feign 调用时,写一个拦截器自动从当前线程上下文中取出 traceId 塞进请求头:
public class TraceFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String traceId = TraceContext.getTraceId();
if (traceId != null) {
template.header("X-Trace-Id", traceId);
}
}
}
Dubbo 场景类似,用 RpcContext 的隐式传参:
// 消费者端
RpcContext.getContext().setAttachment("traceId", TraceContext.getTraceId());
// 提供者端,在 Filter 里取出
String traceId = RpcContext.getContext().getAttachment("traceId");
gRPC 用 Metadata 传递,在客户端拦截器里塞进去,服务端拦截器里取出来。
MQ 是重灾区。很多团队只在消息体里放业务数据,traceId 丢了。正确做法是用消息的扩展属性/用户属性。以 RocketMQ 为例:
Message message = new Message(topic, body);
message.putUserProperty("TRACE_ID", TraceContext.getTraceId());
producer.send(message);
消费端从 MessageExt 里取出 TRACE_ID 并设置到当前线程上下文。注意,如果消费逻辑里又发起了新的 RPC 调用,这个 traceId 会继续往下传,整条异步链路就串起来了。
定时任务的情况特殊一点——没有上游请求触发,自然没有传入的 traceId。我们的做法是在调度平台传参时带一个 job_trace_id 字段,由调度器生成,任务执行时取出作为整条链路的根 traceId。
日志里怎么存:MDC 是标配,但别忘了一个关键配置
traceId 存进日志这件事,90% 的团队都在用 MDC(Mapped Diagnostic Context),这没问题。但很多人的日志配置里只输出了 traceId,没输出 spanId 和上游服务名,排查时还是得猜调用顺序。
日志格式里至少输出 traceId、spanId、serviceName 三个字段,且用 JSON 格式落盘,方便后续结构化检索。
我们 Logback 的 logback-spring.xml 配置里,encoder 的 pattern 长这样:
<encoder>
<pattern>{"timestamp":"%d{yyyy-MM-dd HH:mm:ss.SSS}","level":"%level","service":"${SERVICE_NAME}","traceId":"%X{traceId}","spanId":"%X{spanId}","thread":"%thread","logger":"%logger","message":"%msg"}%n</pattern>
</encoder>
SERVICE_NAME 通过 Spring 的 spring.application.name 在启动时注入到 Logback 的变量里。spanId 则是在每次发起或接收调用时生成一个新的,表示当前服务内的一次操作。这样在 ELK 里按 traceId 搜索时,能按 spanId 排序还原出完整调用拓扑。
还有一个容易忽略的点:线程池里的 MDC 传递。Tomcat 的请求线程有 MDC,但一旦用了 @Async 或自定义线程池,子线程默认不继承父线程的 MDC。需要显式处理:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
然后在 ThreadPoolTaskExecutor 上设置这个 decorator。漏了这一步,异步线程里的日志就是没有 traceId 的,断层又出现了。
排查时怎么高效检索:前缀匹配 + 时间范围,别上来就全文搜索
日志进了 ELK 或 Loki 之后,检索策略直接决定排查速度。很多人习惯把 traceId 复制进去全文搜索,这在日志量大的时候慢得离谱。
用 traceId 前缀加时间范围过滤,优先命中时间分片索引,再在结果集里做精确匹配。
因为我们的 traceId 前 8 位是时间戳的十六进制表示,所以同一个时间段内的 traceId 前缀相同。在 Kibana 里搜索时,先根据事故时间反推前缀范围,然后加 filter:
traceId: "1a2b3c4d*" AND @timestamp: [2025-01-15T10:00:00 TO 2025-01-15T10:05:00]
这一步利用了 Elasticsearch 的 prefix query 和时间范围索引裁剪,查询通常在几百毫秒内返回。如果直接用完整 traceId 做 term query,反而要走倒排索引全量匹配,数据量大时可能要好几秒。
如果 traceId 断了,先用 trace_source:self_generated 搜出所有自行生成 traceId 的节点,这些就是断点位置。然后在这些节点的日志里搜上游请求的入参,找到本该传入但没传入的 traceId 信息。常见原因就那么几种:拦截器没配、MQ 消费端没取扩展属性、线程池没传递 MDC、或者 HTTP Header 被网关/nginx 吃了(比如默认的 underscores_in_headers on 没开,带下划线的自定义头会被丢弃)。
另外一个实用技巧:在 ELK 里建一个索引模板,把 traceId 字段设成 keyword 类型,并且配置一个 traceId_prefix 的 text 子字段做前缀索引。这样既支持精确匹配,又能走前缀查询的优化路径。
常见问题
traceId 用 UUID 行不行?为什么非要雪花算法?
能用,但不推荐。UUID 随机分布,在 Elasticsearch 里做前缀搜索时无法利用时间局部性,查询会扫描更多分片。雪花算法生成带时间前缀的 ID,查询时能利用索引的时间分片裁剪,在大日志量场景下性能差距明显。如果你的日志量每天在百万级以下,UUID 也可以接受,但养成好习惯没坏处。
网关层要不要生成 traceId?还是让第一个业务服务生成?
让网关生成是最干净的方案。网关是所有请求的入口,在这里生成 traceId 能覆盖整条链路,包括网关自身的逻辑。如果让第一个业务服务生成,网关的日志就缺失 traceId,排查时看不到入口信息。我们是在 Spring Cloud Gateway 的 GlobalFilter 里做的,优先级设成最高,确保第一个执行。
Dubbo 的隐式传参有没有大小限制?traceId 太长会不会有问题?
Dubbo 的 attachment 底层用 Map 承载,理论没有硬性长度限制,但过大会影响序列化性能。我们的 traceId 长度是 12 到 16 字节的十六进制字符串,完全在合理范围内。如果你额外传了 spanId、parentSpanId 等字段,总大小控制在 512 字节以内就没问题。
MQ 消息重试时 traceId 应该保持不变还是重新生成?
保持不变。重试是同一业务操作的延续,换了 traceId 会让重试日志与原请求脱钩,排查时对不上。RocketMQ 的重试机制下,消息的 Message ID 不变,用户属性也会原样保留,所以 traceId 自然延续。唯一要注意的是,消费端日志里建议加一个 retry_times 字段,标识这是第几次重试,方便判断是否有重复处理。