多租户基于Springboot+MybatisPlus实现使用一个数据库一个表 使用字段进行数据隔离

发布时间 2023-06-22 19:25:50作者: 白嫖老郭

多租户实现方式

多租户在数据存储上主要存在三种方案,分别是:

1. 独立数据库
即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。

优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。

缺点:增多了数据库的安装数量,随之带来维护成本和购置成本的增加。

2. 共享数据库,独立 Schema
也就是说 共同使用一个数据库 使用表进行数据隔离
多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA。

优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。

缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;

3. 共享数据库,共享 Schema,共享数据表
也就是说 共同使用一个数据库一个表 使用字段进行数据隔离

即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。

简单来讲,即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据,这也是我们系统目前用到的(tenant_id)

优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。

项目依赖Springboot+Mybatisplus

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>cloud</artifactId>
        <groupId>com.gton</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ManyUser</artifactId>

    <description>Spring Boot 集成 Mybatis-Plus 多租户架构实战</description>

    <properties>
        <knife4j.version>3.0.3</knife4j.version>
        <mybatisplus.verison>3.5.1</mybatisplus.verison>
        <mysql-version>8.0.25</mysql-version>
        <fastJson-version>2.0.18</fastJson-version>
    </properties>
    <dependencies>

        <!--web项目驱动-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>${spring-boot-start-version}</version>
        </dependency>
        <!--Knife4j(增强Swagger)-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
            <version>${knife4j.version}</version>
        </dependency>
        <!--Mybatis-plus 代码生成器-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatisplus.verison}</version>
        </dependency>
        <!--Mysql数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql-version}</version>
        </dependency>
        <!--lombok-实体类简化依赖-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.16</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastJson-version}</version>
        </dependency>
    </dependencies>

</project>

配置文件

application.properties

#数据源
spring.datasource.url=jdbc:mysql://120.53.238.87:3366/cloud_market?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=guotong199114
# 应用名称
spring.application.name=more-user-use
# 启动环境
spring.profiles.active=mybatis
# 应用服务 WEB 访问端口
server.port=8889
#Springboot2.6以上需要手动设置
spring.mvc.pathmatch.matching-strategy=ant_path_matcher
# 配置数据库连接池
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle=3
spring.datasource.hikari.maximum-pool-size=10
# 不能小于30秒,否则默认回到1800秒
spring.datasource.hikari.max-lifetime=30000
spring.datasource.hikari.connection-test-query=SELECT 1

mybatis.pwd.key=d1104d7c3b616f0b

application-mybatis.yaml

mybatis-plus:
  type-aliases-package: com.gton.user.entity
  mapper-locations: classpath*:com/gton/user/mapper/xml/*Mapper.xml,classpath*:/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true  #开启驼峰命名
    cache-enabled: false #开启二级缓存
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台日志
  check-config-location: true # 检查xml是否存在
  type-enums-package: com.gton.enumPackage  #通用枚举开启
  global-config:
    db-config:
      logic-not-delete-value: 1
      logic-delete-field: isDel
      logic-delete-value: 0

测试表

CREATE TABLE `tenant_auth_login_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键-自增',
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码:AES加密',
  `rule` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限',
  `sex` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '性别',
  `head_portrait` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '头像',
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '电话',
  `address` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '地址',
  `email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '邮箱',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '个人介绍',
  `is_del` int NOT NULL COMMENT '辑删除(0-标识删除,1-标识可用)',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NOT NULL COMMENT '修改时间',
  `tenant_id` bigint NOT NULL COMMENT '多租户下的租户ID',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci STATS_PERSISTENT=1 COMMENT='多租户表';

实现多租户:参考官方

https://gitee.com/baomidou/mybatis-plus-samples/blob/master/mybatis-plus-sample-tenant/src/main/java/com/baomidou/mybatisplus/samples/tenant/config/MybatisPlusConfig.java

https://baomidou.com/pages/aef2f2/#tenantlineinnerinterceptor
image

第一步 implements TenantLineHandler

package com.gton.user.handler;

import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.schema.Column;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
 * @description: 租户处理器 -主要实现mybatis-plus   TenantLineHandler
 * <p>
 * 如果用了分页插件注意先 add TenantLineInnerInterceptor
 * 再 add PaginationInnerInterceptor
 * 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false
 * @author: GuoTong
 * @createTime: 2023-06-22 16:43
 * @since JDK 1.8 OR 11
 **/
@Slf4j
@Component
public class SysTenantHandlerImpl implements TenantLineHandler {


    /**
     * 多租户标识
     */
    private static final String SYSTEM_TENANT_ID = "tenant_id";


    /**
     * 需要过滤的表
     */
    private static final List<String> IGNORE_TENANT_TABLES = new ArrayList<>();


    /**
     * 获取租户 ID 值表达式,只支持单个 ID 值
     * <p>
     *
     * @return 租户 ID 值表达式
     */
    @Override
    public Expression getTenantId() {
        // 获取当前租户信息
        String tenantId = TenantRequestContext.getTenantLocal();
        String requestUser = StringUtils.defaultIfEmpty(tenantId, "1001");
        return new LongValue(requestUser);
    }


    /**
     * 获取租户字段名
     * <p>
     * 默认字段名叫: tenant_id
     *
     * @return 租户字段名
     */
    @Override
    public String getTenantIdColumn() {
        return SYSTEM_TENANT_ID;
    }


    /**
     * 根据表名判断是否忽略拼接多租户条件
     * <p>
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    @Override
    public boolean ignoreTable(String tableName) {
        return IGNORE_TENANT_TABLES.contains(tableName);
    }

    @Override
    public boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
        // 新增排除自己携带了这个多租户字段的新增
        for (Column column : columns) {
            if (column.getColumnName().equalsIgnoreCase(tenantIdColumn)) {
                return true;
            }
        }
        return false;
    }
}

建立一个基础类,用于同一个线程上下文变量恒定

package com.gton.user.handler;

/**
 * @description: 保存当前请求用户的的信息,
 * 使用threadlocal来实现,
 * 和当前请求线程绑定
 * @author: GuoTong
 * @createTime: 2023-06-22 16:59
 * @since JDK 1.8 OR 11
 **/
public class TenantRequestContext {

    private static ThreadLocal<String> tenantLocal = new ThreadLocal<>();

    public static void setTenantLocal(String tenantId) {
        tenantLocal.set(tenantId);
    }

    public static String getTenantLocal() {
        return tenantLocal.get();
    }

    public static void remove() {
        tenantLocal.remove();
    }
}

第二步拦截器拦截请求,获取请求头里面的租户标识放入线程上下文

package com.gton.user.handler;

import com.gton.user.handler.TenantRequestContext;
import com.mysql.cj.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @description: 拦截器主要是获取请求头中的租户id,
 * 然后放到上下文中,
 * 供mybatisPlus获取
 * @author: GuoTong
 * @createTime: 2023-06-22 17:02
 * @since JDK 1.8 OR 11
 **/
@Slf4j
public class TenantUserInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String userId = request.getHeader("tenant_id");
        if (!StringUtils.isNullOrEmpty(userId)) {
            // 当前上下文的线程私有域注入多租户信息
            TenantRequestContext.setTenantLocal(userId);
            log.info("当前租户ID:" + userId);
        }
        return HandlerInterceptor.super.preHandle(request, response, handler);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 当前上下文的线程私有域释放多租户信息
        TenantRequestContext.remove();
    }
}

注册拦截器到Spring容器中


/**
 * @description: SpringBoot-Web配置
 * @author: GuoTong
 * @createTime: 2021-10-05 15:37
 * @since JDK 1.8 OR 11
 **/
@Configuration
public class SpringBootConfig implements WebMvcConfigurer {


    /**
     * Description:  添加全局跨域CORS处理
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 设置允许跨域的路径
        registry.addMapping("/**")
                //设置允许跨域请求的域名
                .allowedOrigins("http://127.0.0.1:8787")
                // 是否允许证书
                .allowCredentials(true)
                // 设置允许的方法
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }


    /**
     * Description: 静态资源过滤
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        //ClassPath:/Static/** 静态资源释放
        registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
        //释放swagger
        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        //释放webjars
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }




    /**
     * Description:  过滤器
     *
     * @param registry
     * @author: GuoTong
     * @date: 2023-06-03 12:32:39
     * @return:void
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(new TenantUserInterceptor()).addPathPatterns("/**")
                .excludePathPatterns("/doc.html")
                .excludePathPatterns("/swagger-resources/**")
                .excludePathPatterns("/webjars/**")
                .excludePathPatterns("/v2/**")
                .excludePathPatterns("/favicon.ico")
                .excludePathPatterns("/sso/**")
                .excludePathPatterns("/swagger-ui.html/**");
    }

}

注册mybatisplus的多租户实现到 MybatisPlusInterceptor

package com.gton.user.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
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.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.gton.user.handler.EasySqlInjector;
import com.gton.user.handler.SysTenantHandlerImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @description: Mybatis相关组件配置
 * @author: GuoTong
 * @createTime: 2022-11-25 15:33
 * @since JDK 1.8 OR 11
 **/
@Configuration
public class MybatisConfig {

    @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
    private String pattern;


    /**
     * Description:  新的分页插件
     *
     * @author: GuoTong
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加多租户插件
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new SysTenantHandlerImpl()));
        // 添加乐观锁插件
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        // 向Mybatis过滤器链中添加分页拦截器
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }


    /**
     * Description: 批量插入优化
     *
     * @author: GuoTong
     */
    @Bean
    public EasySqlInjector sqlInjector() {
        return new EasySqlInjector();
    }

    /**
     * Description:  localDateTime 序列化器
     *
     * @author: GuoTong
     * @return:
     */
    @Bean
    public LocalDateTimeSerializer localDateTimeSerializer() {
        return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(pattern));
    }

    /**
     * Description:  localDateTime 反序列化器
     *
     * @author: GuoTong
     * @return:
     */
    @Bean
    public LocalDateTimeDeserializer localDateTimeDeserializer() {
        return new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(pattern));
    }

    /**
     * Description:  Json序列化JDK8新时间APILocalDateTime
     *
     * @author: GuoTong
     * @date: 2022-12-05 16:20:01
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder -> {
            //返回时间数据序列化
            builder.serializerByType(LocalDateTime.class, localDateTimeSerializer());
            //接收时间数据反序列化
            builder.deserializerByType(LocalDateTime.class, localDateTimeDeserializer());
            builder.simpleDateFormat(pattern);
        };


    }


}

测试Controller


/**
 * 多租户表(TenantAuthLoginUser)表控制层
 *
 * @author 郭童
 * @since 2023-06-22 16:28:10
 */
@RestController
@RequestMapping("tenantAuthLoginUser")
@SwaggerScanClass
public class TenantAuthLoginUserController {
    /**
     * 服务对象
     */
    @Autowired
    private TenantAuthLoginUserService tenantAuthLoginUserService;

    @Value("${mybatis.pwd.key:d1104d7c3b616f0b}")
    private String mybatiskey;

    /**
     * 分页查询数据
     *
     * @param limitRequest 查询实体
     * @return 所有数据
     */
    @PostMapping("/queryLimit")
    public Resp<BaseLimitResponse<TenantAuthLoginUser>> queryPage(@RequestBody BaseLimitRequest<TenantAuthLoginUser> limitRequest) {
        // 分页查询
        IPage<TenantAuthLoginUser> page = this.tenantAuthLoginUserService.queryLimitPage(limitRequest);
        // 封装返回结果集
        BaseLimitResponse<TenantAuthLoginUser> data = BaseLimitResponse.getInstance(page.getRecords(), page.getTotal(), page.getPages(), limitRequest.getPageIndex(), limitRequest.getPageSize());
        return Resp.Ok(data);
    }

    /**
     * 通过主键查询单条数据
     *
     * @param id 主键
     * @return 单条数据
     */
    @GetMapping("/queryOne/{id}")
    public Resp<TenantAuthLoginUser> selectOne(@PathVariable("id") Serializable id) {
        return Resp.Ok(this.tenantAuthLoginUserService.getById(id));
    }

    /**
     * 新增数据
     *
     * @param tenantAuthLoginUser 实体对象
     * @return 新增结果
     */
    @PostMapping("/save")
    public Resp<String> insert(@RequestBody TenantAuthLoginUser tenantAuthLoginUser) {
        boolean save = false;
        String executeMsg = ContextCommonMsg.USER_NAME_EXITS;
        ;
        try {
            String username = tenantAuthLoginUser.getUsername();
            Long count = tenantAuthLoginUserService.lambdaQuery().eq(StringUtils.isNotEmpty(username), TenantAuthLoginUser::getUsername, username).count();
            if (count >= 1) {
                return Resp.error(executeMsg);
            }
            tenantAuthLoginUser.setPassword(AES.encrypt(tenantAuthLoginUser.getPassword(), mybatiskey));
            save = this.tenantAuthLoginUserService.save(tenantAuthLoginUser);
            executeMsg = "新增成功,id 是:" + tenantAuthLoginUser.getId();
        } catch (Exception e) {
            executeMsg = e.getMessage();
        }
        return save ? Resp.Ok(executeMsg) : Resp.error(executeMsg);
    }

}

省略单表的CRUD的三层架构,各种Mybatisx或者EasyCode都可以全自动生成

测试数据


INSERT INTO `tenant_auth_login_user`(`id`, `username`, `password`, `rule`, `sex`, `head_portrait`, `phone`, `address`, `email`, `description`, `is_del`, `create_time`, `update_time`, `tenant_id`) VALUES (1671817343047143426, '全球最强', 'JRaZunLuzVfNLNfCpe/Ahg==', 'ADMIN', '中立', 'http://localhost:8889/', '110-7654321', '重庆市神魂村天之痕路一号', 'guotong@qq.com', '黑暗万岁', 1, '2023-06-22 17:48:10', '2023-06-22 17:48:10', 1100);
INSERT INTO `tenant_auth_login_user`(`id`, `username`, `password`, `rule`, `sex`, `head_portrait`, `phone`, `address`, `email`, `description`, `is_del`, `create_time`, `update_time`, `tenant_id`) VALUES (1671817697516163073, '地表最强', 'JRaZunLuzVfNLNfCpe/Ahg==', 'ADMIN', '中立', 'http://localhost:8889/', '110-7654321', '天之痕路一号', 'guotong@qq.com', '光明万岁', 1, '2023-06-22 17:49:35', '2023-06-22 17:49:35', 1001);

Mybatisplus帮我们在增删改查的所有操作都给拼接上携带租户条件。。一劳永逸,当你不需要使用多租户的条件时,TenantLineHandler实现类里

    /**
     * 根据表名判断是否忽略拼接多租户条件
     * <p>
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    @Override
    public boolean ignoreTable(String tableName) {
        return IGNORE_TENANT_TABLES.contains(tableName);
    }

    @Override
    public boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
        // 新增排除自己携带了这个多租户字段的新增
        for (Column column : columns) {
            if (column.getColumnName().equalsIgnoreCase(tenantIdColumn)) {
                return true;
            }
        }
        return false;
    }

swagger测试接口新增;如果参数传了租户值就使用当前租户ID,否则使用默认租户ID完成新增

image

swagger测试接口查询(请求头里设置租户信息就使用该租户信息查询,否则就使用默认租户ID实现查询)

image