微服务上下文传递:TTL vs 请求头透传,究竟有啥不同?
背景故事引入
最近再复习的时候,有一个场景感觉很有意思:用户登录后,把用户信息存在了ThreadLocal里,结果一调用其他服务,对方死活收不到用户ID。当时就懵了——明明存进去了啊,怎么就丢了呢?
其实是搞混了两个东西:TTL(TransmittableThreadLocal)和请求头透传。这俩看起来都能“传数据”,但适用场景完全不同。今天就用大白话给你讲明白,保证看完不再踩坑。
一、TTL 是什么?先从 ThreadLocal 说起
1. ThreadLocal 是啥?
简单说,ThreadLocal就是每个线程独有的“小本本”。你往里面写东西,只有当前线程能看到,其他线程看不到。
// 就像每个线程都有自己的记事本ThreadLocal<String>local=newThreadLocal<>();local.set("我是主线程的数据");// 子线程去读?读不到!newThread(()->{System.out.println(local.get());// 输出 null}).start();2. TTL 解决了什么问题?
实际开发中,我们经常用线程池异步处理任务。但子线程拿不到主线程的上下文,这就很头疼。
TransmittableThreadLocal(简称 TTL)就是来解决这个问题的——它能自动把父线程的值传给子线程。
// 项目中的真实代码:SysContextHolder.javaprivatestaticfinalTransmittableThreadLocal<SystemInfo>APP_CONTEXT=newTransmittableThreadLocal<>();privatestaticfinalTransmittableThreadLocal<SysUserInfo>USER_CONTEXT=newTransmittableThreadLocal<>();3. TTL 的典型使用场景
在我们的项目中,TTL 主要用于同一服务内的上下文传递:
场景1:异步方法调用
// 用 @ABThreadPool 注解标记异步方法@ABThreadPoolpublicvoidasyncProcess(){// 这里能拿到主线程设置的用户信息LonguserId=SysContextHolder.getUserId();// ... 处理业务}场景2:事件总线
// EventProcessor.java 中使用 TtlCallable 包装任务ExecutorServicees=Executors.newFixedThreadPool(10);es=TtlExecutors.getTtlExecutorService(es);// 用 TTL 包装线程池// 提交任务时,上下文会自动传递es.submit(()->{// 这里能拿到主线程的上下文System.out.println(SysContextHolder.getUserId());});关键点:TTL 只能在同一个 JVM 进程内传递数据,出了这个进程就失效了。
二、请求头透传是什么?
1. 核心思想
微服务之间调用,本质上是HTTP 请求。HTTP 请求有请求头(Header),我们可以把需要传递的信息塞进请求头里,这样下游服务就能收到了。
2. 项目中的实现
我们项目里有两个关键的拦截器:
FeignRequestInterceptor:透传所有请求头
// FeignRequestInterceptor.javapublicvoidapply(RequestTemplaterequestTemplate){// 1. 从原始请求中获取所有请求头HttpServletRequestrequest=attributes.getRequest();Enumeration<String>headerNames=request.getHeaderNames();while(headerNames.hasMoreElements()){Stringname=headerNames.nextElement();Stringvalues=request.getHeader(name);requestTemplate.header(name,values);// 透传给下游服务}// 2. 把 TTL 里的信息也放进请求头if(null!=SysContextHolder.getUserId()){requestTemplate.header(Constants.REQUEST_HEADER_USER_ID,String.valueOf(SysContextHolder.getUserId()));}}GrayReleaseFeignRequestInterceptor:专门透传灰度信息
// GrayReleaseFeignRequestInterceptor.javapublicvoidapply(RequestTemplaterequestTemplate){Metadatametadata=GrayReleaseContextHolder.get();// 从 TTL 取if(metadata!=null){requestTemplate.header(GrayConstant.VERSION,metadata.getVersion());// 放入请求头}}3. 网关层怎么配合?
网关是请求的入口,它负责解析请求头,设置初始值:
// GrayscaleGlobalFilter.java@OverridepublicMono<Void>filter(ServerWebExchangeexchange,GatewayFilterChainchain){// 解析请求头中的灰度版本Stringversion=exchange.getRequest().getHeaders().getFirst(GrayConstant.VERSION);// 设置到新的请求头中,传给下游服务ServerHttpRequestmutatedRequest=exchange.getRequest().mutate().header(GrayConstant.VERSION,version).build();returnchain.filter(exchange.mutate().request(mutatedRequest).build());}三、两者对比:一张表说清楚
| 对比维度 | TTL(TransmittableThreadLocal) | 请求头透传 |
|---|---|---|
| 作用范围 | 同一个 JVM 进程内,跨线程 | 不同 JVM 进程之间,跨网络 ,跨服务 |
| 数据存在哪 | JVM 内存(线程的私有空间) | HTTP 请求头(网络协议) |
| 传递方式 | 自动(线程池包装) | 手动(拦截器设置) |
| 性能 | 高(内存操作) | 中(需要网络传输) |
| 代码复杂度 | 低(直接 get/set) | 中(需要写拦截器) |
| 跨服务能力 | ❌ 不能 | ✅ 能 |
| 典型场景 | 异步任务、事件总线、线程池 | Feign 调用、网关转发 |
四、为什么 TTL 跨服务就失效了?
1. 一张图解释
┌─────────────────────────────────────────────────────────┐ │ 服务A的JVM进程 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ 主线程 │ │ 线程池线程1│ │ 线程池线程2│ │ │ │TTL:userId=123│ │TTL:userId=123│ │TTL:userId=123│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────┘ ↓HTTP请求(网络传输) ↓ ┌─────────────────────────────────────────────────────────┐ │ 服务B的JVM进程 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ 主线程 │ │ 线程池线程1│ │ 线程池线程2│ │ │ │TTL:null│ │TTL:null│ │TTL:null│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────┘关键点:
- 服务 A 的 TTL 变量存在服务 A 的 JVM 内存里
- 服务 B 的 JVM 是另一个独立进程,内存完全隔离
- HTTP 请求只能传文本数据(请求头、请求体),不能传内存引用
2. 举个生活中的例子
想象你有两个房间(两个 JVM 进程),每个房间都有自己的白板(ThreadLocal)。
- TTL:你在房间 A 的白板上写了“用户ID=123”,然后房间 A 内的所有人都能看到。
- 跨服务调用:你想把信息传给房间 B,但两个房间之间只有一部电话(HTTP 请求)。你不能把白板搬过去,只能念给对方听(通过请求头传递)。
所以,正确的做法是:
- 在房间 A,把白板上的信息念出来(从 TTL 取出,放入请求头)
- 通过电话告诉房间 B(HTTP 请求)
- 房间 B 听到后,写在自己的白板上(解析请求头,存入自己的 TTL)
五、完整流程:两者如何配合?
在实际项目中,TTL 和请求头透传是配合使用的:收到请求 → 从请求头解析信息 → 存入自己的TTL → 使用TTL进行业务处理 → 调用下游服务时从自己的TTL取出放入新请求头。
用户登录请求 ↓ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 网关 │ → │ 服务A│ → │ 服务B│ → │ 服务C│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ ↓ ↓ ↓ ↓ 解析请求头 解析请求头 解析请求头 解析请求头 设置灰度版本 存入TTL存入TTL存入TTL业务处理 业务处理 业务处理 放入新请求头 放入新请求头 放入新请求头关键点:每个服务都是先从请求头解析,再存入自己的 TTL,然后才能在业务逻辑中使用。服务之间永远通过请求头传递,不能直接访问对方的 TTL。
具体步骤:
- 网关层:解析请求头,设置灰度版本等信息(网关是请求的入口)
- 服务 A:从请求头解析信息,存入自己的 TTL,业务逻辑直接用
SysContextHolder.getUserId() - 服务 A 调用服务 B:Feign 拦截器从自己的 TTL取出信息,放入新请求头(注意:这里是服务 A 的拦截器,不是服务 B 的)
- 服务 B:从请求头解析信息,存入自己的 TTL,业务逻辑直接用
SysContextHolder.getUserId() - 服务 B 调用服务 C:重复步骤 3-4,保证整个调用链信息一致
代码示例:
// 1. 网关设置请求头(GrayscaleGlobalFilter.java)mutate.header(GrayConstant.VERSION,version);// 2. 服务 A 从请求头解析,存入 TTL(GrayReleaseContextInterceptor.java)Stringversion=request.getHeader(GrayConstant.VERSION);Metadatametadata=newMetadata();metadata.setVersion(version);GrayReleaseContextHolder.set(metadata);// 3. 服务 A 调用服务 B 时,从 TTL 取出放入请求头(GrayReleaseFeignRequestInterceptor.java)Metadatametadata=GrayReleaseContextHolder.get();// 从服务 A 的 TTL 取if(null!=metadata.getVersion()){requestTemplate.header(GrayConstant.VERSION,metadata.getVersion());// 放入请求头}// 4. 服务 B 从请求头解析,存入自己的 TTL(GrayReleaseContextInterceptor.java)Stringversion=request.getHeader(GrayConstant.VERSION);// 从请求头取Metadatametadata=newMetadata();metadata.setVersion(version);GrayReleaseContextHolder.set(metadata);// 存入服务 B 的 TTL六、各自适用场景总结
什么时候用 TTL?
✅同一服务内,需要跨线程传递上下文:
- 异步方法调用(@ABThreadPool)
- 事件总线处理
- 线程池任务
- 需要保持用户登录状态、租户信息等
优点:性能高,代码简洁,自动传递
什么时候用请求头透传?
✅跨服务调用,需要传递上下文:
- Feign 调用其他微服务
- 网关转发请求
- 需要传递认证信息、灰度版本、链路追踪ID等
优点:标准 HTTP 协议,所有服务都能识别
最佳实践
- 进程内:优先使用 TTL,性能更好
- 跨服务:必须使用请求头透传,这是微服务标准做法
- 两者结合:TTL 管理服务内上下文,请求头透传负责服务间传递,形成完整的上下文传递链
总结
记住一句话:TTL 是"服务内的地铁系统",请求头是"服务间的高铁/飞机"。
- 你不能用地铁连接两个不同的城市(跨服务),必须通过高铁或飞机(网络通信)
- 但在同一个城市内(同一服务),地铁(TTL)是最快的选择
下次遇到上下文丢失的问题,先问自己:这是在同一个服务内,还是跨服务调用?答案就清楚了。
