Spring Boot 构建 SaaS 多租户架构

在 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 之后,拦截所有非法请求。

核心校验逻辑:

  1. 防越权 (Anti-Spoofing)
    当用户已登录时,必须校验 User.tenantIdHeader.tenantId 是否一致。
  • 为什么要做这一步? 防止恶意用户通过 Postman 篡改 Header,试图访问其他租户的数据。
  1. 状态检查
    校验目标租户是否处于"开启"状态,拦截已过期或被封禁的租户请求。
  2. 白名单机制
    对于登录、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):

  1. 意图检测 :检查请求头是否包含 Visit-Tenant-Id
  2. 权限"核武器"检查:调用权限服务,确认当前用户是否拥有"租户切换"的超级权限。
  3. 偷梁换柱 :校验通过后,强制覆盖 TenantContextHolder 中的 tenantId
  4. 现场还原 (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
    • 结果:所有租户共享同一份数据。
  • 业务数据(租户 1001)

    • Redis Key: users:1001::888
    • 结果:物理隔离,互不干扰。


六、 注册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 :通过 WebMvcConfigurerTenantVisitContextInterceptor 注册到 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
    这是业务逻辑的载体。你需要实现特定的接口(TenantLineHandlerMultiDataPermissionHandler),在其中编写"决策代码"。例如:告诉插件当前租户 ID 是多少,或者当前用户能访问哪些部门的数据。
  • 第二步:构建 Interceptor
    这是 SQL 改写的引擎。你需要以 Handler 为基础,实例化对应的 InnerInterceptor 实现类(TenantLineInnerInterceptorDataPermissionInterceptor)。它们都继承自 MP 的底层拦截器接口,负责解析 SQL 语法树并执行 Handler 指定的策略。
  • 第三步:注册到MybatisPlusInterceptor
    这是组装生效的关键。你需要将上述拦截器添加到 MP 的总管家 MybatisPlusInterceptor 中。注意 :这里必须手动控制顺序(addInterceptor),通常多租户插件需要排在最前面,以确保优先锁定数据范围。

2. 差异:定位与粒度

虽然技术同源,但它们的业务定位截然不同:

  • 多租户 (TenantLine)"基础设施级" 的隔离。
    • 规则刚性 :通常只处理 tenant_id 一个字段,逻辑标准且固定。
    • 控制粒度 :主要是 表级控制 (通过 ignoreTable 配置哪些表不需要隔离)。
    • 语义:代表"物理世界的墙",绝对不可逾越。
  • 数据权限 (DataPermission)"业务逻辑级" 的过滤。
    • 规则灵活 :条件复杂多变,可能是 dept_id IN (...),也可能是 user_id = ?,甚至是跨表子查询。
    • 控制粒度 :主要是 方法/注解级控制 (配合 @DataPermission 注解,灵活控制某个 Service 方法使用哪种策略)。
    • 语义:代表"视野的窗",不同角色看到的范围不同。

3. 协作关系:漏斗模型

在实际生产架构中,这两个拦截器通常是同时存在的。为了保证效率和逻辑正确,它们的执行顺序构成了"漏斗模型":

  • 第一层(多租户拦截器):先执行。将数据池锁定在"当前租户"的大范围内(SaaS 基石)。
  • 第二层(数据权限拦截器):后执行。在租户的大池子里,进一步根据用户角色过滤出"可见数据"。

相关推荐
小码编匠2 小时前
完美替代 Navicat,一款开源免费、集成了 AIGC 能力的多数据库客户端工具!
数据库·后端·aigc
顺流2 小时前
从零实现一个数据结构可视化调试器(一)
后端
掘金者阿豪2 小时前
Redis键值对批量删除全攻略:安全高效删除包含特定模式的键
后端
星浩AI2 小时前
深入理解 LlamaIndex:RAG 框架核心概念与实践
人工智能·后端·python
用户2190326527352 小时前
配置中心 - 不用改代码就能改配置
后端·spring cloud·微服务
快手技术2 小时前
打破信息茧房!快手搜索多视角正样本增强引擎 CroPS 入选 AAAI 2026 Oral
后端·算法·架构
qq_12498707532 小时前
基于springboot的鸣珮乐器销售网站的设计与实现(源码+论文+部署+安装)
java·spring boot·后端·spring·毕业设计·计算机毕业设计
海南java第二人2 小时前
SpringBoot核心注解@SpringBootApplication深度解析:启动类的秘密
java·spring boot·后端