MyBatis-Plus多租户案例:从零搭建SaaS级数据隔离方案(含完整代码)
目录导读
- 多租户架构核心概念:什么是SaaS多租户?三种隔离模式优劣对比
- 为什么选择MyBatis-Plus实现多租户:市面上主流方案横评
- 实战案例:基于MyBatis-Plus的租户隔离完整实现:从配置到代码的全流程
- 多租户下的复杂场景处理:跨租户查询、动态表名、缓存穿透
- 性能优化与安全加固:索引设计、拦截器扩展、数据权限二次过滤
- 高频面试问答:大厂关于多租户的经典考题与解答
多租户架构核心概念
多租户技术(Multi-Tenancy)是SaaS(软件即服务)架构的基石,它允许单个应用实例同时服务多个租户(企业/组织),每个租户拥有独立的数据空间与业务配置,但共享底层资源。

三种主流隔离模式对比
| 模式 | 数据隔离级别 | 成本 | 适用场景 |
|---|---|---|---|
| 独立数据库 | 最强,物理隔离 | 最高,需维护多库 | 金融、政务等强合规场景 |
| 共享数据库,独立Schema | 较强,逻辑隔离 | 中,Oracle/PostgreSQL常用 | 中型SaaS平台 |
| 共享Schema,租户ID区分(重点) | 一般,逻辑隔离 | 最低,MySQL首选 | 初创SaaS、ToB快速迭代产品 |
关键点:MyBatis-Plus多租户插件采用第三种“共享表+租户ID”方案,通过在SQL执行前自动拼接
WHERE tenant_id = ?实现隔离,业务代码零侵入。
为什么选择MyBatis-Plus实现多租户?
市面上常用的多租户方案对比:
| 方案 | 实现原理 | 优点 | 缺点 |
|---|---|---|---|
| Hibernate Filter | Hibernate级联过滤 | 成熟稳定 | 侵入性强,需维护实体关系 |
| MyBatis-Plus租户插件 | 拦截Executor动态改写SQL |
✅ 无侵入、性能高、支持各种SQL语法 | 对复杂子查询需额外配置 |
| MyCat/ShardingSphere | 中间件层改写 | 支持跨库 | 部署复杂,中小团队驾驭难 |
手动where条件 |
业务层自己拼 | 可控 | 极易漏加,风险超高 |
MyBatis-Plus租户插件核心优势:
- 自动拦截
INSERT、UPDATE、SELECT、DELETE,全SQL覆盖。 - 支持忽略某些表(如字典表、全局配置表)。
- 兼容逻辑删除、分页、多表join。
实战案例:基于MyBatis-Plus的租户隔离完整实现
环境准备
Spring Boot 2.7+ / MyBatis-Plus 3.5.x / MySQL 8.x / Maven
1 引入依赖(pom.xml)
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.5.3</version>
</dependency>
2 配置TenantLineInnerInterceptor拦截器
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
TenantLineHandler tenantHandler = new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从上下文(如Token、ThreadLocal)获取当前租户ID
Long tenantId = TenantContextHolder.getTenantId();
return new LongValue(tenantId == null ? 0L : tenantId);
}
@Override
public String getTenantIdColumn() {
return "tenant_id"; // 租户字段名
}
@Override
public boolean ignoreTable(String tableName) {
// 忽略字典表、系统配置表
return "sys_dict".equals(tableName) || "sys_config".equals(tableName);
}
};
tenantInterceptor.setTenantLineHandler(tenantHandler);
interceptor.addInnerInterceptor(tenantInterceptor);
// 分页插件(放在多租户后面)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
3 自定义租户上下文(ThreadLocal)
public class TenantContextHolder {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static Long getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
4 创建带租户字段的实体
@Data
@TableName("biz_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private BigDecimal amount;
private Long tenantId; // 租户字段
}
5 业务调用(无需手动写租户条件)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public List<Order> getOrderList() {
// 模拟从Session创建租户上下文
TenantContextHolder.setTenantId(1001L);
try {
// 自动生成: SELECT * FROM biz_order WHERE tenant_id = 1001
return orderMapper.selectList(null);
} finally {
TenantContextHolder.clear();
}
}
}
执行SQL自动变为:
SELECT id, order_no, amount, tenant_id FROM biz_order WHERE tenant_id = 1001
多租户下的复杂场景处理
1 跨租户查询(例如后台管理员)
// 方式一:临时忽略租户拦截
@InterceptorIgnore(tenantLine = "true")
public List<Order> adminQueryAllOrders() {
return orderMapper.selectList(null);
}
// 方式二:使用自定义单独Mapper(推荐)
@Mapper
public interface AdminOrderMapper extends BaseMapper<Order> {
@Select("SELECT * FROM biz_order") // 不走插件拦截
List<Order> queryAll();
}
2 动态表名(如表名带租户号)
某些极端场景下,表名直接包含租户ID,只需在TenantLineHandler中改写表名:
@Override
public String getTenantIdColumn() {
return null; // 不添加列条件
}
@Override
public boolean ignoreTable(String tableName) {
return tableName.endsWith("_t" + TenantContextHolder.getTenantId());
}
3 缓存穿透与租户缓存隔离
Redis key必须拼接租户ID:
public Order getOrderCache(Long orderId) {
String key = "order:" + TenantContextHolder.getTenantId() + ":" + orderId;
// 先从Redis查,查不到走DB(DB已有租户条件)
}
性能优化与安全加固
1 数据库索引设计
必加复合索引:(tenant_id, id)、(tenant_id, order_no),无租户前缀的索引,在高并发下会失效,导致慢查询。
2 拦截器扩展:防止恶意绕过
拦截器只对应用内部有效,若有人直接访问数据库,依然可以跨租户查询,建议:
- 数据库账号禁止REPLACE、DROP
- 定期审计SQL,使用
performance_schema监控异常大查询
3 数据权限二次过滤
多租户只处理“租户级”隔离,如果是部门/用户级权限,需叠加DataScopeInterceptor:
// 在MybatisPlusInterceptor中继续添加(顺序:多租户 -> 数据权限 -> 分页) interceptor.addInnerInterceptor(new DataPermissionInterceptor(...));
高频面试问答
Q1:MyBatis-Plus多租户插件是如何实现自动加条件的?
A:插件实现了InnerInterceptor,在Executor执行前,通过JSqlParser解析SQL语法树,在WHERE根节点注入 tenant_id = ? 条件;对 INSERT 语句,自动在字段列表末尾追加tenant_id列及值。
Q2:如果某张表不需要租户隔离,如何处理?
A:在ignoreTable方法中返回true,例如系统字典、全局配置表,同时需检查该表是否被租户表关联,多表join时插件只处理非忽略表。
Q3:逻辑删除与多租户插件冲突吗?
A:不冲突,MyBatis-Plus的LogicDelete使用TableLogic注解,执行删除时自动改为UPDATE;租户插件同样会拦截UPDATE语句加重租户条件,确保安全。
Q4:性能上,多租户插件对SQL有损耗吗? A:解析SQL有微小开销(毫秒级),建议:高频查询使用固定SQL(注解方式)绕过插件;在数据库连接池开启预编译缓存,减少重复解析。
Q5:微服务架构下,各服务都要配置多租户插件吗?
A:是的,每个服务都可能触发数据访问,必须在网关层解析JWT中的租户ID,并通过ThreadLocal传递到各服务,注意跨服务调用时需通过Feign传递租户Header,防止丢失。
MyBatis-Plus多租户插件极大简化了SaaS数据隔离的开发流程,配合合理的索引与上下文管理,可以快速打造安全高效的多租户系统。记住核心三点:拦截器配置、租户上下文传递、忽略表策略,希望本案例能助你少走弯路,构建出稳固可扩展的SaaS底座。