MyBatisPlus逻辑删除坑?我们避免使用软删设计
在一次金融级用户中心系统的重构中,我们曾为“用户注销是否可恢复”争论了整整两天。团队最初一致认为:必须支持撤销删除,于是果断启用了 MyBatisPlus 的逻辑删除功能——只需加个@TableLogic注解,删除变更新,查询自动过滤,开发效率拉满。
三个月后,问题接踵而至:一位已“注销”的用户无法重新注册,报表系统统计出的活跃用户数离谱地高,订单服务还在给早已“删除”的用户生成交易记录……我们这才意识到,看似优雅的“软删”,正在悄悄腐蚀系统的可靠性与一致性。
这不是 MyBatisPlus 的错,而是我们对“删除”这一操作的本质理解出现了偏差。
MyBatisPlus 的逻辑删除机制本质上是一种 SQL 拦截策略。当你调用removeById()时,它不会执行DELETE,而是改写成一条UPDATE语句,将标记字段(如deleted = 1)置位。同时,在所有查询中自动附加AND deleted = 0条件,试图让上层业务“无感”。
实现方式也很简洁:
@Data @TableName("user") public class User { private Long id; private String name; @TableLogic private Integer deleted; // 0-未删除, 1-已删除 }配合全局配置:
mybatis-plus: global-config: db-config: logic-delete-value: 1 logic-not-delete-value: 0再注册拦截器:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new LogicDeleteInnerInterceptor()); return interceptor; }一切看起来天衣无缝。但正是这种“透明封装”,埋下了隐患的种子。
数据膨胀不是缓慢老化,而是性能雪崩的前兆
逻辑删除最直接的问题是:数据只增不减。
你以为只是“标记一下”,但实际上,每一条被“删除”的记录仍在表中占据空间。InnoDB 的页机制并不会因为deleted=1就释放存储,反而随着数据量增长,全表扫描、备份恢复、主从同步的延迟都会显著上升。
更致命的是索引效率。假设你在name字段上有索引,但没有把deleted包含进去,那么查询:
SELECT * FROM user WHERE name = 'Alice' AND deleted = 0;很可能仍需回表扫描大量已删除的数据块。除非你建立复合索引(deleted, name),否则这个查询的成本会随着历史数据积累线性甚至指数级上升。
我们曾在一个日增万级用户的项目中观察到:上线半年后,单表行数突破 500 万,其中 80% 是逻辑删除数据。一次简单的分页查询响应时间从 50ms 暴涨到 1.2s,DBA 最终建议:“要么清理数据,要么换数据库。”
关联查询中的“幽灵数据”:你以为删了,其实还在
多表关联是逻辑删除的重灾区。
设想一个常见场景:订单(order)和订单项(order_item)。主表做了逻辑删除,但从表没跟上。这时执行:
SELECT o.id, oi.product_name FROM `order` o LEFT JOIN order_item oi ON o.id = oi.order_id WHERE o.user_id = 123;即使订单已被“删除”,它的订单项依然能被查出来——这不仅是数据冗余,更是潜在的信息泄露。
更复杂的情况出现在缓存层面。比如用户服务将用户信息缓存到 Redis,当执行逻辑删除时,缓存并未失效。其他服务(如订单、积分)继续基于旧缓存做判断,导致权限控制失效。
要解决这个问题,你得引入事件驱动机制:删除时发消息通知所有依赖方刷新缓存。但这又增加了系统间的耦合度,违背了微服务的设计初衷。
唯一约束冲突:删了也占着茅坑
这是我们在实际项目中最痛的一个点。
用户表中email字段有唯一索引。用户 A 注销账号后,其邮箱仍被deleted=1的记录占用。新用户 B 想注册相同邮箱,系统提示“邮箱已被使用”——合理吗?显然不合理。
你可能会说:“加个条件(email, deleted)联合唯一不就行了?” 可这样一来,允许多个deleted=1存在,意味着你可以反复“恢复”同一个用户,也可能导致状态混乱。
MySQL 8.0 支持函数索引,可以用:
CREATE UNIQUE INDEX uk_email_active ON user(email) WHERE deleted = 0;但如果你还在用 5.7 或更低版本,这条路走不通。而现实中,大量生产环境仍在使用老版本数据库。
最终我们不得不妥协:要么允许邮箱复用(破坏唯一性),要么让用户永远不能再注册原邮箱(破坏体验)。无论哪种选择,都是技术限制带来的业务妥协。
“删除”不该是一个通用状态
很多团队滥用逻辑删除的根源,在于把“删除”当成了万能状态开关。
实际上,“删除”在不同场景下含义完全不同:
- 用户主动注销 → 应进入待归档流程;
- 账号违规被封 → 实际是禁用,应设
status = 'LOCKED'; - 审核未通过 → 状态应为
'REJECTED'; - 数据迁移完成 → 才是真正意义上的归档或物理删除。
用一个deleted字段概括所有情况,等于抹杀了业务语义的差异性。久而久之,代码里到处都是if (!user.getDeleted()),却没人能说清“未删除”到底代表什么状态。
我们后来的做法是:彻底移除deleted字段,代之以明确的状态机:
ALTER TABLE user ADD COLUMN status ENUM('ACTIVE', 'LOCKED', 'PENDING_DELETION', 'ARCHIVED') DEFAULT 'ACTIVE';每个状态对应清晰的业务行为,权限控制、展示逻辑、API 访问都基于status判断,不再依赖“是否删除”。
我们的选择:回归物理删除 + 可追溯机制
经过多次事故复盘,我们决定关闭 MyBatisPlus 的逻辑删除功能,并推行一套新的数据生命周期管理方案:
1. 正常删除 → 物理删除 + 异步归档
-- 删除前先归档 INSERT INTO user_archive SELECT *, NOW() AS archived_at FROM user WHERE id = ?; -- 再执行物理删除 DELETE FROM user WHERE id = ?;归档表独立存储,保留 6~12 个月,支持按需恢复。
2. 删除操作纳入审批流
敏感操作(如删除核心用户)需走工单审批,触发企业微信/钉钉通知,防止误删。
3. 审计日志独立化
所有数据变更通过监听 Binlog 投递至 Kafka,写入 Elasticsearch,构建统一审计平台。支持按时间、操作人、字段变化进行检索。
4. 提供“软性撤销”能力
对于普通用户注销,设置 7 天冷静期。期间可在前端自助恢复,后台通过快照还原数据;超过期限则自动执行物理删除。
这套机制的核心思想是:真正的安全不是“删不掉”,而是“删了也能找回来”。
软删真的有必要吗?
回顾整个过程,我们发现启用逻辑删除的动机往往来自两个误区:
- 怕删错:认为物理删除等于“永久丢失”;
- 图省事:觉得加个字段比设计归档流程简单。
但现实是:
- 如果没有完善的备份与审计体系,逻辑删除照样“找不回”;
- 如果缺乏跨系统协同,软删反而制造更多不一致;
- 长期来看,数据膨胀带来的维护成本远高于一次规范的物理删除。
只有在极少数合规场景下,比如 GDPR 要求的“被遗忘权”,才需要延迟删除。即便如此,最佳实践也是“定时任务+物理清除”,而非长期保留标记数据。
结语
MyBatisPlus 的逻辑删除功能本身并无过错,它是工具,不是设计哲学。问题在于我们太容易被“自动化”迷惑,误以为加上一个注解就能解决复杂的业务问题。
软件工程的本质,从来不是“什么都不删”,而是“删了也能追溯,错了也能纠正”。
当我们放弃“靠不删来保安全”的思维定式,转而构建可靠的归档机制、清晰的状态模型和完整的审计链路时,才能真正实现既高效又安全的数据治理。
下次当你准备加上@TableLogic时,不妨先问一句:
我们是真的需要恢复数据,还是只是懒得设计恢复路径?