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

相关推荐
mjhcsp1 小时前
C++ 后缀自动机(SAM):原理、实现与应用全解析
java·c++·算法
张np1 小时前
java基础-Vector(向量)
java
光头程序员1 小时前
学习笔记——常识解答之垃圾回收机制
java·笔记·学习
渡我白衣1 小时前
并行的野心与现实——彻底拆解 C++ 标准并行算法(<execution>)的模型、陷阱与性能真相
java·开发语言·网络·c++·人工智能·windows·vscode
czlczl200209251 小时前
SpringBoot中web请求路径匹配的两种风格
java·前端·spring boot
bill4471 小时前
BPMN2.0,flowable工作流指向多节点,并且只能选择其中一个节点的处理方式
java·工作流引擎·bpmn
2022.11.7始学前端2 小时前
n8n第四节 表单触发器:让问卷提交自动触发企微消息推送
java·前端·数据库·n8n
Catcharlotte2 小时前
异常(3)
java
岁岁种桃花儿2 小时前
Java应用篇如何基于Redis共享Session实现短信登录
java·开发语言