EF Core 软删除与唯一索引的一次踩坑
项目里商品、供应商这类档案用的是软删除(打 IsDeleted 标记,查询时过滤)。为了保证「编码不重复」,很自然地在唯一索引里把 IsDeleted 也放进去,结果线上报了数据库唯一键冲突。这篇记录原因和修法。
问题:bool 只有两个值
唯一索引列里包含 IsDeleted 时,考虑这个序列:
- 编码
A001的记录删除(IsDeleted = 1); - 用
A001重建一条(IsDeleted = 0),不冲突; - 新建的这条再删除(IsDeleted = 1)—— 和第 1 步那条已删除记录在索引列上完全相同,撞唯一键。
也就是说同一条数据只要经历「删除 → 重建 → 再删除」,第二次删除必然失败。bool 只有两个值,这条路注定走不通。
修法:过滤索引
正确姿势是唯一索引列里去掉 IsDeleted,改用数据库的过滤索引,只对未删除的行生效。PostgreSQL 写法:
builder.HasIndex(x => new { x.TenantId, x.Code })
.IsUnique()
.HasFilter("\"IsDeleted\" = false");
效果:未删除的行之间编码唯一,已删除的行不参与唯一性判断,删除多少条都不冲突。
两个附带注意点
- 改索引前先验证存量:过滤索引只管新写入,如果历史数据里已经有「多条未删除同编码」的脏数据,迁移会直接失败,先跑一遍查重;
- 不同数据库语法不同:
HasFilter里的 SQL 是裸字符串,PostgreSQL 的列名要带引号,SQL Server 则用[IsDeleted] = 0,换库时这里要跟着改。
小结
软删除的唯一性约束,本质是「只在活着的行里唯一」,这个语义天然应该用过滤索引表达,而不是把删除标记塞进索引列。判断标准很简单:如果两个已删除的行在索引列上可能撞车,这个索引设计就是错的。