第4章 租户上下文传递设计:请求链路中的租户ID传递机制、线程局部变量与JWT Claims
边界定义
租户上下文传递的边界起点为用户请求到达系统入口(API网关或负载均衡器),终点为数据访问层执行租户隔离过滤。整个传递链路跨越网关层、认证层、业务服务层与数据访问层,不包含后台批处理或离线任务(此类场景通过独立的租户上下文初始化器处理)。上下文载体包括:HTTP请求头、JWT Claims、线程局部变量(ThreadLocal)以及RPC元数据。
分层架构与组件职责
| 分层 | 组件 | 职责 |
|---|---|---|
| 网关层 | Token解析过滤器 | 从Authorization头提取JWT,校验签名与过期时间,将JWT Claims中的tenant_id写入HTTP请求头X-Tenant-ID |
| 认证层 | 身份校验中间件 | 验证JWT完整有效性,解析出租户ID后生成认证上下文对象,包含用户ID、角色列表、租户ID |
| 业务服务层 | 租户上下文拦截器 | 取请求头或认证上下文中的租户ID,存入ThreadLocal变量;在请求结束时清除防止内存泄漏 |
| 数据访问层 | 多租户过滤器 | 从ThreadLocal读取租户ID,注入SQL WHERE子句或ORM拦截器,实现行级数据隔离 |
关键设计原则:租户上下文的存储与传播必须与请求线程绑定,且不允许跨线程泄漏;任何异步或线程池操作都必须显式传递上下文(使用继承型ThreadLocal或ContextPropagation库)。
数据流与信任链路
完整的租户ID传递数据流如下:
- 客户端在请求头
Authorization: Bearer <JWT>中携带经过签名的JWT。 - API网关校验JWT签名后,将Claims中的
tenant_id提取并存入HTTP请求头X-Tenant-ID(同时可选覆盖原JWT,以消除下游对JWT的二次解析依赖)。 - 认证服务(或网关自身)完成用户身份与权限校验,生成包含租户ID的认证令牌(可能仍为JWT,但加入内部签名)。
- 业务服务的拦截器从
X-Tenant-ID读取租户ID,或从认证上下文读取,写入当前线程的ThreadLocal。 - 若业务服务需要调用下游服务(微服务场景),则通过RPC元数据(gRPC metadata)或HTTP头将
tenant_id透传。 - 数据访问层通过MyBatis/Spring Data的拦截器从
ThreadLocal获取租户ID,动态拼接WHERE tenant_id = ?。
信任链路说明:从网关到下游服务,每个环节都必须对租户ID的来源进行信任验证。网关是信任锚点——只有网关从合法JWT中提取的租户ID才是可信的;下游服务不应信任请求头直接传入的
X-Tenant-ID(防止伪造),而应依赖网关注入的经过签名的内部令牌或请求头与原始JWT的双重校验。
选型权衡:上下文传递机制对比
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| JWT Claims直接传递 | 无状态、端到端安全;无需额外存储 | 每次请求解析额外开销;JWT体积膨胀 | 单体或无状态微服务 |
HTTP头X-Tenant-ID | 简单、易调试;网关可统一注入 | 需要防止外部伪造;需额外校验 | 网关代理模式,内部服务间调用 |
| gRPC Metadata | 天然支持上下文传播;与protobuf集成 | 仅适用于gRPC协议;跨语言适配复杂 | 微服务全gRPC链路 |
| ThreadLocal | 透明、无侵入;与ORM无缝集成 | 异步/线程池场景需显式传递;不当使用导致内存泄漏 | 同步请求处理(Web应用核心层) |
| 分布式上下文传播库 | 自动跨线程、跨网络传播;集成追踪(如OpenTelemetry) | 增加依赖与学习成本;调试复杂度提升 | 大型微服务/事件驱动架构 |
风险点与应对策略
- 租户上下文丢失:当请求进入异步线程(如
@Async、CompletableFuture)或线程池时,ThreadLocal变量不会自动继承。应对:使用InheritableThreadLocal(注意子线程会在创建时快照,后续父线程变更不更新)或显式传递上下文(如将租户ID作为方法参数传入异步任务)。 - 内存泄漏:请求结束后未清除ThreadLocal,导致容器线程池复用后残留旧租户信息。强制在过滤器/拦截器的
finally块中调用remove()。 - 租户ID伪造:未校验内部请求头来源,攻击者直接发送
X-Tenant-ID绕过权限。策略:网关在注入租户ID前必须删除原始请求头中的该字段;内部服务间使用mTLS或共享密钥签名。 - 异步消息队列传递:消息生产者将租户ID写入消息头,消费者在消费时从消息头提取并写入ThreadLocal,需保证消息头字段不被篡改(消息签名)。
落地步骤
- 定义统一租户上下文接口:设计
TenantContext接口,包含getTenantId()、setTenantId()、clear()方法,底层使用ThreadLocal实现。 - 实现网关注入过滤器:在网关层(如Spring Cloud Gateway)编写全局过滤器,从JWT中提取
tenant_id,写入请求头并删除原有X-Tenant-ID。 - 实现服务端拦截器:在业务服务中注册
HandlerInterceptor或Java Filter,在preHandle中从请求头或认证对象读取租户ID并存入TenantContext,在afterCompletion中调用clear()。 - 配置ORM多租户插件:以MyBatis为例,实现
Interceptor接口拦截Executor.query(),从TenantContext获取租户ID并追加SQL条件。 - 异步线程适配:对
@Async或自定义线程池,包装Runnable或Callable,将父线程的TenantContext拷贝到子线程(使用TransmittableThreadLocal或手动传递)。 - 跨服务传递:在RPC客户端拦截器中自动将
TenantContext.getTenantId()附加到gRPC Metadata或HTTP头。
验收标准
| 条目 | 验证方式 | 预期结果 |
|---|---|---|
| 同步请求租户隔离 | 使用不同租户的JWT访问同一接口 | 返回各自租户的数据,不存在数据交叉 |
| 异步线程上下文有效 | 在@Async方法中使用TenantContext | 子线程正确获取与请求一致的租户ID |
| 请求结束后无泄漏 | 持续压力测试,监控线程局部变量 | ThreadLocal内存不增长,无陈旧租户残留 |
| 伪造租户ID被拦截 | 直接向服务发X-Tenant-ID请求头 | 被网关或服务端校验拒绝,返回401/403 |
| 跨服务透传成功 | 微服务A调用B,B的上下文包含正确租户ID | B的数据访问按租户隔离 |
最佳实践提醒:在Spring Boot项目中,可直接使用
RequestContextHolder配合单独封装的TenantContext,但需注意RequestContextHolder本身依赖ServletRequest,不适合异步场景。推荐使用独立的TransmittableThreadLocal实现,并集成到TaskDecorator中自动传递。
本章详细阐述了租户ID在请求全链路中的传递机制、组件职责与实现步骤。下一环节将进入《第5章:数据库多租户隔离策略:共享表、独立Schema与混合模型》,讨论底层数据存储层面的隔离设计。