1. 当逻辑删除成为“拦路虎”:一个真实的业务场景
最近在重构一个老项目的报表导出功能时,我遇到了一个典型的“逻辑删除”困境。需求很简单:导出一份包含所有历史订单的明细,无论这些订单是否已被用户“删除”。项目用的是 MyBatis-Plus(后面简称 MP),默认开启了全局逻辑删除。这意味着,当我调用orderMapper.selectList(new QueryWrapper<>())时,MP 会自动在 SQL 后面加上WHERE deleted = 0,那些deleted = 1的记录就被过滤掉了。这本来是保护数据、防止误删的好设计,但在某些特定业务场景下,比如数据归档、后台审计、全量数据分析时,它就成了必须绕过的“拦路虎”。
不止是导出,像定时任务清理过期“已删除”数据、管理员需要查看被“删除”的用户反馈、或者在某些复杂的联表查询中主表需要忽略逻辑删除条件等,都会遇到同样的问题。网上搜一圈,关键词无非是“MyBatis-Plus 忽略逻辑删除”、“动态取消租户隔离”,但很多方案要么语焉不详,要么有性能或侵入性隐患。今天,我就结合自己的踩坑和实战,系统梳理一下在 MP 框架下,如何安全、优雅且灵活地“排除”、“停止”或“绕过”逻辑删除功能。我们会从原理入手,再到多种实战方案,并重点分析每种方案的适用场景和潜在风险。
2. 理解 MP 逻辑删除的底层机制:它如何“偷偷”修改你的 SQL
在讨论如何绕过之前,我们必须先弄清楚 MP 的逻辑删除是怎么工作的。知其然,更要知其所以然,这样才能找到正确的“开关”。
MP 的逻辑删除本质上是一个内置的插件(com.baomidou.mybatisplus.extension.plugins.inner.LogicSqlInjector及相关拦截器)。它的工作流程可以概括为以下几个关键步骤:
实体类标记:在你的实体类对应逻辑删除的字段上(比如
deleted)加上@TableLogic注解。这是触发器。@Data public class User { private Long id; private String name; @TableLogic private Integer deleted; // 0-未删除, 1-已删除 }SQL 注入:MP 在启动时,会通过
LogicSqlInjector向 Mapper 中注入处理逻辑删除的 SQL 片段。这决定了SELECT、UPDATE、DELETE语句的默认行为。拦截器加工:核心在于
com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor的变种或专门的逻辑删除处理器(在 MP 3.4+ 版本后,逻辑删除主要通过com.baomidou.mybatisplus.core.plugins.MybatisPlusInterceptor添加InnerInterceptor实现)。当执行一条 Mapper 方法调用时,拦截器会介入。- 对于 SELECT 查询:拦截器会解析你的 QueryWrapper,如果发现没有主动设置关于
deleted字段的条件,并且该实体类启用了逻辑删除,则会自动追加WHERE deleted = {未删除值}条件。这个“未删除值”在@TableLogic注解或全局配置中定义(通常是0)。 - 对于 DELETE 语句:当你调用
mapper.deleteById(1)时,拦截器会将其重写为UPDATE table SET deleted = {已删除值} WHERE id = 1。这就是“逻辑”删除的由来,物理数据并没有被抹去。 - 对于 INSERT 语句:如果你在插入数据时,逻辑删除字段为
null,拦截器会自动将其填充为“未删除值”(如0)。这就是为什么有时你插入数据,该字段为null却最终变成0的原因。
- 对于 SELECT 查询:拦截器会解析你的 QueryWrapper,如果发现没有主动设置关于
关键点:这个自动追加条件的行为,发生在 SQL 语句被发送到数据库之前,是在 MyBatis 的MappedStatement层面进行的修改。因此,你的任何在QueryWrapper中构造的、看似包含了deleted字段的复杂条件,都可能与这个自动行为发生冲突或重复。
理解了这一点,我们就明白,所谓的“绕过”,其实就是如何让这个拦截器在特定的执行上下文中“失效”,或者如何构造出让它“无从下手”的查询条件。
3. 方案一:使用自定义 SQL 或 Wrapper 显式覆盖条件(最直接)
这是最直观、侵入性最小的方法。既然 MP 是在没有deleted条件时自动追加,那我们主动提供一个包含deleted字段的完整条件不就行了?
3.1 在 QueryWrapper 中手动指定 deleted 条件
你可以在构建查询条件时,明确地设置deleted字段的查询范围。
// 查询所有未删除的数据(MP默认行为,这里演示原理) QueryWrapper<Order> wrapper1 = new QueryWrapper<>(); wrapper1.eq("deleted", 0); // MP看到你已经设置了deleted条件,就不会再重复追加 List<Order> activeOrders = orderMapper.selectList(wrapper1); // 查询所有已删除的数据 QueryWrapper<Order> wrapper2 = new QueryWrapper<>(); wrapper2.eq("deleted", 1); List<Order> deletedOrders = orderMapper.selectList(wrapper2); // 查询所有数据(包括已删除和未删除)-- 核心绕过方法 QueryWrapper<Order> wrapper3 = new QueryWrapper<>(); wrapper3.isNull("deleted").or().eq("deleted", 0).or().eq("deleted", 1); // 或者更简洁地,使用 in 语句 // wrapper3.in("deleted", Arrays.asList(0, 1)); List<Order> allOrders = orderMapper.selectList(wrapper3);为什么这样可行?因为 MP 的拦截器在准备追加deleted=0条件前,会先检查当前QueryWrapper中是否已经存在对deleted字段的操作(通过判断 SQL 片段中是否包含deleted这个列名)。如果存在,它就会认为你已经手动处理了逻辑删除逻辑,从而不再画蛇添足。我们通过wrapper.in(“deleted”, Arrays.asList(0, 1))覆盖了所有可能的值,自然就查出了全部数据。
注意:这种方法有一个潜在的坑。MP 判断“是否已处理逻辑删除字段”的规则可能在不同版本间有细微差别。有些版本是严格判断
WHERE子句中是否出现了deleted这个列名。如果你使用的是lambda表达式,如wrapper.eq(Order::getDeleted, 1),MP 同样能正确识别。但为了绝对保险,尤其是在复杂嵌套OR条件下,建议在构造完Wrapper后,打印一下生成的 SQL 语句确认最终条件是否符合预期。
3.2 使用自定义 XML SQL 或 @Select 注解
当你需要执行非常复杂的查询,或者QueryWrapper的链式调用无法满足需求时,直接编写原生 MyBatis SQL 是终极武器。在 XML 文件或@Select注解中编写的 SQL,MP 的拦截器是不会对其进行任何修改的。
// 在 Mapper 接口中定义方法 @Select("SELECT * FROM t_order WHERE ...") // 这里的 WHERE 条件由你完全掌控 List<Order> selectAllOrdersForReport(Map<String, Object> params);<!-- 在 OrderMapper.xml 中 --> <select id="selectAllOrdersForReport" resultType="com.example.entity.Order"> SELECT * FROM t_order <where> <!-- 你的自定义条件,完全不受逻辑删除干扰 --> <if test="startTime != null"> AND create_time >= #{startTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> <!-- 你可以自由决定是否包含 deleted=1 的数据 --> <!-- 如果想包含所有:要么不写deleted条件,要么显式写 AND (deleted=0 OR deleted=1) --> </where> ORDER BY create_time DESC </select>方案评价:
- 优点:简单直接,理解成本低,无需修改全局配置。自定义 SQL 方式功能最强大最灵活。
- 缺点:
QueryWrapper方式需要在每次需要绕过的地方都手动添加条件,不够通用,容易遗漏。自定义 SQL 则放弃了 MP 的条件构造器便利性。 - 适用场景:在少数几个特定的、复杂的查询场景下使用。不适合需要在整个服务层或多次查询中批量忽略逻辑删除的情况。
4. 方案二:利用 MP 的 SqlParser 忽略表(旧版 API 警告)
在 MP 3.x 的早期版本(例如 3.4.0 之前),流行一种通过SqlParser解析器动态忽略特定表逻辑删除的方案。其核心是使用@SqlParser注解或Configuration配置。
// 旧版方式(MP 3.4.0 之前可能有效) @Mapper public interface OrderMapper extends BaseMapper<Order> { @SqlParser(filter = true) // 标记此方法忽略 SQL 解析(包括逻辑删除、租户等) List<Order> selectAllWithoutLogicDelete(); }或者在application.yml中全局配置忽略解析:
mybatis-plus: global-config: sql-parser-cache: true # 这个配置项在较新版本中已变化或废弃重要警告:从 MyBatis-Plus 3.4.0 开始,官方已明确废弃并移除了@SqlParser注解以及相关的sqlParser全局配置。原来的 SQL 解析拦截器(SqlParserHandler)被更精细化的InnerInterceptor体系(如TenantLineInnerInterceptor,BlockAttackInnerInterceptor,逻辑删除也整合其中)所取代。如果你在较新版本(>=3.4.0)的代码中看到或使用@SqlParser,它将是无效的,并且 IDEA 会提示Cannot resolve symbol ‘SqlParser’。
因此,对于使用 MP 3.4.0+ 的项目,请不要再寻找或使用@SqlParser方案,它已经是一条死胡同。我们需要关注新的拦截器体系下的解决方案。
5. 方案三:动态构造 Wrapper 与 ThreadLocal 上下文(推荐方案)
这是目前社区和实践中比较推崇的一种平衡了灵活性和清晰度的方案。核心思想是:利用 ThreadLocal 或 Request 上下文,在需要忽略逻辑删除的代码块前后,动态地改变查询行为。我们可以通过一个工具类或 AOP 切面来实现。
5.1 核心工具类设计
我们创建一个名为IgnoreLogicDeleteHelper的工具类。
public class IgnoreLogicDeleteHelper { private static final ThreadLocal<Boolean> IGNORE_LOGIC_DELETE = ThreadLocal.withInitial(() -> Boolean.FALSE); /** * 开启当前线程的逻辑删除忽略模式 */ public static void enableIgnore() { IGNORE_LOGIC_DELETE.set(Boolean.TRUE); } /** * 关闭当前线程的逻辑删除忽略模式 */ public static void disableIgnore() { IGNORE_LOGIC_DELETE.remove(); } /** * 判断当前线程是否应忽略逻辑删除 */ public static boolean isIgnore() { return Boolean.TRUE.equals(IGNORE_LOGIC_DELETE.get()); } /** * 在忽略逻辑删除的上下文中执行任务 */ public static <T> T doWithoutLogicDelete(Supplier<T> supplier) { enableIgnore(); try { return supplier.get(); } finally { disableIgnore(); } } }5.2 自定义一个忽略逻辑删除的 QueryWrapper
我们继承或包装 MP 的QueryWrapper,在其内部根据IgnoreLogicDeleteHelper的状态动态调整条件。
public class IgnoreLogicDeleteQueryWrapper<T> extends QueryWrapper<T> { @Override public String getSqlSegment() { // 在生成SQL片段前,如果当前线程要求忽略逻辑删除,则主动注入一个覆盖所有deleted值的条件 if (IgnoreLogicDeleteHelper.isIgnore()) { String entityClass = getEntityClass(); // 这里需要想办法获取实体类,判断是否有@TableLogic字段 // 简化演示:假设我们知道逻辑删除字段名是 `deleted` // 更严谨的做法是通过反射获取实体类的 @TableLogic 注解字段 if (!this.getExpression().getNormal().toString().contains("deleted")) { this.and(wrapper -> wrapper.isNull("deleted").or().in("deleted", Arrays.asList(0, 1))); } } return super.getSqlSegment(); } // 更优雅的做法:结合自定义的 Mapper 方法或 Interceptor,这里展示思路。 }5.3 更实用的方式:结合自定义 Mapper 或 AOP
实际上,更常见的做法不是修改QueryWrapper,而是在Service层或Mapper层通过 AOP 动态处理。
方式A:在 Service 方法上使用注解+切面
- 定义一个注解
@IgnoreLogicDelete。 - 编写一个 AOP 切面,在方法执行前
enableIgnore(),执行后disableIgnore()。 - 在需要的地方,让
QueryWrapper的构建逻辑感知这个状态(这需要自定义一个Condition处理器,比较复杂)。
方式B:使用自定义的 Mapper 方法(更清晰)我更喜欢这种方式,因为它职责单一,调用方意图明确。
public interface OrderMapper extends BaseMapper<Order> { /** * 查询所有订单,忽略逻辑删除状态。 * 使用自定义的 Wrapper 或 SQL 实现。 */ default List<Order> selectAllIgnoreLogicDelete() { // 方法1:使用自定义SQL(推荐,最稳定) // return this.selectList(new QueryWrapper<Order>().in(“deleted”, 0, 1)); // 方法2:利用ThreadLocal,但需要配套的Interceptor支持(见下文) return IgnoreLogicDeleteHelper.doWithoutLogicDelete(() -> this.selectList(new QueryWrapper<>()) ); } }为了让方法2生效,我们需要一个自定义的 MyBatis 拦截器(Interceptor),它能够拦截selectList等方法的执行,并根据IgnoreLogicDeleteHelper.isIgnore()的状态,对最终生成的MappedStatement或BoundSql进行干预,手动移除或覆盖自动添加的deleted=0条件。这种实现需要对 MyBatis 内部机制有较深理解,但一旦封装好,使用起来非常优雅。
方案评价:
- 优点:灵活度高,可以精确控制忽略逻辑删除的范围(某个方法、某个线程内)。代码意图清晰(通过方法名或注解表达)。
- 缺点:实现相对复杂,尤其是需要编写自定义拦截器时。如果使用 ThreadLocal,必须注意在 finally 块中清理,防止内存泄漏和状态污染(例如在异步线程中)。
- 适用场景:需要在多个地方、以声明式方式忽略逻辑删除的中大型项目。是方案一的升级版,提供了更好的封装和复用性。
6. 方案四:临时调整全局配置(高风险,慎用)
理论上,你可以通过编程方式,在运行时获取到 MP 的全局配置对象GlobalConfig,找到逻辑删除的配置项(GlobalConfig.DbConfig下的logicDeleteField,logicDeleteValue,logicNotDeleteValue),临时修改它们,执行完查询后再改回来。
// 伪代码,强烈不推荐在生产环境使用 GlobalConfig.DbConfig dbConfig = MybatisPlusProperties.getGlobalConfig().getDbConfig(); String originalField = dbConfig.getLogicDeleteField(); Integer originalDeleteValue = dbConfig.getLogicDeleteValue(); Integer originalNotDeleteValue = dbConfig.getLogicNotDeleteValue(); try { // 临时“禁用”逻辑删除:将删除值设为未删除值,这样自动追加的条件就无效了 // 或者更粗暴地,将逻辑删除字段名设为一个不存在的字段 dbConfig.setLogicDeleteField(“a_non_existent_column”); // 执行你的查询 List<Order> allOrders = orderMapper.selectList(new QueryWrapper<>()); } finally { // 恢复原配置 dbConfig.setLogicDeleteField(originalField); dbConfig.setLogicDeleteValue(originalDeleteValue); dbConfig.setLogicNotDeleteValue(originalNotDeleteValue); }方案评价:
- 优点:看似一劳永逸。
- 缺点:
- 线程安全问题:
GlobalConfig通常是单例、全局的。在一个多线程的 Web 应用中,你修改全局配置的瞬间,其他正在处理的请求也会受到影响,可能导致灾难性的数据错乱。这是最致命的问题。 - 配置恢复失败风险:如果
try块中的代码抛出异常,可能会跳过finally块中的恢复代码(取决于异常类型和捕获位置),导致配置永久错乱。 - 违反设计原则:MP 将逻辑删除作为全局插件设计,就是为了提供一致的、不可轻易颠覆的数据保护层。这种“硬改”配置的方式破坏了框架的封装性和一致性。
- 线程安全问题:
- 结论:绝对不推荐在任何正式环境使用此方案。它带来的风险远大于便利性。
7. 方案对比与选型指南
| 方案 | 实现难度 | 灵活性 | 侵入性 | 线程安全 | 推荐指数 | 最佳适用场景 |
|---|---|---|---|---|---|---|
| 方案一:Wrapper/自定义SQL | 低 | 低(每次需手动处理) | 低 | 高 | ★★★★ | 少数特定、复杂的查询(如报表SQL)。简单直接。 |
| 方案三:动态上下文/AOP | 中高 | 高 | 中(需引入工具类/切面) | 需谨慎处理ThreadLocal | ★★★★★ | 需要在多处、以声明式方式忽略逻辑删除的项目。平衡了优雅与可控。 |
| 方案四:修改全局配置 | 低 | 低(实际是全局影响) | 高 | 极低(危险) | ★(不推荐) | 仅用于理解原理,严禁生产环境。 |
选型建议:
- 如果只是偶尔一两个特殊查询:毫不犹豫地选择方案一。在
QueryWrapper里加上.in(“deleted”, 0, 1),或者直接写自定义 XML SQL。这是最安全、最没有副作用的做法。 - 如果项目中存在多个类似“数据看板”、“后台审计”等需要忽略逻辑删除的模块:建议投入精力实现方案三。可以从小做起,先实现一个基于
ThreadLocal和工具方法的简易版,确保在finally中清理状态。随着需求复杂,再升级为基于自定义注解和 AOP 的完整方案。这能极大提升代码的可维护性和开发体验。 - 永远不要使用方案四。
8. 实战中的陷阱与进阶思考
8.1 联表查询时的逻辑删除问题
当你使用 MP 的selectJoin或自己写LEFT JOIN进行联表查询时,逻辑删除的自动追加只作用于主表(From 后的表)。例如:
SELECT a.*, b.name FROM order a LEFT JOIN user b ON a.user_id = b.id如果Order和User实体都配置了逻辑删除,MP 默认只会在a(order表)后自动加AND a.deleted = 0,而不会给b(user表)加。这可能导致你关联出一个已经被逻辑删除的用户信息。
解决方案:在联表查询的QueryWrapper中,必须手动为所有需要逻辑删除过滤的关联表添加条件。
QueryWrapper<Order> wrapper = new QueryWrapper<>(); wrapper.eq(“a.deleted”, 0) // 主表条件,MP可能已加,但手动加上更保险 .eq(“b.deleted”, 0); // 关联表条件,必须手动加 orderMapper.selectJoinPage(page, wrapper);8.2 逻辑删除字段在 Insert 时的 null 值处理
如第 2 节原理所述,MP 会在插入时自动填充逻辑删除字段。如果你在insert(entity)时,该字段为null,它会被设置为@TableLogic中定义的delval(默认是1的逻辑删除值?这里注意,delval是删除时设置的值,插入时填充的是value或全局配置的logic-not-delete-value,通常是0)。但如果你明确设置了该字段的值,MP 会尊重你的设置。
这意味着,如果你想通过insert语句“恢复”一条被逻辑删除的数据(即直接设置deleted=0),是可行的。但更规范的做法是使用update语句将deleted从1改为0。MP 没有提供官方的“恢复”API,需要你自己写update。
8.3 与多租户(Tenant Line)等插件共存
MP 的插件体系(MybatisPlusInterceptor)是一个责任链。逻辑删除、多租户、数据权限等插件(InnerInterceptor)会按添加顺序依次执行。当多个插件同时修改 SQL 时,顺序很重要。
如果你的项目同时配置了多租户隔离和逻辑删除,并且想在某个查询中同时忽略两者,那么方案三(动态上下文)需要维护一个更复杂的上下文状态,或者分别调用忽略多租户和忽略逻辑删除的方法。社区有一些开源的工具库尝试统一管理这种“动态数据过滤”上下文,可以借鉴。
8.4 性能考量
自动追加WHERE deleted=0条件本身对性能影响微乎其微。但是,如果你在deleted字段上没有建立索引,而你的表数据量巨大(百万级以上),那么这个条件可能会导致全表扫描。务必为逻辑删除字段建立索引(通常是一个普通的二级索引)。对于deleted这种区分度可能不高(大部分是0,少量是1)的字段,索引依然能有效提升“查询未删除数据”的效率。
绕过逻辑删除查询全部数据时,由于没有了deleted=0的限制,查询范围变大,可能会比平时慢,这是正常的。对于海量历史数据归档查询,建议配合分页和合理的其他查询条件。
最后,我的个人经验是,逻辑删除是一个非常好的实践,但在架构设计初期就要考虑到这些“绕过”场景。在项目规范中明确约定:所有绕过逻辑删除的查询,必须经过严格评审,并且只能在特定的 Service 方法中进行,这些方法命名必须包含IgnoreLogicDelete或AllIncludingDeleted等明显标识,以警示后续维护者这里操作的是全量数据。通过制度和命名约定来管理风险,比单纯依赖技术手段更有效。