资讯动态

EF Core 软删除、只读与审计:SaveChanges 拦截器实践

发布时间:2026/10/27 2:41:47 来源:尧图企业网站定制
先说我自己的经历EF Core 里做 SafeDelete、ReadOnly、Audit 这三件事我在四五个项目里各实现过一遍每次实现的方式都不一样——第一版是重写 SaveChanges第二版换成拦截器第三版又在拦截器外面套了一层统一契约。原因很简单软删除、只读、审计看着是三件独立的事其实它们共享同一个数据入口就是 ChangeTracker 里那一堆 EntityEntry。你只要动其中任何一个的状态或属性另外两个的结论就可能全部作废。所以这套东西要么不做要做就得放在一起设计顺序错了排查起来会让你怀疑人生。这篇内容聊的是怎么在 EF Core 里把这三件套做成一套可复用的基础设施软删除怎么从“Delete 变 Update”而不引入一堆查询噪音唯一索引怎么和软删除共存只读实体怎么让误写在提交前就抛异常而不是靠人自觉审计表怎么记才不至于半年后变成几十 G 的垃圾数据。适合正在做后台管理系统、财务凭证、订单履约这类“数据不能真删、关键表不能乱改、所有变更要留痕”的读者也适合已经写过一版软删除但被全局过滤器和导航属性坑过的人。1. 三件套要解决的问题与整体设计思路1.1 为什么这三件事必须在数据层兜底业务代码里写if (xxx ! null) { ... }这种防御永远防不住三种人新来的同事、三个月后的自己、以及凌晨两点改 bug 的你。软删除写在业务层结果就是 A 服务删了用RemoveB 服务删了用Update(IsDeleted)C 服务直接写 SQL 物理删除最后数据状态一锅粥。只读更典型你在一张字典表上贴个注释“禁止修改”第二天就有人在后台管理里加了个编辑按钮因为“产品说需要”。审计则是另一种情况。业务层记日志的做法通常是每个方法末尾补一句await _log.WriteAsync(...)写了三十个方法之后你会发现漏了十一个而且漏的都是出问题的那几个。真正靠谱的位置只有一个实体框架把变更翻译成 SQL 之前的那一刻。那时候所有的新增、修改、删除都已经收敛成统一的 EntityEntry 状态你只需要遍历一次没有任何业务代码能绕过去——除非它绕过了 SaveChanges这个后面会专门讲是个大坑。所以我的整体判断是软删除、只读、审计三件事属于典型的“横切关注点”用 AOP 的思路塞在持久化管道的入口最合适。业务代码只管写业务安全边界的判定交给基础设施这才是把复杂度关进笼子的做法。1.2 重写 SaveChanges、拦截器、数据库触发器怎么选实现位置有三个选项我一个个说清楚取舍。重写 DbContext.SaveChanges / SaveChangesAsync。这是最朴素的做法在方法开头遍历 ChangeTracker 处理软删除处理完调base.SaveChanges()。优点是零依赖、调试直观缺点是每个 DbContext 都得抄一遍或者搞个基类而且一旦你用了IDbContextFactory或者多个 DbContext继承链会越来越丑。更麻烦的是它只能在上下文自己的保存流程里生效如果哪天引入了别的持久化方式逻辑就断层了。EF Core 拦截器SaveChangesInterceptor。这是我目前的主力方案。它跟 DbContext 解耦通过AddInterceptors注册可以注入ICurrentUser、TimeProvider、IHttpContextAccessor这些服务测试时也能轻松替换成假的。SavingChanges在 SQL 生成之前触发SavedChanges在事务提交之后触发两个时机各有用途。唯一的坑是同步和异步是两套方法你重写了SavingChangesAsync而漏了SavingChanges某段老代码走同步保存时逻辑就静默失效了这个我后面会重点提。数据库触发器。理论上最安全任何写入路径都逃不掉包括 DBA 手工改数据。但工程上不推荐作为主方案逻辑藏在数据库里版本管理麻烦调试困难ORM 拿不到“哪一行被改了”的上下文业务审计需要的用户 ID、请求追踪 ID 在触发器里根本拿不到。我的建议是把它当第二道防线只在核心财务表上补一个用来捕捉绕过应用的写入。结论很清楚应用层拦截器为主数据库约束为兜底重写 SaveChanges 只在极简项目里用。1.3 统一契约用接口把三类实体标记出来在动手写拦截器之前先把契约定下来。这三个接口看着简单但字段的选择会直接影响后面所有实现public interface ISoftDelete { bool IsDeleted { get; set; } DateTimeOffset? DeletedAt { get; set; } string? DeletedBy { get; set; } } public interface IReadOnlyEntity { // 空接口纯标记 } public interface IAuditable { DateTimeOffset CreatedAt { get; set; } string? CreatedBy { get; set; } DateTimeOffset? UpdatedAt { get; set; } string? UpdatedBy { get; set; } }这里有三个决定值得解释。第一软删除用IsDeleted布尔字段而不是DeletedAt单字段判断。原因是全局查询过滤器要参与索引布尔字段的选择性虽然低但表达式!e.IsDeleted最容易被数据库优化器识别成常量过滤配合过滤索引效果最好。DeletedAt只承担“什么时候删的”这个信息职责不参与过滤判断。第二DeletedBy用字符串而不是外键。审计和删除标记这类字段我倾向存“不产生关联的快照值”因为一旦做成外键用户表本身可能被软删除导航属性一加查询过滤器的链条就变长性能和维护成本都上去了。第三只读用空接口。有人喜欢在接口里放个bool CanWrite我觉得没必要——只读是类型级别的属性不是实例级别的放在实例上反而给了运行时改的余地。2. SafeDelete 软删除过滤、改写与索引的三重配合2.1 全局查询过滤器怎么加边界在哪里软删除的第一半是“查不出来”。EF Core 的HasQueryFilter是最省事的做法在OnModelCreating里批量给实现了ISoftDelete的实体挂上protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); var setFilter typeof(AppDbContext).GetMethod( nameof(SetSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)!; foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType)) { setFilter.MakeGenericMethod(entityType.ClrType) .Invoke(null, new object[] { modelBuilder }); } } } private static void SetSoftDeleteFilterTEntity(ModelBuilder builder) where TEntity : class, ISoftDelete { builder.EntityTEntity().HasQueryFilter(e !e.IsDeleted); }这段反射写法网上到处都是能用但有几个必须知道的边界。过滤器表达式里不要引用 DbContext 的实例成员。比如你想加租户隔离e !e.IsDeleted e.TenantId _tenantId_tenantId是上下文实例字段。EF Core 的模型是缓存的模型缓存键默认不包含这个字段的值结果就是第一个请求的租户 ID 被编译进表达式后面所有请求都用那个值查询——这种 bug 表现为“数据串了”排查时极其折磨。正确做法是给IModelCacheKeyFactory加上租户维度或者干脆不在过滤器里做租户隔离改用查询侧的显式条件。过滤器只作用于 LINQ 查询不作用于原生 SQL 和ExecuteDelete。你写FromSqlRaw(select * from Orders)过滤器是不生效的因为 EF 没办法改写你的 SQL。这个是设计使然不是 bug但必须让团队里所有人知道。IgnoreQueryFilters()的作用范围是整个查询。一旦调用主实体和所有 Include 进来的子实体都会解除过滤。很多人以为它只影响当前表结果连同子表的历史数据一起查出来了分页数量对不上查了半天。注意全局过滤器会让Find、FirstOrDefault这类按主键取数的方法也返回 null。如果业务上需要“已删除数据也能按 ID 查出来用于恢复”就得单独开一个IgnoreQueryFilters的查询入口别在通用仓储里偷偷加。2.2 删除动作转 Update拦截器里的状态改写细节第二半是“删不干净”。拦截器把EntityState.Deleted改写成 Update这是核心动作public sealed class SoftDeleteInterceptor : SaveChangesInterceptor { private readonly ICurrentUser _currentUser; private readonly TimeProvider _clock; public SoftDeleteInterceptor(ICurrentUser currentUser, TimeProvider clock) { _currentUser currentUser; _clock clock; } public override InterceptionResultint SavingChanges( DbContextEventData eventData, InterceptionResultint result) { Apply(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTaskInterceptionResultint SavingChangesAsync( DbContextEventData eventData, InterceptionResultint result, CancellationToken cancellationToken default) { Apply(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } private void Apply(DbContext? context) { if (context is null) return; // 先冻结集合后续遍历中的状态改写不会打乱枚举 var targets context.ChangeTracker.EntriesISoftDelete() .Where(e e.State EntityState.Deleted) .ToList(); foreach (var entry in targets) { // 关键一步先降级成 Unchanged再逐列标记 entry.State EntityState.Unchanged; entry.Entity.IsDeleted true; entry.Entity.DeletedAt _clock.GetUtcNow(); entry.Entity.DeletedBy _currentUser.Id; entry.Property(e e.IsDeleted).IsModified true; entry.Property(e e.DeletedAt).IsModified true; entry.Property(e e.DeletedBy).IsModified true; } } }这里有几个细节写错了不报错但会出问题。为什么用Unchanged 逐列IsModified而不是直接Modified。直接设成Modified会让 EF 更新这张表的所有列包括你根本没碰的字段。如果表上有RowVersion并发列或者有由数据库默认值维护的列全列更新会带来额外的锁和不可预期的覆盖。逐列标记能生成最小化的 UPDATE 语句只更新那三列日志量小、锁范围小、并发冲突概率也低。这是我在一个高写入表上实测出来的差异全列更新时锁等待明显更高。必须是先改 State 再改属性。如果你先给IsDeleted赋值再改 StateEF 在状态切换时可能认为实体没有变化属性修改被吞掉。顺序反过来则很稳State 切到 Unchanged 保证不进 DELETE然后属性赋值加标记保证进 UPDATE。同步和异步两个方法都要重写。这个坑我踩过一次某个批量任务用的是同步SaveChanges软删除拦截器只实现了异步版本结果那批数据被真删了。检查方式很简单全局搜一下项目里还有没有同步调用或者干脆在同步方法里抛 NotSupportedException 强制统一走异步。2.3 级联软删除EF 不会帮你递归实体框架的级联删除是数据库层面的行为靠外键的ON DELETE CASCADE实现。你把删除变成了 Update外键约束压根不触发所以子表数据一条都不会动。这意味着“删订单顺带删订单明细”这种需求必须自己递归。private void CascadeSoftDelete(DbContext context, IReadOnlyListEntityEntryISoftDelete roots) { var visited new HashSetobject(ReferenceEqualityComparer.Instance); var queue new QueueEntityEntry(); foreach (var root in roots) queue.Enqueue(root); while (queue.Count 0) { var entry queue.Dequeue(); if (!visited.Add(entry.Entity)) continue; foreach (var collection in entry.Collections) { if (collection.CurrentValue is null) continue; if (!typeof(ISoftDelete).IsAssignableFrom(collection.Metadata.TargetEntityType.ClrType)) continue; foreach (var child in collection.CurrentValue) { if (child is not ISoftDelete soft || soft.IsDeleted) continue; var childEntry context.Entry(child); if (childEntry.State EntityState.Deleted) continue; childEntry.State EntityState.Unchanged; soft.IsDeleted true; soft.DeletedAt _clock.GetUtcNow(); soft.DeletedBy _currentUser.Id; childEntry.Property(nameof(ISoftDelete.IsDeleted)).IsModified true; childEntry.Property(nameof(ISoftDelete.DeletedAt)).IsModified true; childEntry.Property(nameof(ISoftDelete.DeletedBy)).IsModified true; queue.Enqueue(childEntry); } } } }这段代码有一个致命前提集合导航必须已经被加载。如果删除订单时没有Include(o o.Items)collection.CurrentValue是 null级联静默失效子数据成了孤儿。所以我的做法是在业务服务层强制要求“删除前先带齐聚合内的子集合”写一个LoadAggregateAsync之类的封装拦截器只负责状态改写不负责补数据。注意另一个容易被忽略的点是导航方向。EF 的entry.Collections只包含集合导航一对一的引用导航要用entry.References写法类似但CurrentValue是单对象。如果聚合里有 1:1 的子表务必两边都处理不然只级联了一半。2.4 唯一索引与软删除的正面冲突这是软删除绕不过去的一道坎。用户表的邮箱是唯一索引用户 A 被软删除后行还在表里邮箱还占着坑新用户注册同一个邮箱直接报唯一约束冲突。三种解法按数据库支持程度选。方案适用数据库实现方式缺陷过滤索引SQL Server、PostgreSQL、SQLiteHasFilter([IsDeleted] 0)MySQL 不支持组合唯一索引全数据库(Email, DeletedFlag)删除时改标记同一邮箱只能删一次除非标记带时间戳删除时改写字段全数据库删除时把 Email 改成原值__deleted_时间戳数据不再“原样保留”恢复要还原过滤索引最干净SQL Server 和 PostgreSQL 都支持modelBuilder.EntityUser() .HasIndex(u u.Email) .IsUnique() .HasFilter([IsDeleted] 0); // SQL Server 语法PostgreSQL 的写法略有不同要用带引号的标识符和布尔值HasFilter(\IsDeleted\ false)。这里踩过一次坑——照抄 SQL Server 的方括号语法到 PostgreSQL建表时直接报语法错误而且错误信息指向的是一整段 SQL不好定位。MySQL 用户只能走组合索引的路子加一个DeletedSeq字段未删除时固定为 0删除时写入当前时间戳的 ticks 或者一个自增序号然后把唯一索引改成(Email, DeletedSeq)。这样未删除的行因为DeletedSeq都是 0邮箱唯一性依然成立删除后的行因为序号不同可以保留多个历史记录。方案三改写字段我不推荐因为它破坏了“软删除保留原样数据”的初衷将来做数据恢复或者审计比对会很别扭。3. ReadOnly 只读实体让误写在提交前失败3.1 两种只读语义别混为一谈“只读”这个词在 EF Core 语境下至少有两个含义混用会做出四不像的东西。第一种是实体级只读某张表的数据只允许系统初始化时写入运行期任何修改都非法。典型例子是字典表、状态码表、审计日志表本身。这种只读是业务规则应该在保存时强制校验。第二种是查询级只读某次查询不需要跟踪用完就扔为了性能。这是AsNoTracking()的领域跟业务规则无关。很多人把它们混在一起认为“我用 AsNoTracking 查询了所以就是只读”——这是错的。AsNoTracking返回的对象你随便改EF 根本不知道改完也不会报错只是不保存而已。等到某天有人把这段代码改成跟踪查询误修改就静默成功了。所以我的原则是性能优化用 AsNoTracking业务约束用接口加拦截器两者分开做不要互相替代。3.2 拦截器里做状态校验的实现只读实体的守门拦截器非常短短到很多人觉得没必要但它的价值在“防御性”不在代码量public sealed class ReadOnlyGuardInterceptor : SaveChangesInterceptor { public override InterceptionResultint SavingChanges( DbContextEventData eventData, InterceptionResultint result) { Guard(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTaskInterceptionResultint SavingChangesAsync( DbContextEventData eventData, InterceptionResultint result, CancellationToken cancellationToken default) { Guard(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } private static void Guard(DbContext? context) { if (context is null) return; foreach (var entry in context.ChangeTracker.EntriesIReadOnlyEntity()) { if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) { var key entry.Properties .FirstOrDefault(p p.Metadata.IsPrimaryKey())?.CurrentValue; throw new ReadOnlyEntityException( $实体 {entry.Metadata.ClrType.Name} 被标记为只读 $禁止执行 {entry.State} 操作主键{key ?? (新增)}。); } } } }这里有几个设计上的考量值得展开。为什么抛异常而不是静默跳过。静默跳过看起来“优雅”实际上是把问题藏起来了。业务代码以为自己改成功了返回了 200用户刷新页面发现没变然后来投诉。抛异常至少能让开发阶段立刻暴露配合全局异常中间件转成 400 加明确提示体验反而更好。异常信息里带主键。这是被线上问题逼出来的习惯。只报“实体是只读的”你在几百条记录里根本不知道是哪一条触发的。带上主键和实体名日志一搜就能定位到具体请求。只读和软删除的组合要小心。如果一张表既实现了ISoftDelete又实现了IReadOnlyEntity那么删除时软删除拦截器先把状态改成 Update只读拦截器后跑看到 Modified 就抛异常了——这其实是正确行为因为这张表本来就不该删。但你要确保拦截器顺序是先软删除后只读否则错误信息会变成“禁止 Deleted 操作”误导排查方向。3.3 只读的配套措施与常见误判光有拦截器还不够还得配上三样东西才算完整。第一仓储层不暴露写接口。对于只读实体注入的仓储只给IQueryableT或者只读的查询服务不给Add、Update、Remove。编译期就挡住一半的误用比运行时抛异常更早。第二数据库账号权限兜底。运维层面给应用的数据库账号只授予业务表的 SELECT 权限这是最后一道防线。如果有人绕过了 EF 直接执行 SQL或者用了ExecuteSqlRaw应用层的拦截器全部失效只有数据库权限能拦住。第三ExecuteUpdate/ExecuteDelete要单独管控。EF Core 7 之后引入的批量操作 API 走的是另一条路径不经过 ChangeTracker也不触发任何 SaveChanges 拦截器。你在只读表上写一句db.Users.Where(...).ExecuteUpdateAsync(...)拦截器完全不知情数据就改了。我的处理方式是在代码评审里把这两个 API 列为重点检查项或者在仓储基类里干脆不包装它们需要用时必须显式申请。只读保障手段能拦住什么拦不住什么接口 拦截器SaveChanges 路径下的增删改原生 SQL、批量 API、外部直连仓储只暴露查询编译期误调用绕过仓储直接用 DbContext数据库账号只读权限一切写入需要 DBA 配合不适合所有表AsNoTracking无纯性能优化无注意不要把AsNoTracking当成只读保障写进文档它真的只是性能开关。我在一个项目里见过注释写着“此查询使用 AsNoTracking数据只读”结果半年后有人为了用导航属性把它改成了跟踪查询只读约束瞬间消失。4. Audit 审计把变更记录做成可追溯的证据4.1 审计表结构先想清楚要回答什么问题审计表的字段设计不是拍脑袋来的先想清楚半年后你会拿它查什么某个订单是谁在什么时候把金额从多少改成了多少某个用户的操作记录里有没有异常时间点的删除某张表在某个时间段的变更峰值是多少。这些问题决定了字段清单。public class AuditLog { public long Id { get; set; } public string EntityName { get; set; } default!; // 实体类型名 public string TableName { get; set; } default!; // 映射表名便于 DBA 排查 public string Action { get; set; } default!; // Added / Modified / Deleted public string? PrimaryKey { get; set; } public string? ChangedColumns { get; set; } // 逗号分隔便于快速筛选 public string? OldValues { get; set; } // JSON仅变更列 public string? NewValues { get; set; } // JSON仅变更列 public string? OperatorId { get; set; } public string? OperatorName { get; set; } // 快照不依赖用户表 public string? TraceId { get; set; } // 串联同一次请求的多条记录 public string? Ip { get; set; } public DateTimeOffset OccurredAt { get; set; } }几个字段的选择说明一下。为什么存EntityName又存TableName。前者给应用查后者给人查。开发看代码用实体名DBA 在数据库里排查用表名两者映射关系还可能因为表拆分而变化都存下来最省事。为什么存OperatorName快照。用户表里的人是会改名的甚至会被软删除。如果审计只存 ID三年后你把日志导出来全是 GUID没人看得懂。快照冗余在这里是合理的反范式。为什么存TraceId。一次业务操作可能改五张表产生八条审计记录。没有 TraceId这八条就是散的有了它一条 SQL 就能把同一次操作的全貌拉出来。这个字段是我在实际排障中觉得性价比最高的一个成本几乎为零价值极高。为什么OldValues只存变更列。全量存的话一张 50 列的表改一个字段也要写一整行 JSON半年就是几百 G。只存变更列体积能降到十分之一以下。代价是恢复数据时需要先找到上一条完整记录但这个场景本来就很少。索引建议(EntityName, PrimaryKey, OccurredAt)用于按对象追溯(OperatorId, OccurredAt)用于按人追溯(OccurredAt)用于归档清理。别在审计表上建太多索引它是只写的索引越多写入越慢。4.2 拦截器里捕获变化原值、新值、临时值审计拦截器的骨架不复杂难点全在细节private void BuildAudits(DbContext? context) { if (context is null) return; context.ChangeTracker.DetectChanges(); var logs new ListAuditLog(); var now _clock.GetUtcNow(); foreach (var entry in context.ChangeTracker.Entries().ToList()) { if (entry.Entity is AuditLog) continue; if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue; if (entry.State EntityState.Modified) { // 过滤掉标记为已修改但值没变的噪音 var realChanged entry.Properties .Any(p p.IsModified !Equals(p.OriginalValue, p.CurrentValue)); if (!realChanged) continue; } var oldValues new Dictionarystring, object?(); var newValues new Dictionarystring, object?(); foreach (var prop in entry.Properties) { if (prop.Metadata.IsShadowProperty() !prop.Metadata.IsPrimaryKey()) continue; var state entry.State; var changed state ! EntityState.Modified || prop.IsModified; if (!changed) continue; if (state is EntityState.Added or EntityState.Modified !prop.IsTemporary) newValues[prop.Metadata.Name] MaskIfNeeded(prop, prop.CurrentValue); if (state is EntityState.Modified or EntityState.Deleted) oldValues[prop.Metadata.Name] MaskIfNeeded(prop, prop.OriginalValue); } logs.Add(new AuditLog { EntityName entry.Metadata.ClrType.Name, TableName entry.Metadata.GetTableName() ?? entry.Metadata.ClrType.Name, Action entry.State.ToString(), PrimaryKey BuildKeyString(entry), ChangedColumns string.Join(,, newValues.Keys), OldValues oldValues.Count 0 ? null : JsonSerializer.Serialize(oldValues, JsonOpts), NewValues newValues.Count 0 ? null : JsonSerializer.Serialize(newValues, JsonOpts), OperatorId _currentUser.Id, OperatorName _currentUser.Name, TraceId _currentUser.TraceId, OccurredAt now }); } if (logs.Count 0) context.SetAuditLog().AddRange(logs); }几个必须讲清楚的细节。DetectChanges()要显式调一次。在SavingChanges阶段 EF 通常已经检测过变更了但如果你的拦截器在链式注册中排在前面或者业务代码刚改完属性还没触发检测这里手动调一次能保证拿到最新状态。代价是可能重复检测性能上可以接受。IsTemporary的坑。新增实体的自增主键在保存前是一个临时值通常是 0 或者负数。如果你把它写进审计的PrimaryKey新增记录的审计就全是 0完全没法用。处理方式有两种一是主键改用客户端生成的 GUID推荐Guid.CreateVersion7()这类有序 GUID保存前就是最终值二是把新增的主键留空在SavedChangesAsync里回填。第二种要小心回填本身又是一次 SaveChanges容易触发循环需要用一个标志位隔开。值相等的过滤不能省。EF 有时候会把属性标记为 Modified 但值其实没变典型场景是从 DTO 全量映射到实体值一样也覆盖一遍。不过滤的话审计表里会塞满“把 A 改成 A”的记录真正有价值的变更淹没在里面。序列化选项。中文默认会被转义成\uXXXX查日志时看到一堆乱码所以要用JavaScriptEncoder.UnsafeRelaxedJsonEscaping。另外DateTimeOffset的默认序列化格式在不同版本下略有差异建议统一配置一次private static readonly JsonSerializerOptions JsonOpts new() { Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull, WriteIndented false };4.3 敏感字段脱敏与体积控制审计日志最容易被忽视的风险是它把敏感数据复制了一份而且往往是权限管理最松的一张表。手机号、身份证、银行卡号、密码哈希一旦进了审计表等于绕过了脱敏逻辑。实现上很简单加一个标记特性序列化前统一处理[AttributeUsage(AttributeTargets.Property)] public sealed class AuditMaskedAttribute : Attribute { } private static object? MaskIfNeeded(PropertyEntry prop, object? value) { if (value is null) return null; if (prop.Metadata.PropertyInfo? .GetCustomAttributeAuditMaskedAttribute() is null) return value; return value switch { string s when s.Length 4 ***, string s s[..2] new string(*, s.Length - 4) s[^2..], _ *** }; }体积控制是另一个必须提前做的事。审计表是只写不改的增长是线性的而且业务越活跃增长越快。我的做法是三条第一只存变更列前面已经说了第二超过 4000 字符的大字段比如富文本、JSON 配置只记录长度和哈希值的前八位不存原文第三建表时就按时间分区或者至少准备一个归档任务把 180 天前的数据搬到冷表。注意审计表自身的删除操作要慎重。合规场景下审计日志是不允许应用层删除的所以我会把AuditLog实现成IReadOnlyEntity同时给数据库账号不授予 DELETE 权限归档走后台任务加专用账号。这样即使有人写错了代码也删不掉历史记录。4.4 事务一致性与“审计自身不被审计”审计记录和业务数据是不是同一个事务这个选择没有标准答案取决于你对“审计缺失”的容忍度。策略一致性风险适用场景同一 SaveChanges 内写入强一致审计写失败导致业务回滚财务、合规、凭证类独立 DbContext 异步写入最终一致业务成功但审计丢高并发日志类业务表 Outbox最终一致实现复杂审计需要跨库绝大多数后端系统我建议直接走第一种在拦截器里context.SetAuditLog().AddRange(logs)让 EF 把审计记录和业务变更放在同一个事务里提交。代码最简单一致性最强。代价是审计表写入慢的时候会拖累业务所以审计表的索引要克制避免成为热点。如果审计要写到另一个库同一事务就不成立了只能走独立连接加补偿或者用 Outbox 模式业务变更时往本地表写一条待发送记录后台任务异步搬运。后者工程量大只在真正需要跨库时上。还有一个小陷阱是审计自身被审计。你在拦截器里遍历ChangeTracker.Entries()如果之前已经往上下文里 Add 了审计实体遍历时它也在里面会导致审计的审计。所以循环开头必须if (entry.Entity is AuditLog) continue;。另外Entries().ToList()这个 ToList 也别省一边遍历一边 Add 会抛集合修改异常。5. 组合顺序与完整落地5.1 拦截器执行顺序审计必须在软删除之后这是整套方案里最容易出错、也最难排查的一点。多个拦截器通过AddInterceptors注册执行顺序就是注册顺序同一事件阶段内。如果你的顺序是“审计 → 软删除”那么审计抓到的状态是Deleted写出来的记录 Action 是 Deleted新旧值对照全都不对而软删除拦截器随后把它改成 Update 并只更新三列——审计记录的“新值”里根本没有IsDeletedtrue这个信息追溯链断掉。正确的顺序是services.AddDbContextAppDbContext((sp, options) { options.UseSqlServer(connectionString) .AddInterceptors( sp.GetRequiredServiceSoftDeleteInterceptor(), // 1. 先把 Deleted 改写掉 sp.GetRequiredServiceTimestampInterceptor(), // 2. 补时间戳 sp.GetRequiredServiceReadOnlyGuardInterceptor(), // 3. 校验只读约束 sp.GetRequiredServiceAuditInterceptor()); // 4. 最后抓最终状态 });为什么时间戳排在只读前面因为只读校验失败就没有保存的必要了先跑时间戳没有副作用但可以保证审计抓到的时间戳是最终值。为什么审计必须最后因为它记录的是“即将落库的最终形态”任何后续的状态改写都不能再发生。顺带一提AddInterceptors里从容器里解析拦截器要求这些拦截器在 DI 中注册为 Scoped因为它们依赖ICurrentUser这类请求作用域的服务services.AddScopedICurrentUser, CurrentUser(); services.AddScopedSoftDeleteInterceptor(); services.AddScopedTimestampInterceptor(); services.AddScopedReadOnlyGuardInterceptor(); services.AddScopedAuditInterceptor();用 Singleton 会在多线程下共享状态ICurrentUser一旦是 Scoped容器直接抛异常算是天然的保护。5.2 上下文与实体的最小落地清单把前面的片段拼起来一个可运行的最小落地大概长这样public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetOrder Orders SetOrder(); public DbSetAuditLog AuditLogs SetAuditLog(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.EntityOrder(b { b.HasQueryFilter(o !o.IsDeleted); b.HasIndex(o o.OrderNo).IsUnique().HasFilter([IsDeleted] 0); b.Property(o o.RowVersion).IsRowVersion(); }); modelBuilder.EntityAuditLog(b { b.ToTable(SysAuditLog); b.HasIndex(a new { a.EntityName, a.PrimaryKey, a.OccurredAt }); b.HasIndex(a new { a.OperatorId, a.OccurredAt }); }); } }实体侧public class Order : ISoftDelete, IAuditable { public Guid Id { get; set; } Guid.CreateVersion7(); // 客户端生成审计可直接取 public string OrderNo { get; set; } default!; public decimal Amount { get; set; } public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; } public DateTimeOffset CreatedAt { get; set; } public string? CreatedBy { get; set; } public DateTimeOffset? UpdatedAt { get; set; } public string? UpdatedBy { get; set; } public byte[]? RowVersion { get; set; } }这里的参数选择也解释一下。主键用Guid.CreateVersion7()而不是自增 long原因有三个客户端能提前生成审计拦截器在 Added 阶段就能拿到最终主键有序 GUID 在 InnoDB 聚簇索引上不会造成随机写入分库分表时不需要额外的号段服务。代价是存储占用从 8 字节变成 16 字节索引体积增加对于千万级以上的表要评估一下。审计日志表用独立表名SysAuditLog加前缀主要是方便运维在数据库里区分业务表和系统表做备份策略时好分开处理。5.3 配置参数与行为开关的取舍一套基础设施要给团队用就得留几个开关不然总有人提“我这张表不要软删除”“我这张表不要审计”。[AttributeUsage(AttributeTargets.Class)] public sealed class SkipAuditAttribute : Attribute { } [AttributeUsage(AttributeTargets.Class)] public sealed class SkipSoftDeleteAttribute : Attribute { } // 拦截器里判断 if (entry.Metadata.ClrType.IsDefined(typeof(SkipAuditAttribute), true)) continue; if (entry.Metadata.ClrType.IsDefined(typeof(SkipSoftDeleteAttribute), true)) continue;两个开关够用了别设计更多。开关一多团队里没人说得清某张表到底是什么行为最后还是要翻代码。有一个参数值得单独说软删除的DeletedBy和审计的OperatorId是否允许为空。系统后台任务、数据迁移脚本、初始化种子数据这类场景没有当前登录用户。我的做法是允许为空但拦截至上写明来源比如_currentUser.Id ?? system、_currentUser.Id ?? job:migrate-2024。这比留空好至少能在审计里区分“人操作”和“程序操作”。6. 常见问题与排查速查6.1 几个真实踩过的坑软删除后列表里还能看到数据。第一反应是拦截器没生效实际上多数是查询走了IgnoreQueryFilters()或者原生 SQL。排查方法打开 EF 的 SQL 日志看生成的语句里有没有WHERE [IsDeleted] 0。没有就是过滤器没挂上有就是查询绕过了。审计记录 Action 全是 Deleted。前面说过拦截器顺序反了。改注册顺序即可但要注意改完之后历史数据的语义已经不一致了可能需要写个脚本修正或者至少在文档里标注切换时间点。删除操作报并发冲突。软删除走的是 Update如果表上有RowVersion而你在删除前的一次查询和删除之间隔了很久并发令牌就对不上了。这不是软删除本身的错是并发控制的正常表现前端要做的是提示重试而不是报 500。审计表半年涨到 80G。检查两件事是不是把大字段全量存进去了是不是把每一条查询也记进去了。前者用列过滤加截断后者说明你的拦截器写错了位置——只有在状态是 Added/Modified/Deleted 时才记Unchanged 一律跳过。只读实体的拦截器不生效。三种可能实体没实现IReadOnlyEntity拦截器没注册进AddInterceptors或者业务代码走的是ExecuteUpdate。第三种最隐蔽因为代码看起来完全正常建议在仓储层把这两个 API 包一层加上日志或者直接禁用。软删除的子表数据成了孤儿。级联没做或者做了但删除时没Include子集合。排查方法查一下子表里ParentId对应的父记录是不是IsDeleted1而子记录还是 0。修复方案是写个后台任务做一次性的孤儿数据清理然后在前面的删除服务里强制预加载。6.2 排查速查表现象大概率原因处理动作已删除数据仍可查询走了原生 SQL / 批量 API / IgnoreQueryFilters查 EF 生成的 SQL确认过滤条件是否存在数据被物理删除同步方法未重写、拦截器未注册、走 ExecuteDelete全局搜Remove与ExecuteDelete检查双方法重写审计缺记录异步/同步方法只重写了一个补全SavingChanges与SavingChangesAsync审计主键为 0自增主键在 Added 阶段是临时值改客户端 GUID或 SavedChanges 后回填审计新旧值一样未过滤IsModified但值相等的属性加!Equals(Original, Current)判断唯一索引冲突软删除行仍占用唯一键过滤索引或组合唯一索引只读实体被改AsNoTracking 后重新 Attach或走批量 API检查查询跟踪方式与批量 API 使用删除报并发异常RowVersion 冲突前端重试或改用软删除时间戳做乐观锁审计表暴涨全量存储 无归档只存变更列大字段截断按月归档拦截器顺序异常注册顺序与预期不符显式按 1-2-3-4 顺序注册并写注释6.3 几条踩坑之后的个人习惯第一我会在项目初始就把这三个拦截器和一个统一的AuditLog表建好哪怕当前业务用不上。原因很现实基础设施这种东西等到“真的需要审计”的时候再加往往已经有几十万条历史数据没法追溯了而且改造时要动所有实体的基类和所有 DbContext 的映射成本比一开始就做高十倍。第二软删除的字段我倾向于定义在一个公共基类SoftDeletableEntity里而不是每个实体手写三个属性。手写一次可能漏一个漏掉的那个实体就是将来的事故点。但基类不要做太深一层足够继承链一深EF 的 TPH 映射和查询过滤器都会变得难以预料。第三审计日志我只在开发环境和预发环境开全量生产环境按表配置。核心表全记录日志表、会话表、临时表这些关掉。全开的后果我见过——某项目的审计表比业务表还大备份时间从十分钟变成一小时最后不得不做在线归档代价远超收益。第四凡是涉及软删除、只读、审计的改动我一定会写一个集成测试跑一遍完整链路新增、修改、删除、查询、恢复。单测很难覆盖拦截器的顺序问题只有真正跑一次SaveChanges才能发现“审计记录里怎么没有 IsDeleted”这种问题。这个测试写一次能省掉后面无数次线上排查。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑