MyBatis-Plus逻辑删除机制解析与四种绕过方案实战
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的原因。关键点这个自动追加条件的行为发生在 SQL 语句被发送到数据库之前是在 MyBatis 的MappedStatement层面进行的修改。因此你的任何在QueryWrapper中构造的、看似包含了deleted字段的复杂条件都可能与这个自动行为发生冲突或重复。理解了这一点我们就明白所谓的“绕过”其实就是如何让这个拦截器在特定的执行上下文中“失效”或者如何构造出让它“无从下手”的查询条件。3. 方案一使用自定义 SQL 或 Wrapper 显式覆盖条件最直接这是最直观、侵入性最小的方法。既然 MP 是在没有deleted条件时自动追加那我们主动提供一个包含deleted字段的完整条件不就行了3.1 在 QueryWrapper 中手动指定 deleted 条件你可以在构建查询条件时明确地设置deleted字段的查询范围。// 查询所有未删除的数据MP默认行为这里演示原理 QueryWrapperOrder wrapper1 new QueryWrapper(); wrapper1.eq(deleted, 0); // MP看到你已经设置了deleted条件就不会再重复追加 ListOrder activeOrders orderMapper.selectList(wrapper1); // 查询所有已删除的数据 QueryWrapperOrder wrapper2 new QueryWrapper(); wrapper2.eq(deleted, 1); ListOrder deletedOrders orderMapper.selectList(wrapper2); // 查询所有数据包括已删除和未删除-- 核心绕过方法 QueryWrapperOrder wrapper3 new QueryWrapper(); wrapper3.isNull(deleted).or().eq(deleted, 0).or().eq(deleted, 1); // 或者更简洁地使用 in 语句 // wrapper3.in(deleted, Arrays.asList(0, 1)); ListOrder allOrders orderMapper.selectList(wrapper3);为什么这样可行因为 MP 的拦截器在准备追加deleted0条件前会先检查当前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注解中编写的 SQLMP 的拦截器是不会对其进行任何修改的。// 在 Mapper 接口中定义方法 Select(SELECT * FROM t_order WHERE ...) // 这里的 WHERE 条件由你完全掌控 ListOrder selectAllOrdersForReport(MapString, Object params);!-- 在 OrderMapper.xml 中 -- select idselectAllOrdersForReport resultTypecom.example.entity.Order SELECT * FROM t_order where !-- 你的自定义条件完全不受逻辑删除干扰 -- if teststartTime ! null AND create_time #{startTime} /if if testendTime ! null AND create_time #{endTime} /if !-- 你可以自由决定是否包含 deleted1 的数据 -- !-- 如果想包含所有要么不写deleted条件要么显式写 AND (deleted0 OR deleted1) -- /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 BaseMapperOrder { SqlParser(filter true) // 标记此方法忽略 SQL 解析包括逻辑删除、租户等 ListOrder 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 ThreadLocalBoolean 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(SupplierT supplier) { enableIgnore(); try { return supplier.get(); } finally { disableIgnore(); } } }5.2 自定义一个忽略逻辑删除的 QueryWrapper我们继承或包装 MP 的QueryWrapper在其内部根据IgnoreLogicDeleteHelper的状态动态调整条件。public class IgnoreLogicDeleteQueryWrapperT extends QueryWrapperT { 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 BaseMapperOrder { /** * 查询所有订单忽略逻辑删除状态。 * 使用自定义的 Wrapper 或 SQL 实现。 */ default ListOrder selectAllIgnoreLogicDelete() { // 方法1使用自定义SQL推荐最稳定 // return this.selectList(new QueryWrapperOrder().in(“deleted”, 0, 1)); // 方法2利用ThreadLocal但需要配套的Interceptor支持见下文 return IgnoreLogicDeleteHelper.doWithoutLogicDelete(() - this.selectList(new QueryWrapper()) ); } }为了让方法2生效我们需要一个自定义的 MyBatis 拦截器Interceptor它能够拦截selectList等方法的执行并根据IgnoreLogicDeleteHelper.isIgnore()的状态对最终生成的MappedStatement或BoundSql进行干预手动移除或覆盖自动添加的deleted0条件。这种实现需要对 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”); // 执行你的查询 ListOrder 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 默认只会在aorder表后自动加AND a.deleted 0而不会给buser表加。这可能导致你关联出一个已经被逻辑删除的用户信息。解决方案在联表查询的QueryWrapper中必须手动为所有需要逻辑删除过滤的关联表添加条件。QueryWrapperOrder 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语句“恢复”一条被逻辑删除的数据即直接设置deleted0是可行的。但更规范的做法是使用update语句将deleted从1改为0。MP 没有提供官方的“恢复”API需要你自己写update。8.3 与多租户Tenant Line等插件共存MP 的插件体系MybatisPlusInterceptor是一个责任链。逻辑删除、多租户、数据权限等插件InnerInterceptor会按添加顺序依次执行。当多个插件同时修改 SQL 时顺序很重要。如果你的项目同时配置了多租户隔离和逻辑删除并且想在某个查询中同时忽略两者那么方案三动态上下文需要维护一个更复杂的上下文状态或者分别调用忽略多租户和忽略逻辑删除的方法。社区有一些开源的工具库尝试统一管理这种“动态数据过滤”上下文可以借鉴。8.4 性能考量自动追加WHERE deleted0条件本身对性能影响微乎其微。但是如果你在deleted字段上没有建立索引而你的表数据量巨大百万级以上那么这个条件可能会导致全表扫描。务必为逻辑删除字段建立索引通常是一个普通的二级索引。对于deleted这种区分度可能不高大部分是0少量是1的字段索引依然能有效提升“查询未删除数据”的效率。绕过逻辑删除查询全部数据时由于没有了deleted0的限制查询范围变大可能会比平时慢这是正常的。对于海量历史数据归档查询建议配合分页和合理的其他查询条件。最后我的个人经验是逻辑删除是一个非常好的实践但在架构设计初期就要考虑到这些“绕过”场景。在项目规范中明确约定所有绕过逻辑删除的查询必须经过严格评审并且只能在特定的 Service 方法中进行这些方法命名必须包含IgnoreLogicDelete或AllIncludingDeleted等明显标识以警示后续维护者这里操作的是全量数据。通过制度和命名约定来管理风险比单纯依赖技术手段更有效。
