MyBatis-Plus多租户案例

wen java案例 2

MyBatis-Plus多租户案例:从零搭建SaaS级数据隔离方案(含完整代码)

目录导读

  • 多租户架构核心概念:什么是SaaS多租户?三种隔离模式优劣对比
  • 为什么选择MyBatis-Plus实现多租户:市面上主流方案横评
  • 实战案例:基于MyBatis-Plus的租户隔离完整实现:从配置到代码的全流程
  • 多租户下的复杂场景处理:跨租户查询、动态表名、缓存穿透
  • 性能优化与安全加固:索引设计、拦截器扩展、数据权限二次过滤
  • 高频面试问答:大厂关于多租户的经典考题与解答

多租户架构核心概念

多租户技术(Multi-Tenancy)是SaaS(软件即服务)架构的基石,它允许单个应用实例同时服务多个租户(企业/组织),每个租户拥有独立的数据空间与业务配置,但共享底层资源。

MyBatis-Plus多租户案例

三种主流隔离模式对比

模式 数据隔离级别 成本 适用场景
独立数据库 最强,物理隔离 最高,需维护多库 金融、政务等强合规场景
共享数据库,独立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租户插件核心优势

  • 自动拦截 INSERTUPDATESELECTDELETE,全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底座。

抱歉,评论功能暂时关闭!