拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MyBatis-Plus多租户插件TenantLineInnerInterceptor实战指南

1. 项目概述多租户数据隔离的“守门人”在构建SaaS软件即服务应用或者任何需要为不同客户群体提供独立数据视图的后台系统时数据隔离是架构设计的基石。想象一下你开发了一套电商后台管理系统要同时服务于A公司、B公司和C公司。这三家公司的运营数据比如订单、商品、用户信息必须严格分开A公司的管理员绝对看不到B公司的任何数据。这种“数据沙箱”的需求就是典型的多租户场景。手动在每一条SQL语句后面加上WHERE tenant_id xxx显然是不可持续的不仅开发效率低下更容易因为开发人员的疏忽导致严重的数据泄露问题。我们需要一个在数据访问层自动、透明地实现数据过滤的机制。MyBatis-Plus作为MyBatis的增强工具包其提供的TenantLineInnerInterceptor插件正是为了解决这个问题而生的“守门人”。它通过拦截执行的SQL在运行时动态地为你添加上租户隔离条件让开发者从繁琐且易错的手工过滤中解放出来专注于业务逻辑本身。最近社区里关于MyBatis-Plus多租户实现的讨论热度一直很高也印证了这是中后台系统开发中的一个刚需和痛点。2. 核心设计思路与方案选型2.1 多租户实现的常见模式在深入插件之前有必要先了解多租户数据隔离的几种常见模式这决定了你如何使用这个插件。独立数据库每个租户使用完全独立的数据库实例。隔离级别最高安全性最好但成本也最高运维复杂。TenantLineInnerInterceptor在这种模式下作用不大因为物理层面已经隔离。共享数据库独立Schema所有租户共享同一个数据库实例但每个租户有自己的一套表Schema。例如tenant_a.order_table和tenant_b.order_table。隔离性较好备份恢复相对灵活。插件可以通过动态修改表名如${tenantId}_order或Schema来实现但这通常需要更复杂的处理。共享数据库共享Schema所有租户的数据都存放在同一套表的同一套Schema中通过一个关键的tenant_id或类似的字段来区分数据行。这是最经济、最常用的模式也是TenantLineInnerInterceptor插件主要发力的场景。它的核心工作就是在查询这些表时自动加上AND tenant_id ?条件。我们的讨论将聚焦于第三种模式这也是该插件最典型、最直接的应用场景。2.2 TenantLineInnerInterceptor 的工作原理这个插件是MyBatis-Plus拦截器体系中的一员属于“内部拦截器”它会在SQL语句被真正执行前对其进行解析和重写。其工作流程可以概括为拦截当MyBatis执行一条Mapper接口方法时该插件会拦截对应的StatementHandler。解析插件利用JSqlParser等工具将原始的SQL语句解析成抽象语法树AST。识别与改写遍历AST识别出需要添加租户条件的表。对于这些表的查询SELECT、更新UPDATE、删除DELETE操作在对应的WHERE子句中插入租户字段的等值条件。对于插入INSERT操作则为租户字段自动赋值。执行将改写后的SQL语句交给下一个处理环节最终执行。这个过程的巧妙之处在于它对开发者是透明的。你写的Mapper方法是这样的ListOrder orderList orderMapper.selectList(new QueryWrapperOrder().eq(status, 1));经过插件处理后实际执行的SQL变成了SELECT * FROM t_order WHERE status 1 AND tenant_id your_tenant_id这个your_tenant_id是如何来的呢这就是插件配置的核心。2.3 为何选择MyBatis-Plus的解决方案市面上实现多租户的方式有很多比如在Spring的AOP层面切Service层或者在ORM框架的实体监听器里处理。选择MyBatis-Plus的插件方案主要基于以下几点考量非侵入性你不需要修改大量的业务代码只需要在实体类上添加注解并进行统一配置。业务逻辑保持纯净。底层统一处理在SQL层面进行拦截和改写这是最彻底、最统一的方式。无论你的查询是通过Wrapper、自定义XML还是注解方式构建最终都会经过这里确保无一遗漏。与MyBatis-Plus生态无缝集成如果你已经在使用MyBatis-Plus的其它功能如分页插件、性能分析插件等加入多租户插件非常自然配置风格一致学习成本低。灵活性插件提供了丰富的接口TenantLineHandler让你自定义租户ID的获取逻辑从ThreadLocal、Session、JWT Token中解析等以及决定哪些表需要过滤、哪些SQL需要忽略如全表统计、租户管理员查询等。3. 核心配置与 TenantLineHandler 详解3.1 基础依赖与配置类搭建首先确保你的项目中已经引入了MyBatis-Plus的依赖。这里以Spring Boot项目为例。关键配置类示例Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加多租户插件注意插件添加的顺序一般放在分页插件等之前 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler())); // 可以继续添加其他插件如分页插件 // interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里创建了一个MybatisPlusInterceptor的Bean并将TenantLineInnerInterceptor添加进去。插件的核心逻辑由我们自定义的MyTenantLineHandler实现。3.2 自定义 TenantLineHandler 实现TenantLineHandler是一个接口你需要实现它来提供租户ID和定义过滤规则。这是整个配置的灵魂所在。Component public class MyTenantLineHandler implements TenantLineHandler { /** * 获取当前租户ID的方法。 * 这里是最关键的部分你需要从当前请求上下文中获取租户标识。 * 通常的做法是使用ThreadLocal、SecurityContextHolder或自定义的RequestContextHolder。 */ Override public Expression getTenantId() { // 示例从ThreadLocal中获取当前租户ID String currentTenantId TenantContextHolder.getCurrentTenantId(); if (StringUtils.isBlank(currentTenantId)) { // 根据你的业务逻辑可以抛出异常或者返回一个“公共”租户ID或者忽略过滤需在ignoreTable方法中排除 throw new RuntimeException(无法获取当前租户ID); } // 返回一个SQL表达式通常就是租户ID的值 return new StringValue(currentTenantId); } /** * 获取租户ID对应的字段名。 * 你的数据库表中用于区分租户的列名是什么这里就返回什么。 * 默认是tenant_id。 */ Override public String getTenantIdColumn() { return tenant_id; } /** * 根据表名判断是否需要进行租户过滤。 * param tableName 表名 * return true: 忽略即不加租户条件; false: 需要过滤默认 * 这是一个非常重要的方法用于排除那些不需要租户隔离的表。 */ Override public boolean ignoreTable(String tableName) { // 1. 定义一些系统表、公共配置表不需要过滤 ListString ignoreTables Arrays.asList(sys_config, sys_dict, common_region); if (ignoreTables.contains(tableName)) { return true; } // 2. 可以通过当前租户ID来判断例如超级管理员租户可以查看所有数据 String currentTenantId TenantContextHolder.getCurrentTenantId(); if (admin_super.equals(currentTenantId)) { // 如果是超级管理租户忽略所有表的过滤 return true; } // 默认情况下所有表都需要过滤 return false; } }关键点解析与实操心得getTenantId()是核心这个方法被频繁调用其实现必须高效且线程安全。TenantContextHolder是一个典型的工具类它内部使用ThreadLocal来存储当前请求的租户ID。这个ID通常在认证拦截器如JWT Filter或Spring MVC的拦截器中从请求头、Token或Session中解析出来并设置进去。注意务必确保在每次请求结束后清理ThreadLocal中的值否则可能导致租户ID串到其他请求引发严重的数据混乱。可以在拦截器的afterCompletion方法或使用PreDestroy注解的方法中清理。ignoreTable的灵活运用系统表像字典表、配置表、全国地区表等全局共享的数据必须在这里忽略。权限控制如上例中的超级管理员租户。这是一种常见的模式即某个特定的租户如平台方拥有查看所有数据的权限。通过在此处判断并返回true可以实现灵活的权限覆盖。动态忽略你也可以结合注解或更复杂的规则引擎来实现动态忽略但保持逻辑简单清晰是首要原则。租户字段名getTenantIdColumn()默认返回tenant_id。如果你的数据库表使用不同的列名如company_id,org_id只需重写此方法返回对应的字段名即可。但强烈建议所有需要隔离的表使用统一的字段名以减少配置复杂度和潜在错误。3.3 实体类注解配置为了让插件知道哪些实体类对应的表需要租户隔离你需要在实体类上添加TableName注解或者使用MyBatis-Plus的全局配置。但更精确的方式是在实体类字段上使用TableField注解进行标记。实体类示例Data TableName(t_order) public class Order { TableId(type IdType.AUTO) private Long id; private String orderSn; private BigDecimal amount; // ... 其他业务字段 /** * 标记此字段为租户ID字段。 * 插件在插入INSERT时会自动调用TenantLineHandler.getTenantId()来填充此字段。 * 在查询时会自动将此字段作为过滤条件。 */ TableField(fill FieldFill.INSERT) // 通常只在插入时填充 private String tenantId; }这里通过TableField(fill FieldFill.INSERT)指定了tenantId字段仅在插入时自动填充。填充器MetaObjectHandler需要配合插件工作我们稍后介绍。踩坑提醒如果你的实体类中没有显式定义租户ID字段但数据库表中有插件在插入时可能会因为找不到对应字段而无法自动填充导致插入失败或数据错误。因此强烈建议在实体类中显式定义该字段。4. 完整集成与实战演练4.1 配套组件MetaObjectHandler 自动填充为了让插件在插入数据时能自动填充tenantId字段我们需要实现MetaObjectHandler接口。它的insertFill方法会在执行INSERT操作前被调用。Component public class MyMetaObjectHandler implements MetaObjectHandler { Autowired private MyTenantLineHandler tenantLineHandler; Override public void insertFill(MetaObject metaObject) { // 获取当前租户ID Expression tenantIdExpression tenantLineHandler.getTenantId(); // 注意getTenantId()返回的是Expression我们需要将其值取出 String tenantIdValue null; if (tenantIdExpression instanceof StringValue) { tenantIdValue ((StringValue) tenantIdExpression).getValue(); } // 其他类型处理略... if (StringUtils.isNotBlank(tenantIdValue)) { // 获取实体类中租户ID字段的属性名 String tenantIdColumn tenantLineHandler.getTenantIdColumn(); // 注意这里填充的是实体类的属性名不是数据库列名。 // 通常我们让属性名和列名一致下划线转驼峰或者通过TableField指定。 // 假设实体类属性名就是“tenantId” this.strictInsertFill(metaObject, tenantId, String.class, tenantIdValue); // 如果你的字段名不同例如 companyId则改为 // this.strictInsertFill(metaObject, companyId, String.class, tenantIdValue); } } Override public void updateFill(MetaObject metaObject) { // 更新时通常不需要填充租户ID除非有特殊业务如变更租户归属但极少见 // 所以这里一般留空 } }这里的关键是insertFill方法中我们调用了tenantLineHandler.getTenantId()来获取值并填充到实体对象中。这样就实现了插入数据时租户ID的自动赋值。4.2 复杂SQL与自定义Mapper的处理插件主要作用于MyBatis-Plus生成的SQL以及使用QueryWrapper等条件构造器构建的查询。对于在XML文件中编写的复杂自定义SQL插件默认也会进行解析和改写。示例XML SQL!-- OrderMapper.xml -- select idselectComplexOrder resultTypeOrder SELECT o.*, u.name as user_name FROM t_order o LEFT JOIN t_user u ON o.user_id u.id WHERE o.status #{status} AND o.create_time #{startTime} /select插件会正确识别出t_order和t_user表如果t_user也需要租户隔离并自动在WHERE子句末尾添加AND o.tenant_id ? AND u.tenant_id ?条件。前提是t_user表也在ignoreTable方法中返回了false即需要过滤。如何忽略特定SQL有时你可能需要执行一条不添加租户条件的SQL例如全局数据统计。MyBatis-Plus提供了InterceptorIgnore注解。public interface OrderMapper extends BaseMapperOrder { // 此方法将被插件忽略不添加租户条件 InterceptorIgnore(tenantLine true) Select(SELECT COUNT(*) FROM t_order) Long countAll(); }通过在Mapper方法上添加InterceptorIgnore(tenantLine true)可以告知插件跳过此方法的租户过滤。这是一个非常实用的功能。4.3 初始化数据与超级管理员场景在系统初始化或需要跨租户操作时我们需要一个“上帝视角”。常见的做法是使用特定的租户ID如上面TenantLineHandler.ignoreTable中提到的判断当前租户ID为超级管理员ID时忽略所有过滤。临时切换租户上下文在需要执行跨租户操作的服务方法中临时修改TenantContextHolder中的租户ID执行完毕后再恢复。public void systemMaintenanceTask() { String originalTenantId TenantContextHolder.getCurrentTenantId(); try { // 切换到超级管理员上下文或者置空以触发忽略逻辑取决于你的handler实现 TenantContextHolder.setCurrentTenantId(admin_super); // 执行需要跨租户的清理或统计任务 orderService.cleanExpiredData(); } finally { // 务必恢复原始上下文避免污染后续操作 TenantContextHolder.setCurrentTenantId(originalTenantId); } }重要使用try...finally确保租户上下文一定能被恢复这是防止数据污染的黄金法则。5. 常见问题排查与性能优化5.1 典型问题与解决方案速查表问题现象可能原因排查步骤与解决方案查询结果包含其他租户数据1.TenantLineHandler.getTenantId()返回了null或空值。2. 该表在ignoreTable方法中被意外地返回了true。3. SQL是通过InterceptorIgnore注解忽略的。1. 调试getTenantId()方法确认当前请求线程的租户ID已正确设置。2. 检查ignoreTable方法逻辑确认目标表名是否在忽略列表中。3. 检查Mapper方法是否有忽略注解。插入数据时租户ID字段为NULL1. 实体类未定义租户ID字段或字段名不匹配。2.MetaObjectHandler.insertFill未正确执行或填充字段名错误。3. 插入操作绕过了MyBatis-Plus如直接使用SqlSession执行原生SQL。1. 确认实体类有对应字段且TableField配置正确。2. 调试MetaObjectHandler确认tenantId值被成功获取和填充。3. 确保使用MyBatis-Plus的BaseMapper或Service进行插入。多表联查时部分表未加条件联查的表如t_user也需要租户隔离但其对应的实体类可能未配置或表名被忽略。1. 确认联查表对应的实体类是否存在且映射正确。2. 检查ignoreTable方法确保联查的表名没有被忽略。3. 确认联查表本身有tenant_id字段。性能明显下降1. SQL解析JSqlParser带来开销。2. 关联表过多拼接的AND tenant_id ?条件也增多。3.getTenantId()方法逻辑复杂或IO操作频繁。1. 对于性能极度敏感且简单的查询考虑使用InterceptorIgnore跳过。2. 优化getTenantId()方法确保是从内存如ThreadLocal中获取避免每次查数据库或远程调用。3. 审视联查逻辑是否必要考虑冗余字段或缓存。动态数据源切换与租户插件冲突同时使用了动态数据源根据租户切库和租户行级过滤插件导致逻辑混乱。明确架构如果采用“独立数据库”模式应使用动态数据源路由禁用行级过滤插件。如果采用“共享数据库”模式则使用行级过滤插件。两者通常不同时使用。5.2 性能考量与最佳实践索引是命根子tenant_id字段必须和常用的查询条件字段建立联合索引。例如你的查询经常是WHERE tenant_id ? AND status ?那么建立(tenant_id, status)的联合索引能极大提升查询效率。没有索引全表扫描过滤租户数据将是性能灾难。谨慎使用ignoreTable忽略的表越多插件解析SQL的负担越小因为不需要改写。但务必确保被忽略的表确实不需要租户隔离。一个安全的方法是维护一个明确的“系统表白名单”只有在这个名单里的表才返回true。避免在getTenantId()中执行耗时操作这个方法在每次执行SQL时都会被调用。绝对不要在这里进行数据库查询、远程HTTP调用等IO操作。租户ID应该在用户请求进入时如拦截器就解析好并存入线程上下文。测试测试再测试多租户是数据安全的重中之重。必须进行全面的测试单元测试测试TenantLineHandler和MetaObjectHandler的逻辑。集成测试模拟不同租户用户请求验证数据是否严格隔离。边界测试测试超级管理员、租户ID为空、忽略表等边界情况。5.3 与MyBatis-Plus其他插件的协作TenantLineInnerInterceptor需要与其他插件协同工作顺序很重要。一般的添加顺序是多租户插件 (TenantLineInnerInterceptor)动态表名插件 (DynamicTableNameInnerInterceptor)如果你有分表需求分页插件 (PaginationInnerInterceptor)乐观锁插件 (OptimisticLockerInnerInterceptor)性能分析插件等原理是SQL的改写应该按照“表名处理 - 租户条件添加 - 分页处理 - 其他”的逻辑顺序进行。在MybatisPlusInterceptor中addInnerInterceptor的顺序就是插件执行的顺序。6. 进阶在复杂场景下的应用思考6.1 多租户字段不止一个有些系统可能同时需要按公司(company_id)和部门(dept_id)进行层级隔离。TenantLineInnerInterceptor默认只支持一个租户字段。要实现多级隔离有几种思路方案A扩展插件自定义一个拦截器继承或模仿TenantLineInnerInterceptor重写其SQL改写逻辑支持添加多个条件。这种方式最灵活但也最复杂。方案B组合字段在数据库设计时使用一个复合字段如tenant_path其值为公司ID:部门ID。在getTenantId()中返回这个复合值在查询时使用LIKE或精确匹配。这种方式简化了插件逻辑但查询性能可能受影响且需要精心设计tenant_path的索引。方案C业务层过滤对于第二层级如部门不在SQL插件层面解决而是在Service层业务逻辑中通过额外的QueryWrapper条件进行过滤。这要求开发人员有很强的纪律性。对于大多数场景坚持单一的、明确的租户隔离维度通常是公司或组织是最清晰和可维护的。6.2 历史数据迁移与清洗在已有数据的系统中引入多租户面临历史数据tenant_id为空的问题。迁移方案如下停机迁移在业务低峰期为所有历史数据分配一个默认的租户ID如‘legacy’。然后开启插件。后续可逐步将‘legacy’数据归类到真实租户下。双写过渡在一段时间内代码同时向tenant_id字段和原有业务字段写入。插件配置为当tenant_id为空时回退到按原有业务逻辑过滤这需要高度自定义插件。待历史数据被新流程产生的数据自然替换后再完全切换到新插件。无论哪种方案都需要详细的备份、回滚计划和充分测试。6.3 关于BaseMapper的updateById在热词中提到了“mybatis-plus 的basemapper的updatebyid可以修改字段值为null吗”这是一个常见问题。默认情况下MyBatis-Plus的updateById方法使用的是“非null更新”策略即实体类中为null的字段不会更新到数据库。这与多租户的关系是如果你在更新一个实体时希望将某个字段非租户ID字段显式地更新为NULL你需要在实体类字段上使用TableField(strategy FieldStrategy.IGNORED)但这会全局影响该字段。使用UpdateWrapper通过set(column, null)来指定。在全局配置中设置update-strategy为ignored不推荐风险高。对于租户ID字段tenant_id在更新操作中你几乎永远不应该去修改它。因此在实体类中通常会给tenantId字段加上TableField(fill FieldFill.INSERT, updateStrategy FieldStrategy.NEVER)表示只在插入时填充更新时绝不参与。这样即使不小心在更新对象中设置了tenantId它也会被忽略保证了租户数据的稳定性。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门