在后端开发中,软删除(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) -- 这里的唯一索引会导致问题
);
冲突场景
- 用户创建了一个记录:
name = "东方方方正正",deleted = 0。(成功) - 用户删除了该记录:
deleted更新为1。 - 用户再次 创建同名记录:
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 值 的特性来实现。
核心原理
- 创建一个额外的虚拟列(辅助列),例如叫
name_unique。 - 逻辑: 当
deleted = 0时,name_unique等于name;当deleted = 1时,name_unique为NULL。 - 对
name_unique建立唯一索引。 - 关键点: 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 软删除与唯一索引冲突的最佳实践是:
- 放弃 单纯的
UNIQUE(name)。 - 放弃 联合索引
UNIQUE(name, deleted)。 - 使用生成列技术 :利用
AS (CASE WHEN deleted = 0 THEN name ELSE NULL END)创建辅助列。 - 在辅助列上建立唯一索引。
这种方案既保证了数据的完整性,又完美兼容了软删除的业务逻辑。
💡 扩展阅读
- MySQL 5.7 Generated Columns Documentation
- Why NULL is not equal to NULL in SQL Unique Constraints