在订单、会员、商品这类业务系统里,删除操作往往不能真的把数据从数据库里抹掉。物理删除之后审计无法追溯,误删的数据无法恢复,监管类业务甚至明确要求历史数据必须保留。软删除的思路是给每条记录打上一个删除标记,查询时自动跳过这些记录,数据本身仍然留在表里。在Entity Framework Core中,这套机制主要依靠两个能力组合完成:一个是全局查询过滤器,负责让所有查询自动排除已删除数据;另一个是SaveChanges拦截,负责把删除动作改写成更新操作。下面把这套方案的完整实现过程和容易踩的坑逐一讲清楚。

软删除的实体设计:先定好标记字段
实现软删除的第一步是在实体上定义标记字段。最简单的做法是加一个bool类型的IsDeleted列,但生产环境里通常还会搭配删除时间和删除人两个字段,方便后续做回收站、审计追溯或者定时清理。这些公共字段建议抽到一个基类里,所有需要软删除的实体统一继承,后面批量配置过滤器时也能直接复用这一层抽象,避免每个实体重复声明。
public abstract class BaseEntity
{
public long Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string DeletedBy { get; set; }
}
public class Order : BaseEntity
{
public string OrderNo { get; set; }
public decimal Amount { get; set; }
public long CustomerId { get; set; }
public Customer Customer { get; set; }
}
public class Customer : BaseEntity
{
public string Name { get; set; }
public List<Order> Orders { get; set; }
}
这里有一个容易被忽略的细节:唯一索引冲突。假设Customer表的Name列建了唯一索引,某客户被软删除后想新建一个同名客户,插入会直接失败,因为旧记录还躺在表里占着索引位置。解决办法是给唯一索引加过滤条件,让它只对未删除的数据生效。SQL Server和SQLite可以通过HasFilter传入一段SQL片段,PostgreSQL的写法是把条件写成带双引号的列名形式,MySQL在早期版本不支持过滤索引,常见替代方案是把IsDeleted改成可空类型并让删除时间戳参与唯一约束。
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Name)
.IsUnique()
.HasFilter("[IsDeleted] = 0");
HasQueryFilter全局查询过滤器:查询侧的核心
EF Core提供全局查询过滤器能力,通过HasQueryFilter方法在模型层面给实体挂一个LINQ谓词,之后所有针对该实体的查询,包括DbSet上的LINQ查询、Include关联加载、懒加载,都会自动拼上这个条件。对业务代码来说,已删除的数据就像不存在一样,不需要每个查询都手动写Where条件,这也正是软删除方案里最关键的一环。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted);
modelBuilder.Entity<Customer>()
.HasQueryFilter(c => !c.IsDeleted);
}
配置完成后执行一个普通查询,观察EF Core生成的SQL就能看到效果。查询context.Orders时,最终发到数据库的语句会自动带上WHERE [o].[IsDeleted] = 0,这个条件由框架注入,业务层完全无感知。有两个规则需要记住:过滤器表达式只能引用实体自身的标量属性,不能引用导航属性;每个实体只能配置一个过滤器,重复调用HasQueryFilter会覆盖之前的配置,所以软删除条件和租户隔离条件必须合并到同一个表达式里。另外过滤器表达式可以直接引用DbContext的实例字段,这是实现多租户过滤的标准做法,后面会展开。
重写SaveChanges:把DELETE拦截成UPDATE
过滤器解决了查询侧的问题,写入侧还需要处理删除动作。如果直接调用Remove再SaveChanges,EF Core会发出真正的DELETE语句,软删除就形同虚设。标准做法是重写DbContext的SaveChanges和SaveChangesAsync两个方法,在提交前扫描变更跟踪器,把状态为Deleted且实体继承自BaseEntity的条目改成Modified,同时给IsDeleted和DeletedAt赋值,这样最终生成的SQL就从DELETE变成了UPDATE。
public class AppDbContext : DbContext
{
public override int SaveChanges()
{
ApplySoftDelete();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplySoftDelete();
return base.SaveChangesAsync(cancellationToken);
}
private void ApplySoftDelete()
{
var deletedEntries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Deleted
&& e.Entity is BaseEntity);
foreach (var entry in deletedEntries)
{
entry.State = EntityState.Modified;
entry.Property(nameof(BaseEntity.IsDeleted)).CurrentValue = true;
entry.Property(nameof(BaseEntity.DeletedAt)).CurrentValue = DateTime.UtcNow;
}
}
}
这段拦截逻辑对上层调用方完全透明,业务代码照常调用Remove和SaveChanges,实际落库的却是软删除标记,上层不需要写任何额外代码。有一个必须提醒的坑:EF Core 7引入的ExecuteDelete和ExecuteUpdate是直接在数据库端执行批量操作的,完全不经过变更跟踪器,上面的拦截对它们无效,调用之后数据会被物理删除。如果项目启用了软删除,要么在团队规范里禁用这两个方法,要么明确它们只允许用在确需物理删除的场景。同理,直接执行原生DELETE语句也不会触发拦截,需要单独管控。
批量配置过滤器:让所有实体自动生效
实体数量少的时候逐个配置HasQueryFilter还能接受,实体一多就容易漏配,而且新增实体时还要记得回来补配置。更稳妥的方式是在OnModelCreating里遍历模型中的所有实体类型,筛选出继承了BaseEntity的那些,用表达式树在运行时动态构造IsDeleted等于false的谓词,统一挂上过滤器。这样后续新增实体只要继承基类,过滤器自动生效,配置代码完全不用动。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
{
continue;
}
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(BaseEntity.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
这段代码的关键在于Expression.Parameter和Expression.Property的组合。由于每个实体的ClrType不同,没法写一个泛型表达式直接复用,必须借助表达式树按类型逐个构造谓词。如果项目同时需要多租户隔离,可以在DbContext构造函数里接收当前租户ID存到私有字段,过滤器表达式直接引用该字段,每次实例化上下文时传入不同租户即可,条件记得和软删除合并进同一个表达式。
public class AppDbContext : DbContext
{
private readonly long _tenantId;
public AppDbContext(long tenantId)
{
_tenantId = tenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 每个实体只能有一个过滤器,多条件必须合并到同一个表达式
modelBuilder.Entity<Order>().HasQueryFilter(
o => !o.IsDeleted && o.TenantId == _tenantId);
}
}
IgnoreQueryFilters与导航属性:绕不开的坑
过滤器全面生效后,回收站、数据恢复这类功能反而需要看到已删除的数据,这时可以调用IgnoreQueryFilters让单个查询绕过全局过滤器,拿到全量记录。要特别注意的是,这个方法会取消该查询范围内所有实体的全部过滤器,如果同时配置了软删除和租户过滤,两者会被一起绕过,此时必须手动补上租户条件,否则存在跨租户数据越权的风险。
// 普通查询:过滤器自动生效,只返回未删除数据
var activeOrders = context.Orders.ToList();
// 回收站页面:绕过过滤器,只看已删除数据
var deletedOrders = context.Orders
.IgnoreQueryFilters()
.Where(o => o.IsDeleted)
.OrderByDescending(o => o.DeletedAt)
.ToList();
导航属性是另一个高频踩坑点。全局过滤器同样作用于Include和懒加载,比如通过客户加载订单集合时,被软删除的订单不会出现在集合里,这通常符合预期;但反过来,查询一条已软删除的订单并Include客户时,如果客户本身也被过滤器排除,导航属性会是null,EF Core在必需导航指向被过滤实体时会在日志里给出警告,代码层面则可能抛出空引用异常。遇到这类问题的处理方式有三种:在具体查询处IgnoreQueryFilters、把关系配置成可选、或者在业务层面保证关联主体不会被软删除。
最后是性能层面的提醒。过滤器带来的WHERE条件本身开销很小,真正的问题是软删除会让表持续膨胀,索引统计信息也可能逐渐失真,数据量大的系统要配合定时归档策略,把软删除超过一定期限的数据搬到历史表。过滤器条件里避免放复杂计算,保持简单的列比较,让数据库能正常走索引,这一点在表数据量大时尤其重要。
方案总结
回顾整套方案:BaseEntity定义标记字段,OnModelCreating里用表达式树批量挂HasQueryFilter过滤已删除数据,重写SaveChanges把删除动作转成更新,必要时用IgnoreQueryFilters访问全量数据。三个环节各自独立又互相配合,任何一个缺失都会让软删除出现漏洞,没有过滤器查询会漏出已删数据,没有拦截删除会变成物理删除,没有绕过机制回收站功能无法实现。把这些点全部覆盖,再加上唯一索引过滤和导航属性的处理,软删除方案才算真正在生产环境站得住脚。

