
1. 项目概述从“硬删除”到“软着陆”的数据管理思维转变在任何一个涉及数据持久化的业务系统里“删除”操作都是一个绕不开的核心功能。早期做项目我们最熟悉的就是DELETE FROM table WHERE id ?这种简单粗暴的物理删除。数据从磁盘上被彻底抹去干净利落但也意味着一旦误操作数据恢复将变得异常困难除非有完备的备份和日志。随着业务复杂度的提升特别是对数据安全性和可追溯性要求极高的系统如电商订单、财务流水、操作日志逻辑删除逐渐成为了事实上的标准方案。它不真正移除数据而是通过一个标记字段如is_deleted、deleted来标识记录已“失效”查询时自动过滤这些数据。这就像给文件扔进了“回收站”而非直接“粉碎”。Mybatis-Plus简称MP作为Mybatis的增强工具包其“开箱即用”的特性在逻辑删除这块做得尤为出色。它几乎零配置地就将这套机制集成到了你的CRUD操作中让开发者从繁琐的WHERE deleted 0判断中解放出来。但用得好和用得明白是两回事。最近在团队Review代码时我发现不少同事对MP逻辑删除的实现原理、使用细节以及可能遇到的“坑”理解并不深入导致出现了数据不一致、查询结果异常甚至插入失败的问题。比如为什么开启了逻辑删除后有时候更新操作会失效又或者如何优雅地查询已被逻辑删除的数据今天我就结合自己多年的实战经验把Mybatis-Plus的物理删除与逻辑删除尤其是逻辑删除的里里外外彻底讲透。2. 核心概念辨析物理删除与逻辑删除的适用场景在深入MP的实现之前我们必须先厘清两个核心概念的本质区别和适用边界。这决定了你在设计数据表时该如何选择。2.1 物理删除决绝的“清道夫”物理删除即执行标准的SQLDELETE语句将数据记录从数据库表中永久移除。释放磁盘空间表的主键ID序列通常不受影响取决于数据库实现但自增序列不会回退。适用场景临时性或缓存数据如用户会话信息、临时计算中间表这些数据生命周期短无保留价值。高度敏感信息的彻底销毁在法律合规要求下某些个人信息必须在特定时限后不可恢复地删除。日志类数据的滚动清理例如仅保留最近30天的操作日志超过期限的日志可以物理删除以节省存储。中间表或关系表在多对多关联中仅表示关系的表关系解除后记录无存在意义。操作特点与风险不可逆性是最大的风险。一旦执行数据恢复成本极高需从备份恢复或通过专业工具扫描磁盘碎片。影响外键如果存在外键约束且未设置级联删除可能会因违反参照完整性而导致删除失败。性能考量对于海量数据DELETE操作会记录大量事务日志可能引起锁竞争和性能下降。大批量删除时建议分批次进行。2.2 逻辑删除智慧的“归档员”逻辑删除是一种“标记删除”。通过在表中增加一个状态字段例如deletedTINYINT0表示未删除1表示已删除在执行“删除”操作时实际执行的是UPDATE table SET deleted 1 WHERE id ?。所有常规的查询操作都会自动附加WHERE deleted 0条件从而“屏蔽”已删除的数据。适用场景几乎是现代业务系统的标配需要数据追溯与审计如订单系统用户取消订单后后台仍需能看到订单详情以处理售后或财务对账。防止误操作提供“回收站”功能用户或管理员可以恢复误删的数据。关联数据完整性如果数据被其他记录引用物理删除会导致历史关联信息断裂。逻辑删除保留了这条“链路”。满足法律合规性某些法规要求业务数据必须保留一定年限即使“删除”也不能物理抹除。优势与代价优势数据安全系数高支持恢复维护了数据的历史关联性。代价存储空间数据永远占用存储需定期归档至历史表。查询性能所有查询都隐式增加了条件对复合索引的设计提出了更高要求。如果deleted字段选择性不高即大部分数据未删除影响不大但如果已删除数据占比很大可能需要单独建立包含deleted字段的索引。唯一约束冲突这是逻辑删除最经典的“坑”。假设用户表user有唯一索引uk_username(username)用户“张三”被逻辑删除后deleted1就无法再创建一个名为“张三”的新用户因为唯一索引约束的是整个索引列的组合deleted状态不同也被视为同一索引值。解决方案通常是将deleted字段也纳入唯一索引或者使用删除时间delete_time等字段保证唯一性。注意选择逻辑删除并非一劳永逸。对于增长极快的表如日志必须配套设计数据归档与清理策略例如定期将标记删除超过一年的数据迁移到冷存储或历史表并对主表进行物理删除以控制表体积。3. Mybatis-Plus逻辑删除的配置与底层原理剖析Mybatis-Plus通过内置插件和全局配置将逻辑删除的逻辑对开发者几乎完全透明。理解其配置方式和运行原理是避免踩坑的关键。3.1 基础配置从字段注解到全局配置MP支持两种配置方式局部注解和全局配置。我强烈推荐全局配置因为它能保持项目内定义的一致性便于管理。1. 数据库表添加字段首先在你的实体表例如user表中添加一个用于标识删除状态的字段。常见的命名有deleted、is_deleted、del_flag。字段类型通常为数值型如TINYINT、INT或时间戳DATETIME。数值型更为常见。ALTER TABLE user ADD COLUMN deleted TINYINT DEFAULT 0 COMMENT 逻辑删除标识0-未删除1-已删除;2. 实体类字段添加TableLogic注解局部配置在实体类的对应属性上添加注解。这是最直观的方式。import com.baomidou.mybatisplus.annotation.TableLogic; public class User { private Long id; private String username; TableLogic // 标记此字段为逻辑删除字段 private Integer deleted; // 值对应数据库字段 // ... getters and setters }TableLogic提供了两个属性value: 未删除时的字段值默认为0。delval: 删除后的字段值默认为1。 你可以根据数据库字段的实际含义进行修改例如TableLogic(value false, delval true)对应布尔类型。3. 全局配置推荐在Spring Boot的application.yml或application.properties中配置这样所有实体无需添加TableLogic注解只要字段名匹配即可生效。mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除的实体字段名 logic-delete-value: 1 # 逻辑已删除值默认为 1 logic-not-delete-value: 0 # 逻辑未删除值默认为 0如果同时使用了TableLogic注解注解的优先级高于全局配置。3.2 原理解析SQL注入与自动过滤MP的逻辑删除功能核心是通过一个名为LogicSqlInjector的组件和AbstractLogicMethod对应的SQL方法实现。它主要做了两件事1. “删除”操作的替换当你调用mapper.deleteById(1)或service.removeById(1)时MP不会生成DELETE FROM user WHERE id1而是会生成UPDATE user SET deleted1 WHERE id1 AND deleted0注意后面的AND deleted0这是一个乐观锁式的设计防止重复“删除”已删除的数据。这个逻辑被固化在LogicDeleteById等SQL方法中。2. “查询”操作的自动过滤在调用任何查询方法如mapper.selectList(queryWrapper)、service.getById(id)时MP会自动在SQL语句的WHERE条件中追加关于逻辑删除字段的条件。例如你的Wrapper里写了eq(status, 1)最终生成的SQL会是SELECT * FROM user WHERE status 1 AND deleted 0这个自动追加的行为是由LogicSelectById、LogicSelectList等方法实现的。这意味着只要你开启了全局逻辑删除你几乎无法通过常规的MP查询方法获取到已删除的数据。这是一个非常重要的设计约定。底层拦截机制这些逻辑是通过MP的SqlInjectorSQL注入器将自定义的MappedStatement添加到Mybatis的配置中。LogicSqlInjector负责将标准的deleteById、selectById等方法“偷梁换柱”成我们上面看到的带有逻辑删除逻辑的SQL。整个过程对开发者无感体现了MP“约定大于配置”的理念。4. 高级应用与实战场景深度解析仅仅会配置还不够在实际复杂业务中我们会遇到各种边界情况。下面这些场景你可能迟早都会碰到。4.1 场景一如何查询已被逻辑删除的数据这是最常见的需求比如管理员要在后台恢复数据或查看删除记录。由于MP自动过滤我们不能直接用selectById。有几种方法方法A使用selectList并显式指定删除状态推荐通过QueryWrapper明确地设置删除字段的值这会覆盖全局的自动过滤行为。// 查询已被删除的用户 ListUser deletedUsers userMapper.selectList( new QueryWrapperUser().eq(deleted, 1) ); // 或者查询所有数据包括已删除和未删除 ListUser allUsers userMapper.selectList( new QueryWrapperUser().isNotNull(id) // 一个永真条件或者用 .apply(11) ); // 更清晰的写法手动关闭逻辑删除过滤需要MP 3.4.0且谨慎使用 // QueryWrapperUser wrapper new QueryWrapper(); // wrapper.apply(deleted 1 or deleted 0);原理MP在拼接条件时会检查Wrapper中是否已存在关于逻辑删除字段的条件。如果存在则不再自动追加全局的deleted0条件。这是一种“手动覆盖”机制。方法B使用自定义SQL最灵活在Mapper接口中定义方法并编写对应的XML或注解SQL。这是最彻底、最可控的方式。// 在 UserMapper.java 中 Select(SELECT * FROM user WHERE id #{id}) User selectUserIgnoreLogicDelete(Param(id) Long id); // 或者在 UserMapper.xml 中 select idselectIncludeDeleted resultTypeUser SELECT * FROM user /select实操心得对于简单的、临时的查询用方法A的Wrapper覆盖即可。但对于复杂的、需要关联查询已删除数据的场景或者是在Service层需要提供一个稳定的“查询全部”的接口我强烈建议使用方法B——自定义SQL。这样意图最明确不会受到MP未来版本默认行为变化的影响代码可读性也更高。4.2 场景二逻辑删除与唯一索引的冲突解决方案如前所述唯一索引是逻辑删除的“天敌”。假设user表的username字段有唯一索引用户“Alice”id1被逻辑删除后就无法创建另一个“Alice”。解决方案1复合唯一索引修改表结构将删除状态字段加入唯一索引。这是最规范、数据库层面最安全的解决方案。-- 删除旧索引 DROP INDEX uk_username ON user; -- 创建包含deleted字段的新复合唯一索引 CREATE UNIQUE INDEX uk_username_deleted ON user(username, deleted);这样(‘Alice‘, 0)和(‘Alice‘, 1)被视为两条不同的索引记录可以共存。但请注意这要求你的deleted字段是定值如0/1。如果你使用时间戳delete_time作为逻辑删除标记未删除时为NULL删除后为具体时间这个方案就不太适用因为时间戳每次不同。解决方案2使用删除时间字段并以id代替业务字段做唯一约束如果业务允许可以弱化业务字段的唯一性要求。例如将唯一约束建立在id上而对username的重复性在应用层通过“用户名已删除状态”的组合查询来做校验。这增加了应用层的复杂度。解决方案3全局唯一标识符如UUID对于新建系统可以考虑使用与业务无关的UUID作为主键或唯一标识这样即使逻辑删除后业务字段重复也不会违反唯一约束。但这会牺牲一定的查询性能和存储空间。我的选择在大多数情况下如果表结构可控我会首选方案1复合唯一索引。它一劳永逸地从数据库层面解决了问题。在项目初期设计评审时就必须把逻辑删除字段考虑进所有唯一索引的设计中。4.3 场景三逻辑删除下的更新操作“失灵”问题你有没有遇到过开启了逻辑删除后用updateById更新一条已被删除的数据发现返回的affected rows是0更新好像没生效这不是Bug而是特性。回顾一下MP生成逻辑删除UPDATE语句的原理UPDATE user SET name‘新名字‘ WHERE id1 AND deleted0;如果id1的记录deleted1这个WHERE条件就不成立所以更新了0行。MP的逻辑删除插件同样影响了updateById和update方法默认它们只能更新“未删除”的数据。如何更新已删除的数据和方法A一样使用UpdateWrapper并显式地包含删除状态条件或者直接使用自定义SQL。// 方法使用UpdateWrapper覆盖默认条件 int rows userMapper.update(null, new UpdateWrapperUser() .set(email, newemail.com) .eq(id, 1) .eq(deleted, 1) // 明确指定要更新已删除的记录 ); // 或者使用自定义SQL注意事项这个设计是合理的它防止了程序在不知情的情况下对“已删除”的幽灵数据进行了修改可能导致数据状态混乱。当你确实需要修改已删除数据时应清晰地意识到自己在做什么。5. 常见问题排查与性能优化指南即使理解了原理在真实开发中还是会遇到一些稀奇古怪的问题。下面是我总结的一个常见问题排查清单和优化建议。5.1 问题排查速查表问题现象可能原因解决方案调用deleteById后数据库记录还在只是deleted字段变了这是正常现象逻辑删除生效。确认logic-delete-value配置是否正确。检查全局配置或TableLogic注解的delval值是否与数据库实际值匹配。查询不到任何数据但数据库里有deleted0的记录1. 逻辑删除全局配置生效但字段名不匹配。2. 手动在Wrapper中错误地设置了deleted1。1. 检查logic-delete-field配置的字段名是否与实体类属性、数据库列名一致注意驼峰映射。2. 检查代码中的QueryWrapper条件。插入数据时逻辑删除字段被自动赋值为null或默认值而不是logic-not-delete-valueMP的逻辑删除插件主要拦截删除和查询操作插入时不会自动填充逻辑删除字段。需要在实体类字段上使用MP的TableField(fill FieldFill.INSERT)注解并配合自定义的MetaObjectHandler来实现自动填充。这是很多人的误区联表查询时逻辑删除条件只加在了主表上副表数据可能包含已删除记录MP的逻辑删除自动过滤通常只作用于你当前Mapper对应的实体主表。在自定义的联表SQL中必须手动为每个关联表加上逻辑删除条件。例如LEFT JOIN table_b b ON a.id b.aid AND b.deleted 0。使用selectMaps、selectObjs等方法时逻辑删除条件失效MP的大部分查询方法都集成了逻辑删除过滤。但极个别非常规的查询方法或使用原生Mybatis接口时可能不经过MP的插件链。优先使用MP包装的Mapper方法。如果使用原生Mybatis方法需在SQL中手动处理。5.2 性能考量与优化建议逻辑删除虽然好用但无脑使用也可能带来性能问题。1. 索引设计优化如果你的业务查询经常附带复杂的条件并且数据量巨大百万级以上那么deleted 0这个几乎每个查询都有的条件必须被考虑到索引设计中。单字段索引如果deleted字段的选择性非常差比如99%的数据都是deleted0单独为它建索引收益很低数据库优化器可能直接忽略。复合索引这是更常见的优化手段。将deleted字段作为复合索引的前缀列或第二列。例如有一个高频查询是WHERE status 1 AND type ‘A‘ ORDER BY create_time DESC那么可以建立索引(deleted, status, type, create_time)。把deleted放在最前面可以让索引快速过滤掉已删除数据大幅缩小扫描范围。2. 数据归档策略对于日志、流水、历史订单等随时间快速增长的表绝不能放任不管。一个典型的归档策略是在线表只保留最近N天如90天的未删除数据。deleted1的数据最多保留M天如7天供误删恢复。归档作业每天定时任务执行将deleted1且删除时间超过7天的数据以及create_time超过90天的所有数据迁移到结构相同的历史表或对象存储中。物理清理从在线表中物理删除已归档的数据。这一步可以定期如每周在业务低峰期执行。 这样主表始终维持在一个可控的大小查询和更新性能得到保障。MP的逻辑删除在这里扮演了数据生命周期管理中的一个状态标识角色。3. 关于deleted字段类型的选型除了常用的TINYINT也有人使用DATETIME未删除为NULL删除后为具体时间。后者优势在于可以直接知道删除时间无需额外字段。但缺点也很明显作为唯一索引的一部分更复杂方案1不适用。占用更多存储空间8字节 vs 1字节。在查询索引时效率可能略低于整型比较。 我个人更倾向于使用TINYINT状态字段 可选的delete_time时间字段结构更清晰灵活性更高。6. 超越默认自定义逻辑删除策略与插件扩展MP默认的逻辑删除策略是“标记为1”查询过滤“标记为0”。但有些业务场景可能需要更复杂的逻辑。场景多租户SaaS系统中每个租户的数据需要彻底隔离。当租户注销时需要“删除”其所有数据。但物理删除风险大逻辑删除用简单的deleted1又无法区分是用户删除还是租户注销。自定义策略我们可以扩展逻辑删除字段的含义。例如使用一个Integer类型的del_state字段。0: 正常1: 用户自行删除可恢复2: 租户注销导致删除不可恢复仅后台可见3: 系统管理员删除实现方法MP的全局配置只支持一个删除值和一个未删除值。要实现多状态我们需要更灵活的控制。方案A自定义SQL注入器高级继承DefaultSqlInjector重写getMethodList方法用自定义的LogicDeleteById等方法替换默认的。在这些自定义方法中你可以根据del_state的值生成不同的SQL。例如deleteById可能只是将状态改为1而一个tenantDeleteById方法会将状态改为2。这种方法功能强大但侵入性较高需要对MP源码有一定了解。方案B使用Mybatis的拦截器更灵活创建一个Mybatis的Interceptor拦截Executor的update和query方法。通过解析MappedStatement的ID判断是否为删除/查询操作然后动态修改SQL。例如你可以判断如果方法名包含 “tenantDelete”则在更新时设置del_state2在查询时自动加上del_state IN (0, 1)即只查询正常和用户删除的数据。这种方式与MP解耦但需要小心处理SQL解析避免性能瓶颈和SQL注入风险。方案C业务层封装推荐给大多数场景在Service层进行封装可能是最务实的选择。不要试图让ORM框架理解所有业务状态。Service public class UserService { Autowired private UserMapper userMapper; // 用户删除 public boolean userDelete(Long id) { User user new User(); user.setId(id); user.setDelState(1); user.setDeleteTime(new Date()); return userMapper.updateById(user) 0; } // 租户注销删除 public boolean tenantDelete(Long id) { User user new User(); user.setId(id); user.setDelState(2); user.setDeleteTime(new Date()); return userMapper.updateById(user) 0; } // 查询时根据业务场景使用不同的Wrapper public ListUser listActiveUsers() { // 只查询状态为0的 return userMapper.selectList(new QueryWrapperUser().eq(del_state, 0)); } public ListUser listAllForAdmin() { // 管理员可以查看所有非租户注销的数据 return userMapper.selectList(new QueryWrapperUser().in(del_state, 0, 1, 3)); } }我的建议除非有非常强烈的、全局性的、统一的多状态逻辑删除需求否则优先采用方案C。它简单、清晰、易于理解和维护将业务逻辑放在它该在的地方——业务层。过度设计框架层面的扩展往往会增加团队的认知负担和未来的维护成本。MP提供的默认逻辑删除已经解决了80%的问题剩下的20%特殊业务逻辑交给业务代码来处理是最稳妥的。