MyBatis-Plus 逻辑删除导致唯一索引冲突的解决方案

MyBatis-Plus 逻辑删除导致唯一索引冲突的解决方案

前言

最近在开发一个 Spring Boot 记账系统时,遇到了一个关于 MyBatis-Plus 逻辑删除的坑:用户删除某个类型后,再次添加同名类型时提示"数据重复"。明明前端查询不到这个类型,为什么还会冲突呢?

经过一番排查,发现是 MyBatis-Plus 逻辑删除机制与数据库唯一索引的冲突导致的。本文将详细分析问题原因,并提供完整的解决方案。


一、问题复现

1.1 业务场景

在记账系统中,用户可以自定义收支类型(如"餐饮"、"交通"、"还钱"等)。为了防止误删,我使用了 MyBatis-Plus 的逻辑删除功能。

操作步骤

  1. 用户添加"还钱(收入)"类型
  2. 用户删除"还钱(收入)"类型
  3. 用户再次添加"还钱(收入)"类型 提示:数据重复,请检查输入

1.2 详细复现过程

让我们看看数据库中到底发生了什么:

第一步:添加类型

java 复制代码
// 用户点击"添加类型",输入"还钱",选择"收入"
POST /billType/add
{
  "typeName": "还钱",
  "billFlag": 1,  // 1表示收入
  "userId": 1
}

数据库插入成功,此时 bill_type 表中的数据:

tex 复制代码
bill_type_id | type_name | bill_flag | user_id | deleted | create_time
-------------|-----------|-----------|---------|---------|------------------
1            | 还钱      | 1         | 1       | 0       | 2026-01-15 10:00:00

第二步:删除类型

java 复制代码
// 用户点击"删除"按钮
DELETE /billType/delete/1

由于使用了逻辑删除,数据库执行的是 UPDATE 而不是 DELETE:

sql 复制代码
-- MyBatis-Plus 自动生成的 SQL
UPDATE bill_type SET deleted = 1 WHERE bill_type_id = 1

此时数据库中的数据:

tex 复制代码
bill_type_id | type_name | bill_flag | user_id | deleted | create_time
-------------|-----------|-----------|---------|---------|------------------
1            | 还钱       | 1         | 1       | 1       | 2024-01-15 10:00:00
                                                 ↑ 变成了1,表示已删除

第三步:再次添加同名类型

java 复制代码
// 用户再次点击"添加类型",输入"还钱",选择"收入"
POST /billType/add
{
  "typeName": "还钱",
  "billFlag": 1,
  "userId": 1
}

后端先检查是否存在同名类型:

java 复制代码
// Controller 层的检查逻辑
BillType existType = billTypeService.getTypeByNameAndFlag("还钱", 1, 1);
// MyBatis-Plus 自动生成的 SQL:
// SELECT * FROM bill_type 
// WHERE type_name='还钱' AND bill_flag=1 AND user_id=1 AND deleted=0
//                                                              ↑ 自动添加
// 查询结果:null(因为 deleted=1 的数据被过滤了)

检查通过,继续插入:

java 复制代码
billTypeMapper.insert(billType);
// 执行 SQL:
// INSERT INTO bill_type (type_name, bill_flag, user_id, deleted) 
// VALUES ('还钱', 1, 1, 0)

💥 报错了!

java 复制代码
java.sql.SQLIntegrityConstraintViolationException: 
Duplicate entry '1-还钱-1' for key 'idx_user_type_flag'

为什么会报错?

因为数据库中已经存在一条记录:

java 复制代码
user_id=1, type_name='还钱', bill_flag=1

虽然这条记录的 deleted=1(已删除),但唯一索引 idx_user_type_flag 对所有数据生效 ,不管 deleted 是 0 还是 1。

1.3 预期 vs 实际

  • 预期行为:删除后应该可以重新添加同名类型(因为前端查询不到这个类型了)
  • 实际行为:提示"数据重复,请检查输入"(因为数据库中还存在这条记录)
  • 用户困惑:"我明明删除了,为什么还说重复?"

二、问题分析

2.1 数据库表结构

sql 复制代码
CREATE TABLE `bill_type` (
  `bill_type_id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `type_name` VARCHAR(50) NOT NULL COMMENT '类型名称',
  `bill_flag` TINYINT NOT NULL COMMENT '0=支出,1=收入',
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
  `version` INT DEFAULT 0 COMMENT '乐观锁版本号',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY `idx_user_type_flag` (`user_id`, `type_name`, `bill_flag`)#关键
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键点 :表中有唯一索引 idx_user_type_flag,确保同一用户不能添加重复的类型。

2.2 实体类配置

java 复制代码
@Data
@TableName("bill_type")
public class BillType {
    @TableId(type = IdType.AUTO)
    private Long billTypeId;
    
    private String typeName;
    private Integer billFlag;
    private Long userId;
    
    // 逻辑删除字段
    @TableLogic
    private Integer deleted;
    
    @Version
    private Integer version;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

2.3 问题根源

核心矛盾

  • MyBatis-Plus 的逻辑删除 :查询时自动添加 WHERE deleted = 0,已删除的数据对应用层"不可见"
  • 数据库的唯一索引 :对所有数据生效(包括 deleted = 1 的数据)

执行流程

java 复制代码
// Controller 层检查
BillType existType = billTypeService.getTypeByNameAndFlag("还钱", 1, userId);
// MyBatis-Plus 自动添加 WHERE deleted = 0
// SQL: SELECT * FROM bill_type WHERE type_name='还钱' AND bill_flag=1 AND user_id=1 AND deleted=0
// 结果:null(因为已删除的数据被过滤了)

if (existType != null) {
    return Result.fail("类型已存在"); // 这里不会执行
}

// 继续插入
billTypeService.addType(billType);
// SQL: INSERT INTO bill_type (type_name, bill_flag, user_id, deleted) VALUES ('还钱', 1, 1, 0)
// 唯一索引冲突!数据库中已存在 (user_id=1, type_name='还钱', bill_flag=1) 的记录

结论:应用层认为数据不存在,但数据库层认为数据存在,导致唯一索引冲突。


三、解决方案

3.1 方案一:恢复已删除的数据(推荐)

思路:添加类型时,先检查是否存在已删除的同名类型,如果存在则恢复它。

步骤1:在 Mapper 中添加自定义 SQL
java 复制代码
@Mapper
public interface BillTypeMapper extends BaseMapper<BillType> {
    
    // 查询已删除的类型(绕过逻辑删除)
    @Select("SELECT * FROM bill_type WHERE type_name = #{typeName} " +
            "AND bill_flag = #{billFlag} AND user_id = #{userId} AND deleted = 1")
    BillType selectDeletedByNameAndFlag(@Param("typeName") String typeName, 
                                         @Param("billFlag") Integer billFlag, 
                                         @Param("userId") Long userId);
    
    // 恢复已删除的类型(绕过逻辑删除)
    @Update("UPDATE bill_type SET deleted = 0 WHERE bill_type_id = #{billTypeId}")
    int restoreDeleted(@Param("billTypeId") Long billTypeId);
}

为什么要自己写 SQL

因为 MyBatis-Plus 的 @TableLogic 会拦截所有查询,即使你手动指定 eq(BillType::getDeleted, 1) 也会被覆盖为 deleted = 0。只有通过 @Select 注解自己写 SQL,才能绕过逻辑删除机制。

步骤2:修改 Service 层逻辑
java 复制代码
@Override
public Result<Void> addType(BillType billType) {
    String typeDesc = billType.getBillFlag() == 0 ? "支出" : "收入";
    
    // 先检查是否存在已删除的同名类型
    BillType deletedType = billTypeMapper.selectDeletedByNameAndFlag(
            billType.getTypeName(), 
            billType.getBillFlag(), 
            billType.getUserId()
    );
    
    if (deletedType != null) {
        // 恢复已删除的类型
        int rows = billTypeMapper.restoreDeleted(deletedType.getBillTypeId());
        if (rows > 0) {
            log.info("恢复已删除的收支类型,typeId:{}", deletedType.getBillTypeId());
            return Result.success("类型【" + billType.getTypeName() + "(" + typeDesc + ")】已恢复");
        }
    }
    
    // 没有已删除的同名类型,正常添加
    int rows = billTypeMapper.insert(billType);
    if (rows > 0) {
        return Result.success("类型【" + billType.getTypeName() + "(" + typeDesc + ")】添加成功");
    } else {
        return Result.fail("类型【" + billType.getTypeName() + "(" + typeDesc + ")】添加失败");
    }
}

效果展示

  • 第一次添加:"类型【还钱(收入)】添加成功"
  • 删除后再添加:"类型【还钱(收入)】已恢复"

优点

  • 不丢失历史数据
  • 用户体验好(删除后可以重新添加)
  • 保留了逻辑删除的优势

3.2 方案二:修改唯一索引(复杂)

思路:让唯一索引只对未删除的数据生效。

MySQL 8.0+ 的函数索引
sql 复制代码
-- 删除旧索引
ALTER TABLE bill_type DROP INDEX idx_user_type_flag;

-- 创建函数索引(只对 deleted=0 的数据生效)
CREATE UNIQUE INDEX idx_user_type_flag 
ON bill_type(user_id, type_name, bill_flag, (CASE WHEN deleted = 0 THEN 0 ELSE bill_type_id END));

缺点

  • 需要 MySQL 8.0+
  • 语法复杂,不易维护
  • 已删除的数据仍然占用索引空间

3.3 方案三:改为物理删除(不推荐)

思路:直接从数据库删除数据,不使用逻辑删除。

java 复制代码
@Override
public Result<Void> deleteType(Long billTypeId, Long userId) {
    // 物理删除
    int rows = billTypeMapper.deleteById(billTypeId);
    return rows > 0 ? Result.success("删除成功") : Result.fail("删除失败");
}

缺点

  • 丢失历史数据
  • 无法恢复误删的数据
  • 失去了逻辑删除的优势

四、优化全局异常处理

为了让用户看到更友好的提示,可以优化全局异常处理器:

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(DataIntegrityViolationException.class)
    public Result<Void> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
        if (e.getCause() instanceof SQLIntegrityConstraintViolationException) {
            String message = ((SQLIntegrityConstraintViolationException) e.getCause()).getMessage();
            
            if (message.contains("Duplicate entry")) {
                if (message.contains("username")) {
                    return Result.fail("用户名已存在,请更换");
                } else if (message.contains("bill_type") || message.contains("type_name")) {
                    return Result.fail("该类型已存在(可能之前被删除),请使用其他名称");
                }
                return Result.fail("数据重复,请检查输入");
            }
        }
        return Result.fail("数据操作失败,请检查数据完整性");
    }
}

五、总结

5.1 核心要点

  1. MyBatis-Plus 逻辑删除的本质 :在查询时自动添加 WHERE deleted = 0,对应用层隐藏已删除的数据
  2. 唯一索引的作用范围 :对数据库中的所有数据生效,不区分 deleted 字段
  3. 冲突的根源:应用层认为数据不存在,数据库层认为数据存在

5.2 最佳实践

  • 推荐方案一:恢复已删除的数据,兼顾用户体验和数据完整性
  • 使用 @Select 自定义 SQL 绕过逻辑删除
  • 优化全局异常处理,提供友好的错误提示
  • 避免在唯一索引字段上使用逻辑删除(除非有特殊处理)

5.3 扩展思考

什么时候适合用逻辑删除

  • 需要保留历史数据(如订单、日志)
  • 需要支持数据恢复(如回收站功能)
  • 数据之间有复杂的关联关系

什么时候不适合用逻辑删除

  • 数据量大且查询频繁(影响性能)
  • 有唯一性约束且需要重复添加
  • 数据没有恢复需求

六、完整代码

以下只展示与收支类型相关的增加和删除方法:

Controller层

java 复制代码
// 账单类型控制器
@RequiredArgsConstructor
@RestController
@RequestMapping("/billType")
public class BillTypeController {
    
    //利用Lombok注入
    private final BillTypeService billTypeService; 
    
    // 添加类型
    @PostMapping("/add")
    public Result<Void> addType(HttpServletRequest request, @RequestBody @Valid BillType billType) {
        Long userId = TokenHelper.getUserId(request);//TokenHelper为辅助类,用于从请求中获取userId,username等
        billType.setUserId(userId);
        
        //返回前端信息使用
        String typeDesc = billType.getBillFlag() == 0 ? "支出" : "收入";
        
        // 检查添加的类型是否存在
        BillType typeByNameAndFlag = billTypeService.getTypeByNameAndFlag(
            billType.getTypeName(), billType.getBillFlag(), userId);

        if (typeByNameAndFlag != null) {
            return Result.fail("类型【" + billType.getTypeName() + "(" + typeDesc + ")】已存在");
        }

        //进行添加
        return billTypeService.addType(billType);
    }
    
    // 删除类型
    @DeleteMapping("/delete/{billTypeId}")
    public Result<Void> deleteType(HttpServletRequest request, @PathVariable Long billTypeId) {
        Long userId = TokenHelper.getUserId(request);

        // 验证是否是当前用户的类型
        BillType existType = billTypeService.getTypeById(billTypeId, userId);
        if (existType == null) {
            return Result.notFound("类型不存在或无权限");
        }
        
        //进行删除
        return billTypeService.deleteType(billTypeId, userId);
    }
}



//可以设置一个系统常量类,统一管理所有魔法值,提高代码可读性和可维护性
//上面的0(支出)就可以写进里面。比如:Constants.BILL_FLAG_EXPENSE
public class Constants {
    // ==================== 账单相关 ====================    
    // 账单类型标志:支出
    public static final int BILL_FLAG_EXPENSE = 0;
    // 账单类型标志:收入
    public static final int BILL_FLAG_INCOME = 1;
}

Service 层

java 复制代码
@Service
public class BillTypeServiceImpl implements BillTypeService {
    
    @Autowired
    private BillTypeMapper billTypeMapper;
    
    //增加收支类型
    @Override
    public Result<Void> addType(BillType billType) {
        String typeDesc = billType.getBillFlag() == 0 ? "支出" : "收入";
        
        // 检查是否存在已删除的同名类型
        BillType deletedType = billTypeMapper.selectDeletedByNameAndFlag(
                billType.getTypeName(), 
                billType.getBillFlag(), 
                billType.getUserId()
        );
        
        if (deletedType != null) {
            // 恢复已删除的类型
            int rows = billTypeMapper.restoreDeleted(deletedType.getBillTypeId());
            if (rows > 0) {
                return Result.success("类型【" + billType.getTypeName() + "(" + typeDesc + ")】已恢复");
            }
        }
        
        // 正常添加
        int rows = billTypeMapper.insert(billType);
        if (rows > 0) {
            return Result.success("类型【" + billType.getTypeName() + "(" + typeDesc + ")】添加成功");
        } else {
            return Result.fail("类型【" + billType.getTypeName() + "(" + typeDesc + ")】添加失败");
        }
    }
}

    //删除收支类型
    @Override
    public Result<Void> deleteType(Long billTypeId, Long userId) {

        // 先查询类型信息,用于返回消息
        BillType existType = getTypeById(billTypeId, userId);
        if (existType == null) {
            return Result.fail("类型不存在或无权限");
        }
        
        String typeDesc = existType.getBillFlag() == 0 ? "支出" : "收入";
        
        LambdaQueryWrapper<BillType> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(BillType::getBillTypeId, billTypeId)
               .eq(BillType::getUserId, userId);
        int rows = billTypeMapper.delete(wrapper);
        
        if (rows > 0) {
            return Result.success("类型【" + existType.getTypeName() + "(" + typeDesc + ")】删除成功");
        } else {
            return Result.fail("类型【" + existType.getTypeName() + "(" + typeDesc + ")】删除失败");
        }
    }

Mapper 层

java 复制代码
@Mapper
public interface BillTypeMapper extends BaseMapper<BillType> {
    
    @Select("SELECT * FROM bill_type WHERE type_name = #{typeName} " +
            "AND bill_flag = #{billFlag} AND user_id = #{userId} AND deleted = 1")
    BillType selectDeletedByNameAndFlag(@Param("typeName") String typeName, 
                                         @Param("billFlag") Integer billFlag, 
                                         @Param("userId") Long userId);
    
    @Update("UPDATE bill_type SET deleted = 0 WHERE bill_type_id = #{billTypeId}")
    int restoreDeleted(@Param("billTypeId") Long billTypeId);
}

结语

这个问题看似简单,实则涉及到 MyBatis-Plus 逻辑删除机制、数据库唯一索引、全局异常处理等多个知识点。希望本文能帮助你避开这个坑,也欢迎在评论区分享你的经验!

如果这篇文章对你有帮助,请点赞、收藏、关注!有问题欢迎在评论区讨论。

作者:[识君啊]

不要做API的搬运工,要做原理的探索者!

相关推荐
Elieal1 小时前
SpringBoot 数据层开发与企业信息管理系统实战
java·spring boot·后端
Coder_Boy_1 小时前
Java开发者破局指南:跳出内卷,借AI赋能,搭建系统化知识体系
java·开发语言·人工智能·spring boot·后端·spring
QT.qtqtqtqtqt2 小时前
SQL注入漏洞
java·服务器·sql·安全
独自破碎E2 小时前
BISHI23 小红书推荐系统
java·后端·struts
xqqxqxxq2 小时前
Java IO 核心:BufferedReader/BufferedWriter & PrintStream/PrintWriter 技术笔记
java·笔记·php
Aric_Jones2 小时前
idea使用.env运行SpringBoot项目
java·spring boot·intellij-idea
刘一说2 小时前
Java 中实现多租户架构:数据隔离策略与实践指南
java·oracle·架构
beata2 小时前
Java基础-9:深入 Java 虚拟机(JVM):从底层源码到核心原理的全面解析
java·后端
架构师刘伟2 小时前
MyBatis-Dynamic 进阶:无需实体类的全动态数据建模
mybatis