火柴人武林大会
156.74M · 2026-02-04
在开发分布式追踪系统(比如 Zipkin、Jaeger,或者我正在做的 Spring Insight)时,有一个核心问题必须解决:
而要回答这个问题,关键在于——追踪上下文(Trace Context) 的管理。
今天,我们就来聊聊 Spring Insight 中一段看似简单、实则精巧的代码(王婆卖瓜了属于是,哈哈):
private static final ThreadLocal<Deque<TraceSpan>> SPAN_STACK =
new NamedThreadLocal<>("Spring Insight Trace Context") {
@Override
protected Deque<TraceSpan> initialValue() {
return new ArrayDeque<>();
}
};
别小看这短短几行,它背后藏着 Java 并发编程和链路追踪设计的双重智慧。
为了说明我为什么这么写,我们先深入认识一下它的两个“主角”:ThreadLocal 和 Deque。
ThreadLocal 是 Java 提供的一个类,用于为每个线程维护一份独立的变量副本。
你可以把它想象成每个线程都有一个“专属抽屉”,彼此互不干扰。
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("Hello from Thread-" + Thread.currentThread().getName());
System.out.println(threadLocal.get()); // 每个线程看到的值都不同
在 Spring Security 中,SecurityContextHolder 默认就使用 ThreadLocal 存储当前用户的认证信息:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 这个 auth 对象是当前线程独有的,不会被其他请求污染
这样,在 Controller、Service、DAO 层都能随时获取当前用户,而无需层层传递参数。
Spring 的声明式事务(@Transactional)依赖 ThreadLocal 来绑定当前线程的数据库连接和事务状态。
同一个线程内多次调用 DAO 方法,能复用同一个 Connection,保证事务一致性。
SLF4J + Logback 中的 MDC 就是基于 ThreadLocal 实现的。
你可以在请求入口处设置 traceId:
MDC.put("traceId", UUID.randomUUID().toString());
logger.info("Processing request..."); // 日志自动带上 traceId
这样,即使高并发下多个请求混在一起打日志,也能通过 traceId 追踪完整链路。
SaaS 系统中,常通过 ThreadLocal 在请求开始时解析租户 ID,并在线程内全局可用:
TenantContext.setTenantId(tenantId);
// 后续所有 DB 查询自动加上 tenant_id = ? 条件
ThreadLocal 不手动 remove(),其引用的对象无法被 GC。CompletableFuture、@Async 等会切换线程,导致 ThreadLocal 上下文丢失(需用 TransmittableThreadLocal 解决)。Deque(Double-ended Queue)是 Java 集合框架中的双端队列接口,支持从头部和尾部高效插入/删除元素。
虽然名字叫“队列”,但它完全可以当作栈(Stack) 或 普通队列(Queue) 使用。
Deque<String> deque = new ArrayDeque<>();
deque.push("A"); // 栈操作:压入
deque.push("B");
System.out.println(deque.pop()); // 输出 "B"(LIFO)
Java 自带的 Stack 类:
Vector,所有方法都是 synchronized,性能差;而 ArrayDeque:
编译器、解释器常用栈来管理函数调用。每进入一个函数,压入栈帧;返回时弹出。
算法题经典场景:用栈判断 ({[]}) 是否合法,或计算 3 + (2 * 5)。
图形编辑器、文本编辑器的“撤销”功能,本质就是把操作记录压入栈,撤销时弹出并反向执行。
递归容易栈溢出?可以用显式栈(Deque)实现非递归 DFS。
这正是 Spring Insight 的用法!每个方法调用对应一个 TraceSpan,用栈来维护父子关系:
push(rootSpan)
→ push(serviceSpan)
→ push(repoSpan)
← pop(repoSpan)
← pop(serviceSpan)
← pop(rootSpan)
现在,把两者结合起来:
ThreadLocal<Deque<TraceSpan>> SPAN_STACK
这句话的意思是:
假设一个 HTTP 请求进来,执行流程如下:
Controller → Service → Repository → DB
我们希望为每一步生成一个 TraceSpan,并形成父子关系:
而 SPAN_STACK 正是实现这种嵌套结构的关键:
push 到栈;push;pop() 出当前 Span,结束计时,上报;pop() 根 Span,完成整条链路。虽然 Spring Insight 项目还在开发中,但这段代码已经奠定了上下文管理的核心骨架:
TraceContext.startSpan("operation"):内部调用 SPAN_STACK.get().push(newSpan)TraceContext.endSpan():pop() 并结束计时TraceContext.currentSpan():peek() 获取当前 Span,用于传递 traceId/spanIdTraceContext.clear():防止线程池复用导致上下文污染| 组件 | 作用 | 真实世界类比 |
|---|---|---|
ThreadLocal | 线程隔离的上下文存储 | 每个服务员有自己的点菜单 |
Deque(作栈用) | 管理嵌套调用的生命周期 | 函数调用的“回溯路径” |
SPAN_STACK | 二者结合,构建线程安全的追踪栈 | 每个请求的“行车记录仪” |
这就像给每个线程配了一个“行车记录仪”,全程记录它干了啥、花了多久、有没有出错。
而这一切,都始于那几行看似平淡的初始化代码。
如果你对链路追踪、APM、Java Agent 感兴趣,欢迎关注我的开源项目:
目前项目包含:
你的 Star 是对我最大的鼓励!