MyBatis-Plus 逻辑删除导致唯一索引冲突的解决方案
前言
最近在开发一个 Spring Boot 记账系统时,遇到了一个关于 MyBatis-Plus 逻辑删除的坑:用户删除某个类型后,再次添加同名类型时提示"数据重复"。明明前端查询不到这个类型,为什么还会冲突呢?
经过一番排查,发现是 MyBatis-Plus 逻辑删除机制与数据库唯一索引的冲突导致的。本文将详细分析问题原因,并提供完整的解决方案。
一、问题复现
1.1 业务场景
在记账系统中,用户可以自定义收支类型(如"餐饮"、"交通"、"还钱"等)。为了防止误删,我使用了 MyBatis-Plus 的逻辑删除功能。
操作步骤:
- 用户添加"还钱(收入)"类型
- 用户删除"还钱(收入)"类型
- 用户再次添加"还钱(收入)"类型 提示:数据重复,请检查输入
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 核心要点
- MyBatis-Plus 逻辑删除的本质 :在查询时自动添加
WHERE deleted = 0,对应用层隐藏已删除的数据 - 唯一索引的作用范围 :对数据库中的所有数据生效,不区分
deleted字段 - 冲突的根源:应用层认为数据不存在,数据库层认为数据存在
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的搬运工,要做原理的探索者!