Skip to content

引言

多租户(Multi-Tenancy)是一种架构模式,允许多个租户共享同一个应用程序实例,但每个租户的数据是隔离的。

常见的多租户实现方式包括:

1. 独立数据库:每个租户有独立的数据库。
2. 共享数据库,独立 Schema:所有租户共享同一个数据库,但每个租户有独立的 Schema。
3. 共享数据库,共享 Schema:所有租户共享同一个数据库和 Schema,通过字段区分租户数据。

温馨提示

官方全系软件产品目前采用的是上述第三种解决方案:共享数据库,共享 Schema:所有租户共享同一个数据库和 Schema,通过字段区分租户数据。

当然有特殊场景需求的企业、开发者也可以自行实现 独立数据库(即:分库实现方案) 或者 共享数据库,独立 Schema,如使用 PostgreSQL 数据库时可以共享数据库,单独给不同的租户划分不同的 Schema 模块

特别说明:其他实现方案需要对软件产品进行二次开发和改造,如有需要,企业和开发者可以自行实现。

所有租户共享同一个数据库和 Schema,通过字段区分租户数据。

添加租户标识字段:在实体类中添加 tenant_id 字段。
数据过滤:在查询时自动添加 tenant_id 条件。
租户上下文管理:通过请求头或路径参数获取租户标识。

添加多租户依赖

pom.xml 配置文件中引入以下依赖:

js
<!-- 多租户模块 -->
<dependency>
    <groupId>com.xiaomayi</groupId>
    <artifactId>xiaomayi-tenant</artifactId>
</dependency>

多租户配置文件

xiaomayi-modules/xiaomayi-admin 模块的资源目录下,添加多租户 application-tencent.yml 配置文件。

js
# 多租户配置文件
tenant:
  # 是否开启多租户
  enable: true
  # 租户字段名
  column: tenant_id
  # 需要进行租户ID过滤的表名集合
  filterTables:
  # 需要忽略的多租户的表,此配置优先filterTables,若此配置为空则启用filterTables
  ignoreTables:
    - tables
    - columns
    - gen_table
    - gen_table_column
    - gen_template
    - qrtz_blob_triggers
    - qrtz_calendars
    - qrtz_cron_triggers
    - qrtz_fired_triggers
    - qrtz_job_details
    - qrtz_locks
    - qrtz_paused_trigger_grps
    - qrtz_scheduler_state
    - qrtz_simple_triggers
    - qrtz_simprop_triggers
    - qrtz_triggers
    - qrtz_job
    - qrtz_job_log
    - sys_city
    - sys_menu
    - sys_role_menu
    - sys_user_role
    - sys_data_source
    - sys_tenant
  # 需要排除租户过滤的登录用户名
  ignoreLoginNames:
    - demo

参数说明

  1. enable:是否开启多租户,true 为开启多租户功能。
  2. tenant_id:租户字段名,禁止修改。
  3. filterTables:需要进行租户ID过滤的表名集合。
  4. ignoreTables:需要忽略的多租户的表,优先 filterTables,若为空则启用 filterTables
  5. ignoreLoginNames:需要排除租户过滤的登录用户名。

多租户拦截插件

js
package com.xiaomayi.tenant.handler;

import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.xiaomayi.core.utils.StringUtils;
import com.xiaomayi.tenant.config.TenantProperties;
import com.xiaomayi.tenant.content.MybatisTenantContext;
import com.xiaomayi.core.utils.LoginUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;

import java.util.List;
import java.util.Objects;

/**
 * <p>
 * 多租户执行器
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2023-06-16
 */
@Slf4j
@AllArgsConstructor
public class MultiTenantLineHandler implements TenantLineHandler {

    /**
     * 租户属性
     */
    private final TenantProperties tenantProperties;

    /**
     * 获取租户ID
     *
     * @return 返回结果
     */
    @Override
    public Expression getTenantId() {
        // 租户ID
        Integer tenantId = LoginUtils.getTenantId();
        log.debug("当前租户ID为: >> {}", tenantId);
        // 返回租户ID
        return new LongValue(tenantId);
    }

    /**
     * 获取租户字段名
     *
     * @return 返回结果
     */
    @Override
    public String getTenantIdColumn() {
        return tenantProperties.getColumn();
    }

    /**
     * 根据表名判断是否忽略拼接多租户条件
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 数据表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    @Override
    public boolean ignoreTable(String tableName) {
        // 租户登录账号
        String loginName = LoginUtils.getUsername();
        log.debug("当前租户登录账号为: >> {}", loginName);

        // 忽略指定用户对租户的数据过滤
        List<String> ignoreLoginNames = tenantProperties.getIgnoreLoginNames();
        // 判断当前登录租户名是否忽略用户
        if (StringUtils.isNotEmpty(ignoreLoginNames) && ignoreLoginNames.contains(loginName)) {
            return true;
        }

        // 忽略指定表对租户数据的过滤
        List<String> ignoreTables = tenantProperties.getIgnoreTables();
        if (StringUtils.isNotEmpty(ignoreTables) && ignoreTables.contains(tableName)) {
            return true;
        }

        // 根据忽略租户隔离注解判断是否需要租户隔离
        if (Objects.nonNull(MybatisTenantContext.get())) {
            log.info("是否做租户隔离:{}", MybatisTenantContext.get());
            return MybatisTenantContext.get();
        }

        // 返回结果,默认租户隔离
        return false;
    }

}

添加多租户拦截

xiaomayi-common/xiaomayi-mybatis 模块的 MybatisPlusConfig 配置文件中,设置启动多租户并执行多住户执行器。

js
package com.xiaomayi.mybatis.config;

import com.baomidou.mybatisplus.autoconfigure.SpringBootVFS;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisXMLLanguageDriver;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.xiaomayi.core.utils.SpringUtils;
import com.xiaomayi.mybatis.exception.MybatisPlusException;
import com.xiaomayi.mybatis.handler.MybatisMetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.VFS;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.type.JdbcType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

/**
 * <p>
 * MybatisPlus配置类
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@Slf4j
// 开启事务
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
public class MybatisPlusConfig {

    @Autowired
    private Environment env;

    @Autowired
    private DataSource dataSource;

    /**
     * 单页分页条数限制(默认无限制,参见 插件#handlerLimit 方法)
     */
    private static final Long MAX_LIMIT = 1000L;

    @Value("${tenant.enable:false}")
    private boolean elable;

    /**
     * 分页拦截器
     *
     * @return 返回结果
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        // 判断是否开启多租户
        if (elable) {
            // 添加多租户拦截插件
            TenantLineInnerInterceptor tenantLineInnerInterceptor = SpringUtils.getBean(TenantLineInnerInterceptor.class);
            mybatisPlusInterceptor.addInnerInterceptor(tenantLineInnerInterceptor);
        }
        // 添加乐观锁插件拦截器
        mybatisPlusInterceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
        // 添加分页插件拦截器,MySQL数据类型,分页插件放最后处理
        mybatisPlusInterceptor.addInnerInterceptor(paginationInnerInterceptor());
        // 防止全表更新与删除插件: BlockAttackInnerInterceptor
        BlockAttackInnerInterceptor blockAttackInnerInterceptor = new BlockAttackInnerInterceptor();
        mybatisPlusInterceptor.addInnerInterceptor(blockAttackInnerInterceptor);
        return mybatisPlusInterceptor;
    }

    /**
     * 乐观锁插件
     *
     * @return 返回结果
     */
    public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() {
        return new OptimisticLockerInnerInterceptor();
    }

    /**
     * 分页插件
     *
     * @return 返回结果
     */
    public PaginationInnerInterceptor paginationInnerInterceptor() {
        // 实例化分页插件
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
        // 分页合理化
        paginationInnerInterceptor.setOverflow(true);
        // 设置单页分页条数限制
        paginationInnerInterceptor.setMaxLimit(MAX_LIMIT);
        // 返回插件
        return paginationInnerInterceptor;
    }

    /**
     * 异常处理器
     */
    @Bean
    public MybatisPlusException mybatisException() {
        return new MybatisPlusException();
    }

    /**
     * 元对象字段填充
     */
    @Bean
    public MetaObjectHandler metaObjectHandler() {
        return new MybatisMetaObjectHandler();
    }

    /**
     * 注册工厂Bean
     *
     * @return 返回结果
     * @throws Exception 异常处理
     */
    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        // 全局环境配置参数
        String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage");
        String mapperLocations = env.getProperty("mybatis-plus.mapperLocations");
        String configLocation = env.getProperty("mybatis-plus.configLocation");
        VFS.addImplClass(SpringBootVFS.class);

        MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
        // 设置数据源(也可以设置指定数据源)
        sqlSessionFactoryBean.setDataSource(dataSource);
        sqlSessionFactoryBean.setTypeAliasesPackage(typeAliasesPackage);
        // 此处设置为了解决找不到mapper文件的问题
        sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
//        sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation));

        // 设置Mybatis配置
        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setDefaultScriptingLanguage(MybatisXMLLanguageDriver.class);
        configuration.setJdbcTypeForNull(JdbcType.NULL);
        // 自动使用驼峰命名属性映射字段,如:userId user_id
        configuration.setMapUnderscoreToCamelCase(true);
        configuration.setCacheEnabled(false);
        //使用jdbc的getGeneratedKeys获取数据库自增主键值
        configuration.setUseGeneratedKeys(true);
        sqlSessionFactoryBean.setConfiguration(configuration);
        sqlSessionFactoryBean.setPlugins(new Interceptor[]{
                // 添加分页插件
                mybatisPlusInterceptor()
        });

        // 添加全局配置
        sqlSessionFactoryBean.setGlobalConfig((new GlobalConfig()).setMetaObjectHandler(metaObjectHandler()));

        // 返回结果
        return sqlSessionFactoryBean.getObject();
    }

}

元数据填充策略

xiaomayi-common/xiaomayi-mybatis 模块的 MybatisPlusConfig 配置文件中,已注册了数据填充策略 MetaObjectHandler 文件。

js
package com.xiaomayi.mybatis.handler;

import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.HttpStatus;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.xiaomayi.core.config.AppConfig;
import com.xiaomayi.core.exception.BizException;
import com.xiaomayi.mybatis.model.BaseEntity;
import com.xiaomayi.mybatis.model.TenantEntity;
import com.xiaomayi.tenant.constant.TenantConstant;
import com.xiaomayi.core.utils.LoginUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Arrays;


/**
 * <p>
 * 自定义元对象数据填充处理
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-03-23
 */
@Slf4j
@Component
public class MybatisMetaObjectHandler implements MetaObjectHandler {

    /**
     * 插入自动填充
     *
     * @param metaObject 元数据对象
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        try {
            if (ObjectUtil.isNotNull(metaObject)) {
                // 基类基础字段数据填充处理
                if (metaObject.getOriginalObject() instanceof BaseEntity baseEntity) {
                    // 获取当前时间
                    LocalDateTime localDateTime = ObjectUtil.isNotNull(baseEntity.getCreateTime())
                            ? baseEntity.getCreateTime() : LocalDateTime.now();
                    // 设置创建时间
                    baseEntity.setCreateTime(localDateTime);
                    // 设置更新时间
                    baseEntity.setUpdateTime(localDateTime);
                    // 设置填充创建人、更新人
                    if (ObjectUtil.isNull(baseEntity.getCreateUser())) {
                        // 获取用户登录账号
                        String username = LoginUtils.getUsername();
                        if (ObjectUtil.isNotEmpty(username)) {
                            // 设置填充创建人
                            baseEntity.setCreateUser(username);
                            // 设置填充更新人
                            baseEntity.setUpdateUser(username);
                        }
                    }
                }
                // 租户ID字段数据填充处理
                if (metaObject.getOriginalObject() instanceof TenantEntity tenantEntity) {
                    // 元对象自动填充租户ID
                    if (metaObject.hasGetter(TenantConstant.TENANT_ID_PARAM) && ObjectUtil.isNull(tenantEntity.getTenantId())) {
                        tenantEntity.setTenantId(LoginUtils.getTenantId());
                    }
                }
            }
        } catch (Exception e) {
            throw new BizException("填充处理异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
        }
    }

    /**
     * 更新自动填充
     *
     * @param metaObject 元数据对象
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        try {
            if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity baseEntity) {
                // 设置填充更新时间
                baseEntity.setUpdateTime(LocalDateTime.now());
                // 获取用户登录账号
                String username = LoginUtils.getUsername();
                // 设置填充更新人
                if (ObjectUtil.isNotEmpty(username)) {
                    baseEntity.setUpdateUser(username);
                }
            }
        } catch (Exception e) {
            throw new BizException("填充处理异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
        }
    }
}

总结

根据业务需求选择合适的方案,可以有效实现多租户架构,确保数据隔离和系统性能。

小蚂蚁云团队 · 提供技术支持

小蚂蚁云 新品首发
新品首发,限时特惠,抢购从速! 全场95折
赋能开发者,助理企业发展,提供全方位数据中台解决方案。
获取官方授权