软删除本质是数据标记而非物理删除,需通过IsDeleted/DeletedAt字段配合EF Core全局查询过滤、重载删除操作及关联数据处理来实现,语义一致性比技术实现更重要。

软删除本质是数据标记,不是真删
软删除在 C# 中没有语言级支持,它纯粹是业务逻辑层的设计选择:用一个字段(比如 IsDeleted 或 DeletedAt)记录“该不该被查到”,而不是调用 DbContext.Remove() 或执行 DELETE FROM。数据库里那条记录还在,只是被查询时过滤掉了。
常见错误是以为加个字段就完事——结果忘了改所有查询、漏掉 EF Core 的全局过滤、或者在仓储层没统一拦截 Delete 操作。
- 所有读取操作(
FindAsync、Where、Include后的子查询)必须显式或隐式排除已标记删除的数据 -
Remove()调用不能直接走 EF 默认行为,得重写为更新IsDeleted = true - 硬删除(真正从 DB 物理移除)应作为独立操作,命名要明确,比如
HardDeleteAsync()
EF Core 全局查询过滤(Global Query Filter)是核心机制
EF Core 5+ 支持对实体配置全局过滤,这是实现软删除最省力也最容易遗漏的地方。它自动追加到每个查询的 WHERE 条件里,但仅对跟踪查询生效,且不适用于 FromSqlRaw 或原始 SQL。
典型配置:
modelBuilder.Entity<order>()
.HasQueryFilter(x => !x.IsDeleted);</order>
注意几个关键点:
- 过滤表达式必须是可翻译成 SQL 的,不能含本地方法调用(如
DateTime.Now应换成DateTime.UtcNow) - 若实体有继承关系(TPT/TST),需为每个子类单独配置,父类的过滤不会自动继承
- 临时绕过过滤要用
IgnoreQueryFilters(),但务必谨慎——比如后台导出全量数据时才需要,别在普通业务流里滥用 - 启用后,
AsNoTracking()不影响过滤,但AsSplitQuery()下关联查询仍受控
删除操作必须重载,不能依赖 DbContext.Remove
直接调用 context.Orders.Remove(order) 会触发物理删除,哪怕你配了全局过滤也没用——因为 Remove 是状态变更指令,不是查询动作。软删除的正确路径是“更新标记字段 + 触发相应事件”。
推荐做法是在仓储或服务层封装:
- 提供
SoftDeleteAsync(T entity)方法,内部调用context.Entry(entity).Property(x => x.IsDeleted).CurrentValue = true - 避免在
SaveChanges里用ChangeTracker.Entries()扫描判断——性能差且易漏(比如批量导入时未 Attach) - 如果用了领域事件,软删除应发布
OrderSoftDeletedDomainEvent,而非OrderDeletedDomainEvent,语义必须区分清楚 - 外键约束可能干扰软删除:比如订单明细表没设软删除字段,而主订单被软删后,明细仍能被正常查到——这常导致前端显示“已删除订单的未删除明细”,得同步处理关联数据
时间戳字段(DeletedAt)比布尔值更实用但也更易出错
用 DateTime? 类型的 DeletedAt 替代 bool IsDeleted 能记录删除时间、支持恢复逻辑、便于审计,但代价是查询条件变复杂,且容易误判空值。
常见陷阱:
- 全局过滤写成
x => x.DeletedAt == null是对的;但若写成x => !x.DeletedAt.HasValue,EF Core 可能无法翻译,退化为客户端求值(ClientEval) - 恢复操作(取消软删除)不能只清空
DeletedAt,还得检查是否与其他已删除记录冲突(比如唯一索引含软删除字段) - 数据库索引需覆盖
DeletedAt字段,否则带WHERE DeletedAt IS NULL的大表查询会全表扫描 - 迁移脚本中新增
DeletedAt字段时,旧数据默认值必须设为NULL,千万别设成GETDATE()或0001-01-01,否则全表变“已删除”
软删除真正的难点不在代码怎么写,而在于整个团队对“删”的语义是否对齐:API 返回 404 还是 200+ 标记字段?日志里记“删除”还是“标记删除”?备份策略是否包含已软删数据?这些细节比 HasQueryFilter 的语法重要得多。










