本项目是SaaS模式下,基于多租户架构技术,采用字段隔离(共享数据库,共享Schema)方案的demo,旨在了解字段隔离方案的基本工作流程和实现原理,仅做入门使用,不进行深入研究。
项目源码地址:multi-tenancy
一、Mybatis-Plus实现
-
maven依赖
xml<!-- 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 拦截器,并设置租户拦截器
MyTenantLineHandlerjavaimport 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容器中的Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { // 创建MyBatis-Plus拦截器 MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加多租户内部拦截器,传入自定义的租户处理器 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler())); return interceptor; } } -
租户拦截器 MyTenantLineHandler 代码。实现 mybatis 自带的租户 Handler,实现 getTenantId() 方法,mybatis 执行sql 时会通过此方法将得到的租户id条件插入到sql里。
javaimport 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条件拼接 */ @Override public Expression getTenantId() { // 从租户上下文中获取当前租户ID,并包装为SQL表达式 return new StringValue(TenantContext.getCurrentTenant()); } /** * 获取租户ID字段名 * 指定数据库中用于存储租户ID的列名 * * @return String 租户ID字段名 */ @Override public String getTenantIdColumn() { // 返回数据库中租户ID的列名 return "tenant_id"; } /** * 忽略租户过滤的表 * 指定哪些表不需要添加租户条件过滤 * * @param tableName 表名 * @return boolean true表示忽略(不添加租户条件),false表示需要添加租户条件 */ @Override public boolean ignoreTable(String tableName) { // 这里可以添加不需要租户隔离的表名判断逻辑 // 例如:系统表、公共配置表等可以跳过租户过滤 return false; // 默认所有表都需要租户隔离 } } -
租户上下文代码。租户上下文会保存当前请求线程里从请求头获取的租户id。
java/** * 租户上下文管理类 * 用于在多线程环境中存储和获取当前请求的租户信息 * 基于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放入租户上下文。
javaimport 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异常 */ @Override public 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请求头中获取租户ID String tenantId = httpRequest.getHeader("tenant_id"); // 如果请求头中没有租户ID,可以尝试从请求参数中获取 if (tenantId == null || tenantId.trim().isEmpty()) { tenantId = httpRequest.getParameter("tenant_id"); } return tenantId; } } -
添加过滤器规则
javaimport 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容器中的Bean public FilterRegistrationBean registrationBean() { // 创建过滤器注册Bean,传入自定义的TenantFilter实例 FilterRegistrationBean reg = new FilterRegistrationBean(new TenantFilter()); // 设置过滤器拦截的URL模式,这里拦截所有以/tenant/开头的请求 reg.addUrlPatterns("/tenant/*"); return reg; } } -
创建两张表,并插入数据。每张表都需要带有租户id(
tenant_id)字段,和 MyTenantLineHandler 的 getTenantIdColumn() 方法设置的一样。sql-- 公司表 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
员工表实体类:
javaimport 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
javaimport 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<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//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_name from company a join 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保持一致。
json{ "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'条件。sqlSELECT b.*, a.company_name FROM company a JOIN staff b ON a.tenant_id = b.tenant_id AND b.tenant_id = '00001' WHERE a.tenant_id = '00001'
二、分页
-
增加测试数据以方便查看分页效果
sqlINSERT 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依赖
xml<!-- 分页插件 --> <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 拦截器配置中增加分页拦截器
javaimport 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容器中的Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { // 创建MyBatis-Plus拦截器 MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加多租户内部拦截器,传入自定义的租户处理器 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler())); // 添加分页内部拦截器 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); /* 避坑!!! 拦截器的配置顺序必须是租户拦截器在前面,分页拦截器在后面。 */ return interceptor; } } -
Service层增加分页逻辑代码
java/** * 查分页询员工信息 * * @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(); } } -
启动项目测试分页接口

启动项目时若发现上述报错,请参考Spring Boot 2.6+ 整合 PageHelper 启动报错:循环依赖解决方案全解析 -
查询结果
json{ "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 5sqlSELECT b.*, a.company_name FROM company a JOIN staff b ON a.tenant_id = b.tenant_id AND b.tenant_id = '00001' WHERE a.tenant_id = '00001' ORDER BY id DESC LIMIT 5
三、插入和更新
-
插入数据时,同样不需要在参数里传入租户id,Service代码如下
java@Override public boolean saveStaff(Staff staff) { staff.setStaffId(IdUtil.simpleUUID()); return save(staff); }传参如下。没有在参数体里传租户id,而是和查询时一样将租户id放在了请求头。
json{ "staffName": "腾讯果果" }打印sql日志如下
sqlINSERT INTO staff ( staff_id, staff_name, tenant_id ) VALUES ( '0623989066284d239609de8735bcfaa5', '腾讯果果', '00001' ) -
更新的Service层代码如下
java@Override public boolean updateStaff(Staff staff) { return updateById(staff); }传参如下。把插入时新加入的腾讯员工"腾讯果果"改名成"腾讯大石榴"。
json{ "id": 11, "staffName": "腾讯大石榴" }sql打印如下。自动加上了租户条件
tenant_id = '00001'sqlUPDATE staff SET staff_name = '腾讯大石榴' WHERE tenant_id = '00001' AND id = 11