ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Mybatis-Plus多租户插件实战:原理、配置与避坑指南

2026/8/13 15:19:38 拓冰建站 浏览量
Mybatis-Plus多租户插件实战:原理、配置与避坑指南 1. 项目缘起为什么我们需要一个多租户插件在开发企业级SaaS应用或者任何需要为不同客户群体提供独立数据视图的后台系统时多租户架构是一个绕不开的核心设计。简单来说它就像一栋公寓楼每个租户客户住在自己独立的单元里共享大楼的基础设施服务器、数据库连接池但彼此的数据和配置是完全隔离的互不可见。对于开发者而言实现这种数据隔离最直接也最头疼的问题就是如何在每一次数据库操作中自动、准确、无感地添加上租户的过滤条件几年前我和团队接手一个从单体应用向SaaS平台转型的项目。最初的方案是在每个涉及数据查询的Service方法里手动拼接where tenant_id #{currentTenantId}。这个方案在初期看起来简单直接但随着业务模块爆炸式增长我们很快陷入了泥潭上百个Mapper方法需要逐一修改新来的同事很容易忘记添加过滤条件导致数据泄露更别提那些复杂的联表查询和动态SQL了。一次线上事故因为一个隐蔽的查询漏了租户条件导致A客户看到了B客户的订单信息虽然及时修复但信任危机已经产生。那次教训让我们下定决心必须找到一个一劳永逸的、声明式的解决方案。这时Mybatis-Plus简称MP进入了我们的视野。作为MyBatis的增强工具它提供的插件机制Interceptor为我们打开了一扇门。我们需要的正是一个能拦截所有SQL执行在运行时动态注入租户过滤条件的插件。于是“Mybatis-plus多租户插件”从一个模糊的需求变成了一个必须落地的核心基础设施。它要解决的就是如何将租户隔离这个横切关注点从繁琐的业务代码中剥离出来通过配置和少量编码实现全局、自动、安全的数据隔离。2. 核心机制拆解插件是如何“无感”注入租户条件的理解MP多租户插件的核心关键在于弄懂它的工作流程和拦截原理。这不像在代码里写几个if-else那么简单它需要深入到SQL语句的生命周期中在恰当的时机进行精准的“手术”。2.1 插件拦截的时机与对象MP的插件基于MyBatis的Interceptor接口实现。多租户插件主要拦截的是StatementHandler.prepare方法。在这个阶段SQL语句已经完成了动态SQL的解析比如if标签已经被处理但还没有被发送到数据库去创建PreparedStatement。此时获取到的BoundSql对象包含了即将执行的原始SQL和参数映射是进行修改的黄金时间点。插件的核心任务就是分析这个SQL语句判断目标表当前SQL要操作的是哪张表这张表是否需要支持多租户我们通常不会在像tenant这样的系统配置表上添加租户过滤。判断操作类型是SELECT、UPDATE还是DELETE对于INSERT操作处理逻辑不同它需要向实体中自动注入租户ID字段值而不是添加WHERE条件。重写SQL对于查询、更新和删除操作在WHERE条件中智能地添加租户过滤子句例如AND tenant_id ?。2.2 SQL解析与重写的挑战这里有一个非常关键的细节也是很多自制插件容易踩坑的地方如何确保修改后的SQL语法正确且不影响原有的查询逻辑想象一下这个场景原SQL是SELECT * FROM user WHERE status 1 ORDER BY create_time DESC。插件需要将其变为SELECT * FROM user WHERE status 1 AND tenant_id ? ORDER BY create_time DESC。这看起来简单。但考虑以下复杂情况原SQL没有WHERE子句SELECT * FROM user。插件需要生成SELECT * FROM user WHERE tenant_id ?。原SQL包含JOINSELECT u.*, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.status 1。这里涉及user和order两张表如果它们都需要租户隔离那么过滤条件应该加在u.tenant_id和o.tenant_id上并且要用括号妥善处理关联关系避免语义错误。原SQL包含子查询SELECT * FROM user WHERE id IN (SELECT user_id FROM log WHERE typeLOGIN)。子查询中的log表可能也需要加租户条件这会让SQL解析变得异常复杂。MP的多租户插件内部使用了一个SQL解析器早期版本依赖jsqlparser后续有优化来应对这些情况。它并不是简单的字符串拼接而是将SQL解析为抽象语法树AST然后以编程方式修改树节点最后再生成新的SQL字符串。这种方式能最大程度保证语法正确性。注意SQL解析本身是有性能开销的。在高并发、简单查询为主的场景下需要评估这部分开销。因此插件通常提供了“忽略SQL”的配置对于一些明确不需要租户过滤的查询如全表统计由管理员执行可以跳过解析过程。2.3 租户ID的上下文管理插件知道了“怎么加”还需要知道“加什么值”即当前请求的租户ID是什么。这里就引入了“租户上下文”的概念。获取租户ID的常见策略有基于Web请求最常用的方式。通过拦截器如Spring MVC的HandlerInterceptor或过滤器从请求头如X-Tenant-Id、Cookie或JWT Token中解析出当前租户ID并将其存储到线程局部变量ThreadLocal中。插件执行时再从ThreadLocal中取出。基于登录会话用户登录后其所属租户信息保存在服务端Session或缓存中。动态数据源路由在更复杂的多租户架构中每个租户独立数据库租户ID可能用于决定使用哪个数据源。此时多租户插件可能仅用于在同库不同schema的模式下添加过滤条件。MP插件要求我们实现一个TenantLineHandler接口其中有一个关键方法getTenantId()我们需要在这个方法里返回当前的租户ID。这个接口的实现就是连接业务上下文租户ID从哪里来和插件机制租户ID用到哪里去的桥梁。public class MyTenantLineHandler implements TenantLineHandler { Override public Expression getTenantId() { // 从ThreadLocal、SecurityContext等地方获取当前租户ID String tenantId TenantContext.getCurrentTenantId(); if (tenantId null) { // 如何处理租户ID为空的情况抛出异常或返回一个默认值 // 这取决于你的业务设计但必须明确处理。 throw new RuntimeException(无法获取当前租户信息); } return new StringValue(tenantId); } Override public String getTenantIdColumn() { // 返回数据库中表示租户ID的字段名默认为“tenant_id” return tenant_id; } Override public boolean ignoreTable(String tableName) { // 判断哪些表不需要进行租户过滤 // 例如全局配置表、租户信息表本身 return tableName.startsWith(sys_); } }3. 实战配置从零开始集成与配置插件理解了原理我们来看如何一步步将它集成到Spring Boot项目中。这里我会分享一个经过生产环境验证的配置流程并穿插一些容易忽略的细节。3.1 环境准备与依赖引入首先确保你的项目已经引入了Mybatis-Plus的Spring Boot Starter。多租户功能是MP的核心功能之一无需额外引入特殊依赖。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version !-- 请使用最新稳定版 -- /dependency3.2 数据库表结构设计在物理隔离每个租户独立数据库和逻辑隔离共享数据库用tenant_id字段区分中我们讨论的是后者。你的业务表需要添加一个统一的租户标识字段通常命名为tenant_id当然你也可以自定义。字段类型可以是VARCHAR租户编码或BIGINT租户主键建议与你的租户主键类型保持一致。CREATE TABLE your_biz_table ( id BIGINT NOT NULL COMMENT 主键, tenant_id VARCHAR(32) NOT NULL COMMENT 租户ID, other_columns ..., PRIMARY KEY (id), INDEX idx_tenant_id (tenant_id) -- 非常重要必须为tenant_id建立索引 ) COMMENT 你的业务表;关键点务必为tenant_id字段创建索引。几乎所有查询都会带上这个条件没有索引会导致全表扫描性能灾难。3.3 核心配置类编写接下来是重头戏在配置类中声明多租户插件。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 创建并配置多租户插件 TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { // 动态获取当前租户ID String tenantId TenantContextHolder.get(); if (StringUtils.isBlank(tenantId)) { // 这里的选择至关重要是抛异常还是允许查询“公共数据” // 对于后台管理类查询可能允许为空。对于普通业务请求通常抛异常。 throw new BusinessException(租户信息缺失); } return new StringValue(tenantId); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { // 定义忽略租户过滤的表 ListString ignoreTables Arrays.asList(sys_config, sys_tenant); return ignoreTables.contains(tableName); } Override public boolean ignoreInsert(ListColumn columns, String tenantIdColumn) { // MP默认会对INSERT语句的租户字段进行自动填充 // 如果你在某些特殊场景下不想自动填充可以在这里判断 return false; } }); // 2. 将租户插件添加到拦截器链中。**顺序很重要** interceptor.addInnerInterceptor(tenantInterceptor); // 3. 可以继续添加其他插件如分页插件、乐观锁插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }配置要点解析TenantLineHandler实现这是核心。getTenantId()方法必须能够可靠地获取当前上下文租户ID。TenantContextHolder是一个工具类内部使用ThreadLocal存储租户ID需要在你的认证拦截器中设置值。忽略表ignoreTable谨慎配置。除了明确的全局表一些字典表、地区码表如果所有租户共享也应该忽略。否则查询这些表时会自动加上AND tenant_id ?导致查不到任何数据。插件执行顺序通过interceptor.addInnerInterceptor()添加插件的顺序决定了它们的执行顺序。通常租户插件应该在分页插件之前添加。因为租户条件需要在分页计算总数和查询数据前就被添加到SQL中否则分页的总数会是错误的全租户数量。3.4 租户上下文管理我们需要一个地方来存储和传递租户ID。通常在一个全局的请求拦截器中实现。Component public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头中获取租户ID这是一种常见方式 String tenantId request.getHeader(X-Tenant-Id); // 如果没有可以尝试从JWT Token或登录信息中解析 if (StringUtils.isBlank(tenantId)) { // 根据你的认证体系调整 Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication ! null authentication.getPrincipal() instanceof CustomUserDetails) { tenantId ((CustomUserDetails) authentication.getPrincipal()).getTenantId(); } } // 将租户ID设置到当前线程上下文中 if (StringUtils.isNotBlank(tenantId)) { TenantContextHolder.set(tenantId); } else { // 如果业务允许某些请求无租户如超级管理员可以跳过或设置一个默认值 // 否则可以在这里直接返回false终止请求 // throw new UnauthorizedException(租户标识缺失); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后务必清除ThreadLocal中的数据防止内存泄漏和上下文污染 TenantContextHolder.clear(); } } // 简单的ThreadLocal上下文持有器 public class TenantContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void set(String tenantId) { CONTEXT.set(tenantId); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }关键经验afterCompletion中清理ThreadLocal是必须的。特别是在使用线程池的Web服务器如Tomcat中线程会被复用。如果不清除下一次请求可能会错误地使用上一个请求的租户ID导致严重的数据错乱。4. 高级场景与避坑指南插件配置好后在大部分简单CRUD场景下可以完美工作。但一旦业务复杂起来各种边界情况就会浮现。下面是我在实战中遇到的一些典型问题和解决方案。4.1 场景一如何执行跨租户的数据查询管理员后台这是最常见的需求。比如平台管理员需要统计所有租户的订单总量。插件默认会给所有查询加上租户条件这显然不行。MP提供了几种“开关”机制使用InterceptorIgnore注解推荐在Mapper方法上直接标注告诉插件忽略此方法的租户过滤。InterceptorIgnore(tenantLine true) ListOrder selectAllTenantOrders(MapString, Object params);这种方式最清晰声明式地表达了意图。使用TenantLineHandler的ignoreTable方法如果你有一整张表如sys_operation_log需要全局查询可以在这里配置忽略。动态关闭租户过滤谨慎使用通过MP的SqlHelper工具类可以在代码块中临时关闭。try { // 关闭当前线程的租户过滤 TenantLineHelper.setIgnore(true); // 执行你的全局查询... mapper.selectList(null); } finally { // 恢复租户过滤 TenantLineHelper.setIgnore(false); }坑点务必使用try-finally确保恢复否则后续的查询会“泄露”数据。这种方式适合在复杂业务逻辑的某个深层方法中临时使用但可读性较差需加详细注释。4.2 场景二联表查询与子查询的租户条件这是插件能力的一个考验点。假设我们查询用户及其订单SELECT u.*, o.* FROM user u LEFT JOIN order o ON u.id o.user_id。如果user和order表都有tenant_id字段理想情况下插件应该生成... WHERE u.tenant_id ? AND o.tenant_id ?。但更常见的做法是只在主表驱动表上过滤。因为业务逻辑上一个租户的用户只能看到自己租户的订单通过u.tenant_id ?已经可以保证关联的order也是同一租户的前提是外键关系正确。MP插件默认可能只会在它识别出的每个表上添加条件这可能导致SQL过于复杂或出错。实战建议对于常规联表尽量设计成通过主表的租户ID即可完成数据隔离。避免多表都需要加租户条件的复杂JOIN。如果确实需要务必在测试阶段仔细验证生成的SQL语句是否正确。可以使用MP的p6spy或log4jdbc等工具在日志中打印出最终执行的SQL进行核对。对于极度复杂的SQL考虑使用InterceptorIgnore关闭插件过滤然后在XML中手动编写包含租户条件的SQL。虽然失去了自动化但获得了绝对的控制权。4.3 场景三数据导入、初始化与历史数据迁移当你需要为某个新租户初始化数据或者将旧系统的数据迁移到新的多租户架构时插件可能会成为障碍。批量插入使用MP的saveBatch方法时插件会自动为每一条记录的tenant_id字段如果实体类中有该字段注入当前上下文租户ID。这通常是我们期望的行为。指定租户ID插入如果你想在代码中明确指定另一个租户ID进行插入比如数据迁移工具你需要先切换TenantContextHolder中的值或者使用InterceptorIgnore忽略该方法然后在代码中手动设置实体对象的tenant_id字段。历史数据补填tenant_id对于已有的、没有tenant_id的数据需要写一个数据迁移脚本根据业务规则如关联的用户ID为每一条数据补上正确的租户ID。这个操作必须在插件启用之前完成否则这些数据对新架构来说就是“不可见”的。4.4 常见问题排查清单当多租户插件不按预期工作时可以按照以下清单进行排查SQL根本没加租户条件检查插件是否成功注入到SqlSessionFactory中。查看启动日志确认TenantLineInnerInterceptor已被加载。检查当前执行的Mapper方法是否被InterceptorIgnore(tenantLine true)标注。检查操作的数据库表是否在ignoreTable列表中。检查TenantLineHandler.getTenantId()方法返回的是否是有效的Expression对象且不为null。租户条件加错了导致查不到数据打开SQL日志查看最终执行的SQL语句。确认tenant_id的条件值是否正确。确认TenantContextHolder在当前线程中设置的值是否正确。注意异步任务如Async会切换线程ThreadLocal值会丢失需要手动传递。确认数据库表中的tenant_id字段值与上下文中的值类型、格式是否一致特别是字符串类型注意空格。性能突然下降检查tenant_id字段是否建立了索引。没有索引是最大的性能杀手。检查是否出现了跨租户的笛卡尔积查询如果联表时租户条件没加对。考虑是否可以对一些全局的、频繁查询的小表如字典表配置到ignoreTable中避免不必要的SQL解析和条件添加。插入数据时tenant_id为null确认实体类中是否有tenant_id字段或你配置的字段名。确认该字段是否配置了MP的TableField(fill FieldFill.INSERT)自动填充策略虽然插件主要处理WHERE但INSERT的填充通常也由MP的MetaObjectHandler配合完成或插件自身处理。检查你的MetaObjectHandler实现。5. 插件生态与“动态取消租户隔离”的思考在社区的热词中我注意到了“mybatis-plus 动态取消租户隔离”这个搜索趋势。这反映了一个普遍的进阶需求如何在一次请求流程中根据更细粒度的业务规则动态地决定是否启用租户过滤这超出了标准插件的静态配置能力。标准的InterceptorIgnore是方法级别的是静态的。而“动态”意味着可能在同一个Service方法内根据参数不同有时需要过滤有时不需要。实现这种动态性通常需要更精巧的设计自定义注解AOP可以定义一个如DynamicTenant(ignore true)的注解通过AOP在方法执行前判断注解或方法参数动态调用TenantLineHelper.setIgnore()。基于数据权限的扩展将租户隔离视为数据权限的一种。可以设计一个统一的数据权限拦截器它根据当前用户角色超级管理员、租户管理员、普通用户和资源范围动态计算出一个“数据权限过滤器”这个过滤器可能包含租户ID也可能不包含。这样租户插件就演变成了一个更通用的数据安全层的一部分。MP的多租户插件是一个强大的“开箱即用”的基础组件它解决了80%的常规需求。但对于剩下20%的复杂、动态场景我们需要理解其原理并敢于在其基础上进行扩展和整合。它的价值在于提供了一套标准化的、基于SQL解析的解决方案范式让我们无需从零开始造轮子可以将精力集中在业务特有的那部分数据隔离逻辑上。在SaaS化的大潮下这样一个稳定可靠的数据隔离底层无疑是系统长期稳健运行的基石之一。