在 SaaS 系统的构建中,多租户是最底层的地基。一个优秀的租户架构,不仅要实现数据的物理隔离,更要保证业务开发的无感 与链路的绝对安全。
本文将从表结构设计 、前后端交互协议 到核心代码实现,全链路拆解一套生产级的多租户方案。
一、 表结构设计
在"共享数据库、共享 Schema"的模式下,数据库设计是隔离的基础。
1. 标准租户表
对于所有需要隔离的业务表(如订单、用户),必须强制包含租户字段。建议设计一个 TenantBaseDO 基类,所有业务实体继承该类,统一规范字段。
- 字段名 :
tenant_id - 类型 :
bigint(Long) - 索引:通常建议建立索引,或作为联合索引的第一列,确保查询性能。
java
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {
private Long tenantId;
}
2. 全局系统表
并不是所有表都需要隔离。对于系统级的配置表(如 sys_menu, sys_dict),它们属于"全平台共享资源",无需添加 tenant_id,需要通过配置或注解将其标记为"忽略隔离"。
二、 前后端交互设计
这是容易混淆的地方:前端到底该怎么传参?后端如何区分"我是谁"和"我要看谁"?
我们将交互分为两种核心模式:
1. 普通用户模式
普通用户登录后,Token 代表了他的身份,同时也锁死了他的数据范围。
- 前端行为 :Header 中携带
Tenant-Id(可选)。 - 后端逻辑 :如果 Header 没传,后端自动从 Token 中解析用户的
tenantId并填入上下文。如果传了,后端必须强制校验Token.tenantId == Header.tenantId,防止越权。 - 一对多情形 :如果一个用户可以属于多个租户,此种方案就要设计为:Header中携带用户具体要操作的
Tenant-Id,后端校验Tenant-Id是否为用户的租户。并将Tenant-Id放入上下文。
2. 超级管理员模式
超级管理员可以查看所有租户的数据。
- 前端行为 :Header 中携带
Visit-Tenant-Id(显式声明)。 - 后端逻辑 :后端识别到该 Header 后,校验当前用户是否拥有
system:tenant:visit权限。若通过,则将上下文中的租户 ID 临时修改为Visit-Tenant-Id。
三、 核心代码设计
第一道关卡:上下文初始化
组件:TenantContextWebFilter
这是一个基于 OncePerRequestFilter 的过滤器。它的定位是 "前台接待",只负责通过,不负责安保。
- 核心职责 :从 HTTP Header 中提取
Tenant-Id,并将其写入全局的TenantContextHolder(ThreadLocal)。 - 关键细节 :必须在
finally块中执行clear()操作。因为 Tomcat 线程池是复用的,如果不清理,会导致严重的"数据串号"事故。
java
public class TenantContextWebFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 获取Header中的tenantid字段
Long tenantId = WebFrameworkUtils.getTenantId(request);
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
// 清理
TenantContextHolder.clear();
}
}
}
第二道关卡:安全防线与防越权
组件:TenantSecurityWebFilter
这是整个架构中最重要的一环,定位是 "安保人员"。它必须执行在 ContextFilter 之后,拦截所有非法请求。
核心校验逻辑:
- 防越权 (Anti-Spoofing) :
当用户已登录时,必须校验User.tenantId与Header.tenantId是否一致。
- 为什么要做这一步? 防止恶意用户通过 Postman 篡改 Header,试图访问其他租户的数据。
- 状态检查 :
校验目标租户是否处于"开启"状态,拦截已过期或被封禁的租户请求。 - 白名单机制 :
对于登录、Swagger 等公共接口,通过isIgnoreUrl逻辑直接放行。
java
public class TenantSecurityWebFilter extends ApiRequestFilter {
private final TenantProperties tenantProperties;
/**
* 允许忽略租户的 URL 列表
*
* 目的:解决 <a href="https://gitee.com/zhijiantianya/yudao-cloud/issues/ICUQL9">修改配置会导致 @TenantIgnore Controller 接口过滤失效</>
*/
private final Set<String> ignoreUrls;
private final AntPathMatcher pathMatcher;
private final GlobalExceptionHandler globalExceptionHandler;
private final TenantFrameworkService tenantFrameworkService;
public TenantSecurityWebFilter(WebProperties webProperties,
TenantProperties tenantProperties,
Set<String> ignoreUrls,
GlobalExceptionHandler globalExceptionHandler,
TenantFrameworkService tenantFrameworkService) {
super(webProperties);
this.tenantProperties = tenantProperties;
this.ignoreUrls = ignoreUrls;
this.pathMatcher = new AntPathMatcher();
this.globalExceptionHandler = globalExceptionHandler;
this.tenantFrameworkService = tenantFrameworkService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Long tenantId = TenantContextHolder.getTenantId();
// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。
LoginUser user = SecurityFrameworkUtils.getLoginUser();
if (user != null) {
// 如果获取不到租户编号,则尝试使用登陆用户的租户编号
if (tenantId == null) {
tenantId = user.getTenantId();
TenantContextHolder.setTenantId(tenantId);
// 如果传递了租户编号,则进行比对租户编号,避免越权问题
} else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
user.getTenantId(), user.getId(), user.getUserType(),
TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
"您无权访问该租户的数据"));
return;
}
}
// 如果非允许忽略租户的 URL,则校验租户是否合法
if (!isIgnoreUrl(request)) {
// 2. 如果请求未带租户的编号,不允许访问。
if (tenantId == null) {
log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),
"请求的租户标识未传递,请进行排查"));
return;
}
// 3. 校验租户是合法,例如说被禁用、到期
try {
tenantFrameworkService.validTenant(tenantId);
} catch (Throwable ex) {
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
ServletUtils.writeJSON(response, result);
return;
}
} else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错
if (tenantId == null) {
TenantContextHolder.setIgnore(true);
}
}
// 继续过滤
chain.doFilter(request, response);
}
private boolean isIgnoreUrl(HttpServletRequest request) {
String apiUri = request.getRequestURI().substring(request.getContextPath().length());
// 快速匹配,保证性能
if (CollUtil.contains(tenantProperties.getIgnoreUrls(), apiUri)
|| CollUtil.contains(ignoreUrls, apiUri)) {
return true;
}
// 逐个 Ant 路径匹配
for (String url : tenantProperties.getIgnoreUrls()) {
if (pathMatcher.match(url, apiUri)) {
return true;
}
}
for (String url : ignoreUrls) {
if (pathMatcher.match(url, apiUri)) {
return true;
}
}
return false;
}
}
第三道关卡:超级管理员的租户切换
组件:TenantVisitContextInterceptor
为什么这里要用 Interceptor 而不是 Filter?因为"租户切换"是一个强业务行为,它依赖于 Bean 注入(查询权限服务)和 Spring MVC 的全局异常处理。
核心执行流程 (preHandle):
- 意图检测 :检查请求头是否包含
Visit-Tenant-Id。 - 权限"核武器"检查:调用权限服务,确认当前用户是否拥有"租户切换"的超级权限。
- 偷梁换柱 :校验通过后,强制覆盖
TenantContextHolder中的tenantId。 - 现场还原 (afterCompletion):请求结束后,必须将上下文恢复为用户原本的 ID,确保不影响后续处理。
java
@RequiredArgsConstructor
@Slf4j
public class TenantVisitContextInterceptor implements HandlerInterceptor {
private static final String PERMISSION = "system:tenant:visit";
private final TenantProperties tenantProperties;
private final SecurityFrameworkService securityFrameworkService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 如果和当前租户编号一致,说明是普通用户或超级管理员用户要查询自己的租户,直接放过
Long visitTenantId = WebFrameworkUtils.getVisitTenantId(request);
if (visitTenantId == null) {
return true;
}
if (ObjUtil.equal(visitTenantId, TenantContextHolder.getTenantId())) {
return true;
}
// 其它情况说明是有人要查询自己所在租户之外的信息,因此需要校验是否为超级管理员
// 首先必须是登录用户
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
return true;
}
// 校验用户是否可切换租户
if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {
throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换租户");
}
// 【重点】切换租户编号
loginUser.setVisitTenantId(visitTenantId);
TenantContextHolder.setTenantId(visitTenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 【重点】清理切换,换回原租户编号
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser != null && loginUser.getTenantId() != null) {
TenantContextHolder.setTenantId(loginUser.getTenantId());
}
}
}
四、 数据层的无感隔离
当请求通过上述三道关卡后,TenantContextHolder 中已经存储了这一刻准确的 tenant_id。接下来就是 MyBatis Plus 发挥作用的时候。
组件:TenantLineInnerInterceptor & TenantDatabaseInterceptor
通过实现 MP 的租户插件接口,系统会在 SQL 执行前自动拦截,根据上下文动态拼接 WHERE tenant_id = ?。同时,配合 ignoreTable 策略,决定哪些表需要隔离。
java
public class TenantDatabaseInterceptor implements TenantLineHandler {
private final Map<String, Boolean> ignoreTables = new HashMap<>();
public TenantDatabaseInterceptor(TenantProperties properties) {
// 不同 DB 下,大小写的习惯不同,所以需要都添加进去
properties.getIgnoreTables().forEach(table -> {
addIgnoreTable(table, true);
});
// 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错
addIgnoreTable("DUAL", true);
}
@Override
public Expression getTenantId() {
return new LongValue(TenantContextHolder.getRequiredTenantId());
}
@Override
public boolean ignoreTable(String tableName) {
// 情况一,全局忽略多租户
if (TenantContextHolder.isIgnore()) {
return true;
}
// 情况二,忽略多租户的表
tableName = SqlParserUtils.removeWrapperSymbol(tableName);
Boolean ignore = ignoreTables.get(tableName.toLowerCase());
if (ignore == null) {
ignore = computeIgnoreTable(tableName);
synchronized (ignoreTables) {
addIgnoreTable(tableName, ignore);
}
}
return ignore;
}
private void addIgnoreTable(String tableName, boolean ignore) {
ignoreTables.put(tableName.toLowerCase(), ignore);
ignoreTables.put(tableName.toUpperCase(), ignore);
}
private boolean computeIgnoreTable(String tableName) {
// 找不到的表,说明不是 yudao 项目里的,不进行拦截(忽略租户)
TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
if (tableInfo == null) {
return true;
}
// 如果继承了 TenantBaseDO 基类,显然不忽略租户
if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {
return false;
}
// 如果添加了 @TenantIgnore 注解,则忽略租户
TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);
return tenantIgnore != null;
}
}
五、 Redis 缓存
我们需要在 Redis 层实现两件事:Key 的自动隔离(自动加上Tenant)。
缓存的自动隔离
Spring Cache 的 @Cacheable 是开发中最常用的注解,默认生成的 Redis Key 通常是 cacheName::key。我们的目标是:让开发者照常写注解,底层自动拼接租户 ID。
核心组件:TenantRedisCacheManager
这个类继承自 TimeoutRedisCacheManager,核心逻辑在于重写 getCache(String name) 方法。它像一个中间人,在创建缓存空间时,动态地修改 cacheName。
代码逻辑设计:
java
@Override
public Cache getCache(String name) {
// 1. 判断是否需要隔离(未开启全局忽略 + 上下文有租户ID + 不在白名单内)
if (!TenantContextHolder.isIgnore()
&& TenantContextHolder.getTenantId() != null
&& !CollUtil.contains(ignoreCaches, name)) {
// 2. 【核心动作】拼接租户后缀
// 原本的 cacheName 是 "user_info",现在变成 "user_info:1001"
name = name + ":" + TenantContextHolder.getTenantId();
}
// 3. 继续后续创建流程
return super.getCache(name);
}
实际效果对比:
假设代码如下:@Cacheable(value = "users", key = "#id")
-
全局字典(白名单内):
- Redis Key:
users::gender_types - 结果:所有租户共享同一份数据。
- Redis Key:
-
业务数据(租户 1001):
- Redis Key:
users:1001::888 - 结果:物理隔离,互不干扰。
- Redis Key:
六、 注册Bean
在这个配置类中,我们不仅要实例化 Bean,更重要的是要利用 Spring 的机制解决 "执行顺序" 、"依赖注入" 和 "元数据扫描" 三大难题。
1. 自动配置类定义
多租户是一个侵入性很强的功能。在某些私有化部署场景下,客户可能只需要单租户模式。因此,我们需要一个"总闸"。
java
@AutoConfiguration
// 只有当配置文件中 yudao.tenant.enable=true 时,整个多租户模块才生效
// matchIfMissing = true 表示默认开启
@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true)
@EnableConfigurationProperties(TenantProperties.class)
public class YudaoTenantAutoConfiguration {
// ... 组件注册逻辑
}
通过 @ConditionalOnProperty,我们实现了"一套代码,两种模式(单/多租户)"的灵活切换。
2. TenantIgnore机制
问题背景 :
我们在 Controller 上使用了 @TenantIgnore 注解来标记不需要隔离的接口。但是,TenantSecurityWebFilter 是运行在 Servlet 容器层的,它根本看不懂 Spring MVC 的注解,也不知道当前请求对应哪个 Controller 方法。
解决方案 :
在自动配置阶段(应用启动时),主动扫描 Spring 容器中所有的 Controller,提取出带有 @TenantIgnore 注解的 URL,提前生成一份 "白名单",并在实例化 Filter 时传进去。
java
/**
* 核心逻辑:解决 Filter 无法读取 Controller 上注解的问题
*/
private Set<String> getTenantIgnoreUrls(ApplicationContext context) {
Set<String> ignoreUrls = new HashSet<>();
// 1. 获取所有 RequestMapping (URL <-> Method 映射)
RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class);
Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
// 2. 遍历所有接口,检查是否有 @TenantIgnore
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : map.entrySet()) {
HandlerMethod handlerMethod = entry.getValue();
if (handlerMethod.hasMethodAnnotation(TenantIgnore.class)
|| handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) {
// 3. 提取 URL 加入白名单
ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns());
}
}
return ignoreUrls;
}
3. 控制注册顺序
A. Web 层顺序
我们必须保证:上下文初始化 (Context) 先于 安全校验 (Security)。
java
@Bean
public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() {
FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new TenantContextWebFilter());
registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);
return registrationBean;
}
@Bean
public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties,
WebProperties webProperties,
GlobalExceptionHandler globalExceptionHandler,
TenantFrameworkService tenantFrameworkService) {
FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(),
globalExceptionHandler, tenantFrameworkService));
registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);
return registrationBean;
}
B. 数据库层顺序
我们必须保证:多租户过滤 先于 分页统计 。
否则,分页插件生成的 SELECT COUNT(*) 语句将统计全表数据,导致严重的数据泄露。
java
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
MybatisPlusInterceptor interceptor) {
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
// 添加到 interceptor 中
// 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
MyBatisUtils.addInterceptor(interceptor, inner, 0);
return inner;
}
4. 替换核心组件
最后,我们需要用自定义的实现替换掉 Spring Boot 的默认组件。
- Redis :使用
@Primary注解注册TenantRedisCacheManager,接管系统的缓存管理。 - MVC :通过
WebMvcConfigurer将TenantVisitContextInterceptor注册到 Spring MVC 的拦截链路中。
5. 完整配置类代码
java
@AutoConfiguration
@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户
@EnableConfigurationProperties(TenantProperties.class)
public class YudaoTenantAutoConfiguration {
@Resource
private ApplicationContext applicationContext;
@Bean
public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) {
return new TenantFrameworkServiceImpl(tenantApi);
}
// ========== AOP ==========
@Bean
public TenantIgnoreAspect tenantIgnoreAspect() {
return new TenantIgnoreAspect();
}
// ========== DB ==========
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
MybatisPlusInterceptor interceptor) {
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
// 添加到 interceptor 中
// 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
MyBatisUtils.addInterceptor(interceptor, inner, 0);
return inner;
}
// ========== WEB ==========
@Bean
public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() {
FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new TenantContextWebFilter());
registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);
return registrationBean;
}
@Bean
public TenantVisitContextInterceptor tenantVisitContextInterceptor(TenantProperties tenantProperties,
SecurityFrameworkService securityFrameworkService) {
return new TenantVisitContextInterceptor(tenantProperties, securityFrameworkService);
}
@Bean
public WebMvcConfigurer tenantWebMvcConfigurer(TenantProperties tenantProperties,
TenantVisitContextInterceptor tenantVisitContextInterceptor) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantVisitContextInterceptor)
.excludePathPatterns(tenantProperties.getIgnoreVisitUrls().toArray(new String[0]));
}
};
}
// ========== Security ==========
@Bean
public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties,
WebProperties webProperties,
GlobalExceptionHandler globalExceptionHandler,
TenantFrameworkService tenantFrameworkService) {
FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(),
globalExceptionHandler, tenantFrameworkService));
registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);
return registrationBean;
}
/**
* 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中
*
* @return 忽略租户的 URL 集合
*/
private Set<String> getTenantIgnoreUrls() {
Set<String> ignoreUrls = new HashSet<>();
// 获得接口对应的 HandlerMethod 集合
RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping)
applicationContext.getBean("requestMappingHandlerMapping");
Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods();
// 获得有 @TenantIgnore 注解的接口
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethodMap.entrySet()) {
HandlerMethod handlerMethod = entry.getValue();
if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class) // 方法级
&& !handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) { // 接口级
continue;
}
// 添加到忽略的 URL 中
if (entry.getKey().getPatternsCondition() != null) {
ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns());
}
if (entry.getKey().getPathPatternsCondition() != null) {
ignoreUrls.addAll(
convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
}
}
return ignoreUrls;
}
// ========== Redis ==========
@Bean
@Primary // 引入租户时,tenantRedisCacheManager 为主 Bean
public RedisCacheManager tenantRedisCacheManager(RedisTemplate<String, Object> redisTemplate,
RedisCacheConfiguration redisCacheConfiguration,
YudaoCacheProperties yudaoCacheProperties,
TenantProperties tenantProperties) {
// 创建 RedisCacheWriter 对象
RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
// 创建 TenantRedisCacheManager 对象
return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches());
}
}
七、 Q&A
我注意到返回Bean的时候,返回的是FilterRegistrationBean<TenantSecurityWebFilter>。为什么不直接返回TenantSecurityWebFilter,要加上一个FilterRegistrationBean
Filter 本身:通常只包含 doFilter 逻辑(怎么过滤)。RegistrationBean:包含 urlPatterns 配置(过滤谁)以及order配置(setOrder(-99))配置前后执行顺序。而我们前文提到过,一定是先执行TenantContextWebFilter放入上下文,后执行TenantSecurityWebFilter根据上下文判断用户租户权限
TenantVisitContextInterceptor用来实现超级管理员越租户访问。为什么要用HandlerInterceptor实现,而不用Filter实现
主要原因在于其与业务相关:
java
// 校验用户是否可切换租户
if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {
throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换租户");
}
业务逻辑通常伴随着业务异常。只有在 Interceptor 层,才能享受到 Spring MVC 提供的统一异常处理机制,从而保持代码的整洁和统一。如果非要在 Filter 里实现,代码得写成这样:
为了让 Filter 也能返回 JSON,你必须手动操作 Response 流,代码会变得非常丑陋:
java
// ❌ 繁琐示范:Filter 为了实现同样的功能
public void doFilter(...) {
if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {
// 1. 不能直接抛异常,必须手动设置状态码
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
// 2. 手动拼接 JSON 字符串(或者调用 Jackson 序列化)
String jsonResult = "{\"code\": 403, \"msg\": \"您无权切换租户\"}";
// 3. 手动写入流
response.getWriter().write(jsonResult);
// 4. 强制结束,不让请求往下走
return;
}
chain.doFilter(request, response);
}
这也验证了软件工程中的一个原则:把业务逻辑放在离业务处理器(Controller)更近的地方。
在前后端交互设计上,前端在请求header负载tenantid似乎是完全没有必要的,因为用户的tenantid是和accesstoken强绑定的,后端只需要拿到accesstoken就可以拿到用户的tenantid
有道理,但是如果在"一个用户对应多个租户"的场景下,前端就有必要传参,此次究竟是在操作哪个租户,因此需要传参tenantid。但在这种"一对一"场景下,确实冗余。
在前后端交互设计上,前端在请求header中除了传参tenantid外,还要传参visittenantid,解释一下两者的区别
在前后端交互设计中,Tenant-Id 用于标识用户的归属租户 ,它是后端进行安全边界校验的核心依据。后端在处理请求时,会强制校验 Token 中解析出的用户租户 ID 与 Header 中传递的 Tenant-Id 是否一致,若不一致则判定为越权访问并直接拦截。对于绝大多数普通用户,其数据访问范围被严格限制在归属租户内,因此该参数决定了默认的数据库查询范围。
Visit-Tenant-Id 则用于标识用户当前请求的目标租户 ,是管理员进行跨租户数据管理的上下文切换参数。当具有特定权限的管理员需要查询其他租户的数据时,前端需显式传递此参数。后端拦截器在验证用户拥有"跨租户访问权限"后,会将当前线程的数据库查询上下文强制覆盖为 Visit-Tenant-Id 的值,从而在不改变用户登录状态的前提下,将 SQL 查询范围指向目标租户。
java
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
MybatisPlusInterceptor interceptor) {
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
// 添加到 interceptor 中
// 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
MyBatisUtils.addInterceptor(interceptor, inner, 0);
return inner;
}
为了实现无感添加where租户条件,这里做了两步:1.调用MybatisPlusInterceptor对象的addInterceptor方法,并添加一个我们定义的TenantLineInnerInterceptor对象;2.注册一个TenantLineInnerInterceptor的Bean。这两个步骤哪个才是关键
第一步调用 MyBatisUtils.addInterceptor 是真正的关键步骤,因为它直接决定了租户插件的生效状态 和执行优先级 。MyBatis-Plus 的插件机制基于责任链模式运行,必须将自定义的 TenantLineInnerInterceptor 显式添加到主拦截器链中才能拦截 SQL;更重要的是,代码中强制指定索引为 0,确保了租户过滤逻辑优先于分页插件执行,从而避免分页查询时的 COUNT(*) 语句统计全量数据,保证了数据隔离的正确性。
第二步注册 Bean 主要是为了遵循 Spring 容器的管理规范,使该对象能被依赖注入或监控,但它本身并不直接驱动 MyBatis 的拦截逻辑。由于 MybatisPlusInterceptor(总拦截器)通常在其他地方已经被初始化,仅仅将租户插件声明为 Bean 并不会自动将其插入到总拦截器的特定位置,因此必须依靠第一步的手动添加来完成组件的组装与生效。
在 MyBatis-Plus 中,多租户隔离与通用数据权限的技术实现有何异同?它们是同一套模式吗?
结论先行:是的,它们在底层架构上完全属于同一套设计模式。
两者本质上都是基于 MyBatis-Plus 的插件机制(InnerInterceptor) 结合 JSqlParser(SQL 解析器) 实现的 "AOP 式 SQL 动态改写"。我们可以从共性、差异和协作三个维度来深度理解:
1. 共性:技术流程
无论是多租户还是数据权限,其代码落地都严格遵循 "定义策略 -> 构建拦截器 -> 注册生效" 的标准化流程:
- 第一步:定义 Handler
这是业务逻辑的载体。你需要实现特定的接口(TenantLineHandler或MultiDataPermissionHandler),在其中编写"决策代码"。例如:告诉插件当前租户 ID 是多少,或者当前用户能访问哪些部门的数据。 - 第二步:构建 Interceptor
这是 SQL 改写的引擎。你需要以 Handler 为基础,实例化对应的InnerInterceptor实现类(TenantLineInnerInterceptor或DataPermissionInterceptor)。它们都继承自 MP 的底层拦截器接口,负责解析 SQL 语法树并执行 Handler 指定的策略。 - 第三步:注册到MybatisPlusInterceptor
这是组装生效的关键。你需要将上述拦截器添加到 MP 的总管家MybatisPlusInterceptor中。注意 :这里必须手动控制顺序(addInterceptor),通常多租户插件需要排在最前面,以确保优先锁定数据范围。
2. 差异:定位与粒度
虽然技术同源,但它们的业务定位截然不同:
- 多租户 (TenantLine) 是 "基础设施级" 的隔离。
- 规则刚性 :通常只处理
tenant_id一个字段,逻辑标准且固定。 - 控制粒度 :主要是 表级控制 (通过
ignoreTable配置哪些表不需要隔离)。 - 语义:代表"物理世界的墙",绝对不可逾越。
- 规则刚性 :通常只处理
- 数据权限 (DataPermission) 是 "业务逻辑级" 的过滤。
- 规则灵活 :条件复杂多变,可能是
dept_id IN (...),也可能是user_id = ?,甚至是跨表子查询。 - 控制粒度 :主要是 方法/注解级控制 (配合
@DataPermission注解,灵活控制某个 Service 方法使用哪种策略)。 - 语义:代表"视野的窗",不同角色看到的范围不同。
- 规则灵活 :条件复杂多变,可能是
3. 协作关系:漏斗模型
在实际生产架构中,这两个拦截器通常是同时存在的。为了保证效率和逻辑正确,它们的执行顺序构成了"漏斗模型":
- 第一层(多租户拦截器):先执行。将数据池锁定在"当前租户"的大范围内(SaaS 基石)。
- 第二层(数据权限拦截器):后执行。在租户的大池子里,进一步根据用户角色过滤出"可见数据"。