MyBatis-Plus逻辑删除机制深度解析与四种绕过方案实践

发布时间:2026/8/17 9:02:09
MyBatis-Plus逻辑删除机制深度解析与四种绕过方案实践 1. 项目概述为什么我们需要“绕过”逻辑删除在基于Spring Boot和MyBatis-Plus后文简称MP的项目里逻辑删除几乎是标配功能。它通过在数据库表里加一个deleted字段比如is_deleted或del_flag将物理删除操作变成一次UPDATE把该字段值从0改成1。这样做的好处显而易见数据安全误删可恢复符合审计要求。MP通过TableLogic注解和全局配置把这个功能封装得极其简单一行配置就能让所有查询自动带上deleted 0的条件开发者几乎感知不到它的存在。但正是这种“无感”在某些特定场景下会变成一种“束缚”。想象一下这些情况你需要做一个后台数据回收站功能把已逻辑删除的记录展示出来供管理员恢复或者在做数据统计报表时需要分析包含历史删除数据的完整趋势又或者在数据迁移、ETL任务中需要原样处理所有记录不管它是否被标记为删除。在这些场景下MP自动添加的deleted 0条件就成了拦路虎。你写的select * from user在MP执行时会被偷偷改成select * from user where deleted 0这让你无法触及那些deleted 1的数据。所以“排除/停止/绕过 MyBatis-Plus的逻辑删除”这个需求本质上是在享受逻辑删除带来的数据安全红利的同时寻求一种可控的、临时性的“越狱”手段。它不是要永久关闭这个功能而是在特定的数据操作边界内获得一次“上帝视角”去查看或处理全量数据。这需要我们对MP的逻辑删除机制有透彻的理解并掌握在不同粒度全局、Mapper方法级、语句级下进行精细控制的方法。2. 逻辑删除机制深度解析与“绕行”原理要绕过它必须先彻底理解它是如何工作的。MP的逻辑删除实现核心是依托于MyBatis的插件机制具体来说是com.baomidou.mybatisplus.core.plugins.inner.InnerInterceptor这个内部拦截器链。当我们启用逻辑删除后MP会向这个链中注入一个负责处理逻辑删除的拦截器。2.1 MP逻辑删除的底层拦截逻辑这个拦截器主要在两个关键时机介入查询SELECT在SQL语句被组装好之后、执行之前拦截器会分析SQL如果发现目标表配置了逻辑删除并且当前SQL中没有显式地包含对该删除字段的条件过滤那么拦截器会自动追加WHERE deleted 未删除值默认是WHERE deleted0的条件。这个追加动作非常底层是在抽象语法树AST层面操作的所以无论你的查询条件多复杂它都能准确地把条件加在WHERE关键字后面。删除DELETE当你调用mapper.deleteById()或其它删除方法时拦截器会将原生的DELETE FROM table WHERE id ?语句改写为UPDATE table SET deleted 已删除值 WHERE id ?。这就是逻辑删除的本质。理解了这个机制我们“绕过”的思路也就清晰了我们要么让拦截器“失灵”要么告诉拦截器“这次不用你管”。关键在于MP提供了一些“后门”和配置允许我们在特定场景下覆盖或跳过这个全局行为。2.2 全局配置与局部行为的冲突管理MP的逻辑删除配置通常是全局生效的在application.yml里类似这样mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段名 logic-delete-value: 1 # 逻辑已删除值 logic-not-delete-value: 0 # 逻辑未删除值这个配置一旦生效就像给整个应用套上了一层滤镜所有通过MP进行的数据库操作都会经过这层滤镜处理。而我们后续讨论的所有“绕过”技巧都是在寻找在这层滤镜上开一个“洞”的方法。这些方法需要遵循一个核心原则最小影响范围。即只在确实需要的地方、以尽可能小的作用域来临时性取消逻辑删除避免对系统其他部分造成意料之外的副作用。3. 实操多种粒度下的逻辑删除绕过方案根据不同的场景和需求我们可以选择从全局到语句的不同粒度来控制逻辑删除行为。下面从作用域由大到小进行介绍。3.1 方案一全局性临时关闭不推荐但需了解这是最“暴力”的方法直接在代码中动态修改MP的全局配置。强烈不推荐在生产环境的业务代码中使用因为它会影响同一应用内所有线程后续的所有数据库操作极易引发数据混乱和安全问题。但在一些独立的、一次性的运维脚本或数据修复工具中或许可以作为最后的手段。import com.baomidou.mybatisplus.core.config.GlobalConfig; import com.baomidou.mybatisplus.core.MybatisConfiguration; import org.apache.ibatis.session.SqlSessionFactory; import org.springframework.beans.factory.annotation.Autowired; // 警告此方法风险极高请谨慎评估使用场景 public void disableLogicDeleteGlobally() { // 1. 获取SqlSessionFactory SqlSessionFactory sqlSessionFactory ... ; // 通过注入或上下文获取 // 2. 获取全局配置 MybatisConfiguration configuration (MybatisConfiguration) sqlSessionFactory.getConfiguration(); GlobalConfig globalConfig configuration.getGlobalConfig(); // 3. 获取数据库全局配置并清空逻辑删除配置 GlobalConfig.DbConfig dbConfig globalConfig.getDbConfig(); // 方法A将删除字段设为null取决于MP版本可能有效 dbConfig.setLogicDeleteField(null); // 方法B更彻底重新设置一个不存在的“伪”值风险操作 // dbConfig.setLogicDeleteField(_none_); // 注意修改后需要清理Mybatis的Mapper缓存否则可能不立即生效 configuration.clearCache(); }注意这种方法极其危险因为它修改的是全局、静态的配置。在一个多线程的Web应用中一个请求修改了配置会导致其他并发请求的查询行为发生不可预测的变化可能让本该被过滤掉的已删除数据突然出现在用户界面造成严重的数据泄露或业务逻辑错误。仅在单线程、独立运行的离线任务中可考虑且必须确保任务执行前后能恢复配置。3.2 方案二Mapper或Service层定制化查询推荐这是最常用、最安全的“绕过”方式。核心思想是构建一个全新的、独立的查询条件完全覆盖MP自动生成的查询条件。MP的查询条件组装器QueryWrapper或LambdaQueryWrapper在最终生成SQL时其WHERE子句会覆盖拦截器自动添加的逻辑删除条件。场景在UserService中我们需要一个方法给管理员查看所有用户包括已删除的。Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Override public ListUser selectAllIncludingDeleted() { // 关键使用 new QueryWrapper 并手动设置查询条件 QueryWrapperUser wrapper new QueryWrapper(); // 方法1查询所有不设任何条件。此时MP的拦截器仍然会尝试添加deleted0。 // 但如果我们手动添加一个关于deleted字段的条件就能覆盖它。 wrapper.isNotNull(id); // 一个永真的条件作为“锚点” // 更直接的方法显式地设置deleted字段的条件覆盖自动添加的 // wrapper.and(w - w.eq(deleted, 0).or().eq(deleted, 1)); // 但最简单粗暴有效的方法是 wrapper.apply(11); // 这是一个技巧apply会直接拼接SQL片段 // 由于wrapper中已经存在了WHERE条件哪怕是11 // MP的逻辑删除拦截器在大多数情况下就不会再重复追加deleted0了。 // 但更稳妥、意图更明确的做法是使用下面的方法 return this.list(wrapper); } // 更清晰、更推荐的做法使用自定义SQL方案三 }实操心得wrapper.apply(“11”)是一个经典技巧它直接向WHERE后拼接了(11)使得SQL中WHERE关键字已存在。根据MP内部逻辑拦截器检测到已有WHERE可能就不再追加。但这个行为并不绝对可靠依赖于MP的具体版本和内部实现细节属于“技巧”而非“契约”。更健壮的做法是在Wrapper中显式地添加一个关于逻辑删除字段的OR条件如.eq(“deleted”, 0).or().eq(“deleted”, 1)这明确表达了“我要查所有状态”的意图一定能覆盖默认行为。缺点是会生成稍显冗余的SQL。3.3 方案三使用自定义SQL最强大、最灵活当操作复杂或需要绝对控制时在Mapper.xml文件中编写自定义SQL是终极解决方案。MyBatis的XML映射文件中的SQL语句MP的插件默认是不会去解析和修改的除非你使用了MP的${ew.customSqlSegment}等特定语法。场景需要复杂关联查询或进行数据统计必须包含软删除记录。步骤在对应的Mapper接口中定义方法。public interface UserMapper extends BaseMapperUser { // 方法1使用注解Select直接写SQL简单查询适用 Select(SELECT * FROM user WHERE ${ew.customSqlSegment}) ListUser selectAllWithWrapper(Param(Constants.WRAPPER) WrapperUser wrapper); // 方法2在XML中编写复杂SQL推荐 ListUser selectAllUsersForReport(); // 方法3查询已删除的数据 Select(SELECT * FROM user WHERE deleted 1) ListUser selectDeletedUsers(); }在UserMapper.xml文件中编写SQL。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper !-- 关键这里的SQL完全由你掌控MP的逻辑删除拦截器不会干预 -- select idselectAllUsersForReport resultTypecom.example.entity.User SELECT u.*, d.dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id -- 注意这里没有自动的 WHERE u.deleted 0 -- 你可以自己决定是否要加条件 WHERE 11 if teststartTime ! null AND u.create_time #{startTime} /if if testendTime ! null AND u.create_time #{endTime} /if -- 例如在报表中我们可能想包含已删除的数据但标记出来 -- ORDER BY u.deleted ASC, u.create_time DESC /select /mapper注意事项绝对控制权在XML里的SQL你就是国王。MP的自动过滤、字段填充如逻辑删除、租户隔离、字段加解密等插件机制默认都不会生效。这既是优点也是缺点你获得了自由但也失去了MP提供的这些便利功能。你需要手动处理所有条件。${ew.customSqlSegment}的陷阱如果你在自定义SQL中使用了${ew.customSqlSegment}来拼接Wrapper条件那么逻辑删除条件可能会被重新加回来因为ew.customSqlSegment包含了经由MP处理后的条件其中就有自动添加的deleted0。在这种情况下你需要在构造Wrapper时像方案二那样手动覆盖掉逻辑删除条件。3.4 方案四使用SqlInjector与自定义方法高阶玩法对于某些需要高频执行且模式固定的“绕过”操作比如“查询全部”我们可以通过扩展MP的SqlInjectorSQL注入器来为BaseMapper添加一个通用的selectAllIgnoreLogicDelete()方法。这样可以在所有实体Mapper上复用。步骤编写自定义的SQL方法。public class SelectAllIgnoreLogicDelete extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { String sql SELECT %s FROM %s; String fieldSql this.prepareFieldSql(tableInfo); String tableName tableInfo.getTableName(); String sqlStr String.format(sql, fieldSql, tableName); SqlSource sqlSource this.languageDriver.createSqlSource(this.configuration, sqlStr, modelClass); // 这里的关键是这个方法生成的SQL语句不包含WHERE条件 // 且由于是自定义方法逻辑删除拦截器通常不会对其生效取决于注入时机。 return this.addSelectMappedStatementForTable(mapperClass, selectAllIgnoreLogicDelete, sqlSource, tableInfo); } }编写自定义的SqlInjector将上述方法注入。Component public class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass, TableInfo tableInfo) { ListAbstractMethod methodList super.getMethodList(mapperClass, tableInfo); // 添加自定义方法 methodList.add(new SelectAllIgnoreLogicDelete()); return methodList; } }在自定义的通用Mapper接口中声明该方法。public interface MyBaseMapperT extends BaseMapperT { ListT selectAllIgnoreLogicDelete(); }让你的业务Mapper继承MyBaseMapper。public interface UserMapper extends MyBaseMapperUser { // ... other methods }现在你就可以调用userMapper.selectAllIgnoreLogicDelete()来查询所有数据了。踩坑点这种方式高度依赖于MP的内部机制和版本。你需要仔细测试确保自定义方法生成的SQL确实跳过了逻辑删除拦截。在某些MP版本中拦截器可能作用于所有SELECT语句无论其来源。因此此方案复杂度高维护成本也高更适用于框架级开发普通业务开发建议优先使用方案二和三。4. 核心场景下的避坑指南与最佳实践掌握了方法更要知道在什么场景下用哪种方法以及如何避免踩坑。4.1 场景一后台管理“回收站”功能这是最典型的场景。你需要分页查询已删除的数据并提供恢复将deleted从1改为0和彻底删除物理删除的功能。查询已删除数据首选方案三自定义SQL。在AdminUserMapper.xml中编写selectDeletedByPageSQL中明确指定WHERE deleted 1。这样做意图最清晰性能最好直接利用索引且完全不受全局逻辑删除干扰。恢复数据直接使用MP的updateById将实体的deleted字段设置为0即可。MP的更新操作不会受到逻辑删除查询拦截的影响。彻底删除必须使用自定义SQL。mapper.deleteById()在逻辑删除启用时只会执行UPDATE。你需要像这样delete iddeletePhysicallyById DELETE FROM user WHERE id #{id} /delete重要警告物理删除操作必须加上严格的权限控制通常只有超级管理员在确认无误后才能执行。务必在Service层进行二次确认如弹窗确认、操作日志记录。4.2 场景二数据统计与报表分析报表往往需要历史全量数据以反映真实趋势。最佳实践为报表业务单独建立数据仓库或查询视图。通过数据库的视图View功能创建一个名为v_user_for_report的视图其定义就是SELECT * FROM user不包含deleted0条件。然后让MP映射到这个视图实体。这样报表查询和核心业务查询在数据源层面就完全隔离互不影响是最优雅、最安全的解决方案。次选方案在报表服务的Mapper中全部使用方案三自定义SQL。确保每个报表SQL都显式地写出你需要的过滤条件避免依赖MP的自动行为。4.3 场景三数据迁移与ETL任务这类任务通常是离线的、一次性的。推荐做法单独写一个不依赖MP逻辑删除配置的Spring Boot子模块或一个简单的Java应用。在这个独立应用中配置自己的SqlSessionFactory完全不配置logic-delete-field。或者使用原生的JDBC、Spring JdbcTemplate来执行数据抽取和导入。这样做可以确保任务逻辑的纯粹性和可重复性不会受到主业务应用复杂配置的影响。4.4 必须警惕的“天坑”联表查询的陷阱如果你使用MP的TableField(condition SqlCondition.XXX)或者联表查询逻辑删除条件可能会被自动添加到每一张关联表上。例如LEFT JOIN department d ON u.dept_id d.id如果department表也启用了逻辑删除那么自动添加的条件可能会是WHERE u.deleted0 AND d.deleted0这可能导致查询结果与预期不符。在涉及多表的复杂查询中无条件使用自定义SQL是唯一可靠的选择。Wrapper链式调用的顺序当你使用QueryWrapper时条件的拼接顺序会影响最终SQL。wrapper.eq(“status”, 1).or().eq(“deleted”, 1)和wrapper.eq(“deleted”, 1).or().eq(“status”, 1)生成的SQL语义不同。在构造复杂条件以覆盖逻辑删除时务必先用简单的SQL在数据库客户端验证你的逻辑是否正确。缓存污染MyBatis有一级缓存SqlSession级别和二级缓存Mapper级别。如果你通过某种“绕过”方式查询到了已删除的数据这些数据可能会进入缓存。当下一个常规业务查询本应只查未删除数据命中缓存时可能会直接返回缓存中的已删除数据造成严重Bug。在任何“绕过”逻辑删除的查询方法上强烈建议加上CacheEvict注解或直接设置该Mapper的缓存为flushCachetrue避免缓存污染。5. 总结如何选择你的“绕行”方案没有最好的方案只有最适合当前场景的方案。我们可以做一个简单的决策流需求是否简单、单一如在Service中查一次全部用户是- 使用方案二在QueryWrapper中通过apply(“11”)或显式or条件覆盖。快速验证代码内聚。否- 进入下一步。是否是复杂查询、报表或高频操作是-首选方案三自定义SQL/Mapper.xml。意图清晰性能可控与业务逻辑解耦。对于报表强烈建议使用数据库视图。否- 进入下一步。是否是需要全局、永久性改变行为的独立工具或脚本是- 考虑方案一全局关闭或更优的独立数据源配置。务必确保环境隔离。否- 进入下一步。是否希望为整个项目提供一个通用、优雅的“忽略逻辑删除”的API是- 可以考虑方案四自定义SqlInjector但请做好充分的测试和版本兼容性评估。否- 回归方案二或三。最后记住一个核心原则逻辑删除是一种数据保护机制“绕过”它是一项需要慎用的特权。每一次绕过操作都应该有清晰的业务边界、明确的权限控制和完整的操作日志。在代码中为这些特殊方法起一个见名知意的名字如selectAllIncludingDeleted、findForStatistics并在方法注释中明确说明其忽略逻辑删除的意图和潜在风险这是对自己和后续维护者负责。

相关新闻