news 2026/10/27 2:41:17

EF Core 软删除、只读实体与审计日志落地实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
EF Core 软删除、只读实体与审计日志落地实践

1. 这三件事为什么要放在一起做

先说清楚 SafeDelete、ReadOnly、Audit 这三个词在我这个系列里的具体含义,因为不同团队对它们的理解差别挺大。SafeDelete我指的是把物理删除改写成业务删除,同时兜底拦住所有绕过业务层的 DELETE;ReadOnly指的是某类实体一旦写入就不再允许修改,谁能改、改哪个字段由模型层说了算;Audit指的是审计字段自动填充加变更日志留痕,分两层而不是一层。三件事单独拿出来都不复杂,放到一起就有了顺序问题、拦截器职责重叠问题,以及一堆容易被忽略的边角。

我做过的项目里,订单、账单、权限这三类表几乎必然同时需要这三样。有一次凌晨的事故是对着数据库客户端手滑执行范围的删除,事后重建数据花了六个多小时;另一回是某人把一张配置表的 Code 字段改了,缓存 key 全部对不上,排查了两个晚上。从那之后我就养成习惯:凡是进了正式环境的表,删除动作必须先过业务层,修改动作必须分得清哪些字段是一次性写入的,所有变更必须留得下操作人和时间。这套东西不写在需求文档里,但没有它,后面每次出事都得靠运气。

这篇面向的是已经在用 EF Core、写过SaveChanges拦截器、但对全局过滤器、变更跟踪状态机还不太熟的开发者。新手可以照着抄代码,老手可以重点看第二章和第五章那些坑。我不打算讲 EF Core 基础,DbContext 怎么注册、实体怎么配置这些默认你都会。下面所有代码都是我认为可以落地到生产环境的写法,参数取值和拦截顺序我会给出理由,但每个团队的技术栈和业务约定不同,照着改的时候要自己判断。

2. SafeDelete:把删除动作翻译成业务动作

2.1 标记接口加全局过滤器的最小可用配置

软删除的第一步是定义契约。我一般用一个最小的标记接口,字段不多但够用:

public interface ISoftDelete { bool IsDeleted { get; set; } DateTimeOffset? DeletedAt { get; set; } string? DeletedBy { get; set; } }

为什么保留DeletedBy而不是只存一个时间戳?因为实际排查中,问题往往不是"什么时候删的",而是"谁删的、是不是某个批处理误伤"。多存两个字段的成本几乎为零,但事后省下的沟通成本很高。

模型配置这块,我倾向写一个统一的扩展方法,把所有实现ISoftDelete的实体一次性配好:

public static class SoftDeleteModelBuilderExtensions { public static ModelBuilder ApplySoftDeleteFilters(this ModelBuilder builder) { foreach (var entityType in builder.Model.GetEntityTypes()) { if (!typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType)) continue; var param = Expression.Parameter(entityType.ClrType, "e"); var prop = Expression.Property(param, nameof(ISoftDelete.IsDeleted)); var body = Expression.Not(prop); var lambda = Expression.Lambda(body, param); builder.Entity(entityType.ClrType).HasQueryFilter(lambda); builder.Entity(entityType.ClrType).HasIndex(nameof(ISoftDelete.IsDeleted)); } return builder; } }

这里有两个细节值得单独讲。第一,HasIndex必须加,因为绝大多数查询都会带上IsDeleted = 0这个条件,没索引的表在数据量上到几十万行之后会明显变慢。第二,索引只建单列索引是不够的,如果业务查询通常还带租户 ID 或者状态字段,最好建成复合索引,把IsDeleted放在最左边作为等值前缀。

注意:全局过滤器不会自动作用于导航集合。父实体加了过滤器,Include(x => x.Children)拿到的子集合依然包含已软删除的记录,除非子实体自己也声明了过滤器。这个误解非常普遍,我第一次用它的时候就在这上面栽过,界面上出现了删除过的明细行,排查了半天才反应过来。

另外要提醒一点:HasQueryFilter对同一个实体只能设置一次,多条条件必须用&&在同一个表达式里合并。不少人在写多租户过滤时先HasQueryFilter(x => x.TenantId == _tenantId),再想补一个软删除条件,结果后面的直接覆盖前面的,而且不会报错,只是过滤条件莫名失效了。

2.2 拦截器改写:从 Deleted 到 Modified 的正确姿势

光有过滤器还不够,因为它只影响查询,不阻止删除。真正的兜底在SaveChanges拦截器里。我见过最多的写法是直接把状态改成Modified:

entry.State = EntityState.Modified; entry.Entity.IsDeleted = true;

这样能跑通,但有个隐藏代价:改成 Modified 之后,EF Core 会把实体所有属性都标记为已修改,生成的 UPDATE 语句会覆盖全部列。如果这条记录在你读取之后被别的请求改过其他字段,你的这次软删除会把那些修改一并抹掉。正确做法是先降回 Unchanged,只把三个字段标脏:

public sealed class SoftDeleteInterceptor : SaveChangesInterceptor { private readonly TimeProvider _clock; private readonly ICurrentUser _currentUser; public SoftDeleteInterceptor(TimeProvider clock, ICurrentUser currentUser) { _clock = clock; _currentUser = currentUser; } public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { var ctx = eventData.Context; if (ctx is null) return base.SavingChangesAsync(eventData, result, cancellationToken); foreach (var entry in ctx.ChangeTracker.Entries<ISoftDelete>()) { if (entry.State != EntityState.Deleted) continue; entry.State = EntityState.Unchanged; entry.Entity.IsDeleted = true; entry.Entity.DeletedAt = _clock.GetUtcNow(); entry.Entity.DeletedBy = _currentUser.Id; entry.Property(nameof(ISoftDelete.IsDeleted)).IsModified = true; entry.Property(nameof(ISoftDelete.DeletedAt)).IsModified = true; entry.Property(nameof(ISoftDelete.DeletedBy)).IsModified = true; } return base.SavingChangesAsync(eventData, result, cancellationToken); } }

这里用TimeProvider而不是DateTimeOffset.UtcNow,是为了单元测试时能注入假时钟,验证DeletedAt的赋值逻辑。这个抽象在 .NET 8 之后是内置的,没有理由不用。

再说一个容易翻车的地方:把状态从Deleted改成Unchanged之后,如果这个实体同时配置了Owned类型或者有级联删除子实体,EF Core 内部已经标记为Deleted的子项不会因为你改了父项状态而自动回滚。实测下来,一对多关系里父实体软删除时,子实体不会跟着被软删除,除非你在拦截器里遍历导航属性手动处理。我的建议是:软删除的级联只在业务层显式处理,别指望 EF Core 帮你推导,因为"订单软删了明细要不要跟着软删"本身就是业务决策,不是技术决策。

2.3 唯一索引、级联删除与批量命令的坑

软删除最烦人的问题是和唯一索引冲突。举个例子,用户表的 Email 建了唯一索引,某用户被软删除后,同邮箱再次注册时插入会报唯一约束冲突。三种处理方式,我按推荐度排序:

方案做法优点代价
过滤索引HasIndex(x => x.Email).IsUnique().HasFilter("[IsDeleted] = 0")语义最干净,无需改业务数据只支持部分数据库方言;恢复已删记录时可能冲突
复合唯一索引IsUnique加IsDeleted列兼容性好同一条记录删两次会冲突,IsDeleted只能是布尔值就不够用
删除时改写字段删除时把 Email 改成email#deleted#guid恢复逻辑可控业务字段被污染,导出报表时要还原

我实际选的是第一种,SQL Server 和 SQLite 都支持过滤索引,用起来最省心。代价是接受"被删掉的邮箱可以重新注册,而旧记录不能直接恢复"这个事实——多数业务场景下这反而是想要的。

再说级联删除:EF Core 默认对必需关系使用Cascade,一旦你软删除了主表记录,数据库层面的外键级联不会触发(因为你根本没执行 DELETE),但 EF Core 在跟踪状态下仍可能把子集合标记为Deleted。我一般会把所有软删除实体的关系显式改成DeleteBehavior.Restrict,把决定权拿回来,避免出现"删了一个分类,下面对不上的商品也被打了删除标记"这种事故。

最后一个坑,也是最容易忽略的:EF Core 7 之后提供的ExecuteDelete()和ExecuteUpdate()根本不走变更跟踪,拦截器完全捕获不到。你在仓储里写一句db.Users.Where(...).ExecuteDelete(),软删除逻辑形同虚设,数据是真删了。我见过一个项目大体上做了软删除,结果上线三个月后发现有张表的记录一直在少,最后定位到某个批处理任务用了批量删除接口。

防御手段有两层。一层是架构层面:不对外暴露DbSet<T>,统一走仓储,并且仓储不提供ExecuteDelete方法。另一层是技术兜底:用DbCommandInterceptor拦DbCommand,正则匹配以DELETE FROM开头的语句,命中就抛异常,把 CI 环境和测试环境当成护栏。虽然有点重,但换来的是"任何人都不可能写出物理删除",我觉得值。

public sealed class PhysicalDeleteGuardInterceptor : DbCommandInterceptor { public override InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) { Guard(command); return base.ReaderExecuting(command, eventData, result); } public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { Guard(command); return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } private static void Guard(DbCommand command) { var sql = command.CommandText.AsSpan().TrimStart(); if (sql.StartsWith("DELETE", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException( $"检测到物理删除语句,已阻断:{command.CommandText}"); } }

这个拦截器只建议在测试环境和预发环境开启,生产环境开着风险太大(比如你哪天真的需要写一个清理日志的 DELETE),用配置开关控制。

3. ReadOnly:只读实体的分层实现

3.1 属性级只读:SetBeforeSaveBehavior 与 SetAfterSaveBehavior

先分清两个概念:属性级只读和实体级只读。属性级只读是 EF Core 原生支持的能力,通过PropertySaveBehavior控制某个字段在插入和更新时的行为:

modelBuilder.Entity<Order>(b => { b.Property(x => x.OrderNo) .HasMaxLength(32) .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw) .SetBeforeSaveBehavior(PropertySaveBehavior.Save); });

SetAfterSaveBehavior(Throw)的意思很明确:实体一旦插入成功,这个属性就不允许再改,任何试图修改它的操作都会在SaveChanges时抛InvalidOperationException。这是我最推荐给"业务主键"类字段加的约束,比如订单号、流水号、合同编号、设备序列号。这类字段一旦生成就不该变,变了以后对账、追溯、日志关联全乱套。

还有个行为值Ignore,它让 EF Core 在保存时直接忽略这个属性的修改,不报错也不写入。区别在哪?Throw是"你敢改我就崩给你看",Ignore是"你改了我当没看见"。我一般对CreatedAt用Ignore(下游代码无意中设了个值,忽略掉就行,没必要让整个事务失败),对业务主键用Throw(这类字段被改说明代码逻辑出问题了,应该让它立刻暴露)。

提示:SetAfterSaveBehavior只作用于已经存在的实体(State 为 Modified)。对Added状态没有约束力,插入时还是能赋值的,这正是我们想要的效果。

还有一个容易搞混的:PropertySaveBehavior和IsModified = false不是一回事。前者是模型层的硬约束,写在OnModelCreating里,对所有DbContext实例生效;后者是状态层的软操作,只在当前这次SaveChanges有效。别把两者当成一回事。

3.2 实体级只读:标记接口配合变更跟踪拦截

属性级管的是"哪些字段不能改",实体级管的是"整张表就不能改"。典型的场景是字典表、枚举表、历史归档表、日志表。我用的方式是一个空接口:

public interface IReadOnlyEntity { }

然后在保存前统一校验:

public sealed class ReadOnlyGuardInterceptor : SaveChangesInterceptor { private static readonly EntityState[] BlockedStates = { EntityState.Modified, EntityState.Deleted }; public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { var ctx = eventData.Context; if (ctx is null) return base.SavingChangesAsync(eventData, result, cancellationToken); var violations = ctx.ChangeTracker.Entries<IReadOnlyEntity>() .Where(e => BlockedStates.Contains(e.State)) .Select(e => $"{e.Metadata.ClrType.Name}") .Distinct() .ToList(); if (violations.Count > 0) { throw new InvalidOperationException( $"以下只读实体发生了修改或删除,操作已中止:{string.Join(", ", violations)}"); } return base.SavingChangesAsync(eventData, result, cancellationToken); } }

注意我是先收集所有违规再一次性抛,而不是遇到一个就抛。这样开发阶段能看到"一批操作里到底有几个实体违规",调试效率高很多。代价是多了一次 LINQ 遍历,但相对于一次SaveChanges的开销可以忽略。

有一点必须说清楚:这个拦截器只拦SaveChanges,拦不住ExecuteUpdate和ExecuteDelete。所以只读的完整性同样依赖仓储封装,和软删除是同一个问题,只是被忽略得更彻底。我吃过一次亏:一张配置表标了只读,结果有人写了个批量更新接口用ExecuteUpdate刷了一遍,日志里什么都没留下。后来加了仓储层的单元测试,凡是实现对IReadOnlyEntity的ExecuteUpdate就直接让测试挂掉。

还有个设计上的取舍:只读实体要不要允许插入?我见过两种做法。一种是完全禁止,只能靠迁移脚本灌数据;另一种是允许Added,只禁止Modified和Deleted。我倾向后者,因为初始化数据、灰度配置这类场景需要程序化写入,全靠脚本维护成本太高。所以上面的拦截器只拦了两个状态,Added是放行的。

3.3 只读查询与不跟踪查询的取舍

只读实体在查询侧还有个配套动作:AsNoTracking()。道理很直白——数据不会被改,就没必要进变更跟踪器,省内存也省DetectChanges的扫描时间。我在仓储里给只读实体单独开了一个查询方法:

public IQueryable<T> QueryReadOnly<T>() where T : class, IReadOnlyEntity => _db.Set<T>().AsNoTracking();

这里有个常见误区要说清楚:AsNoTracking()不是"性能优化开关",别到处乱加。对于需要立刻更新、需要延迟加载、需要跟其他实体建立关系的数据,加了AsNoTracking()反而会带来重复实例、更新失效等问题。我见过有人写了个全局扩展方法给所有查询都套上AsNoTracking,结果某天发现改了个状态死活保存不上,查了两小时才反应过来。

另一个相关的限制是:不跟踪查询返回的实体,如果你手动Attach回去再改,EF Core 会认为整个实体都是新的或全字段修改,容易产生意料之外的 UPDATE。这种情况我一般用AsNoTrackingWithIdentityResolution(),至少在同一个查询里能保证实体实例唯一,避免图结构被打散。

至于只读实体要不要在数据库账户层面做权限隔离,我的看法是:如果你的数据库账号是共享的,做不了细粒度控制,那就在应用层做,拦截器加仓储约定已经能挡住 95% 的误操作。真正高敏感的表(比如资金流水),可以考虑独立连接串和只读账号,但那是另一层话题,这里不展开。

4. Audit:审计字段和审计日志是两套东西

4.1 自动填充时间与操作人

审计字段是表上的列,审计日志是独立的表。很多人把这两件事混为一谈,结果要么是字段漏填,要么是日志表塞满了无用数据。先把字段这层做扎实:

public interface IAuditable { DateTimeOffset CreatedAt { get; set; } string? CreatedBy { get; set; } DateTimeOffset? UpdatedAt { get; set; } string? UpdatedBy { get; set; } }

填充逻辑放在拦截器里,但有几个判断要仔细:

foreach (var entry in ctx.ChangeTracker.Entries<IAuditable>()) { switch (entry.State) { case EntityState.Added: entry.Entity.CreatedAt = now; entry.Entity.CreatedBy = userId; entry.Entity.UpdatedAt = null; entry.Entity.UpdatedBy = null; break; case EntityState.Modified: entry.Entity.UpdatedAt = now; entry.Entity.UpdatedBy = userId; entry.Property(nameof(IAuditable.CreatedAt)).IsModified = false; entry.Property(nameof(IAuditable.CreatedBy)).IsModified = false; break; } }

为什么在Modified分支要把CreatedAt和CreatedBy显式置为IsModified = false?因为如果实体是从前端整体回传然后Update(entity)的场景,这两个字段会被一并标记为修改,如果不处理,一次更新就会把创建时间刷成当前时间。这个 bug 特别隐蔽——界面上看不出来,只有等到某天要按创建时间做同期对比时才发现数据全乱了。我在两个项目里都见过这个问题,而且都已经跑了很久。

关于操作人的来源,我的做法是定义一个ICurrentUser抽象,实现里从IHttpContextAccessor取,取不到就返回"system":

public sealed class CurrentUser : ICurrentUser { private readonly IHttpContextAccessor _accessor; public CurrentUser(IHttpContextAccessor accessor) => _accessor = accessor; public string Id => _accessor.HttpContext?.User?.FindFirst("sub")?.Value ?? "system"; }

不直接在拦截器里读IHttpContextAccessor的原因很简单:后台任务、消息消费、数据迁移这些场景根本没有 HTTP 上下文,甚至可能连作用域都没有。多一层抽象,将来换成从消息头或者任务上下文取用户标识时,只改实现类就行。

还有一个必须强调的:拦截器要注册成 Scoped,不能是 Singleton。如果在拦截器里存了任何状态(比如后面要讲的审计日志缓冲区),Singleton 会带来跨请求的数据串扰,而且排查起来极其痛苦,因为症状是"偶发的日志错乱",根本定位不到。

4.2 变更日志表的结构与写入时机

字段层解决的是"这条记录是谁什么时候建的、谁什么时候改的",日志层解决的是"到底改了哪几个字段、改前是什么、改后是什么"。两者不能互相替代。

日志表的最小结构我一般这样设计:

列名类型说明
Idbigint自增主键
EntityNamenvarchar(128)实体类型名,加索引
EntityIdnvarchar(64)业务主键值,统一转字符串
ActiontinyintAdded / Modified / Deleted
ChangedColumnsnvarchar(512)变更字段名列表,逗号分隔
OldValuesnvarchar(max)变更前的值,JSON
NewValuesnvarchar(max)变更后的值,JSON
UserIdnvarchar(64)操作人
Timestampdatetimeoffset操作时间

为什么EntityId用字符串?因为主键类型五花八门,有 int、long、Guid、复合键,统一转字符串最省事,代价是索引效率略低于原生类型。对审计日志这种写入量远大于查询量、查询通常还带时间范围条件的表,可以接受。

变更值的序列化,我建议只取entry.Properties.Where(p => p.IsModified),并且排除导航属性。直接序列化整个实体有两个后果:一是日志表体积暴涨,二是可能把敏感字段(手机号、身份证、密码哈希)写进去。序列化前必须过一层字段白名单或者黑名单,这个不是可选项。

写入时机这里有个绕不开的问题:新增实体的主键在SaveChanges之前还没生成。两种解法:

  1. 主键用 GUID,插入前就能拿到,日志可以和业务数据一次性写入,同事务;
  2. 在SavedChangesAsync里回填,先收集变更快照,等业务数据落库后再生成日志并二次SaveChanges。

我用的第二种,因为表主键是自增 bigint,业务方要求。实现上大概是这样:

public override async ValueTask<int> SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken ct) { if (_pending.Count == 0 || eventData.Context is null) return result; foreach (var item in _pending) { var key = item.Entry.Property("Id").CurrentValue; item.Log.EntityId = key?.ToString() ?? string.Empty; eventData.Context.Set<AuditLog>().Add(item.Log); } _pending.Clear(); return result + await eventData.Context.SaveChangesAsync(ct); }

注意这里的二次SaveChangesAsync会再次触发拦截器。因为AuditLog本身不实现IAuditable,也不会产生新的日志条目,所以不会死循环。但如果哪天有人手贱给AuditLog加上IAuditable,那就是无限递归加内存爆掉,所以我在拦截器第一行加了个防御:if (eventData.Context.ChangeTracker.Entries<AuditLog>().Any(e => e.State == EntityState.Added)) return;。

还有一个必须确认的点:二次SaveChanges和第一次是不是同一个事务?如果你在业务层用了显式事务(BeginTransaction),是的,都在同一个事务里;如果没有用显式事务,EF Core 会为每次SaveChanges各自开一个隐式事务,就出现"业务数据写进去了、日志没写进去"的风险。我的处理方式是在DbContext里重写SaveChangesAsync,对多步保存统一包事务,而不是依赖拦截器自己猜。

4.3 与软删除、只读的联动

三个拦截器要一起用,顺序就有讲究了。我按这个顺序注册:

options.AddInterceptors( sp.GetRequiredService<ReadOnlyGuardInterceptor>(), sp.GetRequiredService<SoftDeleteInterceptor>(), sp.GetRequiredService<AuditInterceptor>());

理由很直接。只读校验必须最先执行,因为一旦有违规就应该立刻中止,不该让后面的拦截器做无用功,也不该让软删除去改写一个本来就不该被删的实体。软删除改写放在中间,因为它会改变实体的状态和字段(IsDeleted、DeletedAt),审计层需要看到改写后的最终结果。审计放在最后,它读的是最终态,这样软删除产生的三个字段变更也能被记录到日志里——这在事后追溯时非常有用,你能从日志里清楚看到"某人在某个时间把这条记录标记为已删除"。

这里有个联动细节要处理:软删除的改写让状态从Deleted变成了Modified,审计拦截器看到的是Modified,所以日志的Action字段会被记成"修改"而不是"删除"。如果想要语义精确,可以在软删除拦截器里给实体打一个临时标记,比如用一个ConditionalWeakTable或者上下文里的临时字典记录"这次是被软删除的实体 ID",审计层读取这个标记来判定 Action。这个做法稍微绕,但能避免日后有人按 Action 筛选删除记录时什么都查不到。

只读实体和审计的关系也要说清楚:只读实体基本没有变更日志,因为没有变更。但审计字段还是可以有的,CreatedAt/CreatedBy对只读表同样有价值。所以我在模型配置里把两件事完全解耦:IAuditable管字段,IReadOnlyEntity管状态拦截,一个实体可以只实现前者、只实现后者、或者都实现。这个解耦是我在第三个项目里才想明白的,前两个项目里把两者揉成一个接口,结果一张只读的字典表不需要更新时间和更新人,字段全空着,看着别扭。

5. 实测问题清单与排查路径

5.1 常见问题速查表

下面这张表是我这几年遇到的实际问题汇总,按出现频率从高到低排。遇到症状先对照这张表,能省掉不少时间。

症状大概率原因处理方式
查不到刚软删除的数据,管理后台也查不到全局过滤器生效,IgnoreQueryFilters没加管理端查询显式加IgnoreQueryFilters(),并注意它会一并关掉租户等其他过滤器
软删除后唯一索引冲突唯一索引未做过滤改成过滤索引,或接受复用规则
更新时创建时间被刷新CreatedAt被标记为 Modified拦截器里显式置IsModified = false
只读实体没拦住,数据还是被改了用了ExecuteUpdate/ExecuteDelete仓储层禁止暴露批量接口,加DbCommandInterceptor兜底
审计日志缺失部分记录二次SaveChanges不在同一事务重写SaveChangesAsync统一包事务
拦截器里的状态串到别的请求拦截器注册成了 Singleton改成 Scoped,状态不跨请求
Include出来还有已删除的子记录全局过滤器不级联到导航给子实体也配置过滤器
保存时报属性不能修改SetAfterSaveBehavior(Throw)被触发检查是否有整体Update(entity)的写法,改成按字段赋值

再补充一个不在表里但很要命的:Find()方法对已跟踪实体直接返回,不看过滤条件。如果你的上下文里还跟踪着一个已经被软删除的实体实例,Find会把它返回给你,看起来像过滤器失效了。这种情况下用FirstOrDefault走查询才靠谱。这个行为在 EF Core 文档里有说明,但藏在很偏的位置,知道的人不多。

5.2 几个只有踩过才知道的细节

第一个,全局过滤器的表达式树里不要放需要每次求值的变量。比如HasQueryFilter(x => x.TenantId == _currentTenantId)这种写法,_currentTenantId是DbContext的字段,每次查询时 EF Core 会把它作为参数带进去,看起来没问题。但如果你在过滤器里调用了方法,比如x.CreatedAt > GetCutoffDate(),那整个表达式就没法被参数化,会导致查询计划缓存失效,每次查询都重新编译,批量场景下性能掉得很厉害。解决办法是把它变成一个每次从上下文传入的常量参数,别在过滤器里做计算。

第二个,IgnoreQueryFilters()是"全关"不是"选择性关"。如果你的实体同时有软删除过滤器和租户过滤器,调用它会把两个都关掉,可能出现跨租户数据泄露。EF Core 目前没有提供只关某一个过滤器的官方 API,替代方案是用命名过滤器(HasQueryFilter("SoftDeleteFilter", ...)),或者在管理端专门用一个不配置过滤器的DbContext子类。我选的是后者,一个只读的AdminDbContext,通过OnModelCreating里判断类型来跳过过滤器。

第三个,软删除字段的索引顺序。单列IsDeleted索引在低基数列上基本没用,因为选择性太差。真正有效的是复合索引,把IsDeleted放在最前面,后面跟上业务查询常用的条件,比如(IsDeleted, TenantId, CreatedAt)。这个顺序不能反,把CreatedAt放前面之后IsDeleted的过滤效果就没了。我做过一次压测,同样的查询在百万级数据下,复合索引顺序对了大概 15 毫秒,顺序错了 400 毫秒以上。

第四个,审计日志表的增长控制。审计日志的写入量通常是业务表的 2 到 5 倍,一年下来几千万行很正常,不做分区或者归档,单表查询会慢慢变慢。我的做法是按月分表或者按季度归档到冷存储,同时给Timestamp和EntityId建复合索引,日常查询走时间范围过滤。另外,OldValues/NewValues这两个大文本字段建议单独拆表,主表只留元信息,这样按时间扫描日志列表的时候不会把大字段也读进来。

第五个,测试环境一定要打开物理删除拦截器。这一点我在前面提过,但值得再强调一次。很多软删除方案上线后失效,原因都是某个开发写了一句批量删除,在本地跑通、测试环境没人拦、一直跑到生产才发现。把拦截器在 CI 和测试环境强制性开启,让它在开发阶段就崩,比事后补数据便宜太多。

最后分享一个我在实际项目里养成的小习惯:所有拦截器都带一个Enabled配置开关,从appsettings读。不是为了关掉它们,而是为了在排查问题时能快速二分定位——怀疑是拦截器搞的鬼,先关掉一个跑一遍,确认之后再打开。这个套路帮我省过至少三次漫长的排查。配置键我统一用EfCore:Interceptors:{Name}:Enabled这个格式,方便集中管理。

三个特性的代码量加起来其实不到五百行,但它们带来的约束是长期的。真正难的不是写拦截器,而是让团队里所有人都知道"这里有护栏,别想着绕过去"。文档、代码审查、单元测试三样配合,才能让这套东西一直有效。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:41:03

TensorRT推理部署实战:动态形状、INT8校准与多流并发调优

在把模型从训练框架搬到推理卡上的这条路上&#xff0c;TensorRT 的 Developer Guide 属于那种“第一遍看懂七成&#xff0c;第二遍才发现自己之前全在做无用功”的文档。很多人跑通了官方那个最简 demo&#xff0c;序列化出一个 engine 文件&#xff0c;就以为掌握了它&#x…

作者头像 李华
网站建设 2026/10/27 2:40:42

消费返物业费:智慧社区运营新模式解析

1. 项目概述&#xff1a;消费返物业费的社区运营新模式"美家时代"这个项目最吸引我的地方在于它用"消费返物业费"这个简单直接的钩子&#xff0c;撬动了智慧社区这个万亿级市场。我在社区商业领域摸爬滚打多年&#xff0c;见过太多烧钱补贴的失败案例&…

作者头像 李华
网站建设 2026/10/27 2:40:08

DVMLSTM网络在工业故障诊断中的创新应用

1. 项目背景与核心价值在工业设备故障诊断领域&#xff0c;传统方法往往面临信号非平稳特征提取困难、时序依赖关系建模不足等挑战。这个DVMLSTM诊断网络创新性地融合了时频分析、多尺度特征提取和时序建模三大技术模块&#xff0c;为旋转机械等复杂系统的故障识别提供了新的解…

作者头像 李华
网站建设 2026/10/27 2:39:32

强化学习数学原理:从网格世界到贝尔曼方程的决策建模

简介&#xff1a;《强化学习的数学原理》是西湖大学赵世钰教授撰写的英文专著&#xff0c;面向具备线性代数、概率论与优化基础的研究生及算法工程师&#xff0c;系统解决强化学习理论理解不深、公式推导不清、算法本质难把握等核心痛点。全书从网格世界等直观示例切入&#xf…

作者头像 李华
网站建设 2026/10/27 2:39:18

YOLO26的SPM稀疏提示模块:原理、优化与多任务实战

1. 项目概述&#xff1a;SPM稀疏提示模块的革新价值在计算机视觉领域&#xff0c;YOLO系列算法始终保持着迭代速度与性能突破的标杆地位。最新发布的YOLO26通过引入TPAMI 2026论文提出的SPM&#xff08;Sparse Prompt Module&#xff09;稀疏提示模块&#xff0c;采用top-k稀疏…

作者头像 李华
网站建设 2026/10/27 2:39:18

JavaScript深浅拷贝原理与最佳实践

1. 前端开发中的拷贝机制解析在JavaScript开发中&#xff0c;数据拷贝是每个前端工程师必须掌握的基础概念。特别是在处理对象和数组这类引用类型数据时&#xff0c;拷贝操作会直接影响程序的行为逻辑。最近在review团队代码时&#xff0c;我发现不少初级开发者对拷贝机制的理解…

作者头像 李华