线程染色是让请求在网关入口携带并传递租户身份,通过threadlocal绑定与清理、rpc/异步透传、数据层自动注入隔离条件、运行时资源按租户隔离,实现多租户互不干扰且业务代码无感。

线程染色不是给线程“上色”,而是让每个请求在进入系统时,就携带明确的租户身份,并在整个调用链中持续传递、不丢失、不污染。它要解决的核心问题是:**同一个服务实例里,如何让不同租户的数据操作互不干扰,且业务代码几乎不用改**。
租户上下文必须在网关入口就绑定
不能等请求进到某个服务才去查租户,那样已经晚了。网关是所有流量的统一入口,也是唯一能稳定提取租户标识的位置:
- 优先从标准请求头(如 X-Tenant-ID 或 JWT 的 tenant claim)提取,结构清晰、可验证
- 次选子域名(tenant-a.example.com)、路径前缀(/t/tenant-b/api)或 Cookie 中的 hint 字段
- 提取后立即写入线程上下文容器(如 TenantContextHolder.set(tenantId)),并确保后续所有同步逻辑都能直接读取
- 关键动作:在 Filter 的 doFilter 结束前,必须调用 clear() 清理 ThreadLocal,避免线程复用导致上下文残留
染色信息要穿透异步和RPC调用链
单靠 ThreadLocal 只能管住当前线程,一旦遇到线程池、CompletableFuture、Dubbo 或 Kafka,上下文就会断掉:
- Dubbo 场景下,用 RpcContext.getClientAttachment().setAttachment("TENANT_ID", tenantId) 把租户 ID 注入调用附件,服务端通过 RpcContext.getServerAttachment().getAttachment("TENANT_ID") 拿到并重新绑定上下文
- 异步任务(如 CompletableFuture.supplyAsync)不能直接传 Runnable,要用封装方法显式携带租户 ID:withTenant(tenantId, () -> doSomething())
- 虚拟线程环境下,推荐使用 ScopedValue 替代 ThreadLocal,JVM 会自动在虚拟线程切换时传播值,更安全可靠
数据访问层自动注入隔离条件
业务代码不该手动拼 WHERE tenant_id = ?,而应由框架统一拦截处理:
- MyBatis-Plus 提供 tenantLine 插件,开启后自动为所有查询、更新语句追加租户字段过滤
- ShardingSphere 的多租户插件支持按 tenant_id 动态路由到不同库或 Schema,无需业务感知
- JPA 场景可用 Hibernate 的 @Filter + enableFilter 在 EntityManager 级别启用租户过滤器
- 无论哪种方式,都需配合前置校验:执行 SQL 前检查 TenantContextHolder.get() != null,防止未染色请求绕过隔离
配置与资源也要跟着租户走
隔离不只是数据,还包括限流、熔断、缓存、日志等运行时行为:
- Resilience4j 不要用全局 Registry,而是按租户 ID 创建独立 CircuitBreakerRegistry 实例,各自配各自的失败率阈值
- 限流规则(如 RateLimiter)也按租户维度初始化,避免 A 租户突发流量拖垮 B 租户的服务能力
- 日志框架(如 Logback)通过 MDC 注入 tenantId,使每条日志自带租户标签,便于归因和排查
- 缓存 Key 命名强制包含租户前缀(如 cache:tenant-a:user:1001),杜绝跨租户缓存污染











