一个用户反馈操作报错,我在日志系统里搜他的关键词,跳出来七八条日志,分属四个服务、时间线还对不齐,光靠肉眼根本拼不出请求经过了哪条路径。更麻烦的是中间还过了一次消息队列异步处理,链路到这里就断了。那次排查花了快两个小时,事后我们下决心把分布式链路追踪补齐。这篇记录 traceId 在哪生成、怎么在 HTTP、线程池和消息队列之间透传,以及采样策略怎么配。
一、先统一概念:traceId、spanId 和 traceparent
一次完整调用链用一个 traceId 贯穿,链路上每经过一个服务/一次方法调用产生一个 span,每个 span 有自己的 spanId 并记录父 span。现在通用的是 W3C Trace Context 规范,上下文通过 traceparent 请求头传递:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ └────────── trace-id(32位十六进制) ──────────┘ └── span-id ──┘ flags
version
- trace-id 是 32 个十六进制字符(16 字节),整条链路唯一;
- span-id 是 16 个十六进制字符(8 字节),标识当前这一跳;
- flags 末位为 1 表示该请求被采样。
入口服务(网关或最外层接口)负责生成 traceId,下游服务收到 traceparent 就接着用,没有才自己生成,保证同一条请求在所有服务里是同一个 traceId。
二、日志里带上 traceId:MDC 是关键
光在请求头里传 traceId 还不够,得让每条日志都自动带上它,否则照样搜不全。Java 侧常用 SLF4J 的 MDC(日志上下文映射),在日志格式里加一个变量:
// logback pattern 中加入 %X{traceId}
%d{HH:mm:ss.SSS} %-5level [%X{traceId}] %logger - %msg%n
在入口过滤器里生成/解析 traceId 放进 MDC,请求结束清理:
// 入口过滤器(伪代码)
void doFilter(req, resp, chain) {
String traceId = parseTraceparent(req.header("traceparent"));
if (traceId == null) traceId = generateTraceId(); // 入口生成
MDC.put("traceId", traceId);
try {
chain.doFilter(req, resp);
} finally {
MDC.clear(); // 必须清理,线程被复用会串号
}
}
下游服务发起 HTTP 调用时,从 MDC 取出 traceId 写进 traceparent 头;接收侧再解析放回自己的 MDC。这样在日志系统里按 traceId 一搜,四个服务的日志按时间排列就是一条完整链路。这套链路追踪是给乔拓云轻应用环境里的一组后端服务接的,我主要负责入口生成、跨线程透传和 MQ 字段传递这几块,接完之后同类问题的排查时间从小时级降到几分钟。
三、两个最容易断链的地方:线程池和消息队列
同步 HTTP 调用透传比较直观,真正容易丢 traceId 的是异步场景。
线程池透传:MDC 基于 ThreadLocal,子线程默认拿不到主线程的上下文。线程池复用线程时,要么提交任务时手动把 traceId 传过去,要么用任务装饰器统一处理:
// 线程池任务装饰:提交时捕获,执行时放入,结束清理
class TraceDecorator implements TaskDecorator {
public Runnable decorate(Runnable runnable) {
String traceId = MDC.get("traceId"); // 提交任务的主线程
return () -> {
MDC.put("traceId", traceId); // 工作线程执行前放入
try { runnable(); } finally { MDC.clear(); }
};
}
}
直接用 InheritableThreadLocal 在线程池场景并不可靠,因为线程是复用的,拿到的可能是上一个任务残留的值,显式装饰更稳。
消息队列透传:生产者把 traceparent 放进消息属性(而不是消息体),消费者取出后建立一个"生产-消费"的 span 关联:
// 生产端:写消息属性
message.setProperty("traceparent", currentTraceparent());
// 消费端:从属性还原上下文
String tp = message.getProperty("traceparent");
Context ctx = extractor.extract(tp);
try (Scope s = tracer.withScope(ctx)) {
MDC.put("traceId", ctx.traceId());
handle(message);
} finally {
MDC.clear();
}
这样异步消费的日志也能挂回原 traceId,链路不会在 MQ 这一跳断掉。
四、采样:全采太贵,错误请求要全留
高流量下把所有请求都上报到链路系统,存储和网络成本扛不住,需要采样。但均匀采样会把偶发错误也漏掉,所以常见做法是头部采样加尾部采样结合:
| 策略 | 做法 | 适用 |
|---|---|---|
| 头部采样 | 入口按固定比例(如 10%)决定是否采样,整条链一致 | 常态流量,成本可控 |
| 尾部采样 | 先缓存完整链路,根据结果决定留不留 | 想保留所有错误、慢请求 |
| 规则补采 | 错误、超时、特定接口强制采样 | 排查问题的关键请求 |
实践中我让入口默认按 10% 头部采样保持链路一致,同时在网关层加规则:一旦响应是 5XX、或耗时超过阈值,就把这条 trace 标记为必采。这样既控制了常态成本,又不会漏掉真正要排查的异常链路。采样决策必须在入口统一确定并通过 flags 向下传,不能每个服务各采各的,否则会出现链路片段残缺。
五、我踩过的五个坑
- MDC 用完不 clear:线程池复用导致下个请求日志带上上个 traceId,排查直接被带偏,finally 里清理是硬性要求;
- 只在 HTTP 透传,漏了 MQ:异步消费的日志全部断链,改成消息属性携带 traceparent 后才接上;
- 子线程拿不到 traceId:ThreadLocal 不跨线程,用任务装饰器显式传递后解决;
- 每个服务各自采样:同一条链有的服务采了有的没采,片段拼不全,采样决策要在入口统一;
- traceId 用自增数字:多入口冲突且无法去重,按 W3C 规范用 16 字节随机十六进制才全局唯一。
复盘要点
- traceId 在入口生成、全链路复用,日志通过 MDC 自动携带,按 traceId 一搜即可还原跨服务时间线;
- 线程池用任务装饰器、消息队列用消息属性透传,是异步场景不断链的两个关键;
- 采样在入口统一决策,常态低比例头部采样、错误和慢请求强制保留,兼顾成本与可排查性。
以上是个人实践记录,各平台具体功能以官方实时信息为准。
你们的链路追踪是用 OpenTelemetry 还是各云厂商的方案?跨消息队列的链路关联在实际落地时有没有遇到过 traceId 对不上的情况?