Trace ID、MDC 与上下文传播:关联标识怎样跨越异步边界
同一条线程会先处理请求 A,再处理请求 B。日志中的关联标识必须随任务切换:A 执行期间读取 A 的标识,任务离开后恢复线程原来的状态。线程名只能识别执行资源,无法长期代表某个请求。
请求 A:request_id=req-a
└─ 提交任务 A → 工作线程 pool-1 → A 的日志
请求 B:request_id=req-b
└─ 提交任务 B → 工作线程 pool-1 → B 的日志异步调用把“谁提交任务”和“谁执行任务”分开。上下文传播要在这两个时点之间保留关联,并在正常结束、异常、取消或嵌套调用后正确退出。
关联标识分别描述什么
请求、业务操作与调用片段
| 标识 | 通常标识的对象 | 何时产生新的值 |
|---|---|---|
| requestId | 一次入口请求 | 新请求进入,或按入口策略重新生成 |
| operationId | 一次业务操作 | 新业务操作创建;重试可沿用 |
| traceId | 一组具有因果关系的 Span | 新追踪开始 |
| spanId | 追踪中的一个操作片段 | 创建一个新 Span |
| messageId | 一条消息 | 消息创建;重投是否沿用取决于协议 |
| tenantId | 已确认的租户 | 身份解析结果改变 |
一次下单重试可以产生多个 requestId,共用 operationId;同步调用与异步回调也可能属于不同 Trace,仍能用 operationId 找到同一个订单流程。业务标识的生命周期由业务契约决定,追踪标识由追踪系统管理。
traceId 在同一条 Trace 内保持一致,父子 Span 各有自己的 spanId。日志关联需要把 traceId 写进日志;追踪拓扑则需要保留父上下文并创建子 Span。应用可能只接上其中一项,例如日志能按 traceId 查到,平台里的 Span 却都是独立根节点。
MDC 存字符串,Context 关联当前执行
MDC(Mapped Diagnostic Context)提供按键存取字符串的 API。使用 Logback 时,它与当前线程关联,日志事件可以把当时的 Map 编码为 JSON 字段,或者用 %X{request_id} 输出指定字段。它不负责验证请求头,也不会通过复制一个 traceId 自动生成追踪拓扑。Logback MDC 说明介绍了这一线程模型。
OpenTelemetry Context 是不可变的上下文容器,可携带 Span、Baggage 等信息。context.with(span) 返回新 Context;makeCurrent() 让它在当前执行范围内生效,返回的 Scope 负责恢复此前的 Context。Java Context API中的作用域与包装器用于在调用栈和任务之间传递它。
当前执行点
├─ MDC Map
│ ├─ request_id
│ └─ trace_id(需要日志桥接或应用显式填入)
└─ OpenTelemetry Context
├─ 当前 Span / SpanContext
└─ Baggage两者之间需要桥接。仅安装 OpenTelemetry Scope,原始 SLF4J MDC 不会因此自动多出 trace_id。Spring Boot 配合支持日志关联的追踪桥接,或 Java Agent 的相应日志插桩,可以完成这件事;具体启用方式应与所用日志框架和插桩方案一致。手工填入 MDC 时则由应用负责同步更新和恢复。
在线程池中捕获、安装与恢复
先运行缺失和串线实验
下载日志与上下文实验包,解压后进入 observability-core-lab。Linux 宿主需有 Docker 权限,使用 Maven 3.9.12、Temurin 25,源代码目标为 Java 17;SLF4J 2.0.18 和 Logback 1.5.38 已在 POM 固定。Maven 镜像中的 JDK 补丁以该镜像的 java -version 为准,不与单独的应用运行镜像混用版本号。
以下命令以宿主 UID/GID 构建;.m2 与项目目录须对该用户可写。Docker daemon 的权限独立于容器用户。
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
--entrypoint mvn -e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 -B -ntp \
-Dmaven.repo.local=/m2 -Duser.home=/tmp \
-Dtest=MdcTests clean test正常结果为 4 个测试、零失败,并包含:
captured=task-a caller=task-b workerAfter=worker-original failureRestored=true测试先启动并预热单线程池,再向提交线程写入 MDC。未经包装的工作线程读到空值。随后故意让工作线程写入 leaked 而不清理,下一个任务就读到了这个遗留值。线程池始终只有一个线程,避免把“刚好换了一条线程”误当成清理成功。
正确路径在提交前捕获 task-a,随后把提交线程改成 task-b。任务执行时仍读取 task-a;即使任务抛出预期异常,工作线程也恢复 worker-original,提交线程继续保留 task-b。这三个值分别检查快照时机、线程隔离和异常清理。
依赖下载失败时先检查 Maven 仓库或企业代理配置;挂载不可写时修复宿主目录权限。测试断言失败应保留 target/surefire-reports 检查具体用例,不通过禁用测试绕过。换用 maven:3.9.12-eclipse-temurin-17 可在 Java 17 运行同一组测试。
包装器为什么需要两个快照
完整包装器如下,源码位于包内 MdcTasks.java:
import java.util.Map;
import org.slf4j.MDC;
public final class MdcTasks {
private MdcTasks() { }
public static Runnable capture(Runnable task) {
Map<String, String> captured = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
install(captured);
task.run();
} finally {
install(previous);
}
};
}
private static void install(Map<String, String> context) {
if (context == null) MDC.clear();
else MDC.setContextMap(context);
}
}captured 在提交线程调用 capture 时形成,代表任务应携带的值。previous 在任务真正执行时形成,代表执行线程需要恢复的值。若把第一个快照放进返回的 Runnable 内,读到的就是工作线程自己的 MDC,调用者信息已经丢失。
安装空上下文也需要执行。若 captured == null 时什么都不做,一个没有关联标识的新任务就可能继承工作线程遗留的标识。finally 恢复旧 Map,则同时兼容线程池、嵌套任务和在提交线程直接执行的情况;简单 clear() 会把外层调用仍需使用的上下文一起删掉。
业务提交点使用:
executor.execute(MdcTasks.capture(() -> service.process()));应用还使用 OpenTelemetry API 时,可以同时捕获当前 Context:
Runnable task = Context.current().wrap(
MdcTasks.capture(() -> service.process()));
executor.execute(task);这里的 Context 来自 io.opentelemetry.context.Context,两个包装器都在提交前创建。外层恢复 OTel 当前上下文,内层恢复 MDC;仅复制 MDC 的字符串并不会传递当前 Span。日志与上下文实验包只依赖 SLF4J/Logback,后一种组合用于已引入 OTel API 的应用。
service 是项目自己的服务对象,executor 由应用统一管理生命周期。下载包中的测试提供了真实单线程 Executor 的创建、异常断言和关闭。传入 Runnable 的包装器不会自动覆盖另一个 Executor、未包装的 CompletableFuture 阶段或第三方库内部线程。
清理、取消和执行策略
SLF4J 的 MDC.putCloseable(key, value) 在关闭时删除该键,适合这个键原先不存在的简单范围。它不会保存并还原同名旧值;嵌套覆盖同一键时,应像上面的包装器一样显式恢复。实验第四个测试直接验证这一行为,完整方法契约见 MDC API。
任务被拒绝或尚未运行就取消时,包装器还没有安装上下文,无需进入工作线程清理。运行中的任务收到取消信号后,仍要等执行控制流走到 finally 才能恢复;Future 显示已取消和任务已经停止执行是不同的时点。不要为了清理 MDC 使用强制终止线程。
CallerRunsPolicy 可能把工作放回提交线程,包装器因此必须恢复外层旧值,而不能假定执行线程原本为空。多个异步阶段要在各自提交时捕获,周期任务则应为每次运行建立新的关联范围,避免永久携带最初提交者的请求信息。
MDC 快照包含字符串引用,保持字段少而稳定。把整个请求体、Token 或大对象序列化到上下文,会使排队任务长期保留这些信息。线程池队列越长,快照的存活时间也越长。
跨进程传递的是协议字段
拆开 traceparent
W3C Trace Context 用 traceparent 携带公共追踪字段。一个版本 00 的值可以拆成:
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ └─ flags:最低位为 sampled
│ │ └─ parent-id:16 个十六进制字符
│ └─ trace-id:32 个十六进制字符
└─ version:2 个十六进制字符接收端创建新的 SERVER Span 时,保留 trace-id,以传入 parent-id 建立父子关系,同时为自己的 Span 生成新 spanId。发送端创建 CLIENT Span 后,应注入这个 CLIENT Span 的上下文。原样转发入站 parent-id 会跳过本服务已经创建的调用片段。
规范要求拒绝全零 trace-id、全零 parent-id 和其他无效格式,版本字段也有处理规则。交给标准 propagator 解析,比手写 split 后复制字符串可靠。tracestate 用于厂商相关状态,仍须遵守格式、长度和跨域处理要求;字段定义见 W3C Trace Context。
一次 HTTP 交接包含两个方向:
服务 A 当前 Context
→ 创建 CLIENT Span
→ inject:把当前调用上下文写入请求头
→ HTTP 请求
服务 B
→ extract:从请求头解析远程 Context
→ 创建 SERVER Span,父上下文为解析结果
→ makeCurrent:在 B 的处理范围内安装
→ finally:关闭 Scope,结束 SpanScope 的关闭和 Span 的结束分别完成“恢复当前上下文”和“结束计时”。仅关闭 Scope 会留下未结束 Span;仅调用 end 也不会恢复当前 Context。手工创建的 Span 通常在 finally 中 end,Scope 用 try-with-resources 关闭,并保持同线程、后进先出的退出顺序。
远程字段不承担身份认证
请求方能够构造 traceparent、requestId 和 baggage。应用可以接受它们用于诊断关联,但权限必须来自验证后的身份信息。对公网入口还要限制头长度和数量,避免把用户可控字段无限传播或直接当日志内容拼接。
sampled=1 表示上游采样建议。服务是否记录和导出仍受 SDK、采样器及入口信任策略影响;不能让任意外部用户用一个标志位突破遥测预算。重新开始 Trace 的入口可以保留原上下文为关联信息,但应与网关和平台约定,避免无意切断所有调用。
Baggage 适合跨调用传递少量、允许传播的业务维度,例如有限的客户等级。它可能经过多个服务甚至进入第三方请求,因而应采用字段白名单,在出站域名或服务切换时过滤。Token、密码、手机号和完整用户资料不要放入其中。Baggage 本身也不会自动成为所有 Span 属性或指标标签;应用或插桩需要显式选择使用方式。Baggage 规范给出了传播格式与安全考虑。
按执行模型选择传播位置
Spring、响应式链与虚拟线程
普通 Spring 线程池可以在 TaskExecutor 上配置 ContextPropagatingTaskDecorator。它使用 ContextSnapshot 捕获和恢复已经注册的上下文;哪些 ThreadLocal 被识别,取决于 ContextRegistry 中的 accessor。若项目只是放入一个未注册的自定义 ThreadLocal,增加 decorator 不会自动识别它。API 用法见 Spring TaskDecorator,注册与快照模型见 Micrometer Context Propagation。
接入时先列出应用实际使用的 Executor,把包装集中在公共提交点。@Async 指定的执行器、业务自行创建的线程池、CompletableFuture 的默认池可能是三条路径。用一次嵌套异步调用分别检查日志字段、Span 父子关系和任务结束后的旧值,才能知道哪条路径尚未接上。
Reactor 的数据会在不同线程上处理,Context 属于订阅链,不能把组装 Mono 时某条线程的 MDC 当作后续所有信号的上下文。链内读取使用 deferContextual,写入使用 contextWrite;需要和 ThreadLocal 库交互时再启用传播桥接。Reactor 的默认传播模式只在部分操作符恢复 ThreadLocal;自动模式需要提前启用 Hooks.enableAutomaticContextPropagation(),并配合已注册 accessor,对新的订阅生效。模式区别和 contextCapture 的订阅时机见 Reactor 上下文传播。
虚拟线程仍有各自的线程局部状态。每请求一条虚拟线程可以减少线程复用引发的串线机会,但调用另一个线程池、发送消息和跨进程调用仍然需要传播。不要依赖底层 carrier 线程名关联请求,也不要因为使用虚拟线程就省略作用域退出。
消息、批处理和延后任务
生产消息时,把当前追踪上下文注入消息属性;消费时提取,再按消息处理模型创建 Span。单条消息可以建立明确父子关系,批量消费的一个操作却可能关联多条不同 Trace 的消息,此时多个 Link 比任意选一条作为唯一父 Span 更贴近实际关系。
消息重投和业务重试要同时保留“同一操作”与“不同尝试”。operationId 用于业务关联,attempt 可记录尝试序号,每次真正的处理创建自己的 Span。对于延后数小时的定时任务,可以新建 Trace 并 Link 到原触发上下文;不宜让最初 HTTP Span 一直开着等待任务完成。Span 拓扑、Link 和生命周期的运行实例见 OpenTelemetry 追踪。
消费者 finally 退出自己的 Scope 和 MDC 后,再把线程交还池。ack、事务提交和 Span end 的先后取决于实际处理完成条件:记录到“消息已接收”无法说明处理事务已经提交。应在日志中分清 received、processed、acknowledged 等具体事件,使用消息 ID 和 operationId 交叉查询。
缺字段、断链和串线怎样区分
| 现象 | 优先检查的位置 | 修复后怎样判断 |
|---|---|---|
| 单线程日志有 requestId,异步日志没有 | 提交点是否捕获,实际 Executor 是否经过包装 | 预热池后执行任务,仍能读到提交时值 |
| 下一请求带着上一请求标识 | finally 是否恢复,空快照是否清除遗留值 | 让第一个任务异常,再运行无上下文任务 |
| 日志有 traceId,但平台只看到多个根 Span | HTTP 注入/提取、父 Context 的选择 | 比对请求头和导出数据的 parentSpanId |
| Trace 连续,但日志没有 traceId | 日志桥接、字段名称、Encoder 配置 | 在活跃 Scope 内检查 MDC 和实际 JSON |
| 仅响应式或定时任务丢字段 | 订阅时机、调度边界、accessor | 在切线程后的处理点读取,并检查退出恢复 |
| 外层方法结束后仍是内层 spanId | Scope 没关闭、退出顺序错误或跨线程关闭 | 嵌套范围退出后恢复外层 Span |
应用实际发出的 JSON 可以把排查分成两段:本地已经缺字段,检查日志桥接和执行点;本地存在而平台检索不到,检查采集解析、字段重命名或索引设置。Logback JSON 的 MDC 层级与查询命令见结构化日志。
线程池实验结束后容器自动退出,无常驻服务。target 与 .m2 是本实验的本地生成目录,可在确认路径后按需清理;生产排查期间保留导致异常的请求类型和脱敏输出,不记录请求凭据来“补足上下文”。
权威资料与规范地址
日志与执行范围
- Logback MDC:https://logback.qos.ch/manual/mdc.html
- SLF4J MDC API:https://www.slf4j.org/apidocs/org/slf4j/MDC.html
- OpenTelemetry Java API:https://opentelemetry.io/docs/languages/java/api/
- Spring ContextPropagatingTaskDecorator:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/support/ContextPropagatingTaskDecorator.html
- Micrometer Context Propagation:https://docs.micrometer.io/context-propagation/reference/
- Reactor 上下文传播:https://projectreactor.io/docs/core/release/reference/advanced-contextPropagation.html
网络传播格式
- W3C Trace Context:https://www.w3.org/TR/trace-context/
- W3C Baggage:https://www.w3.org/TR/baggage/
