MybatisPlus优雅实现多租户数据隔离(自定义字段)

MyBatis-Plus 多租户数据隔离(自定义字段版)实战指南

适用:Spring Boot 2.x/3.x + MyBatis-Plus 3.5.x
场景:SaaS 多租户、企业多组织隔离、按 tenant_id/org_id/company_id 等任意自定义列进行透明隔离。


记得点赞加收藏哦😁😁😁

目录

  • 一、多租户模式对比与选择
  • 二、总体方案与依赖版本
  • 三、核心思想
  • 四、租户字段自定义的两种做法
  • 五、快速上手(统一租户列名)
  • 六、进阶:按表自定义租户列名(增强拦截器)
  • 七、白名单/忽略规则
  • 八、租户上下文与登录集成
  • 九、数据初始化与插入填充
  • 十、与逻辑删除/乐观锁/分页的兼容
  • 十一、常见坑位与排查
  • 十二、完整演示项目结构
  • 十三、SQL 与单元测试样例
  • 十四、FAQ

一、多租户模式对比与选择

模式描述优点缺点适用本文支持
独立数据库每租户独立 DB隔离强成本高、运维复杂大客户✅(通过动态数据源扩展)
独立 Schema共享实例、隔离到 Schema平衡成本仍有运维成本中大型⚠️(需要额外路由)
共享库共享表(列区分)所有租户同表,用 tenant_id 区分成本低、改造小逻辑隔离中小型、SaaS✅(本文主线)

本文主线采用 共享库共享表,通过 MyBatis-Plus 的 TenantLineInnerInterceptor 在 SQL 层透明追加 where tenant_id = ? 实现数据隔离。


二、总体方案与依赖版本

  • Spring Boot:2.7.x 或 3.2+
  • MyBatis-Plus3.5.x
  • JSqlParser:由 MP 传递依赖
  • 数据库:MySQL 5.7+/8.0+(其他主流兼容)

Maven 依赖:

<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.5.6</version>
</dependency>

三、核心思想

  • 拦截器追加条件:在 SELECT/UPDATE/DELETE 自动追加 AND <tenant_column> = ?
  • 插入自动填充INSERT 时自动给租户列赋值,避免漏填。
  • 白名单/忽略:可对表、Mapper、方法进行忽略(如公共表)。
  • 租户上下文:从线程上下文取当前请求租户(如从 JWT、Header、网关透传)。

四、租户字段自定义的两种做法

  1. 统一租户列名(推荐)

    • 所有表统一使用同一列名(如 tenant_idorg_id)。
    • 配置简单,官方拦截器开箱即用。
  2. 按表自定义租户列名(增强版)

    • 某些历史表使用 org_id,新表用 tenant_id
    • 通过增强拦截器在解析表名时按表名返回不同的列名,兼容存量库结构。

五、快速上手(统一租户列名)

1. 配置拦截器(统一列名)

// src/main/java/com/example/config/MybatisPlusTenantConfig.java
@Configuration
public class MybatisPlusTenantConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 1) 多租户
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContext.getTenantId();
                if (tenantId == null) {
                    // 可选择抛错或返回一个恒不成立的条件
                    return new LongValue(-1);
                }
                return new LongValue(tenantId);
            }

            @Override
            public String getTenantIdColumn() {
                // 统一列名:修改成你需要的,如 "org_id" / "company_id"
                return "tenant_id";
            }

            @Override
            public boolean ignoreTable(String tableName) {
                // 忽略公共表 / 字典表 / 视图等
                return TenantIgnoreProperties.ignoreTables.contains(tableName);
            }
        });
        interceptor.addInnerInterceptor(tenantInterceptor);

        // 2) 分页(可选,需在 Tenant 之后添加)
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

        return interceptor;
    }
}

2. 租户上下文

// src/main/java/com/example/tenant/TenantContext.java
public final class TenantContext {
    private static final ThreadLocal<Long> TL = new ThreadLocal<>();
    public static void setTenantId(Long tenantId) { TL.set(tenantId); }
    public static Long getTenantId() { return TL.get(); }
    public static void clear() { TL.remove(); }
}

在网关或 Spring MVC 过滤器解析 Header/JWT:

// src/main/java/com/example/tenant/TenantFilter.java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        try {
            String raw = request.getHeader("X-Tenant-Id");
            if (raw != null && !raw.isEmpty()) {
                TenantContext.setTenantId(Long.valueOf(raw));
            } else {
                // 也可从 JWT 中解析
                // Long tid = JwtUtil.parse(request).getTenantId();
                // TenantContext.setTenantId(tid);
            }
            chain.doFilter(req, res);
        } finally {
            TenantContext.clear();
        }
    }
}

3. 插入自动填充

// src/main/java/com/example/tenant/TenantMetaObjectHandler.java
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        Long tid = TenantContext.getTenantId();
        if (tid != null) {
            // 字段名使用实体里的字段名(非列名)
            this.strictInsertFill(metaObject, "tenantId", Long.class, tid);
        }
    }
    @Override
    public void updateFill(MetaObject metaObject) { /* 其他审计字段 */ }
}

4. 实体与 Mapper

// src/main/java/com/example/user/User.java
@Data
@TableName("t_user")
public class User {
    private Long id;
    private String username;

    // 与列 tenant_id 对应
    @TableField("tenant_id")
    private Long tenantId;
}

只要拦截器启用、上下文有租户值,即使不在业务代码里写 tenantId 条件,SQL 也会自动追加。


六、进阶:按表自定义租户列名(增强拦截器)

适用于部分表租户列名不同的历史场景。思路:继承 TenantLineInnerInterceptor,在解析表时动态返回列名。

1) 扩展 Handler(支持按表名返回列名)

// src/main/java/com/example/tenant/ExtendedTenantLineHandler.java
public interface ExtendedTenantLineHandler extends TenantLineHandler {
    /**
     * 按表返回租户列名,若返回 null/空则回落到 getTenantIdColumn()
     */
    default String getTenantIdColumn(String tableName) { return null; }
}

2) 增强拦截器

// src/main/java/com/example/tenant/DynamicTenantLineInnerInterceptor.java
public class DynamicTenantLineInnerInterceptor extends TenantLineInnerInterceptor {

    private final ExtendedTenantLineHandler handler;
    public DynamicTenantLineInnerInterceptor(ExtendedTenantLineHandler handler) {
        super(handler);
        this.handler = handler;
    }

    @Override
    protected String getTenantIdColumn(Table table) {
        // MyBatis-Plus 3.5.x 内部有表对象,可覆写本方法(方法名以你所用版本源码为准)
        String tableName = table.getName();
        String byTable = handler.getTenantIdColumn(tableName);
        return (byTable == null || byTable.isEmpty()) ? handler.getTenantIdColumn() : byTable;
    }
}

注:不同 MP 版本方法签名略有差异,如果没有 getTenantIdColumn(Table) 可参考源码覆写 processTable,从 Table 解析表名后拼接条件。

3) 配置增强拦截器与映射关系

@Configuration
public class MybatisPlusTenantConfigV2 {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        Map<String, String> tableTenantColumn = new HashMap<>();
        tableTenantColumn.put("t_user", "org_id");       // 历史表
        tableTenantColumn.put("t_order", "tenant_id");   // 新表
        // ... 可从配置文件加载

        ExtendedTenantLineHandler handler = new ExtendedTenantLineHandler() {
            @Override
            public Expression getTenantId() {
                Long tid = TenantContext.getTenantId();
                return tid == null ? new LongValue(-1) : new LongValue(tid);
            }
            @Override
            public String getTenantIdColumn() { return "tenant_id"; } // 默认
            @Override
            public String getTenantIdColumn(String tableName) {
                return tableTenantColumn.get(tableName);
            }
            @Override
            public boolean ignoreTable(String tableName) {
                return TenantIgnoreProperties.ignoreTables.contains(tableName);
            }
        };

        interceptor.addInnerInterceptor(new DynamicTenantLineInnerInterceptor(handler));
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

七、白名单/忽略规则

1) 按表忽略

  • ignoreTable(tableName) 返回 true 即可。

2) 按 Mapper 或方法忽略(官方注解)

// 忽略某个方法的租户拦截
@InterceptorIgnore(tenantLine = "true")
@Select("select count(1) from t_user")   // 统计全量
int countAll();

也可加在 Mapper 接口上,作用于所有方法。

3) 读未分表的公共字典

  • 将字典表加入 ignoreTables

八、租户上下文与登录集成

  • 来源:网关透传 X-Tenant-Id、JWT Claim、子域名路由(如 t1.example.com)。
  • 建议:后端统一用 TenantFilter 写入 TenantContext,避免业务代码显式传参。
  • 安全:对管理员 API 放宽,但普通用户的 Token 必须包含并校验 tenantId

九、数据初始化与插入填充

  • 插入时若实体未显式赋值 tenantIdMetaObjectHandler 会自动填充。
  • 批量导入:确保上下文已设置租户再执行;或使用 SQL INSERT ... SELECT 并显示指定列。
INSERT INTO t_user(username, tenant_id) SELECT username, 1001 FROM t_user_tpl;

十、与逻辑删除/乐观锁/分页的兼容

  • 逻辑删除:与 @TableLogic 兼容,SQL 会追加两个条件(租户 + 未删除)。
  • 乐观锁:启用 OptimisticLockerInnerInterceptor,与租户拦截器无冲突。
  • 分页:将 PaginationInnerInterceptor 加到 MybatisPlusInterceptor 中即可。

建议顺序:TenantLineInnerInterceptor -> OptimisticLocker -> Pagination


十一、常见坑位与排查

  1. UPDATE/DELETE 少了租户条件?
    • 该表是否在忽略列表?方法是否加了 @InterceptorIgnore
  2. INSERT 没有填充租户?
    • 是否实现了 MetaObjectHandler?实体字段名与列名是否对应?
  3. 联表查询 A join B
    • MP 会在两个表上都追加租户条件;若某表为公共表需加入忽略。
  4. 子查询/视图
    • 尽量避免在视图上做租户隔离;对子查询可测试解析是否正确。
  5. 手写 SQL
    • 手写 SQL 同样会被解析;但某些复杂 SQL(函数/关键字冲突)需验证。

十二、完整演示项目结构

src
├─ main/java/com/example
│  ├─ config/MybatisPlusTenantConfig.java
│  ├─ config/MybatisPlusTenantConfigV2.java
│  ├─ tenant/TenantContext.java
│  ├─ tenant/TenantFilter.java
│  ├─ tenant/TenantMetaObjectHandler.java
│  ├─ tenant/ExtendedTenantLineHandler.java
│  ├─ tenant/DynamicTenantLineInnerInterceptor.java
│  ├─ user/User.java
│  ├─ user/UserMapper.java
│  └─ user/UserService.java
└─ test/java/com/example/UserMapperTests.java

十三、SQL 与单元测试样例

1) 建表

CREATE TABLE t_user (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(64) NOT NULL,
  tenant_id BIGINT NOT NULL,
  is_deleted TINYINT DEFAULT 0,
  version BIGINT DEFAULT 0,
  KEY idx_tenant (tenant_id)
);

2) 单测

@SpringBootTest
class UserMapperTests {

    @Autowired private UserMapper mapper;

    @Test
    void tenantAutoAppend() {
        TenantContext.setTenantId(1001L);
        try {
            // SELECT * FROM t_user WHERE tenant_id=1001
            List<User> list = mapper.selectList(Wrappers.<User>lambdaQuery());
            assertTrue(list.stream().allMatch(u -> u.getTenantId() == 1001L));
        } finally {
            TenantContext.clear();
        }
    }
}

十四、FAQ

Q1:如何在运行时动态切换租户?
A:在请求入口设置 TenantContext.setTenantId(xxx)。本次请求内的所有 Mapper 操作都会自动带上该租户。

Q2:如何对某些统计报表跨租户?
A:在对应方法上使用 @InterceptorIgnore(tenantLine = "true"),并做好权限校验。

Q3:能否与数据权限(部门/用户级)叠加?
A:可以。再增加一个自定义 MyBatis 拦截器或使用 MP 的 DataPermissionHandler 在 SQL 上继续拼接。

Q4:老库历史表列名不统一怎么办?
A:采用本文“进阶方案”,增强拦截器按表返回不同的租户列名,逐步推进统一。

Q5:多租户 + 多数据源?
A:在动态数据源路由前先解析租户,再选择数据源,并仍保留本文的行级隔离。


完整代码片段汇总

本节汇总上文关键类,方便复制。

TenantContext.java
public final class TenantContext {
    private static final ThreadLocal<Long> TL = new ThreadLocal<>();
    public static void setTenantId(Long tenantId) { TL.set(tenantId); }
    public static Long getTenantId() { return TL.get(); }
    public static void clear() { TL.remove(); }
}
TenantMetaObjectHandler.java
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        Long tid = TenantContext.getTenantId();
        if (tid != null) {
            this.strictInsertFill(metaObject, "tenantId", Long.class, tid);
        }
    }
    @Override public void updateFill(MetaObject metaObject) { }
}
MybatisPlusTenantConfig.java
@Configuration
public class MybatisPlusTenantConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                Long tid = TenantContext.getTenantId();
                return tid == null ? new LongValue(-1) : new LongValue(tid);
            }
            @Override public String getTenantIdColumn() { return "tenant_id"; }
            @Override public boolean ignoreTable(String tableName) {
                return TenantIgnoreProperties.ignoreTables.contains(tableName);
            }
        });
        interceptor.addInnerInterceptor(tenantInterceptor);
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}
DynamicTenantLineInnerInterceptor.java
public class DynamicTenantLineInnerInterceptor extends TenantLineInnerInterceptor {
    private final ExtendedTenantLineHandler handler;
    public DynamicTenantLineInnerInterceptor(ExtendedTenantLineHandler handler) {
        super(handler);
        this.handler = handler;
    }
    @Override
    protected String getTenantIdColumn(Table table) {
        String byTable = handler.getTenantIdColumn(table.getName());
        return (byTable == null || byTable.isEmpty()) ? handler.getTenantIdColumn() : byTable;
    }
}
示例实体 User.java
@Data
@TableName("t_user")
public class User {
    private Long id;
    private String username;
    @TableField("tenant_id")
    private Long tenantId;
}

结语

  • 先统一列名实现 80% 场景,再用增强拦截器兜底历史差异。
  • 结合插入填充、白名单与单测,保障生产可观测性与安全性。

有问题直接定位:打印 SQL(mp log)、确认上下文租户、检查忽略规则。


<think>我们正在讨论多租户环境下的数据隔离问题。用户询问仅使用MyBatis-Plus框架能否实现数据隔离。根据提供的引用,我们可以得出结论:MyBatis-Plus本身提供了多租户数据隔离的支持,主要通过其内置的拦截器机制实现。下面我将详细说明如何实现,并给出代码示例。</think>是的,**仅使用MyBatis-Plus框架即可实现多租户数据隔离**。MyBatis-Plus通过其内置的`TenantLineInnerInterceptor`租户拦截器,在SQL执行层面自动实现租户数据隔离。以下是具体实现原理和方案: --- ### 一、MyBatis-Plus实现数据隔离的核心机制 | 组件 | 作用 | 隔离实现方式 | |------|------|-------------| | `TenantLineInnerInterceptor` | SQL拦截器 | 自动在所有查询/更新语句中注入`tenant_id`条件 | | `TenantLineHandler` | 租户处理器 | 定义租户ID获取规则和租户字段名 | | `@TableField(tenantLine = true)` | 实体注解 | 标记租户ID字段 | --- ### 二、完整实现步骤(基于共享数据库模式) #### 1. 实体类配置租户字段 ```java public class User { private Long id; private String name; @TableField(tenantLine = true) // 标记为租户隔离字段 private Long tenantId; } ``` #### 2. 实现租户处理器 ```java public class MyTenantHandler implements TenantLineHandler { // 获取当前租户ID(通常从ThreadLocal或SecurityContext获取) @Override public Expression getTenantId() { return new LongValue(TenantContext.getCurrentTenantId()); } // 租户字段名(需与实体类字段一致) @Override public String getTenantIdColumn() { return "tenant_id"; } // 需要过滤的表(返回true表示需要自动注入租户条件) @Override public boolean ignoreTable(String tableName) { return !"user".equals(tableName); // 示例: 仅user表需要隔离 } } ``` #### 3. 配置拦截器 ```java @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加租户拦截器 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantHandler())); return interceptor; } } ``` --- ### 三、隔离效果验证 #### 1. 查询操作 ```java userMapper.selectList(new QueryWrapper<User>().eq("name", "张三")); ``` **实际执行SQL**: ```sql SELECT * FROM user WHERE name = &#39;张三&#39; AND tenant_id = 1 -- 自动注入租户条件 ``` #### 2. 插入操作 ```java User user = new User(); user.setName("李四"); userMapper.insert(user); ``` **实际执行SQL**: ```sql INSERT INTO user (name, tenant_id) VALUES (&#39;李四&#39;, 1) -- 自动填充租户ID ``` --- ### 四、高级配置选项 #### 1. 忽略特定SQL(如全表统计) ```java // 在需要忽略租户过滤的方法上添加注解 @InterceptorIgnore(tenantLine = "true") Long selectTotalCount(); ``` #### 2. 动态表名支持(SaaS系统常用) ```java public class MyTenantHandler implements TenantLineHandler { ... @Override public String getTenantIdColumn() { // 根据租户ID动态生成表名: user_tenant1 return TenantContext.isIndependentDb() ? null : // 独立数据库时不注入条件 "tenant_id"; } } ``` --- ### 五、不同隔离方案对比 | 方案类型 | 实现方式 | 适用场景 | MyBatis-Plus支持 | |---------|---------|---------|------------------| | **共享库共享表** | 通过`tenant_id`字段隔离 | 中小型SaaS | ✅ 原生支持 | | **共享库独立表** | 表名包含租户ID(如`order_tenant1`) | 中大型系统 | ✅ 需自定义表名处理器 | | **独立数据库** | 每个租户独立数据库 | 大型/金融系统 | ⚠️ 需结合动态数据源 | > 对于独立数据库方案,可结合`dynamic-datasource-spring-boot-starter`实现: > ```java > @DS("#tenantId") // 根据租户ID切换数据源 > public List<User> selectByTenant(String tenantId) { ... } > ```[^3] --- ### 六、注意事项 1. **租户上下文管理** ```java // 使用ThreadLocal存储租户ID public class TenantContext { private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>(); public static void setTenantId(Long tenantId) { CURRENT_TENANT.set(tenantId); } public static Long getCurrentTenantId() { return CURRENT_TENANT.get(); } } ``` 2. **超级管理员权限** ```java // 在租户处理器中添加豁免逻辑 @Override public boolean ignoreTable(String tableName) { return SecurityUtils.isAdmin(); // 管理员跳过过滤 } ``` 3. **数据初始化** ```sql -- 建表时需包含tenant_id字段 CREATE TABLE user ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, name VARCHAR(50) ); ``` --- **相关问题** 1. 如何在MyBatis-Plus中实现租户数据迁移? 2. 多租户系统如何进行分库分表设计? 3. 租户隔离方案如何影响数据库索引性能? 4. MyBatis-Plus多租户与ShardingSphere集成的最佳实践? 5. 如何审计多租户系统中的数据访问记录?
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值