MySQL 软删除 (Soft Delete) 与唯一索引 (Unique Constraint) 的冲突与解决

在后端开发中,软删除(Soft Delete) 是一个非常通用的需求。我们通常会在数据库表中增加一个 deleted (或 is_deleted) 字段,用来标记数据是否被删除,而不是真正从磁盘上抹去数据。

同时,业务上往往还有 唯一性约束 的需求,比如用户的 username、商品的 code 或者配置项的 name 必须全局唯一。

然而,当这两个需求碰到一起时,就会产生一个数据库设计冲突:如何在保留历史删除记录的同时,允许新数据使用相同的名称?

本文将以 MySQL 数据库为例,深入探讨这个问题,并提供一个基于 生成列 (Generated Column) 的解决方案。

1. 问题复现

假设我们有一张配置表 dynamic_query,要求 name 字段唯一。

初始设计

sql 复制代码
CREATE TABLE dynamic_query (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    deleted TINYINT DEFAULT 0, -- 0:未删除, 1:已删除
    UNIQUE KEY uk_name (name)  -- 这里的唯一索引会导致问题
);

冲突场景

  1. 用户创建了一个记录:name = "东方方方正正"deleted = 0(成功)
  2. 用户删除了该记录:deleted 更新为 1
  3. 用户再次 创建同名记录:name = "东方方方正正"deleted = 0

结果: 数据库报错 Duplicate entry '东方方方正正' for key 'uk_name'

原因: 即使第一条记录被标记为"已删除",但在物理层面它仍然存在于表中,且占据着唯一索引的一个位置。

2. 常见的(错误)尝试

为了解决这个问题,很多开发者会尝试以下方案,但它们都有缺陷:

❌ 尝试一:联合唯一索引 (name + deleted)

sql 复制代码
CREATE UNIQUE INDEX uk_name_deleted ON dynamic_query (name, deleted);

缺陷: 这只能允许一条 被删除的记录。

如果你删除了第一次(name="A", deleted=1),再创建并删除第二次(name="A", deleted=1),数据库会报错。因为此时表里将会有两条 ("A", 1) 的记录,违反了联合唯一约束。

❌ 尝试二:应用层校验

在插入数据前,先查一下数据库是否存在 name="A" AND deleted=0 的记录。
缺陷: 并发不安全。在高并发下,两个请求可能同时通过检查并写入数据,导致脏数据产生。

3. 完美解决方案:MySQL 生成列 (Generated Column)

在 PostgreSQL 或 SQL Server 中,我们可以使用"部分索引" (WHERE deleted = 0) 轻松解决。但在 MySQL (5.7+) 中,我们需要利用 生成列MySQL 唯一索引忽略 NULL 值 的特性来实现。

核心原理

  1. 创建一个额外的虚拟列(辅助列),例如叫 name_unique
  2. 逻辑:deleted = 0 时,name_unique 等于 name;当 deleted = 1 时,name_uniqueNULL
  3. name_unique 建立唯一索引。
  4. 关键点: MySQL 的 UNIQUE 索引允许存在多个 NULL 值。

实施步骤 (SQL)

我们需要修改数据库表结构,添加一个持久化生成列 (Stored Generated Column)

sql 复制代码
-- 1. 添加辅助生成列
ALTER TABLE dynamic_query
ADD COLUMN name_unique VARCHAR(100) AS (
    CASE 
        WHEN deleted = 0 THEN name 
        ELSE NULL 
    END
) STORED;

-- 2. 基于辅助列创建唯一索引
CREATE UNIQUE INDEX uk_name_active ON dynamic_query (name_unique);

效果演示:

id name deleted name_unique (自动计算) 说明
1 东方方方正正 1 (已删) NULL 索引不约束 NULL,允许存在
2 东方方方正正 1 (已删) NULL 即使名字一样,也能再次删除
3 东方方方正正 0 (有效) "东方方方正正" 唯一性生效,阻止 id=4 插入
4 东方方方正正 0 (有效) "东方方方正正" 插入失败 (Duplicate Entry)

4. 在 Spring Data JPA 中的落地

既然数据库层面已经处理好了,Java 代码层(Entity)需要做相应的调整,主要是移除 JPA 层的唯一约束注解,避免 JPA 自动建表时生成错误的索引。

修改前的 Entity

java 复制代码
@Entity
public class DynamicQuery extends BaseEntity {
    // ❌ 移除 unique = true,把控制权交给数据库
    @Column(unique = true, nullable = false) 
    private String name;
    
    // ...
}

修改后的 Entity

java 复制代码
@Entity
public class DynamicQuery extends BaseEntity {

    /**
     * 唯一性由数据库的 Generated Column (name_unique) 保证
     * 此处无需添加 unique = true
     */
    @Column(nullable = false)
    private String name;

    // 软删除字段通常在 BaseEntity 中
    // private Boolean deleted = false;
}

注意:由于 name_unique 是数据库自动维护的列,我们不需要在 Java 实体类中映射它。

5. 总结

解决 MySQL 软删除与唯一索引冲突的最佳实践是:

  1. 放弃 单纯的 UNIQUE(name)
  2. 放弃 联合索引 UNIQUE(name, deleted)
  3. 使用生成列技术 :利用 AS (CASE WHEN deleted = 0 THEN name ELSE NULL END) 创建辅助列。
  4. 在辅助列上建立唯一索引。

这种方案既保证了数据的完整性,又完美兼容了软删除的业务逻辑。


💡 扩展阅读

  • MySQL 5.7 Generated Columns Documentation
  • Why NULL is not equal to NULL in SQL Unique Constraints

相关推荐
是一个Bug1 小时前
Java基础50道经典面试题(四)
java·windows·python
Slow菜鸟1 小时前
Java基础架构设计(三)| 通用响应与异常处理(分布式应用通用方案)
java·开发语言
我是Superman丶2 小时前
《Spring WebFlux 实战:基于 SSE 实现多类型事件流(支持聊天消息、元数据与控制指令混合传输)》
java
廋到被风吹走2 小时前
【Spring】常用注解分类整理
java·后端·spring
是一个Bug2 小时前
Java基础20道经典面试题(二)
java·开发语言
Z_Easen2 小时前
Spring 之元编程
java·开发语言
leoufung2 小时前
LeetCode 373. Find K Pairs with Smallest Sums:从暴力到堆优化的完整思路与踩坑
java·算法·leetcode
阿蒙Amon2 小时前
C#每日面试题-委托和事件的区别
java·开发语言·c#
宋情写2 小时前
java-IDEA
java·ide·intellij-idea
最贪吃的虎2 小时前
Git: rebase vs merge
java·运维·git·后端·mysql