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 变更日志表的结构与写入时机
字段层解决的是"这条记录是谁什么时候建的、谁什么时候改的",日志层解决的是"到底改了哪几个字段、改前是什么、改后是什么"。两者不能互相替代。
日志表的最小结构我一般这样设计:
| 列名 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 自增主键 |
| EntityName | nvarchar(128) | 实体类型名,加索引 |
| EntityId | nvarchar(64) | 业务主键值,统一转字符串 |
| Action | tinyint | Added / Modified / Deleted |
| ChangedColumns | nvarchar(512) | 变更字段名列表,逗号分隔 |
| OldValues | nvarchar(max) | 变更前的值,JSON |
| NewValues | nvarchar(max) | 变更后的值,JSON |
| UserId | nvarchar(64) | 操作人 |
| Timestamp | datetimeoffset | 操作时间 |
为什么EntityId用字符串?因为主键类型五花八门,有 int、long、Guid、复合键,统一转字符串最省事,代价是索引效率略低于原生类型。对审计日志这种写入量远大于查询量、查询通常还带时间范围条件的表,可以接受。
变更值的序列化,我建议只取entry.Properties.Where(p => p.IsModified),并且排除导航属性。直接序列化整个实体有两个后果:一是日志表体积暴涨,二是可能把敏感字段(手机号、身份证、密码哈希)写进去。序列化前必须过一层字段白名单或者黑名单,这个不是可选项。
写入时机这里有个绕不开的问题:新增实体的主键在SaveChanges之前还没生成。两种解法:
- 主键用 GUID,插入前就能拿到,日志可以和业务数据一次性写入,同事务;
- 在
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这个格式,方便集中管理。
三个特性的代码量加起来其实不到五百行,但它们带来的约束是长期的。真正难的不是写拦截器,而是让团队里所有人都知道"这里有护栏,别想着绕过去"。文档、代码审查、单元测试三样配合,才能让这套东西一直有效。