1. 微服务用户信息传递的“拦路虎”与“金钥匙”
大家好,我是老张,在微服务架构里摸爬滚打了十来年,从早期的Dubbo到现在的Spring Cloud全家桶,踩过的坑比走过的路还多。今天咱们不聊那些虚头巴脑的理论,就聚焦一个几乎所有微服务项目都会遇到的、让人头疼又必须解决的问题:用户信息怎么在服务之间安全、高效地“跑来跑去”?
想象一下这个场景:你正在开发一个电商系统,用户下单买了个手机。这个“下单”动作,看起来简单,背后却涉及好几个微服务:订单服务负责创建订单,商品服务要去扣减库存,购物车服务得把用户刚买的手机从购物车里清掉。问题来了,当订单服务去调用购物车服务说“嘿,把用户A的购物车清一下”时,购物车服务怎么知道“用户A”是谁?订单服务可没把用户ID直接告诉它。
这就是微服务拆分后带来的典型挑战。在单体应用时代,用户登录一次,整个应用共享一个Session,用户信息随手可得。但拆成微服务后,每个服务都是独立的进程,甚至部署在不同的机器上,内存都不共享了。你不可能让用户在每个微服务里都登录一遍,那体验太糟糕了;你也不能把包含敏感信息的用户对象在服务间传来传去,既不安全,网络开销也大。
所以,我们需要一套机制,能像“接力棒”一样,把用户的身份标识(比如用户ID)安全、自动地在服务调用链中传递下去。这听起来复杂,但别怕,Spring Cloud生态里早就为我们准备好了两把“金钥匙”:Spring MVC的拦截器和OpenFeign的拦截器。前者负责在服务“内部”接住从网关传来的用户信息,后者负责在服务“之间”把用户信息传递出去。今天,我就手把手带你,用这两把钥匙,彻底解决这个“拦路虎”,实现用户信息的无缝、高效传递。整个过程,我会用最直白的语言和实际的代码示例,保证你跟着做一遍就能完全掌握。
2. 基石:网关统一认证与信息“播种”
在开始动手写代码之前,我们必须先理清整个信息流的起点。用户的所有请求,无论是从手机App还是网页发过来的,都不会直接打到我们的商品、订单这些业务微服务上。它们会先经过一个“总闸口”——API网关(比如Spring Cloud Gateway)。网关在这里承担了两个核心重任:第一,做统一的登录校验(鉴权),确保来的都是合法用户;第二,在校验通过后,把用户的身份信息“播种”到请求里,再转发给后面的业务服务。
注意:网关的具体实现和鉴权逻辑(如JWT解析)不是本文重点,市面上教程很多。我们聚焦在信息传递这个环节,假设你的网关已经能成功从Token里解析出用户ID(比如
userId: 123)。
那么,网关校验完用户身份后,怎么把userId=123这个信息告诉后面的订单服务呢?最直接、最通用的方式,就是利用HTTP协议的特性——请求头(Header)。网关可以在转发请求前,往请求头里塞一个自定义的字段,比如user-info: 123或者更规范一点的X-User-Id: 123。
这里有个关键点:网关在添加这个头信息时,必须确保其安全性和不可篡改性。你不能让前端随便伪造一个X-User-Id: 999就变成管理员了。因此,通常网关放入头信息的,是一个经过签名或加密的令牌,或者至少是一个后端微服务体系内约定好的、不含敏感信息的标识符。在我们的实践里,为了简化示例,我们放入明文用户ID,但在生产环境,你需要结合具体的加密和签名机制来设计这个头的值。
假设我们使用Spring Cloud Gateway,核心代码片段会放在一个自定义的GlobalFilter里。这个过滤器会在网关路由转发(NettyRoutingFilter)之前执行。它的逻辑很简单:从登录上下文中获取用户ID,然后把它放到下游请求的Header中。
@Component
@Order(0) // 设置一个较高的优先级,确保在路由转发前执行
public class UserInfoRelayFilter implements GlobalFilter {
private static final String USER_ID_HEADER = "X-User-Id";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 假设你的鉴权逻辑已经将用户ID放入了exchange的属性中
// 例如:exchange.getAttributes().put("userId", userId);
String userId = (String) exchange.getAttribute("userId");
if (StringUtils.isNotBlank(userId)) {
// 2. 将用户ID添加到下游请求的Header中
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header(USER_ID_HEADER, userId)
.build();
ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
return chain.filter(mutatedExchange);
}
// 3. 如果没有用户信息,直接放行(或者根据业务返回错误)
return chain.filter(exchange);
}
}
就这样,经过网关的请求,就像被盖了一个“已验明正身”的章,并且章上写着用户ID,一起流向了后端的业务微服务。接下来,就看业务服务如何接收并利用这个信息了。
3. 接收:Spring MVC拦截器与ThreadLocal的“内存保险箱”
现在,请求带着X-User-Id: 123这个Header,到达了我们的订单服务。订单服务里可能有几十个Controller方法都需要知道当前是谁在操作。我们当然可以在每个方法的参数里都加一个@RequestHeader(“X-User-Id”) String userId,但这太蠢了,重复代码一大堆,而且容易出错。
这时候,就该Spring MVC拦截器(Interceptor) 登场了。拦截器就像服务门口的“接待员”,每个HTTP请求进来,都会先经过它。我们可以在这里统一把Header里的用户信息取出来,然后存到一个所有业务代码都能方便访问的地方。这个地方,就是ThreadLocal。
你可以把ThreadLocal理解为一个线程级别的“内存保险箱”。每个处理请求的线程都有自己独立的一个保险箱。在拦截器里,我们把用户ID放进当前线程的保险箱;在后续的Service层、甚至更深的业务逻辑里,我们都可以随时从这个保险箱里把用户ID取出来用。线程处理完请求后,这个保险箱会被清空,不会影响到其他线程,完美避免了并发安全问题。
下面我们来看看具体怎么做。首先,我们创建一个工具类UserContext,用来操作这个“保险箱”。
public class UserContext {
private static final ThreadLocal<String> USER_HOLDER = new ThreadLocal<>();
// 存入用户ID
public static void setUserId(String userId) {
USER_HOLDER.set(userId);
}
// 获取用户ID
public static String getUserId() {
return USER_HOLDER.get();
}
// 清除用户ID(非常重要,防止内存泄漏)
public static void clear() {
USER_HOLDER.remove();
}
}
接着,我们实现一个Spring MVC拦截器UserInfoInterceptor,在请求进入Controller之前,把Header里的值取出来,塞进UserContext。
@Component
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从请求头中获取网关传递过来的用户ID
String userId = request.getHeader("X-User-Id");
// 2. 如果获取到了,就存入UserContext(ThreadLocal)
if (StringUtils.isNotBlank(userId)) {
UserContext.setUserId(userId);
} else {
// 3. 这里可以根据业务决定:如果没有用户信息,是直接放行还是拒绝请求
// 例如,对于内部服务间调用,可能允许;对于外部请求,可能返回401。
// log.warn("请求头中未找到用户信息");
}
return true; // 继续执行后续拦截器和Controller
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 请求处理完毕后,务必清除ThreadLocal中的数据!
// 这是关键,否则随着线程被复用到线程池,旧数据会泄露给下一个请求。
UserContext.clear();
}
}
最后,我们需要把这个拦截器注册到Spring MVC中。创建一个配置类WebMvcConfig。
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private UserInfoInterceptor userInfoInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器,并配置拦截路径(通常拦截所有API路径)
registry.addInterceptor(userInfoInterceptor)
.addPathPatterns("/api/**") // 根据你的API路径调整
.excludePathPatterns("/health", "/actuator/**"); // 排除健康检查等端点
}
}
好了,现在你的订单服务就具备了自动接收并存储用户信息的能力。在任何Service方法里,你只需要调用String currentUserId = UserContext.getUserId();就能拿到当前操作的用户ID,就像在单体应用里一样方便。但是,故事到这里只完成了一半。如果订单服务内部需要调用购物车服务呢?这个UserContext里的信息,怎么传给下一个服务?
4. 传递:OpenFeign拦截器实现跨服务“信息接力”
这是整个流程中最精妙的一环。订单服务处理下单逻辑时,需要调用购物车服务的“清空购物车”接口。这个调用是通过OpenFeign这个声明式的HTTP客户端发起的。Feign会帮我们构造HTTP请求、发送、接收响应。但默认情况下,它可不会“多事”地把我们当前线程UserContext里的用户ID自动放到请求头里带过去。
所以,我们需要“教会”Feign做这件事。幸运的是,OpenFeign提供了一个非常棒的扩展点:feign.RequestInterceptor。这是一个请求拦截器接口,Feign在每次发送请求前,都会调用所有实现了该接口的Bean的apply方法。我们正好可以在这里,把UserContext里的用户ID,塞到Feign即将发出的请求头里。
这就完成了一次完美的“信息接力”:网关把用户ID塞给订单服务 -> 订单服务用拦截器+ThreadLocal接住并存起来 -> 订单服务内部用Feign调用其他服务时,再用Feign拦截器把用户ID从ThreadLocal里取出来,塞到新的请求头里 -> 购物车服务用自己的Spring MVC拦截器接住这个ID,存到自己的ThreadLocal中。信息就这样无损地传递了下去。
我们来写这个关键的Feign拦截器:
@Component // 确保被Spring容器管理
public class FeignUserInfoInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 1. 从当前线程的UserContext中获取用户ID
String userId = UserContext.getUserId();
// 2. 如果存在,就将其添加到Feign请求的Header中
if (StringUtils.isNotBlank(userId)) {
template.header("X-User-Id", userId);
}
// 3. 如果不存在,可以选择记录日志,或者不做处理(取决于业务,内部调用可能允许)
}
}
就这么简单!这个类一旦被Spring管理,所有通过@FeignClient注解定义的接口发起的请求,都会自动执行这段逻辑。你不需要在任何Feign客户端接口上做任何修改。
但是,这里有几个实战中极易踩坑的细节,我必须重点提醒你:
- 线程池隔离与上下文丢失:如果你的服务使用了Hystrix、Sentinel等熔断组件,并且配置了线程池隔离模式,Feign的请求会在一个独立的Hystrix线程池中执行。这时,
UserContext.getUserId()会返回null,因为用户信息是存在调用FeignClient方法的那个Web线程的ThreadLocal里,而执行Feign请求的是另一个Hystrix线程。解决这个问题,你需要使用Hystrix的并发策略(HystrixConcurrencyStrategy)或Sentinel的上下文传递功能,手动将ThreadLocal值传递过去。在Spring Cloud Greenwich版本后,如果使用feign.hystrix.enabled=false禁用Hystrix,或者使用Resilience4j,通常可以避免此问题。 - 异步调用:如果你在业务中使用了
@Async或CompletableFuture等进行异步编程,新开启的线程同样无法获取到父线程的ThreadLocal值。你需要使用TaskDecorator或手动传递上下文。 - Feign拦截器的顺序:如果你的系统中有多个
RequestInterceptor(比如还有负责添加认证Token、链路追踪ID的),需要注意它们的执行顺序。Spring会注入所有实现该接口的Bean,但默认顺序不确定。你可以通过@Order注解来指定顺序,确保用户信息在合适的时机被添加。
把这一套组合拳打下来,你的微服务体系内,用户信息的传递就完全自动化、透明化了。开发业务代码的同事,在99%的情况下,根本不需要关心用户ID是从哪来的,他们只需要在需要的时候从UserContext里取就行,无论是在Controller、Service,还是在被Feign调用的下游服务里。
5. 深入:原理剖析与高阶优化方案
理解了怎么用,我们再来稍微深入一点,看看这套机制背后的原理,并探讨一些更优的实践方案,让你的系统更健壮。
为什么是ThreadLocal? 因为它提供了线程隔离的变量存储。在Spring MVC的Servlet容器(如Tomcat)中,每个HTTP请求通常由一个独立的线程处理。从请求进入拦截器,到Controller,到Service,再到DAO层,整个过程都在同一个线程内。因此,在拦截器里存入ThreadLocal的数据,在整个请求生命周期内,对于该线程都是可见的。这比每次都在方法参数中传递要优雅得多,也避免了在方法签名中污染大量的用户信息参数。
OpenFeign拦截器是如何工作的? 当你使用@FeignClient注解时,Spring Cloud会为这个接口动态生成一个代理对象。当调用代理对象的方法时,Feign会做一系列事情:解析注解中的URL、构造请求模板(RequestTemplate)、应用所有注册的RequestInterceptor、最终由HTTP客户端(默认是JDK的HttpURLConnection,也可用OkHttp或Apache HttpClient)发送请求。我们的拦截器就是在“应用”阶段,对RequestTemplate进行修改,添加自定义Header。
有没有更好的信息传递载体? 使用HTTP Header是最通用、侵入性最小的方式。但它也有局限,比如Header有长度限制,不适合传递非常大的用户对象。对于复杂的用户信息(如角色、权限列表),常见的优化方案是:
- 只传ID,下游再查:像我们示例一样,只传递用户ID。下游服务如果需要完整的用户信息,可以调用一个统一的用户信息服务来查询,或者从Redis缓存中获取。这符合微服务“按需获取”的原则,也减少了网络传输量。
- 使用加密的Token:传递一个JWT Token,下游服务自己解析。这要求所有服务共享密钥或使用非对称加密,并都能理解Token格式。好处是下游服务能获取更多信息,且Token本身具有防篡改性。
- 结合链路追踪框架:如果你使用了SkyWalking、Zipkin等链路追踪工具,可以把用户ID等关键信息放在追踪上下文(如
TraceContext)中传递。这些框架通常已经解决了跨线程、跨服务的上下文传递问题,可以复用其机制。
安全性加固:我们示例中传递的是明文ID。在生产环境中,你必须考虑:
- 内部网络安全:确保微服务集群部署在安全的内部网络(如K8s集群网络、VPC内),防止请求被窃听或篡改。
- 服务间认证:除了用户身份,服务本身也需要认证。可以通过为每个服务颁发客户端证书(mTLS),或者使用OAuth2的Client Credentials模式,确保只有合法的服务才能相互调用。这样即使请求头被截获,攻击者也无法冒充其他服务发起调用。
- Header验签:网关在添加用户信息头时,可以对内容(如
userId+timestamp)进行签名。下游服务收到后,用共享密钥验证签名,确保信息来自可信的网关,且未被篡改。
6. 避坑指南:实战中那些“血与泪”的教训
理论讲完了,方案也给出了,但在真实项目里上线这套机制,我踩过不少坑。这里分享几个最典型的,希望能帮你绕过去。
第一个大坑:ThreadLocal内存泄漏。 这是老生常谈但极易忽视的问题。我们的UserInfoInterceptor在afterCompletion里调用了UserContext.clear(),这很重要。因为Tomcat等Web服务器会使用线程池,一个线程处理完请求后不会被销毁,而是放回池中等待下一个请求。如果不清除ThreadLocal,那么上一个请求的用户信息就会残留在线程中,泄露给下一个毫无关系的请求,导致严重的业务逻辑错误和安全问题。所以,务必在拦截器的afterCompletion或postHandle方法中执行清理操作。
第二个坑:Feign拦截器在非Web上下文失效。 想象一个场景:你有一个定时任务(@Scheduled),它在一个独立的线程里运行,需要调用某个Feign接口。这时候,UserContext.getUserId()肯定是空的,因为定时任务线程根本没有经过网关和Spring MVC拦截器。你的Feign拦截器可能就会发送一个没有X-User-Id头的请求。对于这种情况,你需要有降级策略。要么在定时任务中手动设置一个系统用户的ID到UserContext,要么让被调用的服务接口能够识别并处理这种“无用户”的内部调用(例如,通过另一个特定的Header如X-Internal-Call: true来标识)。
第三个坑:网关到服务的Header被“洗掉”。 有些公司的网络架构中,可能会有负载均衡器(如Nginx)或者公司的统一接入层,它们可能会过滤或重写一些自定义的HTTP Header。如果你发现下游服务收不到X-User-Id,一定要检查整个链路上的所有网络设备配置,确保你的自定义Header在转发规则中被保留(proxy_pass相关的配置)。
第四个坑:异步处理导致上下文断裂。 如前所述,一旦你在业务中使用了@Async、消息队列监听、或响应式编程(WebFlux),传统的ThreadLocal方案就失效了。对于Spring的@Async,你可以实现一个AsyncConfigurer,配置一个TaskExecutor,并使用TaskDecorator来包装任务,在子线程执行前将父线程的上下文传递进去。对于WebFlux,由于其基于反应式编程模型,没有ThreadLocal的概念,你需要使用ReactiveContext(如ServerWebExchange的Attributes或Project Reactor的Context)来存储和传递上下文信息,这需要一套完全不同的实现方式。
把这些坑都填上,你的用户信息传递方案才算真正稳固。这套基于OpenFeign和Spring MVC拦截器的方案,经过多个大型项目的检验,在满足绝大多数同步、线程绑定的微服务调用场景下,是简单、高效且可靠的。它把复杂的分布式上下文传递问题,简化成了几个轻量级拦截器的配置,让开发者能更专注于业务逻辑本身。

448

被折叠的 条评论
为什么被折叠?



