ThreadLocal之微服务链路追踪

之前写的可能比较多、粒度比较粗,知识反复的过程,下面咱们结合真实场景再来一篇;写正文之前先来点基础知识:

复制代码
TraceContext.set("trace-123");
System.out.println(TraceContext.get()); // 为什么能拿到?

这两行代码在同一个线程里执行。要理解它,必须看清 ThreadLocal 的底层存储结构。

ThreadLocal 的底层结构

每个线程(Thread)内部都有一个 Map,叫做 ThreadLocalMap

复制代码
Thread(线程对象)
  └── threadLocals(ThreadLocalMap,本质是一个数组)
        ├── Entry[0]: key=TRACE_ID, value="trace-123"
        ├── Entry[1]: key=USER_ID, value="user-A"
        └── Entry[2]: null(空位)
  • 这个 Map 是线程私有的,每个线程都有自己独立的一份
  • Map 的 key 是 ThreadLocal 实例本身(比如 TRACE_ID 这个 static 变量)
  • Map 的 value 是你 set 进去的值

set() 到底做了什么?

复制代码
public class TraceContext {
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
    
    public static void set(String traceId) {
        TRACE_ID.set(traceId);
    }
}

执行 TraceContext.set("trace-123") 时,底层发生的事:

复制代码
// ThreadLocal.set() 的简化逻辑
public void set(T value) {
    // 1. 获取当前线程
    Thread currentThread = Thread.currentThread();
    
    // 2. 获取当前线程的 threadLocals Map
    ThreadLocalMap map = getMap(currentThread); // 就是 currentThread.threadLocals
    
    // 3. 如果 Map 已存在,直接 put;不存在就创建
    if (map != null) {
        map.set(this, value); // this 就是 TRACE_ID 这个实例
    } else {
        createMap(currentThread, value);
    }
}

当前线程(main线程)
  └── threadLocals(ThreadLocalMap)
        └── Entry: key=TRACE_ID, value="trace-123"

get() 到底做了什么?

紧接着执行 TraceContext.get()

复制代码
// ThreadLocal.get() 的简化逻辑
public T get() {
    // 1. 获取当前线程
    Thread currentThread = Thread.currentThread();
    
    // 2. 获取当前线程的 threadLocals Map
    ThreadLocalMap map = getMap(currentThread);
    
    // 3. 如果 Map 存在,用 TRACE_ID 作为 key 去查找
    if (map != null) {
        Entry e = map.getEntry(TRACE_ID); // 用 TRACE_ID 找
        if (e != null) {
            return (T)e.value; // 找到,返回 "trace-123"
        }
    }
    return null;
}
  1. 拿到当前线程的 Map
  2. TRACE_ID 这个 key 去 Map 里找
  3. 找到了,返回对应的 value "trace-123"

为什么同一个线程能拿到?

因为 set()get() 操作的是同一个线程的同一个 Map

复制代码
时间线:
T1: set("trace-123") → 往 main线程的 Map 里存 {TRACE_ID: "trace-123"}
T2: get()           → 从 main线程的 Map 里取 TRACE_ID 对应的值 → "trace-123"

就像你在自己的笔记本上写了一行字,然后自己翻回去看,当然能看到。

为什么异步线程拿不到?

复制代码
TraceContext.set("trace-123");

executorService.submit(() -> {
    System.out.println(TraceContext.get()); // null ❌
});

因为异步线程是另一个线程,它有自己独立的 Map:

复制代码
main线程的 Map: {TRACE_ID: "trace-123"}
异步线程的 Map: {}(空的,从来没有 set 过)

get() 去异步线程自己的 Map 里找 TRACE_ID,找不到,返回 null

就像你在自己的笔记本上写了字,让你同事去他自己的笔记本上找,当然找不到。

复制代码
┌─────────────────────────────────────────────────────────────┐
│  ThreadLocal 本质:每个线程有一个独立的 Map                      │
│                                                              │
│  main线程                    异步线程(线程池里的 worker-1)     │
│  ┌─────────────────┐        ┌─────────────────┐              │
│  │ threadLocals    │        │ threadLocals    │              │
│  │ ┌─────────────┐ │        │ ┌─────────────┐ │              │
│  │ │TRACE_ID:"123"│ │        │ │             │ │             │
│  │ └─────────────┘ │        │ └─────────────┘ │              │
│  └─────────────────┘        └─────────────────┘              │
│       ↑  set/get 操作这里           ↑ 空的,所以 get 返回 null   │
└─────────────────────────────────────────────────────────────------┘

场景:微服务链路追踪(TraceID 传递)

说一下咱们项目中最经典的生产场景。用户请求进来,生成一个 traceId,后续所有日志、数据库操作、远程调用都要带上这个 traceId,方便排查问题。

出问题的代码(用 InheritableThreadLocal)
复制代码
// 上下文持有类
public class TraceContext {
    private static final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>();
    
    public static void set(String traceId) { TRACE_ID.set(traceId); }
    public static String get() { return TRACE_ID.get(); }
    public static void clear() { TRACE_ID.remove(); }
}

// 拦截器:请求进来时生成 traceId
@Component
public class TraceInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String traceId = UUID.randomUUID().toString();
        TraceContext.set(traceId);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        TraceContext.clear(); // 请求结束时清理
    }
}

// 业务代码:异步处理订单
@Service
public class OrderService {
    @Autowired
    private ExecutorService executorService; // 线程池
    
    public void createOrder(Order order) {
        // 主线程能拿到 traceId 
        System.out.println("主线程 traceId: " + TraceContext.get());
        
        // 异步保存订单 拿不到 traceId!
        executorService.submit(() -> {
            System.out.println("异步线程 traceId: " + TraceContext.get()); // 输出 null
            saveToDatabase(order);
        });
    }
}

问题现象 :异步线程打印的 traceId 是 null,日志链路断了,线上排查问题根本对不上。

原因:线程池的线程是提前创建好的,ITL 的"创建时拷贝"机制根本没触发。

更隐蔽的坑:脏数据污染

即使你不用异步,线程池复用也会导致上一个请求的数据被下一个请求读到

复制代码
// 模拟线程池复用导致的数据污染
public class DirtyDataDemo {
    private static final InheritableThreadLocal<String> USER_ID = new InheritableThreadLocal<>();
    private static final ExecutorService pool = Executors.newFixedThreadPool(1); // 只有1个线程
    
    public static void main(String[] args) throws Exception {
        // 请求1:用户A
        USER_ID.set("user-A");
        pool.submit(() -> {
            System.out.println("任务1读到用户: " + USER_ID.get()); // user-A ✅
            // 忘记调用 USER_ID.remove()
        }).get();
        
        // 请求2:用户B(复用了同一个线程)
        USER_ID.set("user-B");
        pool.submit(() -> {
            System.out.println("任务2读到用户: " + USER_ID.get()); // 可能还是 user-A ❌
        }).get();
    }
}

线上真实后果 :用户A登录了,用户B用同一个线程处理请求,结果读到了用户A的身份信息------越权访问

修复方案:TransmittableThreadLocal 实战

第一步:替换为 TTL
复制代码
// 只需要改这一行:InheritableThreadLocal → TransmittableThreadLocal
public class TraceContext {
    private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();
    
    public static void set(String traceId) { TRACE_ID.set(traceId); }
    public static String get() { return TRACE_ID.get(); }
    public static void clear() { TRACE_ID.remove(); }
}
第二步:包装线程池(关键!)
复制代码
@Configuration
public class ThreadPoolConfig {
    
    @Bean
    public ExecutorService ttlExecutorService() {
        // 用 TTL 包装原生线程池
        return TtlExecutors.getTtlExecutorService(
            Executors.newFixedThreadPool(10)
        );
    }
}
第三步:业务代码(无需任何改动)
复制代码
@Service
public class OrderService {
    @Autowired
    private ExecutorService ttlExecutorService; // 注入包装后的线程池
    
    public void createOrder(Order order) {
        System.out.println("主线程 traceId: " + TraceContext.get()); // trace-123
        
        ttlExecutorService.submit(() -> {
            // 现在能正确拿到 traceId 了!✅
            System.out.println("异步线程 traceId: " + TraceContext.get()); // trace-123
            saveToDatabase(order);
        });
    }
}

更复杂的场景:CompletableFuture 链式调用

实际生产中经常用 CompletableFuture 做异步编排,TTL 同样支持:

复制代码
@Service
public class OrderService {
    
    public void createOrder(Order order) {
        String traceId = TraceContext.get(); // trace-123
        
        CompletableFuture
            .supplyAsync(TtlSupplier.get(() -> {
                // 第一步:校验订单
                System.out.println("校验线程 traceId: " + TraceContext.get()); // trace-123 ✅
                return validate(order);
            }), ttlExecutorService)
            .thenApplyAsync(TtlFunction.get(result -> {
                // 第二步:扣减库存
                System.out.println("库存线程 traceId: " + TraceContext.get()); // trace-123 ✅
                return deductStock(result);
            }), ttlExecutorService)
            .thenAcceptAsync(TtlConsumer.get(result -> {
                // 第三步:发送通知
                System.out.println("通知线程 traceId: " + TraceContext.get()); // trace-123 ✅
                sendNotification(result);
            }), ttlExecutorService);
    }
}

注意:CompletableFuture 的每个异步阶段都要用 TtlSupplier.get()TtlFunction.get()TtlConsumer.get() 包装,否则链路会断。

生产环境必须遵守的 5 条铁律

铁律一:finally 中必须 remove()
复制代码
public void handleRequest() {
    try {
        TraceContext.set(UUID.randomUUID().toString());
        // 业务逻辑...
    } finally {
        TraceContext.clear(); // 必须清理,否则线程复用时数据残留
    }
}
铁律二:ThreadLocal 必须声明为 static final
复制代码
// ✅ 正确
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();

// ❌ 错误:局部变量,方法结束就没了,但值还残留在 ThreadLocalMap 里
public void method() {
    TransmittableThreadLocal<String> local = new TransmittableThreadLocal<>();
}
铁律三:不要存大对象
复制代码
// ❌ 错误:存了一个 10MB 的字节数组
private static final TransmittableThreadLocal<byte[]> LARGE_DATA = new TransmittableThreadLocal<>();
LARGE_DATA.set(new byte[10 * 1024 * 1024]);

// ✅ 正确:只存必要的标识符
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();
铁律四:嵌套线程池也要包装
复制代码
ExecutorService outer = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(5));
outer.submit(() -> {
    // 内层线程池也必须包装,否则内层拿不到上下文
    ExecutorService inner = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(5));
    inner.submit(() -> {
        System.out.println(TraceContext.get()); // 能拿到 ✅
    });
});
铁律五:Agent 方式实现零侵入(推荐大型项目)

如果项目已经上线,不想改每一处线程池代码,可以用 Java Agent 方式,启动时加一个 JVM 参数就搞定:

java -javaagent:/path/to/transmittable-thread-local-2.14.2.jar -jar your-app.jar

加了 Agent 之后,所有线程池自动被包装,业务代码完全不用改。

一句话总结

问题 表现 解决方案
异步线程拿不到 traceId 日志链路断裂,线上无法排查 ITL → TTL + 包装线程池
线程复用读到上一个请求的数据 用户A看到用户B的数据(越权) finally 中必须 remove()
CompletableFuture 链路中断 中间某个阶段拿不到上下文 每个阶段用 TtlXXX.get() 包装
老项目改造成本高 到处都要改线程池代码 -javaagent 参数,零侵入
相关推荐
方也_arkling1 小时前
【Java-Day17】API篇-BigInteger和BigDecimal
java·开发语言
程序员三明治1 小时前
【AI】RAG 数据分块(Chunk)策略与实践
java·人工智能·后端·ai·大模型·llm·rag
m0_617493941 小时前
PySide6/PyQt6实现中英文切换完整教程(Qt Designer + Qt Linguist + 动态切换)
开发语言·qt
松仔log1 小时前
Jetpack——DataStore
java·kotlin
咸鱼翻身小阿橙1 小时前
文件读写 + Qt Model/View + 自定义分页+搜索过滤
java·数据库·qt
会编程的土豆1 小时前
前端和后端是怎么配合工作的(Go后端视角)
前端·golang·状态模式
w_t_y_y1 小时前
vue父子组件通信(一)父子调用和通信(2)VUE3
前端·javascript·vue.js
在繁华处1 小时前
Java从零到熟练(十):JVM基础与性能优化
java·jvm·性能优化
眠りたいです1 小时前
现代C++:C++17中的新语言特性
开发语言·c++·c++17