MyBatis-Plus逻辑删除的‘后遗症’:自定义SQL查询全量数据怎么办?附两种修复方案

张开发
2026/4/16 10:33:25 15 分钟阅读

分享文章

MyBatis-Plus逻辑删除的‘后遗症’:自定义SQL查询全量数据怎么办?附两种修复方案
MyBatis-Plus逻辑删除的隐秘陷阱自定义SQL查询全量数据的深度解决方案1. 逻辑删除的优雅与隐患在数据持久层设计中逻辑删除一直是个让人又爱又恨的特性。它通过标记字段替代物理删除保留了数据可追溯性避免了外键约束等问题。MyBatis-Plus以下简称MP将其封装得如此简洁只需一个注解就能自动过滤已删除数据TableLogic(value 1, delval 0) private Integer isDeleted;配置完成后所有通过MP内置方法进行的CRUD操作都会自动带上is_deleted0条件。这种设计让开发者误以为逻辑删除已经一劳永逸直到他们在复杂业务场景中编写自定义SQL时突然发现-- 预期只查询有效数据 -- 实际全量数据泄露 SELECT * FROM user JOIN orders ON user.id orders.user_id这个看似简单的设计实则暗藏玄机。MP的逻辑删除过滤仅作用于框架生成的SQL对开发者自定义的SQL完全透明。当项目中出现以下场景时问题会集中爆发多表关联查询分组统计报表复杂子查询存储过程调用更棘手的是这类问题往往在测试环境难以发现——因为测试数据量小开发人员很少执行逻辑删除操作。等到生产环境运行一段时间后系统可能已经输出了大量包含无效数据的报表造成业务决策偏差。2. 问题本质解析与技术内幕要彻底解决这个问题我们需要深入理解MP的实现机制。逻辑删除的自动过滤发生在MP的SQL解析阶段具体流程如下SQL生成阶段当调用selectList()等方法时MP会通过AbstractWrapper构建条件逻辑删除拦截LogicSqlInjector在SQL中自动追加WHERE is_deleted0条件SQL执行阶段最终生成的SQL语句发送到数据库执行关键点在于只有通过MP的QueryWrapper/LambdaQueryWrapper构建的查询才会触发这个机制。对于以下两种自定义SQL方式过滤条件都会失效// 方式1XML映射文件中的SQL select idfindComplexData resultType... SELECT * FROM table1 t1 JOIN table2 t2 ON t1.id t2.ref_id /select // 方式2注解方式的自定义SQL Select(SELECT * FROM user WHERE age #{minAge}) ListUser findAdults(Param(minAge) int minAge);这种设计其实有其合理性——MP无法确定开发者在复杂SQL中的真实意图。有些场景确实需要查询已删除数据如回收站功能框架不应该过度干预。3. 解决方案一手动注入过滤条件最直接的解决方式是在所有自定义SQL中显式添加删除状态条件。根据SQL编写方式的不同具体实现也有所差异。3.1 XML映射文件方案对于使用XML配置的SQL只需在WHERE子句中添加条件select idfindActiveUsers resultTypeUser SELECT u.*, d.department_name FROM user u LEFT JOIN department d ON u.dept_id d.id WHERE u.is_deleted 0 if testdeptId ! null AND u.dept_id #{deptId} /if /select注意事项在多表关联时需要为每个支持逻辑删除的表添加条件对于LEFT JOIN被关联表的删除条件应该放在ON子句中SELECT u.*, d.department_name FROM user u LEFT JOIN department d ON u.dept_id d.id AND d.is_deleted 0 WHERE u.is_deleted 03.2 注解方案优化使用Select注解时可以通过模板变量保持代码整洁Select(SELECT * FROM user WHERE is_deleted 0 AND ${ew.customSqlSegment}) ListUser selectActiveList(Param(Constants.WRAPPER) WrapperUser wrapper);提示建议创建一个BaseMapper接口统一这些模板方法避免每个Mapper重复定义4. 解决方案二Wrapper条件构造器集成对于已经大量使用Wrapper的项目可以保持代码风格统一让MP自动注入条件。这种方法尤其适合动态查询场景。4.1 基本集成模式public ListUser findUsers(QueryWrapperUser wrapper) { // 确保不覆盖现有条件 wrapper.eq(User::getIsDeleted, 0); return userMapper.selectList(wrapper); }对于自定义方法可以通过Param(Constants.WRAPPER)参数Select(SELECT * FROM user ${ew.customSqlSegment}) ListUser selectByWrapper(Param(Constants.WRAPPER) WrapperUser wrapper); // 调用示例 ListUser users userMapper.selectByWrapper( Wrappers.Userquery() .eq(status, 1) // 不需要显式添加is_deleted条件 );4.2 高级封装技巧我们可以创建装饰器Wrapper自动注入逻辑删除条件public class SafeQueryWrapperT extends QueryWrapperT { Override public QueryWrapperT eq(boolean condition, String column, Object val) { if (is_deleted.equals(column)) { throw new IllegalArgumentException(不允许修改逻辑删除条件); } return super.eq(condition, column, val); } public QueryWrapperT safe() { return this.eq(is_deleted, 0); } } // 使用示例 ListUser users userMapper.selectList( new SafeQueryWrapperUser() .like(name, 张) .safe() );5. 工程化解决方案与最佳实践在大型项目中我们需要系统性地解决这个问题而不是到处打补丁。以下是几种经过验证的架构方案。5.1 AOP统一处理通过切面自动为所有Mapper方法添加条件Aspect Component public class LogicDeleteAspect { Around(execution(* com..mapper.*.*(..)) args(wrapper,..)) public Object aroundQuery(ProceedingJoinPoint pjp, Wrapper? wrapper) throws Throwable { if (wrapper ! null) { // 反射获取entityClass Class? entityClass getEntityClass(wrapper); TableLogic tableLogic getTableLogicAnnotation(entityClass); if (tableLogic ! null) { wrapper.eq(getLogicDeleteColumn(entityClass), tableLogic.value()); } } return pjp.proceed(); } }5.2 自定义SQL拦截器更底层的解决方案是扩展MP的SQL解析过程public class LogicDeleteInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 解析原始SQL String originalSql getSql(invocation); // 识别查询语句且不含逻辑删除条件 if (isSelectSql(originalSql) !containsLogicDelete(originalSql)) { String newSql injectLogicDelete(originalSql); resetSql(invocation, newSql); } return invocation.proceed(); } }5.3 代码规范与检查建立严格的代码审查机制静态代码扫描通过SonarQube等工具检测自定义SQL单元测试规范要求所有数据访问测试包含逻辑删除用例架构守护使用ArchUnit确保Mapper接口规范ArchTest public static final ArchRule no_raw_sql_in_mapper noMethods() .that().areDeclaredInClassesThat().resideInAPackage(..mapper..) .should().callMethodWhere(JavaMethod.Predicates.nameContains(execute)) .orShould().callMethodWhere(JavaMethod.Predicates.nameContains(queryForList));6. 特殊场景处理与边界情况即使采用了上述方案某些复杂场景仍需特别注意。6.1 联表查询的陷阱在多表关联时容易遗漏关联表的逻辑删除条件-- 错误示例只过滤了主表 SELECT a.*, b.name FROM order a JOIN user b ON a.user_id b.id WHERE a.is_deleted 0 -- 正确写法 SELECT a.*, b.name FROM order a JOIN user b ON a.user_id b.id AND b.is_deleted 0 WHERE a.is_deleted 06.2 统计查询的注意事项进行COUNT、SUM等统计时要明确业务需求-- 统计所有历史订单包含已删除的 SELECT COUNT(*) FROM order -- 统计有效订单 SELECT COUNT(*) FROM order WHERE is_deleted 06.3 事务与数据一致性在事务中混合逻辑删除和物理操作时要特别小心Transactional public void transferData(Long sourceId, Long targetId) { // 逻辑删除源数据 sourceMapper.deleteById(sourceId); // 如果此处抛出异常... someService.process(targetId); // 需要确保不会因为回滚导致数据既不在源表也不在目标表 }7. 性能优化与索引策略不当的逻辑删除实现可能导致严重性能问题以下是关键优化点索引设计确保逻辑删除字段包含在复合索引中-- 优化前 ALTER TABLE order ADD INDEX idx_status (status); -- 优化后 ALTER TABLE order ADD INDEX idx_status_del (status, is_deleted);查询重构避免对已删除数据的大表扫描-- 低效 SELECT * FROM history_log WHERE create_time 2023-01-01; -- 高效 SELECT * FROM history_log WHERE create_time 2023-01-01 AND is_deleted 0;归档策略对已删除且无需保留的数据定期归档到历史表策略执行频率影响范围恢复难度纯逻辑删除实时单条记录容易逻辑删除归档定期任务批量数据中等物理删除实时不可逆困难8. 监控与治理体系建立完善的监控机制及时发现逻辑删除相关问题SQL审计监控生产环境SQL捕获缺失逻辑删除条件的查询数据质量检查定期验证报表数据的有效性性能监控关注包含逻辑删除字段的查询性能// 示例通过MyBatis插件记录问题SQL Intercepts({ Signature(type StatementHandler.class, methodquery, args{Statement.class, ResultHandler.class}) }) public class SqlMonitorPlugin implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { String sql ((Statement) invocation.getArgs()[0]).toString(); if (isDangerousSelect(sql)) { log.warn(Potential missing logic-delete condition: {}, sql); metrics.counter(unsafe_query).increment(); } return invocation.proceed(); } }在三年多的企业级项目实践中我们发现逻辑删除问题最常出现在新接手的老代码库中。有个典型案例某财务系统每月生成的报表总是包含已注销账户的数据导致对账差异。问题的根源竟是三年前某个开发人员在存储过程中遗漏了删除条件。这个教训让我们建立了严格的SQL审查流程所有数据访问代码必须包含逻辑删除测试用例。

更多文章