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 deleted=0)的条件。这个追加动作非常底层,是在抽象语法树(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 ServiceImpl<UserMapper, User> implements UserService { @Override public List<User> selectAllIncludingDeleted() { // 关键:使用 new QueryWrapper 并手动设置查询条件 QueryWrapper<User> wrapper = new QueryWrapper<>(); // 方法1:查询所有,不设任何条件。此时,MP的拦截器仍然会尝试添加`deleted=0`。 // 但如果我们手动添加一个关于`deleted`字段的条件,就能覆盖它。 wrapper.isNotNull("id"); // 一个永真的条件,作为“锚点” // 更直接的方法:显式地设置deleted字段的条件,覆盖自动添加的 // wrapper.and(w -> w.eq("deleted", 0).or().eq("deleted", 1)); // 但最简单粗暴有效的方法是: wrapper.apply("1=1"); // 这是一个技巧,apply会直接拼接SQL片段 // 由于wrapper中已经存在了WHERE条件(哪怕是1=1), // MP的逻辑删除拦截器在大多数情况下就不会再重复追加`deleted=0`了。 // 但更稳妥、意图更明确的做法是使用下面的方法: return this.list(wrapper); } // 更清晰、更推荐的做法:使用自定义SQL(方案三) }实操心得:
wrapper.apply(“1=1”)是一个经典技巧,它直接向WHERE后拼接了(1=1),使得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 BaseMapper<User> { // 方法1:使用注解@Select直接写SQL(简单查询适用) @Select("SELECT * FROM user WHERE ${ew.customSqlSegment}") List<User> selectAllWithWrapper(@Param(Constants.WRAPPER) Wrapper<User> wrapper); // 方法2:在XML中编写复杂SQL(推荐) List<User> selectAllUsersForReport(); // 方法3:查询已删除的数据 @Select("SELECT * FROM user WHERE deleted = 1") List<User> selectDeletedUsers(); } - 在
UserMapper.xml文件中编写SQL。<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <!-- 关键:这里的SQL完全由你掌控,MP的逻辑删除拦截器不会干预 --> <select id="selectAllUsersForReport" resultType="com.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 1=1 <if test="startTime != null"> AND u.create_time >= #{startTime} </if> <if test="endTime != 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处理后的条件,其中就有自动添加的deleted=0。在这种情况下,你需要在构造Wrapper时,像方案二那样手动覆盖掉逻辑删除条件。
3.4 方案四:使用SqlInjector与自定义方法(高阶玩法)
对于某些需要高频执行且模式固定的“绕过”操作(比如“查询全部”),我们可以通过扩展MP的SqlInjector(SQL注入器)来为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 List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) { List<AbstractMethod> methodList = super.getMethodList(mapperClass, tableInfo); // 添加自定义方法 methodList.add(new SelectAllIgnoreLogicDelete()); return methodList; } } - 在自定义的通用Mapper接口中声明该方法。
public interface MyBaseMapper<T> extends BaseMapper<T> { List<T> selectAllIgnoreLogicDelete(); } - 让你的业务Mapper继承
MyBaseMapper。public interface UserMapper extends MyBaseMapper<User> { // ... other methods } - 现在,你就可以调用
userMapper.selectAllIgnoreLogicDelete()来查询所有数据了。
踩坑点:这种方式高度依赖于MP的内部机制和版本。你需要仔细测试,确保自定义方法生成的SQL确实跳过了逻辑删除拦截。在某些MP版本中,拦截器可能作用于所有SELECT语句,无论其来源。因此,此方案复杂度高,维护成本也高,更适用于框架级开发,普通业务开发建议优先使用方案二和三。
4. 核心场景下的避坑指南与最佳实践
掌握了方法,更要知道在什么场景下用哪种方法,以及如何避免踩坑。
4.1 场景一:后台管理“回收站”功能
这是最典型的场景。你需要分页查询已删除的数据,并提供恢复(将deleted从1改为0)和彻底删除(物理删除)的功能。
- 查询已删除数据:首选方案三(自定义SQL)。在
AdminUserMapper.xml中编写selectDeletedByPage,SQL中明确指定WHERE deleted = 1。这样做意图最清晰,性能最好(直接利用索引),且完全不受全局逻辑删除干扰。 - 恢复数据:直接使用MP的
updateById,将实体的deleted字段设置为0即可。MP的更新操作不会受到逻辑删除查询拦截的影响。 - 彻底删除:必须使用自定义SQL。
mapper.deleteById()在逻辑删除启用时只会执行UPDATE。你需要像这样:<delete id="deletePhysicallyById"> DELETE FROM user WHERE id = #{id} </delete>重要警告:物理删除操作必须加上严格的权限控制,通常只有超级管理员在确认无误后才能执行。务必在Service层进行二次确认(如弹窗确认、操作日志记录)。
4.2 场景二:数据统计与报表分析
报表往往需要历史全量数据,以反映真实趋势。
- 最佳实践:为报表业务单独建立数据仓库或查询视图。通过数据库的视图(View)功能,创建一个名为
v_user_for_report的视图,其定义就是SELECT * FROM user(不包含deleted=0条件)。然后让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.deleted=0 AND d.deleted=0,这可能导致查询结果与预期不符。在涉及多表的复杂查询中,无条件使用自定义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的缓存为flushCache=true,避免缓存污染。
5. 总结:如何选择你的“绕行”方案
没有最好的方案,只有最适合当前场景的方案。我们可以做一个简单的决策流:
- 需求是否简单、单一?(如:在Service中查一次全部用户)
- 是-> 使用方案二,在
QueryWrapper中通过apply(“1=1”)或显式or条件覆盖。快速验证,代码内聚。 - 否-> 进入下一步。
- 是-> 使用方案二,在
- 是否是复杂查询、报表或高频操作?
- 是->首选方案三(自定义SQL/Mapper.xml)。意图清晰,性能可控,与业务逻辑解耦。对于报表,强烈建议使用数据库视图。
- 否-> 进入下一步。
- 是否是需要全局、永久性改变行为的独立工具或脚本?
- 是-> 考虑方案一(全局关闭)或更优的独立数据源配置。务必确保环境隔离。
- 否-> 进入下一步。
- 是否希望为整个项目提供一个通用、优雅的“忽略逻辑删除”的API?
- 是-> 可以考虑方案四(自定义SqlInjector),但请做好充分的测试和版本兼容性评估。
- 否-> 回归方案二或三。
最后记住一个核心原则:逻辑删除是一种数据保护机制,“绕过”它是一项需要慎用的特权。每一次绕过操作,都应该有清晰的业务边界、明确的权限控制和完整的操作日志。在代码中,为这些特殊方法起一个见名知意的名字,如selectAllIncludingDeleted、findForStatistics,并在方法注释中明确说明其忽略逻辑删除的意图和潜在风险,这是对自己和后续维护者负责。