多租户对应的是单租户,本篇文章重点讲解多租户,单租户为了解内容。
多租户技术或称多重租赁技术,简称SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。简单讲:在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。
单租户与多租户架构区别在于,单租户是为每个用户单独创建各自的软件应用和支撑环境。单租户SaaS被广泛引用在客户需要支持定制化的应用场合,而这种定制或者是因为地域,抑或是他们需要更高的安全控制。通过单租户的模式,每个客户都有一份分别放在独立的服务器上的数据库和操作系统,或者使用强的安全措施进行隔离的虚拟网络环境中。
应用场景:多租户适合同一集团(公司)下的,多个不同公司(部门),即使数据泄露也不会泄露到外面。
这是第一种方案,即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。
这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。
这是第二种方案,即多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA
这是第三种方案,即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。
即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据。
在SaaS实施过程中,有一个显著的考量点,就是如何对应用数据进行设计,以支持多租户,而这种设计的思路,是要在数据的共享、安全隔离和性能间取得平衡。三种模式的特点可以用一张图来概括
分层级-级别,上级权限包含所有下级的权限,示例如下:
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
UTF-8
org.springframework.boot
spring-boot-maven-plugin
${spring-boot.version}
com.ybw.mybatis.multi.tenant.MybatisMultiTenantApplication
repackage
repackage
/*
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;
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);
}
}
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();
}
}
使用的是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;
}
}
访问接口:http://localhost:8080/sysStaff/getToken?id=3
访问接口:http://localhost:8080/user/getUser
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
上面已经将核心逻辑代码写完,如果要看全部代码,可以访问如下地址:
share: 分享仓库 - Gitee.com