软删除本身不引发主键冲突,但会与UNIQUE索引冲突;因UNIQUE索引对所有非NULL值强制唯一,已软删记录仍占位;解决方案包括联合索引、应用层检查、生成列(name_unique+STORED+唯一索引)或deleted_at时间戳替代。

软删除本身不会引发主键冲突,但会和唯一索引冲突;主键冲突只发生在显式指定重复主键值或自增异常时,和软删除逻辑无关。
为什么 UNIQUE 索引 + soft delete 会报 Duplicate entry
MySQL 的 UNIQUE 索引对所有非 NULL 值强制唯一,不管 is_deleted 是 0 还是 1。只要表里已有 name = "foo" 的记录(哪怕 is_deleted = 1),新插入 name = "foo" 就会触发 Duplicate entry 'foo' for key 'uk_name' 错误。
常见错误操作:
- 直接加
UNIQUE KEY uk_name (name),没考虑已删除记录仍占位 - 改用联合索引
UNIQUE (name, is_deleted),结果删两次同名记录就失败(两条("foo", 1)冲突) - 应用层先
SELECT ... WHERE name = ? AND is_deleted = 0再插入,但并发下两个请求同时通过检查,最终写入两条
用生成列(Generated Column)绕过唯一索引限制
MySQL 5.7+ 支持生成列,配合“唯一索引忽略 NULL”的特性,可精准控制哪些行参与唯一约束。
核心做法:定义一个虚拟列 name_unique,仅当 is_deleted = 0 时取真实 name 值,否则为 NULL;再对这个生成列建唯一索引。
实操步骤:
- 添加生成列:
ALTER TABLE users ADD COLUMN name_unique VARCHAR(100) AS (CASE WHEN is_deleted = 0 THEN name END) STORED; - 建唯一索引:
CREATE UNIQUE INDEX uk_name_active ON users (name_unique); - 验证:已删除的
name = "foo"行,name_unique为NULL,不参与唯一校验;新插入的未删除同名记录才被约束
注意:STORED 是必须的,因为只有存储型生成列才能建索引;CASE 表达式不能用函数如 IF(),否则 MySQL 可能拒绝建索引。
用 delete_time + 联合唯一索引替代 is_deleted
如果不想改表结构加生成列,可用时间戳字段替代布尔标记,利用 NULL 在联合唯一索引中的特殊行为。
操作方式:
- 把
is_deleted TINYINT DEFAULT 0换成deleted_at DATETIME NULL DEFAULT NULL - 删数据时设
deleted_at = NOW(),而不是改标志位 - 建联合唯一索引:
CREATE UNIQUE INDEX uk_name_deleted_at ON users (name, deleted_at);
原理:每条记录的 (name, deleted_at) 组合必须唯一;新用户注册时 deleted_at IS NULL,而历史已删记录的 deleted_at 是具体时间,不会冲突;即使删了多次同名用户,每次的 deleted_at 时间不同,也不违反约束。
缺点:无法用 WHERE is_deleted = 0 这种简洁条件查活跃数据,得写 WHERE deleted_at IS NULL;且业务语义上,“删除时间”比“是否删除”多一层含义,需确认团队接受。
别忽略查询性能与默认过滤
软删除不是加个字段就完事。漏掉以下三点,线上大概率出问题:
- 没给
is_deleted或deleted_at加索引:全表扫描查活跃数据,QPS 上去就慢 - ORM 或 DAO 层没统一拦截:某次手写 SQL 忘加
WHERE is_deleted = 0,返回已删数据,前端显示异常 - 没处理关联查询:比如查订单时连带查用户,若用户表用了软删除但 JOIN 条件没过滤,可能关联到已删用户,导致空指针或脏数据
最易被忽略的是生成列索引的维护成本:一旦你改了 name_unique 的表达式逻辑(比如改成只对长度 > 2 的 name 生效),整个索引要重建,大表会锁表较久。上线前务必在影子库压测。


















