1. 为什么微服务之间传递用户信息是个“麻烦事”?

我记得几年前刚开始做单体应用的时候,获取当前登录用户信息简直不要太简单。那时候,用户登录后,我们把用户ID、用户名这些信息往Session里一存,整个应用里任何地方,想用的时候直接从HttpServletRequest或者SecurityContext里拿就行,就跟从自己口袋里掏东西一样方便。但是,自从团队决定把那个庞大的单体应用拆分成一个个微服务,这个“掏口袋”的动作就变得异常复杂了。

想象一下这个场景:用户在前端App点击“下单”。这个请求首先会到达网关,网关做完身份验证,确认你是合法用户张三。然后,网关需要把这个请求转发给“订单服务”去创建订单。订单服务处理时发现,它需要调用“库存服务”去扣减商品库存,同时还得调用“购物车服务”去清空张三的购物车。问题来了:当订单服务去调用购物车服务时,购物车服务怎么知道这个“清空购物车”的请求是来自用户张三,而不是李四呢?订单服务发起的这次内部调用,可不会自动带上张三的令牌或者用户信息。

这就是微服务架构下典型的“身份透传”难题。前端请求经过网关时,身份是明确的,但一旦进入微服务内部,在服务A调用服务B、服务B又调用服务C的链式调用中,用户的身份信息就像断了线的风筝,很容易丢失。如果每个服务都重新去网关或者认证中心验证令牌,不仅性能低下,而且架构上也不优雅。更糟糕的是,如果某些业务逻辑深度依赖当前用户信息(比如查询个人订单、计算用户等级),信息传递失败直接会导致功能错误。

所以,我们今天要解决的,就是如何设计一套优雅、自动化的机制,让用户的身份信息能够像接力棒一样,安全、无缝地在各个微服务之间传递。核心思路就是利用 OpenFeign的拦截器 在发起调用时“送出去”,利用 SpringMVC的拦截器 在接收请求时“接过来”,并用 ThreadLocal 这个“临时口袋”在单个服务内部暂存,方便随时取用。下面,我就带你一步步拆解这个方案,手把手实现它。

2. 基石:理解网关、OpenFeign与ThreadLocal的角色

在动手写代码之前,我们得先搞清楚几个关键角色在这场“身份传递接力赛”中各自承担什么任务。这就像组乐队,你得先知道吉他手、鼓手、主唱分别负责什么,合作起来才顺畅。

首先是网关(Gateway),它是整个系统的唯一入口,扮演着“安检员”和“快递分拣员”的双重角色。所有从外部(比如手机App、网页)来的请求,都必须先经过网关。作为“安检员”,它的核心任务之一就是进行统一的登录校验(比如验证JWT令牌是否有效)。一旦验证通过,它就知道了当前用户是谁(比如用户ID=123)。接下来,它作为“快递分拣员”,需要把这份包裹(用户请求)正确地转发到后端的“订单服务”。关键一步来了:在转发之前,网关需要把“寄件人信息”(用户ID=123)写在外包装(HTTP请求头)上,这样下游服务收到包裹时,才能知道是谁寄的。

然后是OpenFeign,它是微服务之间进行HTTP调用的“代言人”和“快递员”。当订单服务需要调用购物车服务时,我们不会自己去写一堆HttpClient的代码,而是声明一个Feign客户端接口。OpenFeign会帮我们自动生成实现,并发送HTTP请求。我们的目标,就是让这位“快递员”在每次出门送件(发起调用)时,都能自动地把当前服务的“寄件人信息”(也就是它自己收到的用户信息)也抄写到新的包裹(新的HTTP请求)上,继续传递下去。这就需要我们配置一个Feign请求拦截器(RequestInterceptor),在请求被发出前,悄悄地往请求头里加点“料”。

接着是SpringMVC拦截器(Interceptor),它是每个微服务内部的“前台接待”。任何一个HTTP请求(无论是来自网关还是其他微服务)到达本服务时,都会先经过这个“前台”。它的工作就是拦截下请求,检查包裹外包装(HTTP请求头),找到上面写的“寄件人信息”(用户ID),然后把这个信息登记到本服务的“临时访客登记簿”上。这个“登记簿”就是ThreadLocal。ThreadLocal可以理解为每个线程独有的一个储物柜,同一个线程内任何地方都能存取里面的东西。这样,在本服务后续的业务逻辑代码中,无论是Controller、Service还是Repository,只要是在处理同一个请求的同一个线程里,都能随时从ThreadLocal这个“储物柜”里拿到用户信息,而不用反复去解析请求头。

最后是ThreadLocal,它是实现线程级数据隔离的关键。微服务通常使用多线程处理并发请求,每个请求都会由一个独立的线程来处理。用ThreadLocal存储用户信息,可以完美保证用户A的请求线程拿到的永远是用户A的信息,用户B的线程拿到的永远是用户B的信息,两者绝不会混淆。这就好比给每个线程发了一个专属的、带名字的文件袋,信息放进去、拿出来都非常安全。

把这几个角色串起来,整个流程就清晰了:网关鉴权并注入用户信息到请求头 -> 服务A的SpringMVC拦截器取出信息存入ThreadLocal -> 服务A业务逻辑从ThreadLocal使用信息 -> 服务A通过OpenFeign调用服务B时,Feign拦截器从ThreadLocal取出信息并注入新请求头 -> 服务B的SpringMVC拦截器重复上述过程…… 形成了一个完美的闭环。

3. 实战第一步:打造网关的“安检与贴标”功能

好,理论铺垫完毕,我们开始撸起袖子写代码。第一步,我们要强化网关,让它不仅能鉴权,还能给通过的请求“贴”上用户标签。

假设我们使用Spring Cloud Gateway和JWT令牌。首先,我们创建一个全局过滤器AuthGlobalFilter,实现GlobalFilter, Ordered接口。

@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 获取请求路径,判断是否为白名单(如登录、公开API)
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();
        if (isWhiteList(path)) {
            return chain.filter(exchange); // 白名单直接放行
        }

        // 2. 从请求头获取token
        String token = null;
        List<String> authHeaders = request.getHeaders().get("Authorization");
        if (authHeaders != null && !authHeaders.isEmpty()) {
            token = authHeaders.get(0);
            // 通常格式是 "Bearer eyJhbGciOiJ..."
            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7);
            }
        }

        // 3. 校验token
        if (token == null || token.isEmpty()) {
            // 返回401未授权响应
            return unauthorizedResponse(exchange);
        }

        try {
            // 使用JWT工具类解析token,验证签名和过期时间
            Claims claims = JwtUtils.parseToken(token);
            Long userId = claims.get("userId", Long.class);
            String username = claims.getSubject();

            // 4. 校验通过,将用户信息写入请求头,传递给下游服务
            ServerHttpRequest newRequest = request.mutate()
                    .header("X-User-Id", String.valueOf(userId))
                    .header("X-Username", username)
                    .build();

            ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
            log.info("网关认证通过,用户ID: {},路径: {}", userId, path);
            return chain.filter(newExchange);

        } catch (Exception e) {
            log.warn("令牌解析失败: {}", e.getMessage());
            return unauthorizedResponse(exchange);
        }
    }

    private boolean isWhiteList(String path) {
        // 实现你的白名单逻辑,例如包含 /auth/login, /public/ 等
        return path.contains("/auth/login") || path.contains("/public/");
    }

    private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        String body = "{\"code\":401,\"msg\":\"未授权或令牌无效\"}";
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        // 设置一个较高的优先级,确保在路由转发前执行
        return -100;
    }
}

这段代码干了四件关键事:1. 放行白名单请求(如登录接口)。2. 从Authorization头提取JWT令牌。3. 校验并解析JWT,获取其中的用户信息(如userId, username)。4. 最关键的一步:使用mutate()方法克隆原始请求,并添加新的请求头X-User-IdX-Username,然后将这个“贴好标签”的新请求交给过滤器链继续处理,最终转发给下游微服务。

这样,从网关出去的每一个请求,都像贴上了清晰的“发货单”,写明了用户身份。下游服务要做的,就是学会如何读取这张“发货单”。

4. 实战第二步:为每个微服务配备“前台接待”(SpringMVC拦截器)

现在,请求带着用户信息的“标签”到达了具体的微服务(比如订单服务)。我们需要在每个微服务里设置一个“前台”,统一读取这个标签,并做好登记。这个“前台”就是SpringMVC拦截器。

首先,我们创建一个工具类UserContext,利用ThreadLocal来充当那个“线程专属储物柜”。

public class UserContext {
    private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();
    private static final ThreadLocal<String> USERNAME_HOLDER = new ThreadLocal<>();

    public static void setUserId(Long userId) {
        USER_ID_HOLDER.set(userId);
    }

    public static Long getUserId() {
        return USER_ID_HOLDER.get();
    }

    public static void setUsername(String username) {
        USERNAME_HOLDER.set(username);
    }

    public static String getUsername() {
        return USERNAME_HOLDER.get();
    }

    // 非常重要!请求处理完毕后必须清理,防止内存泄漏和线程复用导致的信息错乱
    public static void clear() {
        USER_ID_HOLDER.remove();
        USERNAME_HOLDER.remove();
    }
}

接着,我们实现SpringMVC的拦截器UserInfoInterceptor

@Component
@Slf4j
public class UserInfoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从网关传递过来的请求头中获取用户信息
        String userIdStr = request.getHeader("X-User-Id");
        String username = request.getHeader("X-Username");

        // 2. 解析并存入UserContext (ThreadLocal)
        if (userIdStr != null && !userIdStr.isEmpty()) {
            try {
                Long userId = Long.parseLong(userIdStr);
                UserContext.setUserId(userId);
                UserContext.setUsername(username != null ? username : "");
                log.debug("拦截器设置用户上下文,UserId: {}, Username: {}", userId, username);
            } catch (NumberFormatException e) {
                log.warn("非法的用户ID格式: {}", userIdStr);
                // 可以根据业务决定是放行还是拒绝,这里选择放行但不清除上下文(如果有的话)
            }
        } else {
            // 没有用户信息,可能是内部健康检查或无需认证的接口,直接放行
            log.debug("请求头中未找到用户信息,路径: {}", request.getRequestURI());
        }
        return true; // 始终放行,具体鉴权逻辑可由业务注解(如@PreAuthorize)控制
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 请求处理完毕,无论成功或异常,都必须清理ThreadLocal
        UserContext.clear();
        log.debug("清理用户上下文");
    }
}

这个拦截器在preHandle阶段(控制器方法执行前)工作,从请求头X-User-IdX-Username中取出信息,塞进UserContext。这样,在本线程后续执行的任何地方,调用UserContext.getUserId()就能拿到用户ID。

最后,我们需要注册这个拦截器。为了避免在每个微服务中重复配置,我们可以把它封装在公共模块(比如common-core)里,利用Spring Boot的自动装配。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private UserInfoInterceptor userInfoInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截所有路径,排除一些不需要的路径如健康检查、静态资源等
        registry.addInterceptor(userInfoInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/actuator/health", "/error", "/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**");
    }
}

为了让这个配置类能被其他微服务自动扫描到,需要在公共模块的resources/META-INF/目录下创建spring.factories文件:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.yourcompany.common.config.WebMvcConfig

这样,其他微服务只要引入了这个公共模块的依赖,就会自动装配上这个拦截器,无需任何额外配置。现在,你的订单服务、库存服务、购物车服务,都拥有了一个能自动识别用户身份的“智能前台”。

5. 实战第三步:让服务间的“快递员”自动带货(OpenFeign拦截器)

现在,单个服务内部能拿到用户信息了。但当订单服务需要调用购物车服务时,问题又来了:这次调用是订单服务内部的OpenFeign客户端发起的,是一个全新的HTTP请求。如果我们不做任何处理,这个新请求的请求头里是不会有X-User-Id的,购物车服务的拦截器也就无从获取用户信息。

解决方案是配置一个Feign的请求拦截器。它的作用是在OpenFeign客户端构造好请求、即将发出的一瞬间,拦截这个请求,把我们当前线程UserContext里存好的用户信息,添加到请求头中。

@Component
public class FeignUserInfoInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        // 从当前线程的UserContext中获取用户信息
        Long userId = UserContext.getUserId();
        String username = UserContext.getUsername();

        // 如果存在用户信息,则添加到Feign请求的Header中
        if (userId != null) {
            template.header("X-User-Id", String.valueOf(userId));
        }
        if (username != null && !username.isEmpty()) {
            template.header("X-Username", username);
        }
        // 这里可以添加日志,方便调试
        // log.debug("Feign拦截器添加用户头,目标服务: {}, 用户ID: {}", template.feignTarget().name(), userId);
    }
}

这个类实现了Feign的RequestInterceptor接口,只需要一个apply方法。Feign在每次发起调用前,都会自动调用所有已注册的拦截器的apply方法。我们在这里从UserContext取出信息,通过RequestTemplateheader方法添加进去。

关键点:这个拦截器必须被Spring容器管理(加上@Component注解),并且Feign客户端所在的模块必须能扫描到这个Bean。通常,我们也会把这个拦截器放在公共模块,并确保其被自动装配。这样,所有使用了OpenFeign的微服务,其发起的Feign调用都会自动携带用户信息。

至此,我们完成了整个链条的闭环:网关贴标 -> A服务前台登记 -> A服务业务使用 -> A服务通过Feign调用B服务时自动带货 -> B服务前台登记…… 用户信息就这样在服务间流畅地传递起来了。

6. 避坑指南:那些我踩过的“雷”和最佳实践

方案看起来很美,但在实际生产环境中,我踩过不少坑。这里分享几个关键注意事项,希望能帮你绕开这些“雷区”。

第一坑:ThreadLocal的内存泄漏与清理。 这是最容易出问题的地方。我们使用ThreadLocal存储数据,而Web服务器(如Tomcat)通常会使用线程池。这意味着处理完一个请求后,线程并不会销毁,而是放回池中等待处理下一个请求。如果你不在请求处理完毕后**显式地调用ThreadLocal.remove()**清理数据,那么上一个请求的用户信息就会残留在线程中。当这个线程被复用处理下一个请求时,就可能读取到错误的用户信息,造成严重的安全漏洞和数据混乱。所以,务必像我们上面代码所示,在拦截器的afterCompletion方法中调用UserContext.clear()

第二坑:异步编程导致上下文丢失。 如果你的服务中使用了@Async异步方法,或者CompletableFuture、反应式编程(WebFlux),问题就来了。因为异步操作会在新的线程中执行,而ThreadLocal是线程绑定的,新线程无法访问父线程的ThreadLocal数据。解决方案是使用TransmittableThreadLocal(阿里开源的TTL)或者Spring提供的TaskDecorator,在提交异步任务时手动将上下文传递过去。这是一个进阶话题,但如果你用了异步,一定要提前考虑。

第三坑:Feign拦截器的生效范围与顺序。 确保你的FeignUserInfoInterceptor被正确注入Spring容器。有时候,如果Feign客户端定义在独立的、未扫描该拦截器包的模块中,可能会失效。另外,如果你有多个RequestInterceptor,需要注意它们的执行顺序(默认按Bean定义顺序)。一般用户信息拦截器应该放在比较靠前的位置。

第四坑:网关与服务的协议一致性。 确保网关添加到请求头的键(如X-User-Id),和服务端拦截器读取的键(request.getHeader("X-User-Id"))完全一致,包括大小写。建议将这些Header Key定义成常量字符串,两边共用。

第五坑:用户信息的敏感性与最小化原则。 我们传递的是userIdusername这类标识信息,切勿传递密码、完整手机号等敏感信息。同时,遵循最小化原则,只传递下游服务必需的信息。如果购物车服务只需要用户ID,就不要传递用户名、邮箱等。这既是安全要求,也能减少不必要的网络开销。

第六坑:测试与调试。 这个链路涉及多个组件,调试起来可能有点麻烦。我常用的方法是开启DEBUG日志,特别是在网关过滤器、两个拦截器中打印关键日志。也可以利用HTTP抓包工具(如Wireshark)或Spring Cloud Sleuth链路追踪,直观地查看请求头在各个环节的变化,这是排查问题最有效的手段。

7. 方案扩展:更复杂的场景与优化思路

上面的方案解决了基础的身份透传问题,但在更复杂的生产环境中,你可能还会遇到一些进阶需求。这里提供几个扩展思路。

场景一:需要传递更多用户上下文信息。 有时候,业务可能不仅需要用户ID,还需要部门ID、角色列表、权限码等。我们的方案很容易扩展:只需在JWT令牌的Claims里加入这些字段,网关解析后一并放入请求头,下游服务的UserContext和拦截器相应增加字段即可。但切记遵守“最小化原则”和敏感信息保护。

场景二:内部系统调用或定时任务没有用户上下文。 有些调用并非源自前端请求,比如系统定时任务触发,或者管理后台直接发起的内部调用。这些请求没有携带JWT,网关可能直接放行(走白名单),或者携带一个特殊的“系统令牌”。对于这种情况,可以在网关和拦截器逻辑中做特殊判断。例如,检测到特定的X-System-Token头,则注入一个预设的“系统用户”身份(如userId=0, username=‘system’)。同时,在业务代码中也要能识别和处理这种系统用户。

场景三:性能优化与降级。 每次请求都解析JWT(在网关)和从ThreadLocal存取数据,性能开销极小,通常可忽略。但在极端高性能要求下,可以考虑优化。例如,网关解析JWT后,可以将用户信息对象序列化成JSON字符串再放入一个请求头,下游服务拦截器解析一次后存入ThreadLocal,避免重复解析多个请求头。另外,一定要为整个链路设计降级方案。比如,如果UserContext.getUserId()可能返回null,你的业务代码要能优雅处理(返回友好错误,而不是直接抛NPE)。

场景四:与更完善的安全框架结合。 我们的方案主要解决信息传递,并未涉及细粒度的权限控制(RBAC)。你可以轻松地将此方案与Spring Security结合。在微服务内部,你可以从UserContext中获取用户ID,然后去查询数据库或调用权限服务,获取其权限列表,再结合@PreAuthorize等注解进行方法级权限控制。这样,身份传递和权限校验就分离开了,架构更清晰。

这套基于OpenFeign和SpringMVC拦截器的方案,在我经历的几个中大型微服务项目中都得到了验证,稳定性和可维护性都不错。它最大的好处是对业务代码几乎无侵入。业务开发人员只需要在需要的地方调用UserContext.getUserId(),完全不用关心这个ID是怎么来的、怎么传的,可以更专注于业务逻辑的实现。这种关注点分离的设计,正是构建整洁、可维护的微服务架构所追求的。

更多推荐