1. 逻辑删除的本质与业务场景
在数据库设计中,逻辑删除(Logical Delete)是一种常见的数据处理模式,它通过标记字段(如is_deleted)来标识记录是否被"删除",而不是真正从物理层面删除数据行。这种设计在MyBatis-Plus中有着广泛的应用场景:
- 数据审计需求:金融、医疗等行业需要完整保留操作痕迹,物理删除会导致历史追溯困难
- 关联数据保护:当记录被其他表外键引用时,物理删除会破坏数据完整性
- 误操作恢复:用户误删后可以快速恢复,避免数据永久丢失
- 业务连续性:某些业务需要统计"已删除"数据的聚合信息
在Spring Boot项目中,我们通常这样定义逻辑删除字段:
java复制@TableLogic
private Integer deleted; // 1-已删除 0-未删除
注意:逻辑删除字段的数据库默认值应设为0(未删除),否则新插入的记录会被误判为已删除状态
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MyBatis-Plus的全局逻辑删除配置
2.1 基础配置方式
在application.yml中配置全局逻辑删除规则:
yaml复制mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除字段名
logic-not-delete-value: 0 # 未删除值
logic-delete-value: 1 # 已删除值
这种配置会对所有Mapper生效,执行删除操作时自动转换为UPDATE语句。例如调用deleteById(1)实际执行的是:
sql复制UPDATE user SET deleted=1 WHERE id=1 AND deleted=0
2.2 多租户场景的特殊处理
在SAAS系统中,可能需要结合tenant_id进行逻辑删除:
java复制@Override
public int deleteById(Serializable id) {
// 先查询确保数据属于当前租户
T entity = getById(id);
if(entity == null || !entity.getTenantId().equals(currentTenantId())){
throw new BusinessException("数据不存在或权限不足");
}
return super.deleteById(id);
}
3. 查询时自动过滤已删除数据
3.1 基础查询过滤
配置逻辑删除后,普通查询会自动附加deleted=0条件:
java复制// 实际SQL: SELECT * FROM user WHERE age>18 AND deleted=0
userMapper.selectList(Wrappers.<User>lambdaQuery().gt(User::getAge, 18));
3.2 需要查询已删除数据的场景
有时我们需要查询包含已删除的记录,有几种实现方式:
方法1:使用SQL注入器
java复制@Select("select * from user ${ew.customSqlSegment}")
List<User> selectAllWithDeleted(@Param(Constants.WRAPPER) Wrapper<User> wrapper);
方法2:临时禁用逻辑删除
java复制// 方案1:使用Interceptor
SqlHelper.clearLogicDeleteInterceptor();
// 方案2:手动构造Wrapper
userMapper.selectList(Wrappers.<User>query()
.apply("deleted=1 or deleted=0"));
警告:禁用逻辑删除后务必在finally块中恢复设置,避免影响其他查询
4. 关联查询中的逻辑删除处理
4.1 一对一关联查询
假设User与Department是一对一关系,处理逻辑删除的关联查询:
java复制@Select("SELECT u.*, d.name as deptName FROM user u " +
"LEFT JOIN department d ON u.dept_id=d.id AND d.deleted=0 " +
"WHERE u.deleted=0 ${ew.customSqlSegment}")
List<UserVO> selectUserWithDept(@Param(Constants.WRAPPER) Wrapper<User> wrapper);
4.2 一对多关联查询
使用ResultMap处理一对多关系时,注意子表的逻辑删除过滤:
xml复制<resultMap id="userWithOrders" type="UserVO">
<collection property="orders" ofType="Order"
select="selectOrdersByUserId" column="id"/>
</resultMap>
<select id="selectOrdersByUserId" resultType="Order">
SELECT * FROM order
WHERE user_id=#{userId} AND deleted=0
</select>
5. 逻辑删除的性能优化策略
5.1 索引优化建议
逻辑删除字段必须建立索引:
sql复制ALTER TABLE user ADD INDEX idx_deleted (deleted);
对于高频查询的表,建议使用复合索引:
sql复制ALTER TABLE product ADD INDEX idx_status_deleted (status, deleted);
5.2 大数据量下的分页优化
当表中已删除数据占比很高时,常规分页查询会扫描大量无效记录。优化方案:
方案1:使用id分段查询
java复制Long maxId = userMapper.selectMaxId();
for(long i=0; i<maxId; i+=10000){
userMapper.selectList(Wrappers.<User>query()
.eq("deleted", 0)
.between("id", i, i+10000)
.last("LIMIT 1000"));
}
方案2:使用时间维度分区
sql复制-- 创建按月分区的表
CREATE TABLE user (
id BIGINT PRIMARY KEY,
...
deleted TINYINT DEFAULT 0,
create_time DATETIME
) PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202301 VALUES LESS THAN (TO_DAYS('2023-02-01')),
PARTITION p202302 VALUES LESS THAN (TO_DAYS('2023-03-01')),
...
);
6. 逻辑删除的常见问题与解决方案
6.1 唯一索引冲突问题
当需要保证某字段唯一时(如username),已删除记录可能导致唯一约束冲突。解决方案:
方案1:使用复合唯一索引
sql复制ALTER TABLE user ADD UNIQUE INDEX idx_username_deleted (username, deleted);
方案2:删除后置空
java复制@BeforeDelete
public void beforeDelete(User user) {
if(user.getUsername() != null){
user.setUsername(user.getUsername() + "_DEL_" + System.currentTimeMillis());
}
}
6.2 事务中的逻辑删除
在事务中混合逻辑删除和物理操作时需特别注意:
java复制@Transactional
public void transferData(Long sourceId, Long targetId) {
// 错误示例:先逻辑删除再物理插入可能导致唯一约束冲突
// userMapper.deleteById(sourceId);
// userMapper.insert(targetUser);
// 正确做法:使用临时表或批量操作
User source = userMapper.selectById(sourceId);
source.setDeleted(1);
userMapper.updateById(source);
User target = new User();
BeanUtils.copyProperties(source, target);
target.setId(null);
target.setDeleted(0);
userMapper.insert(target);
}
7. 逻辑删除的进阶应用
7.1 多状态删除标记
某些业务需要区分删除原因:
java复制@Getter
public enum DeleteStatus {
NORMAL(0, "正常"),
USER_DELETED(1, "用户删除"),
ADMIN_DELETED(2, "管理员删除"),
SYSTEM_DELETED(3, "系统删除");
private final int code;
private final String desc;
// ...
}
// 使用枚举作为删除标记
@TableLogic
private DeleteStatus deleted;
对应的查询方法:
java复制public List<User> selectActiveUsers() {
return userMapper.selectList(Wrappers.<User>query()
.eq("deleted", DeleteStatus.NORMAL.getCode()));
}
7.2 逻辑删除与审计日志的结合
通过MyBatis-Plus的MetaObjectHandler实现自动审计:
java复制@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void updateFill(MetaObject metaObject) {
// 逻辑删除时记录删除人和删除时间
Object deleted = metaObject.getValue("deleted");
if(deleted != null && (Integer)deleted > 0){
this.strictUpdateFill(metaObject, "deleteBy", String.class, currentUser());
this.strictUpdateFill(metaObject, "deleteTime", LocalDateTime.class, LocalDateTime.now());
}
}
}
8. 实际项目中的经验总结
在电商项目中处理商品逻辑删除时,我们遇到过几个典型问题:
-
缓存一致性问题:逻辑删除后缓存未及时清除
- 解决方案:在Mapper层添加缓存清除切面
java复制@Around("execution(* com..mapper.*.delete*(..))") public Object clearCache(ProceedingJoinPoint pjp) { Object result = pjp.proceed(); // 清除相关缓存 cacheManager.evictByPattern("product:*"); return result; } -
统计报表偏差:未区分活跃数据和历史数据
- 解决方案:在统计SQL中显式包含deleted条件
sql复制-- 错误:SELECT COUNT(*) FROM product WHERE category_id=1 -- 正确:SELECT COUNT(*) FROM product WHERE category_id=1 AND deleted=0 -
批量操作性能问题:大批量逻辑删除导致锁表
- 优化方案:使用分批处理
java复制public void batchDelete(List<Long> ids) { Lists.partition(ids, 1000).forEach(batch -> { userMapper.update( new User().setDeleted(1), Wrappers.<User>lambdaUpdate().in(User::getId, batch) ); }); }
逻辑删除虽然带来了诸多便利,但也增加了系统复杂度。建议在项目初期就规划好删除策略,建立统一的处理规范,避免后期出现数据混乱。对于特别重要的核心业务数据,可以考虑采用"逻辑删除+归档"的双重机制,定期将已删除数据迁移到历史表。
