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-Plus:
3.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、网关透传)。
四、租户字段自定义的两种做法
-
统一租户列名(推荐)
- 所有表统一使用同一列名(如
tenant_id或org_id)。 - 配置简单,官方拦截器开箱即用。
- 所有表统一使用同一列名(如
-
按表自定义租户列名(增强版)
- 某些历史表使用
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。
九、数据初始化与插入填充
- 插入时若实体未显式赋值
tenantId,MetaObjectHandler会自动填充。 - 批量导入:确保上下文已设置租户再执行;或使用 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。
十一、常见坑位与排查
- UPDATE/DELETE 少了租户条件?
- 该表是否在忽略列表?方法是否加了
@InterceptorIgnore?
- 该表是否在忽略列表?方法是否加了
- INSERT 没有填充租户?
- 是否实现了
MetaObjectHandler?实体字段名与列名是否对应?
- 是否实现了
- 联表查询 A join B
- MP 会在两个表上都追加租户条件;若某表为公共表需加入忽略。
- 子查询/视图
- 尽量避免在视图上做租户隔离;对子查询可测试解析是否正确。
- 手写 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)、确认上下文租户、检查忽略规则。
2504

被折叠的 条评论
为什么被折叠?



