核心是将tenant_id作为业务逻辑默认参数,通过ScopedValue可靠传递、自动生效、难以绕过;封装数据访问层强制租户隔离;透传跨服务调用上下文;统一异常与日志租户归因。

封装租户标识上下文的核心,是把 tenant_id 变成业务逻辑的“默认参数”,而不是每次都要手动传、手动判、手动拼条件。关键不在封装得多漂亮,而在是否可靠传递、是否自动生效、是否难以绕过。
用 ThreadLocal 或 ScopedValue 封装上下文容器
最基础也最关键的一步:提供一个线程(或作用域)级的租户存储入口。Java 19+ 推荐用 ScopedValue 替代传统 ThreadLocal,尤其在虚拟线程场景下:
- ScopedValue 自动随虚拟线程继承,无需手动 copy,避免子线程丢失上下文
- 声明为
private static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance(); - 入口处(如 Filter 或 WebMvcConfigurer)调用
ScopedValue.where(TENANT_ID, tenantId, () -> chain.doFilter(req, res)) - 业务层直接
TENANT_ID.get()获取,不依赖 Spring 的 RequestContextHolder
封装租户感知的数据访问层
不让 DAO 或 Repository 暴露原始 SQL 或无租户防护的接口,而是通过封装强制隔离:
- 定义统一查询构造器:
TenantQuery.selectFrom("orders").where("status = ?", "PAID"),内部自动注入AND tenant_id = ? - MyBatis 使用
<sql>片段定义通用租户过滤模板,所有 mapper 引用它 - JPA 实体类继承抽象基类
TenantEntity,含@Column(name = "tenant_id", updatable = false, insertable = false),并配合@PrePersist自动填充 - 禁止开放
jdbcTemplate.query(String sql, ...)这类裸 SQL 方法;只暴露queryForTenant(String table, Object...)
封装跨服务/异步调用的租户透传机制
单机线程内上下文好办,但远程调用、消息队列、定时任务容易断链:
立即学习“Java免费学习笔记(深入)”;
- HTTP 调用时,拦截器自动在 header 中添加
X-Tenant-ID: xxx - RabbitMQ/Kafka 发送消息前,将当前租户 ID 注入 message headers 或 payload 包装体(如
TenantMessage<Order>) - 定时任务触发时,不直接写
@Scheduled,而是通过租户调度中心注册带 tenantId 的 JobDetail - 使用 Alibaba TransmittableThreadLocal(TTL)或 Spring 的
TaskDecorator,确保线程池任务能继承父线程租户值
封装租户级异常与日志归因能力
让错误和日志天然带租户身份,不靠人工打标:
- DAO 层捕获 SQLException 后,包装为
TenantDatabaseException(tenantId, tableName, e),保留原始 cause - 日志框架(如 Logback)配置 MDC,在请求入口自动 put
"tenant_id", tenantId,后续所有 log 自动携带 - 监控埋点统一从上下文取 tenantId,告警规则支持按租户分组、独立阈值
- 禁止在 service 层 catch 异常后仅打印日志却不 rethrow —— 这会切断租户上下文与根因的关联


















