mysql软删除后唯一索引失效是必然现象,正确解法是数据库层约束:用stored生成列(case when is_deleted=0 then email else null end)配合唯一索引,利用mysql允许多个null但禁止重复非null的特性,实现仅对有效数据生效的唯一性约束。

软删除后唯一索引失效是数据库层的必然现象,不是代码写错了——MySQL 的 UNIQUE 约束对所有行生效,不管 is_deleted 是 0 还是 1。真正有效的解法必须落在数据库约束层面,而不是靠应用层查再插。
MySQL 用生成列 + 唯一索引模拟条件唯一性
这是目前最干净、无并发风险、不改业务逻辑的方案。核心是让已软删记录在唯一列上统一变成 NULL,而 MySQL 允许多个 NULL 共存,但禁止重复非 NULL 值。
- 添加一个
STORED生成列:ALTER TABLE user ADD COLUMN email_unique VARCHAR(255) AS (CASE WHEN is_deleted = 0 THEN email ELSE NULL END) STORED; - 在该列上建唯一索引:
CREATE UNIQUE INDEX uk_email_active ON user (email_unique); - 注意:生成列类型必须和原字段兼容(如
email是VARCHAR(255),则email_unique也得是VARCHAR(255)),否则建索引会报错 - 该方案要求 MySQL ≥ 5.7,且表引擎为 InnoDB;
STORED比VIRTUAL更稳妥,避免某些 ORM 对虚拟列支持不佳
联合唯一索引 + 动态删除标记字段
如果不能升级 MySQL 或团队不熟悉生成列,用联合索引是最广泛落地的替代方案,但关键在于“删除标记字段必须可区分”——不能全填同一个值(比如全填 1),否则联合索引形同虚设。
- 新增字段
deleted_at DATETIME NULL DEFAULT NULL,软删时设为NOW(),未删则保持NULL - 建联合唯一索引:
CREATE UNIQUE INDEX uk_email_deleted_at ON user (email, deleted_at); - 为什么用
NULL而不用固定值?因为 MySQL 唯一索引中,(email='a', deleted_at=NULL)和(email='a', deleted_at=NULL)不冲突(两个NULL被视为不同),但(email='a', deleted_at='2024-01-01')和(email='a', deleted_at='2024-01-01')会冲突——所以每个已删记录的deleted_at必须唯一(用时间戳天然满足) - 别用
TINYINT类型的is_deleted直接参与联合索引,它只有 0/1 两个值,会导致(email='a', is_deleted=1)只能存一条,后续软删同邮箱用户就会失败
应用层校验的坑与补救边界
仅靠 SELECT COUNT(*) 再 INSERT 是高并发下的典型幻读陷阱,但某些场景下仍需配合使用——比如生成列或联合索引已建好,但历史数据里存在违反逻辑的重复项,上线前必须清理。
- 先查出当前活跃重复项:
SELECT email, COUNT(*) FROM user WHERE is_deleted = 0 GROUP BY email HAVING COUNT(*) > 1; - 这类数据必须人工或脚本清理,否则加了生成列索引也会建失败(报
ERROR 3871) - Yii2 等框架中,验证规则要同步改:
['email', 'unique', 'filter' => ['=', 'is_deleted', 0]],否则表单提交时前端没报错,DB 层却插入失败 - 所有
findOne()、findAll()查询必须默认加andWhere(['is_deleted' => 0]),否则软删数据会被业务逻辑误读
生成列方案看似优雅,但它会让每行多存一份字符串,写放大明显;联合索引方案更省空间,但要求你严格控制删除标记字段的取值逻辑。真正容易被忽略的,不是怎么建索引,而是上线前没跑那条 HAVING COUNT(*) > 1 的检查 SQL——历史脏数据会让所有方案在第一步就卡住。











