在SaaS系统开发中,多租户数据隔离是核心架构之一。为了实现零侵入、低成本的方案,本指南采用MyBatis-Plus提供的租户插件(TenantLineInnerInterceptor)。该方案基于SQL解析器,在执行SQL时自动重写语句,在WHERE条件中拼接租户ID字段,从而实现应用层面的数据隔离。
本方案的优势在于:
接下来,我们将基于Spring Boot 3.x + MyBatis-Plus 3.5.x搭建完整的项目。
我们需要准备MySQL数据库,并创建两张表:一张是业务表(需要隔离),一张是系统配置表(不需要隔离)。请在本地MySQL中执行以下SQL脚本。
SQL建表脚本:
```sql CREATE DATABASE IF NOT EXISTS multi_tenant_demo; USE multi_tenant_demo; -- 用户表(需要租户隔离) CREATE TABLE `sys_user` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `tenant_id` BIGINT(20) NOT NULL DEFAULT '0' COMMENT '租户ID', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 系统字典表(全局共享,不隔离) CREATE TABLE `sys_dict` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `dict_key` VARCHAR(50) NOT NULL COMMENT '字典键', `dict_value` VARCHAR(100) NOT NULL COMMENT '字典值', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统字典表'; -- 插入测试数据 INSERT INTO `sys_user` (`username`, `tenant_id`) VALUES ('tenant_a_user', 1); INSERT INTO `sys_user` (`username`, `tenant_id`) VALUES ('tenant_b_user', 2); INSERT INTO `sys_dict` (`dict_key`, `dict_value`) VALUES ('version', '1.0.0'); ```创建Spring Boot项目,并在pom.xml中引入必要的依赖。请确保版本号兼容,Spring Boot使用3.x,JDK使用17+。
Maven依赖配置:
```xml接着配置application.yml,连接数据库并开启SQL日志打印,方便我们观察SQL重写效果。
application.yml配置:
```yaml server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/multi_tenant_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 请修改为本地数据库密码 MyBatis-Plus配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 打印SQL日志 global-config: db-config: logic-delete-field: deleted 全局逻辑删除字段值(可选) logic-delete-value: 1 logic-not-delete-value: 0 ```我们需要一个地方在请求处理期间存储当前线程的租户ID。最标准的做法是使用ThreadLocal。创建一个工具类TenantContextHolder。
TenantContextHolder.java:
```java public class TenantContextHolder { private static final ThreadLocal这是实现隔离的核心。我们需要配置MybatisPlusInterceptor,添加租户拦截器,并告知它:
tenant_id。TenantContextHolder)。sys_dict)。MybatisPlusConfig.java:
```java import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 1. 创建租户拦截器 TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(); // 2. 配置租户ID处理器(从ThreadLocal中获取) tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { @Override public Long getTenantId() { // 获取当前请求的租户ID,如果为null则抛出异常或返回默认值 Long tenantId = TenantContextHolder.getTenantId(); // 如果不想在没有租户ID时报错,可以返回一个默认值,例如 0L // 但通常建议抛出异常,防止数据泄露 return tenantId; } @Override public boolean ignoreTable(String tableName) { // 3. 指定哪些表不需要进行租户隔离 // 这里 sys_dict 表是系统共享表,不需要拼接 tenant_id return "sys_dict".equalsIgnoreCase(tableName); } }); // 将租户拦截器添加到插件链中 interceptor.addInnerInterceptor(tenantInterceptor); // 如果需要分页,这里也可以添加分页插件 // interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } } ```在插入数据时,我们需要自动将当前租户ID填入tenant_id字段,而不是让开发人员手动set。MyBatis-Plus提供了MetaObjectHandler接口来实现这一点。
TenantMetaObjectHandler.java:
```java import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import org.apache.ibatis.reflection.MetaObject; import org.springframework.stereotype.Component; @Component public class TenantMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { // 插入时自动填充 tenant_id Long tenantId = TenantContextHolder.getTenantId(); if (tenantId != null) { this.strictInsertFill(metaObject, "tenantId", Long.class, tenantId); } } @Override public void updateFill(MetaObject metaObject) { // 更新通常不需要修改租户ID,留空 } } ```创建实体类,并在tenantId字段上使用TableField注解标记。
SysUser.java:
```java import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @Data @TableName("sys_user") public class SysUser { @TableId(type = IdType.AUTO) private Long id; private String username; // 标记这是租户ID字段,MP会识别并在SQL中处理它 @TableField("tenant_id") private Long tenantId; } ```SysDict.java(用于对比非隔离表):
```java import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @Data @TableName("sys_dict") public class SysDict { @TableId(type = IdType.AUTO) private Long id; private String dictKey; private String dictValue; } ```创建Mapper接口。
SysUserMapper.java:
```java import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface SysUserMapper extends BaseMapperSysDictMapper.java:
```java import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface SysDictMapper extends BaseMapper为了模拟真实环境,我们需要一个Filter来拦截HTTP请求,从Header中解析出tenant-id并放入TenantContextHolder。
TenantFilter.java:
```java import jakarta.servlet.; import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.IOException; @Component @Order(1) // 确保优先级较高 public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String tenantIdStr = httpRequest.getHeader("tenant-id"); if (tenantIdStr != null) { try { Long tenantId = Long.parseLong(tenantIdStr); TenantContextHolder.setTenantId(tenantId); } catch (NumberFormatException e) { // 处理格式错误 } } try { chain.doFilter(request, response); } finally { // 请求结束后必须清理,防止内存泄漏 TenantContextHolder.clear(); } } } ```
编写一个Controller来测试功能。
TestController.java:
```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; import java.util.List; @RestController public class TestController { @Autowired private SysUserMapper sysUserMapper; @Autowired private SysDictMapper sysDictMapper; @GetMapping("/users") public List启动项目后,使用Postman或cURL进行测试。请关注控制台输出的SQL语句,这是验证隔离是否生效的关键。
测试场景1:查询租户1的数据
请求:GET http://localhost:8080/users
Header:tenant-id: 1
预期结果:
控制台打印的SQL应包含 WHERE tenant_id = 1,且只返回 tenant_a_user。
测试场景2:查询租户2的数据
请求:GET http://localhost:8080/users
Header:tenant-id: 2
预期结果:
控制台打印的SQL应包含 WHERE tenant_id = 2,且只返回 tenant_b_user。
测试场景3:查询非隔离表(字典表)
请求:GET http://localhost:8080/dicts
Header:tenant-id: 1
预期结果:
控制台打印的SQL应不包含 WHERE tenant_id = ...,证明忽略表配置生效。
测试场景4:自动填充插入
请求:GET http://localhost:8080/add
Header:tenant-id: 1
预期结果:
控制台打印的INSERT语句中,tenant_id 字段会被自动填充为 1。
通过以上步骤,你已经成功构建了一个基于Spring Boot和MyBatis-Plus的多租户数据隔离系统。该系统通过SQL拦截器自动处理租户逻辑,业务代码保持极简,能够有效防止SaaS系统中的越权数据访问问题。