多租户、组织架构

1、概念

多租户对应的是单租户,本篇文章重点讲解多租户,单租户为了解内容。

1.1 多租户

多租户技术或称多重租赁技术,简称SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。简单讲:在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。

1.2 单租户

单租户与多租户架构区别在于,单租户是为每个用户单独创建各自的软件应用和支撑环境。单租户SaaS被广泛引用在客户需要支持定制化的应用场合,而这种定制或者是因为地域,抑或是他们需要更高的安全控制。通过单租户的模式,每个客户都有一份分别放在独立的服务器上的数据库和操作系统,或者使用强的安全措施进行隔离的虚拟网络环境中。

1.3 单租户和多租户的区别

  1. 安全管控等级不同。多租户数据库存储来自多个独立租户的数据,虽然设置了安全隔离,但安全控制级别仍然高于单租户。由于单个租户拥有独立的软硬件环境,数据库也只存储一个租户的数据,技术上杜绝了数据泄露的可能,单租户架构有时更适合某些需要安全控制甚至法律合规性要求的行业。
  2. 数据备份复杂程度不同。单租户拥有独立的数据库,客户数据库的备份和恢复非常容易。而多租户是共用一个数据库,租户的数据既有隔离又有共享,系统不能每天自动执行企业的独立备份。
  3. 控制升级时间不同。多租户的系统维护成本低,多租户系统升级时,只需更新一次。维护人员不需要对每个用户更新,节省了大量运维成本。这对于所有客户都在做同样事情的系统来说是很有用的。但倘若系统升级时间是在企业特别忙碌的时候出现,势必会对企业用户造成影响。

应用场景:多租户适合同一集团(公司)下的,多个不同公司(部门),即使数据泄露也不会泄露到外面。

2、多租户数据隔离方案

  • 独立数据库
  • 共享数据库、独立Schema
  • 共享数据库、共享Schema、共享数据表

2.1 独立数据库

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

  • 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
  • 缺点: 增多了数据库的安装数量,随之带来维护成本和购置成本的增加。

这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。

2.2 共享数据库,独立 Schema

这是第二种方案,即多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA

  • 优点: 为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。
  • 缺点: 如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。

2.3 共享数据库,共享 Schema,共享数据表

这是第三种方案,即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式
即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据。

  • 优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多
  • 缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。如果希望以最少的服务器为最多的租户提供服务,并且租户接受牺牲隔离级别换取降低成本,这种方案最适合

2.4 总结

在SaaS实施过程中,有一个显著的考量点,就是如何对应用数据进行设计,以支持多租户,而这种设计的思路,是要在数据的共享、安全隔离和性能间取得平衡。三种模式的特点可以用一张图来概括

多租户、组织架构_第1张图片

3、代码实现共享数据库,共享 Schema,共享数据表

分层级-级别,上级权限包含所有下级的权限,示例如下:

  • 集团;可以包含多个公司。
  • 公司;可以包含多个员工。
  • 员工(租户);

3.1 pom.xml配置



    4.0.0
    com.ybw
    share-table
    1.0.0
    share-table
    多租户-共享table

    
        17
        UTF-8
        UTF-8
        2.7.4
        3.4.1
        2.3
        1.2.12
    

    
        
        
            org.apache.commons
            commons-collections4
            4.4
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            com.alibaba.fastjson2
            fastjson2
            2.0.14
        
        
            org.springframework.boot
            spring-boot-starter-aop
        
        
            com.baomidou
            mybatis-plus-boot-starter
            ${mybatis-plus.version}
        
        
        
            com.baomidou
            mybatis-plus-generator
            ${mybatis-plus.version}
        
        
        
            org.apache.velocity
            velocity-engine-core
            ${velocity.version}
        

        
            mysql
            mysql-connector-java
            runtime
        
        
            org.projectlombok
            lombok
            true
        
        
            org.springframework.boot
            spring-boot-starter-test
            test
            
                
                    org.junit.vintage
                    junit-vintage-engine
                
            
        
        
            com.alibaba
            druid-spring-boot-starter
            ${druid.version}
        
    

    
        
            
                org.springframework.boot
                spring-boot-dependencies
                ${spring-boot.version}
                pom
                import
            
        
    

    
        
            
                org.apache.maven.plugins
                maven-compiler-plugin
                3.8.1
                
                    17
                    17
                    UTF-8
                
            
            
                org.springframework.boot
                spring-boot-maven-plugin
                ${spring-boot.version}
                
                    com.ybw.mybatis.multi.tenant.MybatisMultiTenantApplication
                
                
                    
                        repackage
                        
                            repackage
                        
                    
                
            
        
    


3.2 数据库初始化

/*
 Navicat Premium Data Transfer

 Source Server         : 本地
 Source Server Type    : MySQL
 Source Server Version : 80030
 Source Host           : localhost:3306
 Source Schema         : tenant

 Target Server Type    : MySQL
 Target Server Version : 80030
 File Encoding         : 65001

 Date: 09/10/2022 16:48:33
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for sys_staff
-- ----------------------------
DROP TABLE IF EXISTS `sys_staff`;
CREATE TABLE `sys_staff`  (
  `id` bigint(0) UNSIGNED NOT NULL AUTO_INCREMENT,
  `level` int(0) NOT NULL COMMENT '级别 1:集团;2:公司;3:员工(租户);',
  `pid` bigint(0) NULL DEFAULT NULL COMMENT '父id,sys_staff的id',
  `create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
  `update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_pid`(`pid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '员工' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_staff
-- ----------------------------
INSERT INTO `sys_staff` VALUES (1, 1, NULL, '2022-10-09 14:23:52', '2022-10-09 14:23:52');
INSERT INTO `sys_staff` VALUES (2, 2, 1, '2022-10-09 14:23:57', '2022-10-09 14:23:57');
INSERT INTO `sys_staff` VALUES (3, 3, 2, '2022-10-09 14:24:16', '2022-10-09 14:24:16');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` bigint(0) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '姓名',
  `company_group_id` bigint(0) NOT NULL COMMENT '租户-集团ID',
  `company_id` bigint(0) NOT NULL COMMENT '租户-公司id',
  `tenant_id` bigint(0) NOT NULL COMMENT '租户ID',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_tenant_id`(`tenant_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'Jone', 1, 1, 1);
INSERT INTO `user` VALUES (2, 'Jack', 1, 1, 2);
INSERT INTO `user` VALUES (3, 'Tom', 1, 2, 3);
INSERT INTO `user` VALUES (4, 'Sandy', 2, 3, 4);
INSERT INTO `user` VALUES (5, 'Billie', 2, 4, 5);

-- ----------------------------
-- Table structure for user_addr
-- ----------------------------
DROP TABLE IF EXISTS `user_addr`;
CREATE TABLE `user_addr`  (
  `id` bigint(0) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_id` bigint(0) NOT NULL COMMENT 'user.id',
  `addr` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '地址名称',
  `company_group_id` bigint(0) NOT NULL COMMENT '租户-集团ID',
  `company_id` bigint(0) NOT NULL COMMENT '租户-公司id',
  `tenant_id` bigint(0) NOT NULL COMMENT '租户ID',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_user_id`(`user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user_addr
-- ----------------------------
INSERT INTO `user_addr` VALUES (1, 1, 'addr1', 1, 1, 1);
INSERT INTO `user_addr` VALUES (2, 1, 'addr2', 1, 2, 3);

SET FOREIGN_KEY_CHECKS = 1;

3.3 接口

package com.ybw.controller;


import com.ybw.entity.SysStaff;
import com.ybw.service.SysStaffService;
import com.ybw.service.token.TokenService;
import com.ybw.utils.MyStringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * 员工
 *
 * @author ybw
 * @since 2022-10-07
 */
@RestController
@RequestMapping("/sysStaff")
public class SysStaffController {

    @Resource
    private SysStaffService sysStaffService;
    @Resource
    private TokenService tokenService;

    /**
     * 获取token
     *
     * @param id
     * @methodName: getToken
     * @return: java.lang.String
     * @author: ybw
     * @date: 2022/10/9
     **/
    @GetMapping("/getToken")
    public String getToken(Long id) {
        SysStaff sysStaff = sysStaffService.getById(id);
        if (sysStaff == null) {
            return null;
        }
        String token = MyStringUtils.generateUUIDNoCenterLine();
        tokenService.generateToken(token, sysStaff);
        return token;
    }
}

package com.ybw.controller;


import com.ybw.entity.User;
import com.ybw.service.UserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

/**
 * 用户
 *
 * @author ybw
 * @since 2022-01-10
 */
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private UserService userService;

    @GetMapping("/getUser")
    public List getUser() {
        return userService.lambdaQuery().list();
    }

    /**
     * 测试多租户
     * 1、left join: SELECT u.id, u.name, a.addr AS addr_name FROM user u LEFT JOIN user_addr a ON a.user_id = u.id AND a.company_group_id = 1 WHERE u.name LIKE concat(concat('%', ?), '%') AND u.company_group_id = 1
     * 2、inner join: SELECT u.id, u.name, a.addr AS addr_name FROM user u, user_addr a WHERE a.user_id = u.id AND u.name LIKE concat(concat('%', ?), '%') AND u.company_group_id = 1 AND a.company_group_id = 1
     *
     * @param username
     * @methodName: getUserAndAddr
     * @return: java.util.List
     * @author: ybw
     * @date: 2023/6/13
     **/
    @GetMapping("/getUserAndAddr")
    public List getUserAndAddr(@RequestParam String username) {
        return userService.getUserAndAddr(username);
    }
}

3.4 鉴权

package com.ybw.interceptor;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ybw.config.TenantContext;
import com.ybw.dto.TenantDTO;
import com.ybw.entity.SysStaff;
import com.ybw.service.token.TokenService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;

/**
 * 接口日志
 *
 * @author ybwei
 * @date 2022/2/17 11:40
 **/
@Aspect
@Component
@Slf4j
public class AuthAspect {

    @Resource
    private TokenService tokenService;
    private List ignoreUrlList = Arrays.asList("/sysStaff/getToken");
    private final ObjectMapper mapper;

    @Autowired
    public AuthAspect(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public void requestLog() {
    }

    @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
    public void postLog() {
    }

    @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
    public void getLog() {
    }

    /**
     * 请求参数
     *
     * @param joinPoint
     * @return void
     * @throws
     * @methodName: doBefore
     * @author ybwei
     * @date 2022/2/17 13:54
     */
    @Before("requestLog() || postLog() || getLog()")
    public void doBefore(JoinPoint joinPoint) {
        //1、获取访问路径
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String servletPath = request.getServletPath();
        if (ignoreUrlList.contains(servletPath)) {
            //1.1 不需要鉴权
            return;
        }
        //2、获取token
        String token = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getHeader("token");
        if (StringUtils.isBlank(token)) {
            return;
        }
        //3、获取员工信息
        SysStaff sysStaff = tokenService.getObjectByToken(token);
        if (sysStaff == null) {
            return;
        }
        //4、处理多租户
        TenantContext.set(new TenantDTO(sysStaff.getLevel(), sysStaff.getId()));
    }


    /**
     * 返回参数
     *
     * @param response
     * @return void
     * @throws
     * @methodName: doAfterReturning
     * @author ybwei
     * @date 2022/2/17 13:54
     */
    @AfterReturning(returning = "response", pointcut = "requestLog() || postLog() || getLog()")
    public void doAfterReturning(Object response) {
        TenantContext.remove();
    }
}

3.5 多租户配置

使用的是mybatis plus多租户插件

package com.ybw.config;

import com.ybw.dto.TenantDTO;

/**
 * @author ybw
 * @version V1.0
 * @className TenantContext
 * @date 2022/9/30
 **/
public class TenantContext {

    private static final ThreadLocal context = new ThreadLocal<>();

    /**
     * 构造方法私有化
     *
     * @methodName: TenantContext
     * @return:
     * @author: ybw
     * @date: 2022/9/30
     **/
    private TenantContext() {

    }


    /**
     * 存放租户信息
     *
     * @param tenantDTO
     * @methodName: set
     * @return: void
     * @author: ybw
     * @date: 2022/9/30
     **/
    public static void set(TenantDTO tenantDTO) {
        context.set(tenantDTO);
    }

    /**
     * 获取组合信息
     *
     * @methodName: get
     * @return: com.ybw.dto.TenantDTO
     * @author: ybw
     * @date: 2022/9/30
     **/
    public static TenantDTO get() {
        return context.get();
    }

    /**
     * 清除当前线程内引用,防止内存泄漏
     *
     * @methodName: remove
     * @return: void
     * @author: ybw
     * @date: 2022/9/30
     **/
    public static void remove() {
        context.remove();
    }
}
package com.ybw.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.ybw.contant.LevelConstant;
import com.ybw.contant.TenantConstant;
import com.ybw.dto.TenantDTO;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;

/**
 * 多租户配置
 *
 * @author ybw
 * @version V1.0
 * @className MybatisPlusConfig
 * @date 2022/10/10
 **/
@Configuration
@MapperScan("com.ybw.mapper")
public class MybatisPlusConfig {

    @Resource
    private TenantIgnoreConfig tenantIgnoreConfig;

    /**
     * 新多租户插件配置,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存万一出现问题
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        //1、租户插件
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
            /**
             * 获取租户 ID 值表达式,只支持单个 ID 值
             * 

* * @return 租户 ID 值表达式 */ @Override public Expression getTenantId() { TenantDTO tenantDTO = TenantContext.get(); if (tenantDTO == null) { //默认为0 return new LongValue(0); } return new LongValue(tenantDTO.getTenantId()); } /** * 获取租户字段名 *

* 默认字段名叫: tenant_id * * @return 租户字段名 */ @Override public String getTenantIdColumn() { TenantDTO tenantDTO = TenantContext.get(); if (LevelConstant.COMPANY_GROUP.equals(tenantDTO.getLevel())) { return TenantConstant.Field.COMPANY_GROUP_ID; } if (LevelConstant.COMPANY.equals(tenantDTO.getLevel())) { return TenantConstant.Field.COMPANY_ID; } return TenantConstant.Field.TENANT_ID; } /** * 根据表名判断是否忽略拼接多租户条件 *

* 默认都要进行解析并拼接多租户条件 * * 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件 * @param tableName 表名 * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件 */ @Override public boolean ignoreTable(String tableName) { if (tenantIgnoreConfig.getTableList().contains(tableName)) { return true; } return false; } })); //2、分页插件 // 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor // 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

3.6 接口测试

获取token

访问接口:http://localhost:8080/sysStaff/getToken?id=3

多租户测试-获取用户信息

访问接口:http://localhost:8080/user/getUser

多租户、组织架构_第2张图片

 SQL日志如下,tenant_id = 3为多租户配置生效

[DEBUG] 2022-10-10 13:51:54.561 [http-nio-8080-exec-5] c.y.m.m.t.m.UserMapper.selectList - ==>  Preparing: SELECT id, name, company_group_id, company_id, tenant_id FROM user WHERE tenant_id = 3
[DEBUG] 2022-10-10 13:51:54.562 [http-nio-8080-exec-5] c.y.m.m.t.m.UserMapper.selectList - ==> Parameters: 
[DEBUG] 2022-10-10 13:51:54.564 [http-nio-8080-exec-5] c.y.m.m.t.m.UserMapper.selectList - <==      Total: 1

多租户测试-多表查询

访问接口:/user/getUserAndAddr

SQL日志如下,company_group_id = 1为多租户配置生效,u.company_group_id = 1 AND a.company_group_id = 1为多租户插件生成的。

SELECT u.id, u.name, a.addr AS addr_name FROM user u, user_addr a WHERE a.user_id = u.id AND u.name LIKE concat(concat('%', ''), '%') AND u.company_group_id = 1 AND a.company_group_id = 1

3.7 示例代码

上面已经将核心逻辑代码写完,如果要看全部代码,可以访问如下地址:

share: 分享仓库 - Gitee.com

你可能感兴趣的:(spring,java,java,mybatis,多租户)