微服务之间,到底应如何传递用户信息

从 Token 全链路透传,到网关剥离 + 上下文透传,再到服务网格——一篇讲清微服务里用户身份的传递之道

一个所有人都遇到过的场景

设想一个最常见的下单链路:

1
2
用户 → 网关 → 订单服务 → 库存服务 → 优惠券服务
                       ↘ 消息队列 → 积分服务(异步)

订单服务要写 created_by,库存服务要校验"这个用户是不是有权操作这个仓库",优惠券服务要判断"这张券是不是这个用户的",积分服务在异步消费里要给"这个用户"加分。

四个服务,每一跳都要回答同一个问题:当前是谁在操作?

这件事看似简单,真做起来却处处是坑。下面我们就把"用户信息怎么在微服务之间传"这件事,从最朴素的做法一路讲到 Service Mesh,把每条路的优缺点摊开。


朴素做法:把用户信息当参数到处传

最直觉的做法——既然下游需要 userId,那就在接口签名里多加一个参数好了:

1
2
3
createOrder(userId, items, address)
deductStock(userId, skuId, count)
useCoupon(userId, couponId)

第一眼看着没问题,真用起来你会发现:

  • 侵入性极强:业务接口里混进了和业务无关的"操作人"字段,函数签名越来越长。
  • 极易出错:调用方写错一个 userId,相当于"以 A 的身份替 B 下单",这是越权漏洞。
  • 无法约束:下游服务无法判断这个 userId 是真是假,调用方说是谁就是谁。
  • 新增字段成本爆炸:今天加个 tenantId,明天加个 traceId,所有接口都要改一轮。

所以这个方案在生产里几乎没人用。它给我们一个启示:用户上下文必须从业务参数里剥离出来,走专门的通道


思路一:Token 全链路透传

既然用户登录后拿到了一个 Token(多数情况下是 JWT),最简单的"分布式做法"就是——把这个 Token 一路往下传,每个服务自己解析

1
2
客户端 ──Token──▶ 网关 ──Token──▶ 订单 ──Token──▶ 库存
                                  ──Token──▶ 优惠券

它的好处很明显:

  • 无状态:每个服务凭 Token 自证身份,不依赖中心节点。
  • 解耦:服务之间不需要知道"上游是谁",只看 Token。

但你很快会撞到这些问题:

  1. 重复解析:链路上每个服务都要解一遍 JWT,验签一遍。链路越长,重复劳动越多。
  2. 密钥与公钥的分发:每个服务都得拿到验签公钥,密钥轮换时所有服务都要同步更新。
  3. Token 过期/撤销难:如果链路中段调用耗时较长,Token 在下游可能已经过期;用户登出后已经下发的 Token 也很难"召回"。
  4. 安全面被放大:原始 Token 流到了每一个内部服务,任何一个服务的日志泄漏,都等于泄漏了用户凭证。
  5. 服务间调用不天然带 Token:定时任务、MQ 消费、内部系统触发的调用,根本没有用户 Token,需要专门"造一个"。

所以纯粹的 Token 全链路透传,更适合小规模、链路短、对安全要求中等的场景,不适合作为大型系统的默认方案。


思路二(主线推荐):网关剥离 + 上下文透传

这套思路的核心一句话——鉴权"做一次",上下文"传一路"

整体架构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
                ┌─ 解析 Token
                ├─ 验签 / 校验过期
   网关  ───────┤
                ├─ 提取用户字段:userId / tenantId / roles
                └─ 写入下游请求头:X-User-Id / X-Tenant-Id ...

            ┌────────┐         ┌────────┐
            │ 订单   │ ──────▶ │ 库存   │
            └────────┘         └────────┘
              ↑读上下文          ↑读上下文
  • 网关做唯一一次重活:验 Token、查黑名单、拉用户基本信息,然后把"必要的身份字段"以轻量请求头的形式写进下游请求。
  • 下游服务不再信任原始 Token,只读那几个固定的请求头

这个方案为什么好

  • 重活只做一次:避免链路上重复解析。
  • 下游零负担:业务服务不需要懂 JWT、不需要拿公钥,只关心"当前用户是谁"。
  • 安全收敛:原始 Token 被拦在网关后面,不再扩散到内部。
  • 上下文可扩展:今天传 userId,明天加个 tenantId、灰度标签、AB 实验分组——都只是多一个请求头的事。

一个绕不开的安全约束

下游服务"无脑信任"请求头,意味着——如果有人能绕过网关直接调用内部服务,他就能伪造任何身份

所以这套方案有一个硬前提:

内部服务的入口必须封死,只接受来自网关或其他内部服务的流量。

实现手段可以是:内网网络隔离、Service Mesh 的 mTLS、内部调用专用的鉴权 Header(如服务间共享密钥),或者三者结合。这一步偷懒,整套体系就形同虚设。


服务内部:上下文如何"隐式"流转

网关把用户字段塞进了请求头,下游服务怎么用?最糟的写法是这样:

1
2
3
4
5
6
7
8
9
// 反面教材
public OrderVO createOrder(HttpServletRequest request, OrderDTO dto) {
    String userId = request.getHeader("X-User-Id");
    Order order = new Order();
    order.setCreatedBy(userId);
    stockClient.deduct(userId, dto.getSkuId(), dto.getCount());   // 手动透传
    couponClient.use(userId, dto.getCouponId());                  // 手动透传
    // ...
}

每个业务方法都要从 request 里抠 header,每次调下游又要手动塞回去,这就是把"基础设施的事"塞进了"业务代码"

正确的做法是建一个用户上下文容器,让请求头进来时自动入,调下游时自动出,业务代码完全无感。

接收侧:入口拦截器

在服务最外层装一个拦截器(不同框架叫法不同:Filter、Interceptor、Middleware),统一做一件事:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class UserContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;

        UserContext ctx = UserContext.builder()
                .userId(request.getHeader("X-User-Id"))
                .tenantId(request.getHeader("X-Tenant-Id"))
                .roles(parseRoles(request.getHeader("X-User-Roles")))
                .build();

        UserContextHolder.set(ctx);
        try {
            chain.doFilter(req, resp);
        } finally {
            UserContextHolder.clear();   // 关键:必须清理,避免线程复用污染
        }
    }
}

业务代码里就只剩一句:

1
String userId = UserContextHolder.current().getUserId();

调用侧:出站拦截器

服务调用其他服务时,在 HTTP 客户端的拦截器里把上下文塞回请求头:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class UserContextClientInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        UserContext ctx = UserContextHolder.current();
        if (ctx != null) {
            request.getHeaders().add("X-User-Id",   ctx.getUserId());
            request.getHeaders().add("X-Tenant-Id", ctx.getTenantId());
        }
        return execution.execute(request, body);
    }
}

这样业务代码写 stockClient.deduct(skuId, count),框架自动把用户上下文带过去。

异步场景的坑

上下文容器一般基于"线程本地存储"实现。一旦发生线程切换,上下文就丢了:

  • 把任务扔进线程池
  • 用回调 / Future / 协程切换执行线程
  • 定时任务由调度线程触发

解决思路是:在任务提交那一刻,把当前线程的上下文"拷贝一份"附在任务上;任务被新线程执行前,再把上下文恢复进去。Java 生态里阿里开源的 TransmittableThreadLocal(TTL)就是干这个的,关键是——包装一次线程池,全局生效,不要让业务代码自己去 copy。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 把上下文容器换成 TransmittableThreadLocal
public class UserContextHolder {
    private static final TransmittableThreadLocal<UserContext> CTX = new TransmittableThreadLocal<>();
    public static void set(UserContext c)   { CTX.set(c); }
    public static UserContext current()     { return CTX.get(); }
    public static void clear()              { CTX.remove(); }
}

// 包装一次线程池,之后所有 submit 都会自动捎带上下文
ExecutorService pool = TtlExecutors.getTtlExecutorService(originalPool);
pool.submit(() -> {
    String userId = UserContextHolder.current().getUserId();   // 仍然取得到
    // ...
});

跨进程异步:消息队列怎么传?

HTTP 调用有请求头,消息队列也有"信封"——大多数 MQ 都支持消息 Header / Properties。把用户上下文放进去就行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 生产端:发消息时把上下文塞进 Header(以 Kafka 为例)
public void send(String topic, byte[] payload) {
    UserContext ctx = UserContextHolder.current();
    ProducerRecord<String, byte[]> record = new ProducerRecord<>(topic, payload);
    if (ctx != null) {
        record.headers().add("X-User-Id",   ctx.getUserId().getBytes(StandardCharsets.UTF_8));
        record.headers().add("X-Tenant-Id", ctx.getTenantId().getBytes(StandardCharsets.UTF_8));
    }
    producer.send(record);
}

// 消费端:消费前恢复上下文
public void onMessage(ConsumerRecord<String, byte[]> record) {
    UserContext ctx = UserContext.builder()
            .userId(header(record, "X-User-Id"))
            .tenantId(header(record, "X-Tenant-Id"))
            .build();

    UserContextHolder.set(ctx);
    try {
        handle(record.value());
    } finally {
        UserContextHolder.clear();
    }
}

同样,这件事应该做在 Producer/Consumer 的拦截器里,对业务代码透明

一个被忽视的语义陷阱

异步场景里有个微妙的区别:操作发起人 vs 消费执行人

举个例子:用户 A 下单后,订单服务发了一条消息,积分服务异步消费给 A 加分。

  • 这条消息的"操作发起人"是用户 A——给 A 加分用的就是这个 ID。
  • 但消息消费时,处理这条消息的"执行身份"可能是一个系统账号——发起后续调用(比如调风控)时,下游需要知道的是"系统在调",不是"A 在调"。

所以在比较复杂的系统里,消息上下文里通常要分清两类字段:

  • 业务用户X-User-Id,描述"这件事跟谁有关"
  • 执行主体X-Actor-Id,描述"现在是谁在干"

两者不要混。


进阶视角:Service Mesh 方案

把视角再拔高一层——身份认证、上下文注入这些事,到底应该写在业务进程里,还是放到基础设施里?

Service Mesh(Istio 是典型代表)给了第三种答案:让 Sidecar 来管

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
┌──────────────────┐        ┌──────────────────┐
│   订单服务       │        │   库存服务       │
│  ┌─────────────┐ │        │ ┌─────────────┐  │
│  │  Business   │ │        │ │  Business   │  │
│  └──────▲──────┘ │        │ └──────▲──────┘  │
│         │        │        │        │         │
│  ┌──────┴──────┐ │  mTLS  │ ┌──────┴──────┐  │
│  │  Sidecar    │ ├───────▶│ │  Sidecar    │  │
│  └─────────────┘ │        │ └─────────────┘  │
└──────────────────┘        └──────────────────┘
       ↑                                 ↑
   验 JWT、注入身份头             校验来源、校验身份

每个服务旁边带一个 Sidecar 代理,所有出入流量都被它劫持。

  • 进入服务前:Sidecar 校验 mTLS 证书、解析 JWT、把身份字段以请求头注入
  • 离开服务时:Sidecar 自动签发 mTLS、附带服务身份

业务进程里彻底不需要任何鉴权代码,连"网关解 Token"那一步都被下沉到了基础设施层。

它的代价也很现实:

  • 每个 Pod 多一个代理进程,有性能与资源开销
  • 调用链路上多一跳,问题排查复杂度陡增
  • 需要团队具备 K8s + Mesh 的运维能力

适用边界大致是:服务数量到了几十上百,团队有专门的基础架构组,且对零信任、跨集群通信、灰度路由这类能力有强需求时,才值得引入。中小规模团队用"网关 + 上下文透传"已经足够好。


横向对比与选型建议

方案改造成本安全性性能适用阶段
朴素参数极低几乎不推荐
Token 全链路小型 / 探索期
网关 + 上下文透传大多数公司主流选择
Service Mesh极高中低大规模 / 多语言 / 强基建团队

给一个工程上的建议:

  • 初创/小团队:直接做"网关解 Token + 请求头透传",把上下文工具类和拦截器封装好,业务代码无感即可。Mesh 不必上。
  • 中型团队:在前者基础上,把内网入口封死(mTLS 或共享密钥),把 MQ 也接入同一套上下文透传,做到"链路里任何一跳都知道当前用户"。
  • 大型/多语言团队:考虑把鉴权与上下文注入下沉到 Service Mesh,业务侧只读上下文,不再写任何鉴权代码。

小结

回到开头那个问题——微服务之间到底应如何传递用户信息?

一句话总结:

用户上下文应当像 traceId 一样,在框架层透明流转。业务代码只问"当前是谁",不关心"怎么传过来的"。

具体落到一套清单:

  1. 不要把用户信息混进业务参数,它应该走专门通道。
  2. 鉴权只在网关做一次,下游不重复解析 Token。
  3. 下游通过约定的请求头读取用户上下文,前提是内网入口必须封死。
  4. 服务内部用上下文容器 + 入口/出口拦截器,实现隐式流转。
  5. 异步场景(线程池、MQ)单独处理上下文复制,否则一定会丢。
  6. 规模到了,再考虑用 Service Mesh 把这件事彻底下沉

把这条链条闭环建好,未来无论加多少个服务、加多少种身份字段,业务代码都不用动。这才是"基础设施服务于业务"应有的样子。

使用 Hugo 构建
主题 StackJimmy 设计