从删库到跑路?MyBatis3逻辑删除实战:优雅规避数据灾难
从删库到跑路?MyBatis3 逻辑删除实战:优雅规避数据灾难
在软件开发中,“删除” 操作看似简单,却暗藏玄机。误删数据导致的生产事故屡见不鲜,轻则业务中断,重则造成不可挽回的损失。逻辑删除作为一种数据保护机制,通过标记而非物理删除数据,既能满足业务 “删除” 需求,又能保留数据回溯的可能。本文将深入剖析 MyBatis3 实现逻辑删除的完整方案,从原理到实战,从基础到进阶,带你掌握这一关键技术,让数据操作更安全、更可控。
一、为什么需要逻辑删除?—— 从数据安全说起
在传统的物理删除模式中,执行DELETE
语句后数据会从数据库中永久消失。这种方式看似高效,却存在诸多隐患:
-
数据恢复困难:一旦误删,只能通过备份恢复,耗时费力且可能丢失最新数据。
-
业务追溯断层:许多业务场景需要查询历史数据(如订单删除后仍需查看退款记录),物理删除会导致数据链条断裂。
-
关联数据异常:删除主表数据后,从表的关联数据可能变成 “孤儿数据”,引发查询异常。
-
合规风险:金融、医疗等行业有严格的数据留存法规,物理删除可能违反合规要求。
逻辑删除的核心思想是:不真正删除数据,而是通过一个状态字段标记数据的删除状态。当需要 “删除” 数据时,仅更新该状态字段;查询数据时,自动过滤已标记为删除的记录。这种方式既保留了数据的可恢复性,又不影响正常业务查询,成为企业级应用的标配方案。
二、逻辑删除核心原理与设计规范
2.1 核心原理拆解
逻辑删除的实现依赖三个关键环节:
-
状态字段设计:在数据表中添加一个用于标记删除状态的字段(如
deleted
)。 -
更新操作改造:将
DELETE
语句改为UPDATE
语句,仅更新删除状态字段。 -
查询条件拦截:在所有查询语句中自动添加 “未删除” 条件,过滤已标记的数据。
以用户表为例,物理删除的 SQL 是:
DELETE FROM sys\_user WHERE id = 1;
而逻辑删除的 SQL 则变为:
UPDATE sys\_user SET deleted = 1 WHERE id = 1;
查询时自动附加条件:
SELECT \* FROM sys\_user WHERE deleted = 0;
2.2 数据库表设计规范
实现逻辑删除前,需先规范数据表设计,核心是定义删除状态字段。以下是推荐的设计标准:
字段名 | 类型 | 含义 | 未删除值 | 删除值 | 备注 |
---|---|---|---|---|---|
deleted | tinyint(1) | 逻辑删除标记 | 0 | 1 | 最常用方案,占用空间小 |
delete_flag | bit(1) | 逻辑删除标记 | 0 | 1 | 节省存储空间,适合大数据量场景 |
delete_time | datetime | 删除时间 | NULL | 具体时间 | 可记录删除时间,兼具标记功能 |
以deleted
字段为例,创建表的 SQL 示例:
CREATE TABLE \`sys\_user\` (  \`id\` bigint(20) NOT NULL AUTO\_INCREMENT COMMENT '用户ID',  \`username\` varchar(50) NOT NULL COMMENT '用户名',  \`password\` varchar(100) NOT NULL COMMENT '密码',  \`deleted\` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0-未删除,1-已删除)',  \`create\_time\` datetime NOT NULL DEFAULT CURRENT\_TIMESTAMP COMMENT '创建时间',  PRIMARY KEY (\`id\`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2.3 实体类设计规范
在 Java 实体类中,需对应数据库的删除状态字段,并建议添加注释说明:
import lombok.Data;@Datapublic class User {  private Long id;  private String username;  private String password;     /\*\*  \* 逻辑删除标记  \* 0-未删除,1-已删除  \*/  private Integer deleted;  private LocalDateTime createTime;}
三、MyBatis3 实现逻辑删除的三种方案
MyBatis3 作为主流的 ORM 框架,提供了多种实现逻辑删除的方式。以下将详细介绍三种常用方案,涵盖 XML 配置、注解及通用工具整合,满足不同项目场景需求。
3.1 方案一:XML 映射文件手动实现(基础方案)
这是最直接的实现方式,通过手动编写 SQL 语句实现逻辑删除,适合对 MyBatis 底层操作熟悉的开发者。
3.1.1 步骤 1:定义 Mapper 接口方法
在 Mapper 接口中定义删除和查询方法:
import org.apache.ibatis.annotations.Param;import java.util.List;public interface UserMapper {  /\*\*  \* 逻辑删除用户  \* @param id 用户ID  \* @return 影响行数  \*/  int logicalDeleteById(@Param("id") Long id);     /\*\*  \* 查询未删除的用户列表  \* @return 用户列表  \*/  List\<User> selectNotDeletedList();     /\*\*  \* 根据ID查询未删除的用户  \* @param id 用户ID  \* @return 用户信息  \*/  User selectNotDeletedById(@Param("id") Long id);}
3.1.2 步骤 2:编写 XML 映射文件
在 XML 文件中编写对应的 SQL 语句,重点是将删除改为更新操作,并在查询中添加过滤条件:
\<?xml version="1.0" encoding="UTF-8"?>\<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">\<mapper namespace="com.example.mapper.UserMapper">     \<!-- 逻辑删除:更新deleted字段为1 -->  \<update id="logicalDeleteById">  UPDATE sys\_user  SET deleted = 1,   update\_time = NOW()  WHERE id = #{id}  AND deleted = 0 \<!-- 防止重复删除 -->  \</update>     \<!-- 查询未删除用户列表:过滤deleted=0的记录 -->  \<select id="selectNotDeletedList" resultType="com.example.entity.User">  SELECT id, username, password, deleted, create\_time  FROM sys\_user  WHERE deleted = 0  ORDER BY create\_time DESC  \</select>     \<!-- 根据ID查询未删除用户 -->  \<select id="selectNotDeletedById" resultType="com.example.entity.User">  SELECT id, username, password, deleted, create\_time  FROM sys\_user  WHERE id = #{id}  AND deleted = 0 \<!-- 仅查询未删除记录 -->  \</select>\</mapper>
3.1.3 步骤 3:Service 层调用
在 Service 中调用 Mapper 方法,实现业务逻辑:
import org.springframework.stereotype.Service;import javax.annotation.Resource;import java.util.List;@Servicepublic class UserService {  @Resource  private UserMapper userMapper;     /\*\*  \* 逻辑删除用户  \*/  public boolean deleteUser(Long id) {  if (id == null) {  throw new IllegalArgumentException("用户ID不能为空");  }  // 先查询用户是否存在且未删除  User user = userMapper.selectNotDeletedById(id);  if (user == null) {  throw new RuntimeException("用户不存在或已删除");  }  // 执行逻辑删除  int rows = userMapper.logicalDeleteById(id);  return rows > 0;  }     /\*\*  \* 获取未删除用户列表  \*/  public List\<User> getUserList() {  return userMapper.selectNotDeletedList();  }}
3.1.4 方案优缺点分析
-
优点:实现简单直接,SQL 语句完全可控,适合复杂业务场景。
-
缺点:需手动编写所有 SQL,重复劳动多,易遗漏查询条件导致数据泄露。
3.2 方案二:注解方式实现(简化方案)
MyBatis 支持通过注解编写 SQL,对于简单的逻辑删除场景,可以采用注解方式减少 XML 配置。
3.2.1 步骤 1:定义 Mapper 接口(注解版)
直接在接口方法上使用@Update
和@Select
注解编写 SQL:
import org.apache.ibatis.annotations.Delete;import org.apache.ibatis.annotations.Select;import org.apache.ibatis.annotations.Update;import java.util.List;public interface UserAnnotationMapper {     /\*\*  \* 逻辑删除用户(注解版)  \*/  @Update("UPDATE sys\_user SET deleted = 1, update\_time = NOW() WHERE id = #{id} AND deleted = 0")  int logicalDeleteById(Long id);     /\*\*  \* 查询未删除用户(注解版)  \*/  @Select("SELECT id, username, password, deleted, create\_time FROM sys\_user WHERE deleted = 0")  List\<User> selectNotDeletedList();     /\*\*  \* 根据ID查询未删除用户(注解版)  \*/  @Select("SELECT id, username, password, deleted, create\_time FROM sys\_user WHERE id = #{id} AND deleted = 0")  User selectNotDeletedById(Long id);}
3.2.2 步骤 2:Service 层调用(与 XML 方案一致)
@Servicepublic class UserAnnotationService {  @Resource  private UserAnnotationMapper userAnnotationMapper;     public boolean deleteUser(Long id) {  // 逻辑与XML方案相同  User user = userAnnotationMapper.selectNotDeletedById(id);  if (user == null) {  throw new RuntimeException("用户不存在或已删除");  }  return userAnnotationMapper.logicalDeleteById(id) > 0;  }}
3.2.3 方案优缺点分析
-
优点:无需编写 XML 文件,代码更集中,适合简单 SQL 场景。
-
缺点:复杂 SQL 在注解中可读性差,同样存在重复编写条件的问题。
3.3 方案三:通用 Mapper 整合(企业级方案)
对于中大型项目,推荐使用通用 Mapper(如 MyBatis-Plus)实现逻辑删除,通过全局配置和拦截器自动处理删除标记,减少重复代码。
3.3.1 步骤 1:引入 MyBatis-Plus 依赖
在pom.xml
中添加依赖(以 Spring Boot 为例):
\<!-- MyBatis-Plus核心依赖 -->\<dependency>  \<groupId>com.baomidou\</groupId>  \<artifactId>mybatis-plus-boot-starter\</artifactId>  \<version>3.5.3.1\</version>\</dependency>\<!-- 数据库驱动 -->\<dependency>  \<groupId>com.mysql\</groupId>  \<artifactId>mysql-connector-j\</artifactId>  \<scope>runtime\</scope>\</dependency>
3.3.2 步骤 2:配置逻辑删除(application.yml)
通过配置文件全局定义逻辑删除的字段名和值:
mybatis-plus:  global-config:  db-config:  \# 逻辑删除字段名  logic-delete-field: deleted  \# 逻辑未删除值(默认为0)  logic-not-delete-value: 0  \# 逻辑已删除值(默认为1)  logic-delete-value: 1  configuration:  \# 开启日志,方便调试  log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
3.3.3 步骤 3:定义实体类(添加注解)
在实体类的删除字段上添加@TableLogic
注解,标记为逻辑删除字段:
import com.baomidou.mybatisplus.annotation.\*;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("sys\_user")public class User {  @TableId(type = IdType.AUTO)  private Long id;  private String username;  private String password;     /\*\*  \* 逻辑删除标记  \* 0-未删除,1-已删除  \*/  @TableLogic  private Integer deleted;     // 自动填充创建时间  @TableField(fill = FieldFill.INSERT)  private LocalDateTime createTime;     // 自动填充更新时间  @TableField(fill = FieldFill.INSERT\_UPDATE)  private LocalDateTime updateTime;}
3.3.4 步骤 4:定义 Mapper 接口(继承 BaseMapper)
无需手动编写删除和查询方法,直接继承 MyBatis-Plus 的BaseMapper
:
import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.entity.User;public interface UserPlusMapper extends BaseMapper\<User> {  // 无需编写额外方法,BaseMapper已提供CRUD操作}
3.3.5 步骤 5:Service 层调用(使用内置方法)
MyBatis-Plus 的IService
接口提供了丰富的内置方法,自动支持逻辑删除:
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.example.entity.User;import com.example.mapper.UserPlusMapper;import org.springframework.stereotype.Service;import java.util.List;@Servicepublic class UserPlusService extends ServiceImpl\<UserPlusMapper, User> {     /\*\*  \* 逻辑删除用户(直接调用内置方法)  \*/  public boolean deleteUser(Long id) {  // removeById方法会自动执行逻辑删除  return removeById(id);  }     /\*\*  \* 查询未删除用户列表(自动过滤已删除记录)  \*/  public List\<User> getUserList() {  // list方法会自动添加deleted=0条件  return list();  }     /\*\*  \* 根据ID查询用户(自动过滤已删除记录)  \*/  public User getUserById(Long id) {  // getById方法会自动添加deleted=0条件  return getById(id);  }}
3.3.6 原理揭秘:MyBatis-Plus 的逻辑删除拦截器
MyBatis-Plus 通过LogicDeleteInterceptor
拦截器实现逻辑删除的自动处理:
-
删除拦截:将
delete
方法拦截,转换为update
语句更新deleted
字段。 -
查询拦截:在
select
语句后自动添加WHERE deleted = 0
条件。 -
更新拦截:在
update
语句中自动添加WHERE deleted = 0
条件,防止更新已删除记录。
通过日志可以看到实际执行的 SQL:
\-- 调用removeById(1)时执行的SQLUPDATE sys\_user SET deleted=1 WHERE id=1 AND deleted=0\-- 调用list()时执行的SQLSELECT id,username,password,deleted,create\_time,update\_time FROM sys\_user WHERE deleted=0\-- 调用getById(1)时执行的SQLSELECT id,username,password,deleted,create\_time,update\_time FROM sys\_user WHERE id=1 AND deleted=0
3.3.7 方案优缺点分析
-
优点:全局配置,自动处理所有 CRUD 操作,无需手动编写 SQL,减少重复劳动和出错概率。
-
缺点:需要学习 MyBatis-Plus 的使用规范,对原生 MyBatis 有一定封装,自定义 SQL 时需注意兼容逻辑删除。
四、实战案例:完整实现一个区域管理系统的逻辑删除
以下将通过一个区域管理系统的实战案例,完整展示 MyBatis-Plus 实现逻辑删除的全过程,包括数据库设计、代码实现、接口测试等环节。
4.1 需求分析
实现一个区域管理系统,支持区域的新增、查询、更新和删除操作,其中删除操作需采用逻辑删除,并支持级联删除子区域。
4.2 数据库设计
创建区域表region
,包含层级关系和逻辑删除字段:
CREATE TABLE \`region\` (  \`id\` bigint(20) NOT NULL AUTO\_INCREMENT COMMENT '区域ID',  \`parent\_id\` bigint(20) DEFAULT 0 COMMENT '父区域ID(0表示顶级区域)',  \`region\_name\` varchar(100) NOT NULL COMMENT '区域名称',  \`level\` tinyint(1) NOT NULL COMMENT '层级(1:一级,2:二级...)',  \`deleted\` tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0-未删除,1-已删除)',  \`create\_time\` datetime NOT NULL DEFAULT CURRENT\_TIMESTAMP COMMENT '创建时间',  \`update\_time\` datetime DEFAULT NULL ON UPDATE CURRENT\_TIMESTAMP COMMENT '更新时间',  PRIMARY KEY (\`id\`),  KEY \`idx\_parent\_id\` (\`parent\_id\`),  KEY \`idx\_deleted\` (\`deleted\`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='区域表';
4.3 项目结构
com.example.region├── RegionApplication.java // 启动类├── config // 配置类│ └── MyBatisPlusConfig.java // MyBatis-Plus配置├── entity // 实体类│ └── Region.java // 区域实体├── mapper // Mapper接口│ └── RegionMapper.java // 区域Mapper├── service // Service层│ ├── RegionService.java // 区域Service接口│ └── impl│ └── RegionServiceImpl.java // Service实现└── controller // Controller层  └── RegionController.java // 区域控制器
4.4 核心代码实现
4.4.1 实体类 Region.java
import com.baomidou.mybatisplus.annotation.\*;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("region")@Schema(description = "区域实体类")public class Region {  @TableId(type = IdType.AUTO)  @Schema(description = "区域ID")  private Long id;     @Schema(description = "父区域ID(0表示顶级区域)")  private Long parentId;     @Schema(description = "区域名称")  private String regionName;     @Schema(description = "层级(1:一级,2:二级...)")  private Integer level;     /\*\*  \* 逻辑删除标记  \* 0-未删除,1-已删除  \*/  @TableLogic  @Schema(description = "逻辑删除标记", hidden = true)  private Integer deleted;     @TableField(fill = FieldFill.INSERT)  @Schema(description = "创建时间", hidden = true)  private LocalDateTime createTime;     @TableField(fill = FieldFill.INSERT\_UPDATE)  @Schema(description = "更新时间", hidden = true)  private LocalDateTime updateTime;}
4.4.2 Mapper 接口 RegionMapper.java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.entity.Region;import org.apache.ibatis.annotations.Select;import java.util.List;public interface RegionMapper extends BaseMapper\<Region> {  /\*\*  \* 根据父ID查询子区域(MyBatis-Plus会自动添加deleted=0条件)  \*/  @Select("SELECT \* FROM region WHERE parent\_id = #{parentId}")  List\<Region> selectChildrenByParentId(Long parentId);}
4.4.3 Service 接口与实现
// RegionService.javaimport com.baomidou.mybatisplus.extension.service.IService;import com.example.entity.Region;import java.util.List;public interface RegionService extends IService\<Region> {  /\*\*  \* 级联逻辑删除区域(含子区域)  \* @param id 区域ID  \* @return 是否删除成功  \*/  boolean cascadeDelete(Long id);     /\*\*  \* 查询区域树形结构  \* @return 区域树列表  \*/  List\<Region> getRegionTree();}// RegionServiceImpl.javaimport com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.example.entity.Region;import com.example.mapper.RegionMapper;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.util.List;@Servicepublic class RegionServiceImpl extends ServiceImpl\<RegionMapper, Region> implements RegionService {  @Override  @Transactional(rollbackFor = Exception.class)  public boolean cascadeDelete(Long id) {  // 1. 查询当前区域是否存在  Region region = getById(id);  if (region == null) {  return false;  }     // 2. 递归删除所有子区域  deleteChildren(id);     // 3. 删除当前区域(逻辑删除)  return removeById(id);  }     /\*\*  \* 递归删除子区域  \*/  private void deleteChildren(Long parentId) {  List\<Region> children = baseMapper.selectChildrenByParentId(parentId);  if (children != null && !children.isEmpty()) {  for (Region child : children) {  // 递归删除子区域的子节点  deleteChildren(child.getId());  // 删除当前子区域  removeById(child.getId());  }  }  }     @Override  public List\<Region> getRegionTree() {  // 查询所有顶级区域(parent\_id=0)  QueryWrapper\<Region> queryWrapper = new QueryWrapper<>();  queryWrapper.eq("parent\_id", 0);  return baseMapper.selectList(queryWrapper);  }}
4.4.4 Controller 层实现(带 Swagger 文档)
import com.example.entity.Region;import com.example.service.RegionService;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.tags.Tag;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.\*;import java.util.List;@RestController@RequestMapping("/api/regions")@Tag(name = "区域管理", description = "区域CRUD接口(含逻辑删除)")public class RegionController {  private final RegionService regionService;     // 构造方法注入  public RegionController(RegionService regionService) {  this.regionService = regionService;  }     @PostMapping  @Operation(summary = "新增区域")  public ResponseEntity\<Boolean> addRegion(@RequestBody Region region) {  return ResponseEntity.ok(regionService.save(region));  }     @GetMapping  @Operation(summary = "查询所有未删除区域")  public ResponseEntity\<List\<Region>> getAllRegions() {  return ResponseEntity.ok(regionService.list());  }     @GetMapping("/{id}")  @Operation(summary = "根据ID查询区域")  public ResponseEntity\<Region> getRegionById(  @Parameter(description = "区域ID", required = true)  @PathVariable Long id) {  return ResponseEntity.ok(regionService.getById(id));  }     @DeleteMapping("/{id}")  @Operation(summary = "级联逻辑删除区域")  public ResponseEntity\<Boolean> deleteRegion(  @Parameter(description = "区域ID", required = true)  @PathVariable Long id) {  return ResponseEntity.ok(regionService.cascadeDelete(id));  }     @GetMapping("/tree")  @Operation(summary = "查询区域树形结构")  public ResponseEntity\<List\<Region>> getRegionTree() {  return ResponseEntity.ok(regionService.getRegionTree());  }}
4.5 测试验证
通过 Postman 或 Swagger 文档测试接口,验证逻辑删除效果:
-
新增区域:发送 POST 请求
/api/regions
,添加顶级区域和子区域。 -
查询区域:发送 GET 请求
/api/regions
,返回所有未删除区域。 -
删除区域:发送 DELETE 请求
/api/regions/{id}
,执行逻辑删除。 -
验证删除:再次查询该区域,返回结果为 null;查询数据库,
deleted
字段变为 1。 -
级联删除测试:删除顶级区域,检查其所有子区域的
deleted
字段是否均变为 1。
五、进阶技巧:让逻辑删除更高效、更安全
5.1 级联逻辑删除的实现方案
在树形结构(如区域、菜单)中,删除父节点时需级联删除所有子节点。除了上述案例中的递归删除,还可采用以下优化方案:
5.1.1 方案一:使用数据库存储过程
通过存储过程批量更新子节点,减少 Java 代码中的递归调用:
\-- 创建级联逻辑删除存储过程DELIMITER \$\$CREATE PROCEDURE \`cascade\_logic\_delete\_region\`(IN parentId BIGINT)BEGIN  \-- 声明变量  DECLARE done INT DEFAULT 0;  DECLARE childId BIGINT;  \-- 定义游标  DECLARE childCursor CURSOR FOR   SELECT id FROM region WHERE parent\_id = parentId AND deleted = 0;  \-- 定义异常处理  DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;     \-- 递归处理子节点  OPEN childCursor;  read\_loop: LOOP  FETCH childCursor INTO childId;  IF done THEN  LEAVE read\_loop;  END IF;  \-- 递归调用存储过程处理子节点的子节点  CALL cascade\_logic\_delete\_region(childId);  END LOOP;  CLOSE childCursor;     \-- 更新当前节点的删除状态  UPDATE region SET deleted = 1 WHERE id = parentId;END\$\$DELIMITER ;\-- 调用存储过程删除ID=1的区域及其子区域CALL cascade\_logic\_delete\_region(1);
在 MyBatis 中调用存储过程:
\<update id="cascadeDeleteByProcedure">  CALL cascade\_logic\_delete\_region(#{id})\</update>
5.1.2 方案二:使用 MyBatis-Plus 的批量更新
通过updateBatchById
方法批量更新子节点,减少 SQL 执行次数:
@Override@Transactional(rollbackFor = Exception.class)public boolean cascadeDelete(Long id) {  // 1. 查询所有子节点ID(递归查询)  List\<Long> allChildIds = getAllChildIds(id);  allChildIds.add(id); // 包含当前节点     // 2. 批量更新删除状态  List\<Region> updateList = allChildIds.stream().map(childId -> {  Region region = new Region();  region.setId(childId);  region.setDeleted(1); // 逻辑删除值  return region;  }).collect(Collectors.toList());     // 3. 批量更新  return updateBatchById(updateList);}/\*\* \* 递归查询所有子节点ID \*/private List\<Long> getAllChildIds(Long parentId) {  List\<Region> children = baseMapper.selectChildrenByParentId(parentId);  if (children.isEmpty()) {  return new ArrayList<>();  }  List\<Long> childIds = new ArrayList<>();  for (Region child : children) {  childIds.add(child.getId());  // 递归添加子节点的子节点  childIds.addAll(getAllChildIds(child.getId()));  }  return childIds;}
5.2 逻辑删除与索引优化
逻辑删除会导致表中存在大量 “已删除” 数据,影响查询性能。需通过索引优化提升查询效率:
- 创建联合索引:将
deleted
字段与常用查询条件创建联合索引,如:
\-- 为区域表创建parent\_id+deleted联合索引CREATE INDEX idx\_parent\_deleted ON region(parent\_id, deleted);
- 避免全表扫描:确保查询条件中包含
deleted
字段,MyBatis-Plus 的内置方法已自动处理,但自定义 SQL 需注意:
\<!-- 错误示例:未包含deleted条件,可能导致全表扫描 -->\<select id="selectByParentId" resultType="Region">  SELECT \* FROM region WHERE parent\_id = #{parentId}\</select>\<!-- 正确示例:包含deleted条件,使用联合索引 -->\<select id="selectByParentId" resultType="Region">  SELECT \* FROM region WHERE parent\_id = #{parentId} AND deleted = 0\</select>
- 定期归档清理:对于数据量极大的表,可定期将已删除数据归档到历史表,减少主表数据量:
\-- 创建历史表CREATE TABLE \`region\_history\` LIKE \`region\`;\-- 归档已删除数据INSERT INTO region\_history SELECT \* FROM region WHERE deleted = 1 AND update\_time < DATE\_SUB(NOW(), INTERVAL 1 YEAR);\-- 删除主表中已归档的数据(物理删除)DELETE FROM region WHERE deleted = 1 AND update\_time < DATE\_SUB(NOW(), INTERVAL 1 YEAR);
5.3 逻辑删除与数据审计
结合审计日志记录删除操作,便于追踪和回溯:
- 添加审计字段:在表中增加删除人、删除时间字段:
ALTER TABLE region ADD COLUMN \`delete\_by\` bigint(20) DEFAULT NULL COMMENT '删除人',ADD COLUMN \`delete\_time\` datetime DEFAULT NULL COMMENT '删除时间';
- 实体类添加对应字段:
@TableField(fill = FieldFill.UPDATE) // 删除时自动填充private Long deleteBy;@TableField(fill = FieldFill.UPDATE)private LocalDateTime deleteTime;
- 实现自动填充处理器:
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;import org.apache.ibatis.reflection.MetaObject;import org.springframework.stereotype.Component;import java.time.LocalDateTime;@Componentpublic class MyMetaObjectHandler implements MetaObjectHandler {  @Override  public void insertFill(MetaObject metaObject) {  // 自动填充创建时间  this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());  }     @Override  public void updateFill(MetaObject metaObject) {  // 自动填充更新时间  this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());     // 判断是否为逻辑删除操作(deleted字段被更新为1)  Object deleted = metaObject.getValue("deleted");  if (deleted != null && deleted.equals(1)) {  // 填充删除人(实际应从当前登录用户获取)  this.strictUpdateFill(metaObject, "deleteBy", Long.class, 1L);  // 填充删除时间  this.strictUpdateFill(metaObject, "deleteTime", LocalDateTime.class, LocalDateTime.now());  }  }}
5.4 逻辑删除的数据恢复方案
当误删数据时,可通过以下方式恢复:
- 单条数据恢复:直接更新
deleted
字段为 0:
/\*\* \* 恢复逻辑删除的数据 \*/public boolean recover(Long id) {  Region region = new Region();  region.setId(id);  region.setDeleted(0); // 恢复为未删除状态  return updateById(region);}
- 批量恢复子区域:类似级联删除,递归恢复子区域:
@Transactional(rollbackFor = Exception.class)public boolean cascadeRecover(Long id) {  // 恢复当前区域  Region region = new Region();  region.setId(id);  region.setDeleted(0);  updateById(region);     // 递归恢复子区域  List\<Region> children = baseMapper.selectChildrenByParentId(id);  for (Region child : children) {  cascadeRecover(child.getId());  }  return true;}
六、常见问题与避坑指南
6.1 问题 1:查询时仍能查到已删除数据
可能原因:
-
自定义 SQL 未添加
deleted = 0
条件。 -
MyBatis-Plus 的拦截器未生效(如配置错误)。
-
实体类未添加
@TableLogic
注解。
解决方案:
-
检查自定义 SQL,确保包含删除条件。
-
验证 MyBatis-Plus 配置,确认
logic-delete-field
正确。 -
在删除字段上添加
@TableLogic
注解。
6.2 问题 2:级联删除时子区域未被删除
可能原因:
-
递归逻辑错误,未正确获取所有子节点。
-
事务未生效,部分删除操作失败后未回滚。
-
子区域查询 SQL 未过滤已删除节点,导致重复处理。
解决方案:
-
调试递归方法,确保所有子节点 ID 被正确收集。
-
添加
@Transactional
注解,确保事务完整性。 -
查询子区域时添加
deleted = 0
条件(MyBatis-Plus 自动处理)。
6.3 问题 3:逻辑删除与唯一索引冲突
场景:表中存在唯一索引(如username
),逻辑删除后无法添加同名用户。
原因:逻辑删除的记录仍存在于表中,唯一索引会阻止重复值插入。
解决方案:
- 将
deleted
字段加入唯一索引,如:
\-- 创建包含deleted的唯一索引CREATE UNIQUE INDEX uk\_username\_deleted ON sys\_user(username, deleted);
- 这样,
username
相同但deleted
不同的记录可以共存(未删除记录的deleted=0
,已删除的deleted=1
)。
6.4 问题 4:性能下降,查询变慢
可能原因:
-
表中已删除数据过多,导致索引失效。
-
未创建合适的联合索引,查询走全表扫描。
-
递归查询子区域时产生过多 SQL 调用。
解决方案:
-
定期归档已删除数据,减少主表数据量。
-
优化索引设计,添加
deleted
字段到常用索引。 -
使用批量查询替代递归查询,减少数据库交互。
6.5 问题 5:数据迁移时逻辑删除字段处理
场景:迁移数据到新库时,需保留逻辑删除状态。
解决方案:
-
迁移脚本中明确处理
deleted
字段,避免默认值覆盖。 -
迁移后验证数据,确保未删除记录的
deleted
值正确。 -
示例迁移 SQL:
\-- 从旧表迁移数据到新表,保留deleted状态INSERT INTO new\_region (id, parent\_id, region\_name, level, deleted)SELECT id, parent\_id, region\_name, level, deleted FROM old\_region;
七、总结与展望
逻辑删除作为保障数据安全的重要手段,在企业级应用中不可或缺。本文从原理到实战,详细讲解了 MyBatis3 实现逻辑删除的三种方案,通过完整案例展示了逻辑删除的核心流程,并提供了级联删除、性能优化、数据恢复等进阶技巧。
随着业务复杂度的提升,逻辑删除还可与以下技术结合:
-
数据权限控制:在逻辑删除基础上,结合用户权限过滤数据。
-
软删除与硬删除结合:对超期数据执行物理删除,平衡性能与安全。
-
分布式事务:在微服务架构中,通过 Seata 等框架保证跨服务逻辑删除的一致性。
掌握逻辑删除不仅是技术需求,更是数据安全意识的体现。希望本文能帮助你在实际项目中优雅地实现逻辑删除,规避数据风险,让系统更健壮、更可靠。
最后,记住一句话:在数据领域,“删除” 永远应该是可逆的操作,除非你 100% 确定再也不需要这些数据。逻辑删除,正是这种理念的最佳实践。
(注:文档部分内容可能由 AI 生成)