网站首页/ 信息中心/ 档案百科/

Spring Boot实战:从零构建多租户数据隔离系统

发布时间:2026年06月09日 13:25:21 浏览量:0

技术方案选型与核心原理

在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 org.springframework.boot spring-boot-starter-web com.baomidou mybatis-plus-boot-starter 3.5.5 com.mysql mysql-connector-j runtime org.projectlombok lombok true ```

接着配置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 TENANT_ID = new ThreadLocal<>(); / 设置当前租户ID / public static void setTenantId(Long tenantId) { TENANT_ID.set(tenantId); } / 获取当前租户ID / public static Long getTenantId() { return TENANT_ID.get(); } / 清除当前租户ID / public static void clear() { TENANT_ID.remove(); } } ```

第四步:MyBatis-Plus核心配置

这是实现隔离的核心。我们需要配置MybatisPlusInterceptor,添加租户拦截器,并告知它:

  1. 租户ID的字段名是tenant_id
  2. 从哪里获取当前的租户ID(即调用上面的TenantContextHolder)。
  3. 哪些表需要忽略租户隔离(如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

在插入数据时,我们需要自动将当前租户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 BaseMapper { } ```

SysDictMapper.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(); } } } ```

Spring Boot实战:从零构建多租户数据隔离系统

编写一个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 getUsers() { // 这里只写了一句全表查询代码,MP会自动加上 tenant_id 条件 return sysUserMapper.selectList(null); } @GetMapping("/dicts") public List getDicts() { // 查询字典表,应该不会拼接 tenant_id return sysDictMapper.selectList(null); } @GetMapping("/add") public String addUser() { SysUser user = new SysUser(); user.setUsername("new_user"); // 注意:这里没有设置 tenantId,依赖 MetaObjectHandler 自动填充 sysUserMapper.insert(user); return "插入成功,ID: " + user.getId(); } } ```

验证与实操效果

启动项目后,使用Postman或cURL进行测试。请关注控制台输出的SQL语句,这是验证隔离是否生效的关键。

测试场景1:查询租户1的数据

请求:GET http://localhost:8080/users

Header:tenant-id: 1

预期结果:

控制台打印的SQL应包含 WHERE tenant_id = 1,且只返回 tenant_a_user

```sql ==> Preparing: SELECT id,username,tenant_id FROM sys_user WHERE tenant_id = 1 ```

测试场景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 = ...,证明忽略表配置生效。

```sql ==> Preparing: SELECT id,dict_key,dict_value FROM sys_dict ```

测试场景4:自动填充插入

请求:GET http://localhost:8080/add

Header:tenant-id: 1

预期结果:

控制台打印的INSERT语句中,tenant_id 字段会被自动填充为 1

```sql ==> Preparing: INSERT INTO sys_user (username, tenant_id) VALUES (?, ?) ==> Parameters: new_user(String), 1(Long) ```

通过以上步骤,你已经成功构建了一个基于Spring Boot和MyBatis-Plus的多租户数据隔离系统。该系统通过SQL拦截器自动处理租户逻辑,业务代码保持极简,能够有效防止SaaS系统中的越权数据访问问题。

音频档案管理:别让宝贵的声音资料变成一堆乱麻
音频档案管理:别让宝贵的声音资料变成一堆乱麻
你是不是也这样?手机里存了几百个录音文件,有工作会议、孩子第一次叫妈妈、重要的电话录音,还有自己瞎哼哼的旋律。想找半年前那次关键的会议记录?得,在文件海洋里翻个半小时,最后可能还找错了。更扎心的是,有...
2026年06月09日 13:25:21
微信咨询
电话联系
QQ客服
微信咨询一对一服务
服务热线: 028-8744 4417
QQ客服: 2305721818