云计算百科
云计算领域专业知识百科平台

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

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

前言

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

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


一、问题复现

1.1 业务场景

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

操作步骤:

  • 用户添加"还钱(收入)"类型
  • 用户删除"还钱(收入)"类型
  • 用户再次添加"还钱(收入)"类型 提示:数据重复,请检查输入
  • 1.2 详细复现过程

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

    第一步:添加类型

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

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

    bill_type_id | type_name | bill_flag | user_id | deleted | create_time
    ————-|———–|———–|———|———|——————
    1 | 还钱 | 1 | 1 | 0 | 2026-01-15 10:00:00

    第二步:删除类型

    // 用户点击"删除"按钮
    DELETE /billType/delete/1

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

    — MyBatis-Plus 自动生成的 SQL
    UPDATE bill_type SET deleted = 1 WHERE bill_type_id = 1

    此时数据库中的数据:

    bill_type_id | type_name | bill_flag | user_id | deleted | create_time
    ————-|———–|———–|———|———|——————
    1 | 还钱 | 1 | 1 | 1 | 2024-01-15 10:00:00
    ↑ 变成了1,表示已删除

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

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

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

    // 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 的数据被过滤了)

    检查通过,继续插入:

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

    💥 报错了!

    java.sql.SQLIntegrityConstraintViolationException:
    Duplicate entry '1-还钱-1' for key 'idx_user_type_flag'

    为什么会报错?

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

    user_id=1, type_name='还钱', bill_flag=1

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

    1.3 预期 vs 实际

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

    二、问题分析

    2.1 数据库表结构

    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 实体类配置

    @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 的数据)

    执行流程:

    // 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

    @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 层逻辑

    @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+ 的函数索引

    — 删除旧索引
    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 方案三:改为物理删除(不推荐)

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

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

    缺点:

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

    四、优化全局异常处理

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

    @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 核心要点

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

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

    5.3 扩展思考

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

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

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

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

    六、完整代码

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

    Controller层

    // 账单类型控制器
    @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 层

    @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 层

    @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的搬运工,要做原理的探索者!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » MyBatis-Plus 逻辑删除导致唯一索引冲突的解决方案
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!