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

相关推荐
液态不合群5 小时前
[特殊字符] MySQL 覆盖索引详解
数据库·mysql
virus59455 小时前
悟空CRM mybatis-3.5.3-mapper.dtd错误解决方案
java·开发语言·mybatis
没差c6 小时前
springboot集成flyway
java·spring boot·后端
时艰.6 小时前
Java 并发编程之 CAS 与 Atomic 原子操作类
java·开发语言
编程彩机7 小时前
互联网大厂Java面试:从Java SE到大数据场景的技术深度解析
java·大数据·spring boot·面试·spark·java se·互联网大厂
笨蛋不要掉眼泪7 小时前
Spring Boot集成LangChain4j:与大模型对话的极速入门
java·人工智能·后端·spring·langchain
Yvonne爱编码7 小时前
JAVA数据结构 DAY3-List接口
java·开发语言·windows·python
像少年啦飞驰点、8 小时前
零基础入门 Spring Boot:从“Hello World”到可上线微服务的完整学习指南
java·spring boot·微服务·编程入门·后端开发
眼眸流转8 小时前
Java代码变更影响分析(一)
java·开发语言
Yvonne爱编码8 小时前
JAVA数据结构 DAY4-ArrayList
java·开发语言·数据结构