SaaS多租户架构实践:字段隔离方案(共享数据库+共享Schema)
本项目是SaaS模式下,基于多租户架构技术,采用字段隔离(共享数据库,共享Schema)方案的demo,旨在了解字段隔离方案的基本工作流程和实现原理,仅做入门使用,不进行深入研究。
项目源码地址:multi-tenancy
一、Mybatis-Plus实现
-
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> -
配置 mybatis 拦截器,并设置租户拦截器
MyTenantLineHandlerimport 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;} } -
租户拦截器 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; // 默认所有表都需要租户隔离} } -
租户上下文代码。租户上下文会保存当前请求线程里从请求头获取的租户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();} } -
配置过滤器,过滤器负责将请求头传过来的租户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;} } -
添加过滤器规则
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;} } -
创建两张表,并插入数据。每张表都需要带有租户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', '蔡崇信'); -
查询员工信息的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> -
接口测试,注意携带请求头
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'
二、分页
- 增加测试数据以方便查看分页效果
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'); - 分页插件的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> - 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;} } - 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();} } - 查询结果
{"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": "腾讯"}] } - 打印sql:多了
ORDER BY id DESC和LIMIT 5SELECTb.*,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
三、插入和更新
-
插入数据时,同样不需要在参数里传入租户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' ) -
更新的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
