租户上下文传递机制:别让身份走丢

好,我们来聊聊 SDK 设计里最微妙也最容易出错的一个环节——租户上下文传递。说白了,就是让每个请求的“身份证”能在整个系统里跟着走,别在半路丢了。

我刚开始做多租户 SDK 的时候,犯过一个特别蠢的错误。接口里明明传了租户 ID,结果异步线程一执行,上下文就没了。排查半天才发现,是线程变量没有被正确复制。嗯,从那以后,我对上下文的传递链路就特别敏感。

核心问题:身份在哪里断裂?

我们先画一条调用链,看看身份可能在哪些环节丢失:

  • 网关层:从 HTTP 头解析出租户 ID,传给下游服务
  • 服务间调用:RPC 或 HTTP 调用时,上下文的序列化和反序列化
  • 异步任务:线程池、MQ、定时任务里,ThreadLocal 丢失
  • 数据库访问:连接池中连接复用,上下文没有重置
  • 外部请求:SDK 调用第三方 API 时,是否带上租户标识

每个断裂点,都可能导致数据泄露或越权。我自己就踩过数据库连接的坑——连接池里一个连接被线程 A 设置了租户上下文,线程 B 拿到同一个连接时没清理,直接就拿到了 A 的数据。想想看,这多危险。

设计目标:无侵入、无丢失、无污染

好的上下文传递机制,应该满足三个目标:

目标 说明 实现关键
无侵入 业务代码几乎不需要感知上下文的传递 拦截器、过滤器、AOP
无丢失 同步、异步、跨线程场景下身份都不丢 InheritableThreadLocal 或 TransmittableThreadLocal
无污染 上下文用完即清理,不留下一次使用 try-finally 或 AutoCloseable

我个人习惯把这三个目标写进 SDK 的设计文档里,作为评审时的硬性检查项。少一条,上线前就得打回来重改。

上下文传递的四种主流方式

实践中,我遇到过四种最常见的传递方式。每种都有自己的适用场景和坑:

  1. ThreadLocal:最简单,但只能在线程内传递。子线程拿不到,线程池复用会污染。
  2. InheritableThreadLocal:创建子线程时复制一份上下文。但线程池复用时不触发创建,所以还是有可能拿到旧上下文。
  3. TransmittableThreadLocal:阿里开源的方案,专治线程池场景。通过装饰器在任务提交时捕获上下文,执行时还原。我强烈推荐。
  4. 显式参数传递:在方法签名里加一个 TenantContext 参数。最安全,但侵入性最强,业务代码到处改。

我的推荐组合:

  • 同步调用:ThreadLocal + 拦截器自动注入和清理
  • 异步线程池:TransmittableThreadLocal + 线程池装饰器
  • 跨服务调用:在 RPC 请求头或 HTTP Header 中携带

API 设计:让使用者无感

上下文传递的 API 要尽量简单。我设计过这样一套:

// 设置租户上下文
TenantContext.set(TenantId.of("tenant-123"));

// 业务逻辑...

// 清理上下文(放 finally 里)
TenantContext.clear();

// 在拦截器里自动做
public class TenantInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, ...) {
        String tenantId = request.getHeader("X-Tenant-Id");
        TenantContext.set(TenantId.of(tenantId));
        return true;
    }

    @Override
    public void afterCompletion(...) {
        TenantContext.clear();  // 一定要清理!
    }
}

你想想看,如果每个业务方法都要手动写 set 和 clear,那代码得丑成什么样。所以拦截器或者 AOP 几乎是必须的。

跨线程传递的坑

这是重灾区。我曾经接手过一个项目,异步任务里检查租户 ID 时,发现有时候 true,有时候 false。查了两天才定位到问题:是 ThreadLocal 没有被正确继承。

避坑清单:

  • 线程池场景,务必使用 TransmittableThreadLocal
  • 如果用了 CompletableFuture,在 supplier 里手动传递上下文
  • MQ 生产者发送消息前,把租户上下文序列化到消息头里
  • MQ 消费者消费时,先从消息头反序列化,再设置到当前线程

小技巧: 我习惯在 SDK 里提供一个 TenantContextRunnable 包装类,自动捕获和还原上下文。这样使用者只需要 new TenantContextRunnable(() -> { ... }) 就可以了,不用操心线程细节。

跨服务调用的传递

微服务架构下,服务 A 调用服务 B 时,租户上下文需要通过网络传递。我常用的做法:

  1. 服务 A 在发起调用前,从当前线程上下文读出租户 ID
  2. 把租户 ID 写入 RPC 框架的隐式参数或 HTTP 请求头
  3. 服务 B 在接收到请求后,从请求头解析并设置到本线程的上下文

如果是 gRPC,可以放在 metadata 里;如果是 HTTP,就是自定义 Header,比如 X-Tenant-Id 或 X-Org-Id。我曾经见过一个团队把租户 ID 放在 URL 参数里,结果 URL 被日志系统脱敏了,排查问题时根本找不到租户。这个坑我建议你别踩。

实战:一个完整的传递链路

我把这套机制画了一张流程图,方便你理解全貌:

租户上下文传递链路 网关层 解析请求头 SDK 层 统一上下文管理 业务微服务 业务逻辑处理 数据层 隔离的数据访问 异步任务 线程池 + TTL HTTP Header ThreadLocal 连接池身份注入 异步任务提交 关键说明 1. 网关负责解析和透传租户标识 2. SDK 统一管理上下文生命周期 3. 异步任务需使用 TTL 保证传递

曾经有个血泪教训: 一个 SDK 上线前没有测试数据层连接复用的场景。上线后,租户 A 的请求偶尔能查到租户 B 的数据。排查发现是连接池在归还连接时没有清理上次设置的租户上下文。从那以后,我要求所有连接池配置里加上连接使用前的上下文重置逻辑。

兜底方案:上下文丢失时的降级

即使设计再完善,也难免有漏网之鱼。我的做法是在 SDK 里加一个“上下文缺失检查点”:

  • 在关键的数据库操作、外部 API 调用前,检查当前线程是否有租户上下文
  • 没有上下文时,记录告警日志,并采用默认拒绝策略
  • 可以设置一个白名单,允许部分接口在丢失上下文时走“默认租户”逻辑,但必须在配置里显式声明

这种兜底机制,能在生产环境快速发现上下文传递的断裂点,避免数据错乱扩散。

嗯,上下文传递这块,细节非常多。掌握了这套机制,你的 SDK 在多租户环境下才算真正站稳了脚跟。接下来,我们看看更具体的安全边界——数据隔离和权限校验。