news 2026/10/15 12:53:42

OpenFrame OSS Lib 多租户隔离实战:TenantContext 与 TenantAwareMongoTemplate 实现原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenFrame OSS Lib 多租户隔离实战:TenantContext 与 TenantAwareMongoTemplate 实现原理

【免费下载链接】openframe-oss-lib

Shared libraries between all services

项目地址:https://gitcode.com/gh_mirrors/op/openframe-oss-lib
点击查看免费下载

OpenFrame OSS Lib(openframe-oss-lib)是一个共享服务库仓库,其中的多租户隔离方案是它的亮点之一:通过TenantContext(基于 ThreadLocal 的租户上下文)+TenantAwareMongoTemplate(自动注入 tenantId 的 Mongo 模板),让每个租户的数据严格隔离,业务代码几乎"零感知"。本文带你完整拆解这套隔离机制的工作原理。

多租户隔离为什么难?

在 SaaS 或多租户系统中,所有租户共用同一个数据库。如果每条查询都要手动拼接tenantId条件,一旦漏掉一处,就会发生跨租户数据泄漏——这是多租户系统最严重的安全事故之一。

OpenFrame 的解法是"两道防线":

防线组件职责
请求层TenantContext+TenantContextFilter解析并持有"当前请求属于哪个租户"
数据层TenantAwareMongoTemplate对每条 MongoDB 查询自动注入tenantId过滤条件

相关源码分布在三个模块:

  • openframe-authorization-service-core/ —— 租户上下文(请求解析)
  • openframe-data-mongo-common/ —— 租户标记接口与租户 ID 提供器
  • openframe-data-mongo-sync/ —— 租户感知模板与仓储

第一道防线:TenantContext(ThreadLocal 线程上下文)

核心实现只有 20 行,位于 TenantContext.java:

public final class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }

设计要点:

  • 线程隔离:ThreadLocal保证 Servlet 线程模型下每个请求线程持有独立的租户 ID,互不串扰;
  • 显式清理:clear()必须由调用方在请求结束时执行,防止线程复用导致租户信息"粘"到下一个请求;
  • 零依赖:静态工具类,任何 Service 都能一行取到当前租户:TenantContext.getTenantId()。

TenantContextFilter:租户身份从哪里来?

TenantContextFilter.java 是OncePerRequestFilter,按优先级从三个来源解析租户 ID:

  1. URL 路径(最高优先级):形如/{tenantId}/oauth2/authorize的 OAuth 端点,取第一段路径作为租户 ID(排除login、sso、public等保留词);
  2. 查询参数:?tenant=<tenantId>显式指定;
  3. HTTP Session:读取 Session 中的TENANT_ID,保证多步 OAuth2 流程中租户上下文延续。

解析成功后会同时写入TenantContext和 Session。两个关键的安全细节值得注意 ⚠️:

  • 租户切换即作废:如果 Session 中已存在的租户 ID 与本次请求不一致,Filter 会直接session.invalidate(),防止跨租户会话污染(唯一的例外是 onboarding 临时租户向正式租户的迁移);
  • finally 中清理:filterChain.doFilter()包在 try/finally 中,请求结束必然执行TenantContext.clear(),杜绝线程泄漏。

💡 官方架构文档对该模块的完整说明见 docs/reference/architecture/authorization-service-core/tenant-context.md。

第二道防线:TenantAwareMongoTemplate(查询自动加租户口径)

TenantAwareMongoTemplate.java 继承自 Spring Data 的MongoTemplate,是整个数据层隔离的心脏。

只隔离"该隔离的"实体:TenantScoped 标记接口

模板不会给所有查询都注入条件,它只处理实现了 TenantScoped 标记接口的实体:

public interface TenantScoped { String getTenantId(); void setTenantId(String tenantId); }

所有租户级文档(Machine、Ticket、Notification等)都实现该接口,文档中固定携带tenantId字段。

核心机制:scope() 一行注入

模板重写了find、findOne、count、exists、updateFirst、updateMulti、upsert、remove、findAndRemove等全部 CRUD 入口,统一先经过 scope():

protected Query scope(Query query, Class<?> entityClass) { if (entityClass != null && TenantScoped.class.isAssignableFrom(entityClass) && !query.getQueryObject().containsKey("tenantId")) { query.addCriteria(Criteria.where("tenantId").is(tenantIdProvider.getTenantId())); } return query; }

效果是:你写repository.findByMachineId(machineId),模板会自动把查询变成{ "machineId": "...", "tenantId": "当前租户" }——业务代码完全不用关心租户。

连 Spring Data 派生查询也没放过

Spring Data MongoDB 4.x 的派生查询(findByXxx)走的是ExecutableFindOperation流式入口,会绕过普通的find(Query, Class)方法。模板重写了 query(Class) 方法,用 JDK 动态代理包装返回的ExecutableFind,拦截matching(Query)调用注入tenantId——派生查询、as(Class)、inCollection(...)全链路都被覆盖。

两个必须知道的使用约定

  1. 聚合查询不自动注入:aggregate()被刻意留空不重写,聚合管道必须手动添加$match。模板为此提供了便捷方法:

    mongoTemplate.tenantCriteria(); // 生成 tenantId 匹配条件,用作第一个 $match mongoTemplate.tenantId(); // 用于复合键场景
  2. 禁止裸用 MongoTemplate:官方安全文档明确要求,租户级数据一律走TenantAwareMongoTemplate,详见 docs/development/security/README.md。

自动装配:一个开关切换整套隔离

TenantIsolationAutoConfiguration.java 负责装配:

  • 由配置项openframe.tenant-isolation.enabled=true触发(@ConditionalOnProperty);
  • 将TenantAwareMongoTemplate以@Primary的mongoTemplateBean 注册到容器,Spring Boot 默认的 MongoTemplate 自动退让——隔离模式下,租户感知模板"就是"唯一的 MongoTemplate;
  • TenantIdProvider使用@Lazy注入,代理在首次查询时才解析真实实现,解耦构造顺序。

租户 ID 从哪来?由TenantIdProvider接口抽象,默认实现 DefaultTenantIdProvider 读取环境变量TENANT_ID(默认值oss)——这正体现了"每租户一个 Pod"的部署模型:隔离不仅靠查询过滤,还靠进程级部署。

仓储层规范:让违规直接编译期可见

为了杜绝绕过模板的"野路子",项目建立了三层约定:

  1. TenantAwareRepository:租户侧仓储接口的组合注解(@Repository+ 排除标记),共享服务可通过@EnableMongoRepositories的排除过滤器整体跳过这些仓储;
  2. TenantAwareRepositorySupport:所有自定义仓储实现的基类,强制构造器注入TenantAwareMongoTemplate,并提供tenantCriteria()/tenantId()便捷方法;
  3. ArchUnit 架构测试:强制com.openframe.data.repository下所有*RepositoryImpl必须继承 Support 基类、且不允许声明裸MongoTemplate字段——违反规则测试直接失败。

一个典型的租户级文档 + 仓储组合(新增文档流程参考 docs/development/setup/local-development.md):

@Data @Document(collection = "my_collection") public class MyDocument implements TenantScoped { @Id private String id; private String tenantId; // 业务字段... } public interface MyDocumentRepository extends TenantAwareRepository<MyDocument, String> { // 派生查询自动带租户口径 }

租户内自增序列:TenantSequence

连自增 ID 也是按租户隔离的。TenantSequence 通过复合唯一索引{'tenantId': 1, 'name': 1}实现"每租户一套编号",例如工单号(ticket_number)各租户从 1 开始互不干扰——查询时同样依赖模板自动注入的tenantId过滤,无需复合键查询。

总结:这套隔离方案的设计精髓

设计点做法价值
上下文持有ThreadLocal + 请求级 Filter线程隔离、无状态传递、显式清理
身份解析路径 > 查询参数 > Session 三级回退兼容 OAuth 端点、显式指定与多步流程
会话安全租户切换即作废 Session阻断跨租户会话污染
数据隔离模板层统一scope()注入业务代码零感知,杜绝漏拼条件
派生查询覆盖动态代理拦截matching()Spring Data 4.x 全入口无死角
架构守护组合注解 + 基类 + ArchUnit违规用法在 CI 直接暴露
部署模型每租户一个 Pod +TENANT_ID环境变量查询过滤之外的进程级双保险

📚 延伸阅读:

  • 整体架构:docs/development/architecture/README.md(其中"Multi-tenancy: ThreadLocal TenantContext"为关键设计决策)
  • 安全最佳实践:docs/development/security/README.md
  • 仓储层架构文档:docs/reference/architecture/data-mongo-sync-repositories/data-mongo-sync-repositories.md

对新手来说,理解这套机制后做三件事就够了:给租户级文档实现TenantScoped、让仓储接口继承TenantAwareRepository、聚合查询记得手动加tenantCriteria()——剩下的,交给框架。

【免费下载链接】openframe-oss-lib

Shared libraries between all services

项目地址:https://gitcode.com/gh_mirrors/op/openframe-oss-lib
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 12:53:33

电力系统暂态稳定性分析与Matlab仿真实践

1. 项目背景与核心问题电力系统暂态稳定性分析是保障电网安全运行的关键技术环节。当系统遭受大扰动&#xff08;如短路故障&#xff09;时&#xff0c;发电机转子角度会出现剧烈摆动&#xff0c;这种动态过程如果失控将导致大面积停电事故。我在某区域电网调度中心工作期间&am…

作者头像 李华
网站建设 2026/10/15 12:52:56

Sketch设计系统入门:Symbol与响应式约束实战指南

1. 项目概述&#xff1a;为什么“初识Sketch”这个标题值得花一整篇干货来拆解&#xff1f;Sketch不是Photoshop&#xff0c;也不是Figma&#xff0c;更不是某个过气设计工具的翻版——它是一套为数字界面而生的、高度垂直的矢量设计工作流。我第一次在某高校UI实验室看到导师用…

作者头像 李华
网站建设 2026/10/15 12:52:25

B2B销售邮件采集工具全解析:原理、选型与实操指南

1. 销售勘探场景下的邮件信息采集工具全景拆解做B2B销售的人都有一个共识&#xff1a;客户线索的质量直接决定成单率。而在所有线索字段里&#xff0c;工作邮箱又是最核心的那一个——有了它&#xff0c;你才能发起第一封触达邮件&#xff0c;才能进入后续的跟进序列。问题是&a…

作者头像 李华
网站建设 2026/10/15 12:51:42

TiDB三类JOIN算法原理与实战选型指南

1. 为什么“join方式选错”会让TiDB查询慢得像在等泡面&#xff1f;我第一次在某电商后台系统里看到一个报表SQL跑出17秒响应时&#xff0c;第一反应是查执行计划——结果发现EXPLAIN ANALYZE输出里赫然写着HashJoin&#xff0c;但右表扫描行数高达2300万&#xff0c;而左表才不…

作者头像 李华
网站建设 2026/10/15 12:51:33

LPC1114实战:从启动文件到外设驱动的避坑指南

简介&#xff1a;基于NXP LPC1114&#xff08;ARM Cortex-M0内核&#xff09;微控制器的实际例程与教程压缩包&#xff0c;面向嵌入式初学者与课程学习者&#xff0c;覆盖GPIO、UART、SPI、I2C、ADC、PWM、定时器中断等常用模块的MDK工程示例。包内共658个文件&#xff0c;以o、…

作者头像 李华
网站建设 2026/10/15 12:51:33

SMT软板治具选型与操作指南

1. SMT软板治具概述在电子制造业中&#xff0c;SMT&#xff08;表面贴装技术&#xff09;软板治具是保证柔性电路板&#xff08;FPC&#xff09;高效、精准组装的关键工具。不同于刚性PCB&#xff0c;柔性电路板因其轻薄、可弯曲的特性&#xff0c;在SMT贴装过程中面临定位困难…

作者头像 李华