当前位置: 首页 > news >正文

SaaS多租户架构实践:字段隔离方案(共享数据库+共享Schema)

本项目是SaaS模式下,基于多租户架构技术,采用字段隔离(共享数据库,共享Schema)方案的demo,旨在了解字段隔离方案的基本工作流程和实现原理,仅做入门使用,不进行深入研究。

项目源码地址:multi-tenancy

一、Mybatis-Plus实现

  1. maven依赖

    <!-- Mybatis-Plus依赖 -->
    <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.1</version>
    </dependency>
    <!-- mysql驱动 -->
    <dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.15</version>
    </dependency>
    <!-- 简洁java代码 -->
    <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
    </dependency>
    <dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.16</version>
    </dependency>
    
  2. 配置 mybatis 拦截器,并设置租户拦截器MyTenantLineHandler

    import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
    import com.tenancy.multi.common.interceptor.MyTenantLineHandler;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;/*** MyBatis配置类* 配置MyBatis-Plus的多租户拦截器*/
    @Configuration // 表明这是一个配置类
    public class MyBatisConfig {/*** 配置MyBatis-Plus拦截器* 添加多租户拦截器以实现SQL自动添加租户条件** @return MybatisPlusInterceptor 配置好的MyBatis-Plus拦截器*/@Bean // 将返回的拦截器注册为Spring容器中的Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {// 创建MyBatis-Plus拦截器MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 添加多租户内部拦截器,传入自定义的租户处理器interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler()));return interceptor;}
    }
    
  3. 租户拦截器 MyTenantLineHandler 代码。实现 mybatis 自带的租户 Handler,实现 getTenantId() 方法,mybatis 执行sql 时会通过此方法将得到的租户id条件插入到sql里。

    import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
    import com.tenancy.multi.common.context.TenantContext;
    import net.sf.jsqlparser.expression.Expression;
    import net.sf.jsqlparser.expression.StringValue;/*** 自定义租户处理器* 实现mybatis自带的租户Handler(TenantLineHandler接口),用于处理多租户SQL拦截逻辑**/
    public class MyTenantLineHandler implements TenantLineHandler {/*** 获取当前租户ID* MyBatis-Plus执行SQL时会通过此方法获取租户ID并自动添加到SQL条件中** @return Expression 租户ID的表达式,用于SQL条件拼接*/@Overridepublic Expression getTenantId() {// 从租户上下文中获取当前租户ID,并包装为SQL表达式return new StringValue(TenantContext.getCurrentTenant());}/*** 获取租户ID字段名* 指定数据库中用于存储租户ID的列名** @return String 租户ID字段名*/@Overridepublic String getTenantIdColumn() {// 返回数据库中租户ID的列名return "tenant_id";}/*** 忽略租户过滤的表* 指定哪些表不需要添加租户条件过滤** @param tableName 表名* @return boolean true表示忽略(不添加租户条件),false表示需要添加租户条件*/@Overridepublic boolean ignoreTable(String tableName) {// 这里可以添加不需要租户隔离的表名判断逻辑// 例如:系统表、公共配置表等可以跳过租户过滤return false; // 默认所有表都需要租户隔离}
    }
    
  4. 租户上下文代码。租户上下文会保存当前请求线程里从请求头获取的租户id。

    /*** 租户上下文管理类* 用于在多线程环境中存储和获取当前请求的租户信息* 基于ThreadLocal实现线程隔离的租户信息存储*/
    public class TenantContext {/*** 线程本地变量,用于存储当前线程的租户ID* InheritableThreadLocal确保子线程可以继承父线程的租户信息*/private static final ThreadLocal<String> currentTenant = new InheritableThreadLocal<>();/*** 获取当前租户ID** @return String 当前线程的租户ID,如果没有设置则返回null*/public static String getCurrentTenant() {return currentTenant.get();}/*** 设置当前租户ID** @param tenantId 租户ID*/public static void setCurrentTenant(String tenantId) {currentTenant.set(tenantId);}/*** 清除当前租户信息* 防止内存泄漏,应在请求处理完成后调用*/public static void clear() {currentTenant.remove();}
    }
    
  5. 配置过滤器,过滤器负责将请求头传过来的租户id放入租户上下文。

    import com.tenancy.multi.common.context.TenantContext;
    import org.springframework.core.annotation.Order;import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import java.io.IOException;/*** 租户过滤器* 用于从HTTP请求中提取租户ID并设置到当前线程上下文中* 优先级设置为1,确保在其他过滤器之前执行*/
    @Order(1) // 设置过滤器执行顺序,数值越小优先级越高
    public class TenantFilter implements Filter {/*** 过滤器核心方法* 从请求中提取租户ID并设置到线程上下文中,然后继续执行过滤链** @param servletRequest  HTTP请求对象* @param servletResponse HTTP响应对象* @param filterChain     过滤器链* @throws IOException      输入输出异常* @throws ServletException Servlet异常*/@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {// 从请求中提取租户ID并设置到当前线程上下文TenantContext.setCurrentTenant(getHeaderOrParam(servletRequest));// 继续执行过滤链filterChain.doFilter(servletRequest, servletResponse);}/*** 从HTTP请求头或参数中获取租户ID* 优先从请求头中获取,如果不存在则可以从参数中获取** @param request HTTP请求对象* @return String 租户ID,如果不存在则返回null*/private String getHeaderOrParam(ServletRequest request) {HttpServletRequest httpRequest = (HttpServletRequest) request;// 从HTTP请求头中获取租户IDString tenantId = httpRequest.getHeader("tenant_id");// 如果请求头中没有租户ID,可以尝试从请求参数中获取if (tenantId == null || tenantId.trim().isEmpty()) {tenantId = httpRequest.getParameter("tenant_id");}return tenantId;}
    }
    
  6. 添加过滤器规则

    import com.tenancy.multi.common.filter.TenantFilter;
    import org.springframework.boot.web.servlet.FilterRegistrationBean;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;/*** 过滤器配置类* 用于注册自定义的租户过滤器到Spring容器中*/
    @Configuration // 表明这是一个配置类,Spring Boot启动时会自动加载
    public class FilterConfig {/*** 注册租户过滤器* 创建FilterRegistrationBean实例,配置自定义的TenantFilter** @return FilterRegistrationBean 过滤器注册对象*/@Bean // 将返回的对象注册为Spring容器中的Beanpublic FilterRegistrationBean registrationBean() {// 创建过滤器注册Bean,传入自定义的TenantFilter实例FilterRegistrationBean reg = new FilterRegistrationBean(new TenantFilter());// 设置过滤器拦截的URL模式,这里拦截所有以/tenant/开头的请求reg.addUrlPatterns("/tenant/*");return reg;}
    }
    
  7. 创建两张表,并插入数据。每张表都需要带有租户id(tenant_id)字段,和 MyTenantLineHandler 的 getTenantIdColumn() 方法设置的一样。

    -- 公司表
    CREATE TABLE `company` (`id` int(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',`tenant_id` varchar(60) NOT NULL COMMENT '租户ID',`company_name` varchar(30) DEFAULT NULL COMMENT '公司',PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;INSERT INTO `tenant`.`company`(`id`, `tenant_id`, `company_name`) VALUES (1, '00001', '腾讯');
    INSERT INTO `tenant`.`company`(`id`, `tenant_id`, `company_name`) VALUES (2, '00002', '阿里');-- 员工表
    CREATE TABLE `staff` (`id` int(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',`tenant_id` varchar(60) NOT NULL COMMENT '租户ID',`staff_id` varchar(60) NOT NULL COMMENT '员工id',`staff_name` varchar(30) DEFAULT NULL COMMENT '员工名称',PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (1, '00001', '1', '马化腾');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (2, '00001', '2', '张小龙');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (3, '00002', '1', '马云');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (4, '00002', '2', '蔡崇信');
    
  8. 查询员工信息的Mapper

    员工表实体类:

    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableField;
    import com.baomidou.mybatisplus.annotation.TableId;
    import lombok.Data;/*** 员工表实体类*/
    @Data
    public class Staff {/*** 主键ID*/@TableId(value = "id", type = IdType.AUTO)private Integer id;/*** 租户ID*/private String tenantId;/*** 员工id*/private String staffId;/*** 员工名称*/private String staffName;/*** 公司名称*/@TableField(exist = false)private String companyName;
    }
    

    Mapper.java

    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.tenancy.multi.module.entity.Staff;
    import org.apache.ibatis.annotations.Param;import java.util.List;public interface StaffMapper extends BaseMapper<Staff> {List<Staff> findStaff(@Param("staffName") String staffName);
    }
    

    Mapper.xml

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.tenancy.multi.module.mapper.StaffMapper"><resultMap type="com.tenancy.multi.module.entity.Staff" id="BaseResultMap"><result property="id" column="id"/><result property="tenantId" column="tenant_id"/><result property="staffId" column="staff_id"/><result property="staffName" column="staff_name"/><result property="companyName" column="company_name"/></resultMap><select id="findStaff" resultMap="BaseResultMap">select b.*, a.company_namefrom company ajoin staff b on a.tenant_id = b.tenant_id<where><if test="staffName != null and staffName != ''">and b.staff_name = #{staffName}</if></where></select>
    </mapper>
    
  9. 接口测试,注意携带请求头tenant_id 在这里插入图片描述

    返回结果如下。可见已经按照预期只查询出来了腾讯这家公司的员工信息,和请求头里传递的租户id保持一致。

    {"code": 200,"message": "操作成功","data": [{"id": 2,"tenantId": "00001","staffId": "2","staffName": "张小龙","companyName": "腾讯"},{"id": 1,"tenantId": "00001","staffId": "1","staffName": "马化腾","companyName": "腾讯"}]
    }
    

    sql日志打印结果如下。和原始sql比较后发现,最终的sql不仅在where条件里加入了a.tenant_id = '00001这个条件,还在关联表时on关键字后加了一个AND b.tenant_id = '00001'条件。

    SELECTb.*,a.company_name 
    FROMcompany aJOIN staff b ON a.tenant_id = b.tenant_id AND b.tenant_id = '00001' 
    WHEREa.tenant_id = '00001' 
    

二、分页

  1. 增加测试数据以方便查看分页效果
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (5, '00001', '5', '腾讯员工5');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (6, '00001', '6', '腾讯员工6');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (7, '00001', '7', '腾讯员工7');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (8, '00001', '8', '腾讯员工8');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (9, '00001', '9', '腾讯员工9');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (10, '00001', '10', '腾讯员工10');
    
  2. 分页插件的maven依赖
    <!-- 分页插件 -->
    <dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.3.0</version><!--若报错日志显示:java.lang.NoSuchMethodError: net.sf.jsqlparser.statement.update.Update.getTable()Lnet/sf/jsqlparser/schema/Table;则可以放开下面的注释,这是由于分页插件pagehelper-spring-boot-starter和mybatis-plus的包有冲突导致的,我们将分页插件的maven依赖添加一个排除。--><!--            <exclusions>--><!--                <exclusion>--><!--                    <artifactId>jsqlparser</artifactId>--><!--                    <groupId>com.github.jsqlparser</groupId>--><!--                </exclusion>--><!--            </exclusions>--></dependency>
    
  3. mybatis 拦截器配置中增加分页拦截器
    import com.baomidou.mybatisplus.annotation.DbType;
    import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
    import com.tenancy.multi.common.interceptor.MyTenantLineHandler;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;/*** MyBatis配置类* 配置MyBatis-Plus的多租户拦截器*/
    @Configuration // 表明这是一个配置类
    public class MyBatisConfig {/*** 配置MyBatis-Plus拦截器* 添加多租户拦截器以实现SQL自动添加租户条件** @return MybatisPlusInterceptor 配置好的MyBatis-Plus拦截器*/@Bean // 将返回的拦截器注册为Spring容器中的Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {// 创建MyBatis-Plus拦截器MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 添加多租户内部拦截器,传入自定义的租户处理器interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler()));// 添加分页内部拦截器interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));/*避坑!!!拦截器的配置顺序必须是租户拦截器在前面,分页拦截器在后面。*/return interceptor;}
    }
    
  4. Service层增加分页逻辑代码
    /*** 查分页询员工信息** @param staffName 员工姓名* @return List<Staff> 返回员工列表* @version 2.0*/
    @Override
    public List<Staff> findStaff(String staffName) {try (Page<Staff> pg = PageHelper.startPage(1, 5, "id desc")) {List<Staff> list = getBaseMapper().findStaff(staffName);PageInfo<Staff> pageInfo = new PageInfo<>(list);return pageInfo.getList();}
    }
    
  5. 查询结果
    {"code": 200,"message": "操作成功","data": [{"id": 10,"tenantId": "00001","staffId": "10","staffName": "腾讯员工10","companyName": "腾讯"},{"id": 9,"tenantId": "00001","staffId": "9","staffName": "腾讯员工9","companyName": "腾讯"},{"id": 8,"tenantId": "00001","staffId": "8","staffName": "腾讯员工8","companyName": "腾讯"},{"id": 7,"tenantId": "00001","staffId": "7","staffName": "腾讯员工7","companyName": "腾讯"},{"id": 6,"tenantId": "00001","staffId": "6","staffName": "腾讯员工6","companyName": "腾讯"}]
    }
    
  6. 打印sql:多了ORDER BY id DESCLIMIT 5
    SELECTb.*,a.company_name 
    FROMcompany aJOIN staff b ON a.tenant_id = b.tenant_id AND b.tenant_id = '00001' 
    WHEREa.tenant_id = '00001' 
    ORDER BYid DESC LIMIT 5
    

三、插入和更新

  1. 插入数据时,同样不需要在参数里传入租户id,Service代码如下

    @Override
    public boolean saveStaff(Staff staff) {staff.setStaffId(IdUtil.simpleUUID());return save(staff);
    }
    

    传参如下。没有在参数体里传租户id,而是和查询时一样将租户id放在了请求头。

    {"staffName": "腾讯果果"
    }
    

    打印sql日志如下

    INSERT INTO staff ( staff_id, staff_name, tenant_id )
    VALUES( '0623989066284d239609de8735bcfaa5', '腾讯果果', '00001' )
    
  2. 更新的Service层代码如下

    @Override
    public boolean updateStaff(Staff staff) {return updateById(staff);
    }
    

    传参如下。把插入时新加入的腾讯员工“腾讯果果”改名成“腾讯大石榴”。

    {"id": 11,"staffName": "腾讯大石榴"
    }
    

    sql打印如下。自动加上了租户条件tenant_id = '00001'

    UPDATE staff 
    SET staff_name = '腾讯大石榴' 
    WHEREtenant_id = '00001' AND id = 11
    
http://www.dtcms.com/a/524500.html

相关文章:

  • 企业网站网页设计的步骤房地产网站建设需求说明书
  • 中国专门做生鲜的网站著名品牌展厅设计
  • 开发避坑指南(66):IDEA 2025 Gradle构建安全协议警告:Maven仓库HTTPS切换或允许HTTP的配置方法
  • 前端新手入门-HBuilder工具安装
  • AceContainer::Initialize()函数的分析
  • 石家庄网站建设方案咨询涉县住房与城乡建设厅网站
  • 鸿蒙Next媒体开发全攻略(ArkTS):播放、录制、查询与转码
  • vs2015网站开发基础样式福田庆三下巴
  • Hugging Face介绍
  • 要建立网站和账号违法违规行为数据库和什么黑名单企业所得税优惠税率
  • 大模型训练中的关键技术与挑战:数据采集、微调与资源优化
  • 【文献笔记】arXiv 2018 | PointSIFT
  • 如何检测和解决I2C通信死锁
  • 深度学习快速入门手册
  • 如何看待 AI 加持下的汽车智能化?带来更好体验的同时能否保证汽车安全?
  • Linux中的一些常见命令
  • 三步将AI模型转换为 DeepX 格式并完成精度评估
  • 做第一个网站什么类型天津市建筑信息平台
  • 找一个网站做优化分析app界面设计属于什么设计
  • 【开题答辩全过程】以 毕业设计选题系统的设计与实现为例,包含答辩的问题和答案
  • 语言基础再谈
  • 网站后台用什么开发网页制作怎么做多个网站
  • 每周读书与学习->JMeter主要元件详细介绍(二)函数助手
  • asp网站开发技术免费建网站模板平台
  • 前端竞态问题是什么?怎么解决?
  • 问题记录--elementui中el-form初始化表单resetFields()方法使用时出现的问题
  • 运用jieba库解决词频分析问题
  • 【Linux】自动化构建工具--make/Makefile
  • 乡镇网站建设工作计划商城网站支付端怎么做
  • 咸阳网站开发公司电话seo网站关键词排名优化公司