尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
EF Core 软删除、只读实体与审计日志落地实践
1. 这三件事为什么要放在一起做先说清楚 SafeDelete、ReadOnly、Audit 这三个词在我这个系列里的具体含义因为不同团队对它们的理解差别挺大。SafeDelete我指的是把物理删除改写成业务删除同时兜底拦住所有绕过业务层的 DELETEReadOnly指的是某类实体一旦写入就不再允许修改谁能改、改哪个字段由模型层说了算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拦截器里。我见过最多的写法是直接把状态改成Modifiedentry.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 ValueTaskInterceptionResultint SavingChangesAsync( DbContextEventData eventData, InterceptionResultint result, CancellationToken cancellationToken default) { var ctx eventData.Context; if (ctx is null) return base.SavingChangesAsync(eventData, result, cancellationToken); foreach (var entry in ctx.ChangeTracker.EntriesISoftDelete()) { 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()软删除逻辑形同虚设数据是真删了。我见过一个项目大体上做了软删除结果上线三个月后发现有张表的记录一直在少最后定位到某个批处理任务用了批量删除接口。防御手段有两层。一层是架构层面不对外暴露DbSetT统一走仓储并且仓储不提供ExecuteDelete方法。另一层是技术兜底用DbCommandInterceptor拦DbCommand正则匹配以DELETE FROM开头的语句命中就抛异常把 CI 环境和测试环境当成护栏。虽然有点重但换来的是任何人都不可能写出物理删除我觉得值。public sealed class PhysicalDeleteGuardInterceptor : DbCommandInterceptor { public override InterceptionResultDbDataReader ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResultDbDataReader result) { Guard(command); return base.ReaderExecuting(command, eventData, result); } public override ValueTaskInterceptionResultint NonQueryExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResultint 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.EntityOrder(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 ValueTaskInterceptionResultint SavingChangesAsync( DbContextEventData eventData, InterceptionResultint result, CancellationToken cancellationToken default) { var ctx eventData.Context; if (ctx is null) return base.SavingChangesAsync(eventData, result, cancellationToken); var violations ctx.ChangeTracker.EntriesIReadOnlyEntity() .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 IQueryableT QueryReadOnlyT() where T : class, IReadOnlyEntity _db.SetT().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.EntriesIAuditable()) { 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取取不到就返回systempublic 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 / DeletedChangedColumnsnvarchar(512)变更字段名列表逗号分隔OldValuesnvarchar(max)变更前的值JSONNewValuesnvarchar(max)变更后的值JSONUserIdnvarchar(64)操作人Timestampdatetimeoffset操作时间为什么EntityId用字符串因为主键类型五花八门有 int、long、Guid、复合键统一转字符串最省事代价是索引效率略低于原生类型。对审计日志这种写入量远大于查询量、查询通常还带时间范围条件的表可以接受。变更值的序列化我建议只取entry.Properties.Where(p p.IsModified)并且排除导航属性。直接序列化整个实体有两个后果一是日志表体积暴涨二是可能把敏感字段手机号、身份证、密码哈希写进去。序列化前必须过一层字段白名单或者黑名单这个不是可选项。写入时机这里有个绕不开的问题新增实体的主键在SaveChanges之前还没生成。两种解法主键用 GUID插入前就能拿到日志可以和业务数据一次性写入同事务在SavedChangesAsync里回填先收集变更快照等业务数据落库后再生成日志并二次SaveChanges。我用的第二种因为表主键是自增 bigint业务方要求。实现上大概是这样public override async ValueTaskint 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.SetAuditLog().Add(item.Log); } _pending.Clear(); return result await eventData.Context.SaveChangesAsync(ct); }注意这里的二次SaveChangesAsync会再次触发拦截器。因为AuditLog本身不实现IAuditable也不会产生新的日志条目所以不会死循环。但如果哪天有人手贱给AuditLog加上IAuditable那就是无限递归加内存爆掉所以我在拦截器第一行加了个防御if (eventData.Context.ChangeTracker.EntriesAuditLog().Any(e e.State EntityState.Added)) return;。还有一个必须确认的点二次SaveChanges和第一次是不是同一个事务如果你在业务层用了显式事务BeginTransaction是的都在同一个事务里如果没有用显式事务EF Core 会为每次SaveChanges各自开一个隐式事务就出现业务数据写进去了、日志没写进去的风险。我的处理方式是在DbContext里重写SaveChangesAsync对多步保存统一包事务而不是依赖拦截器自己猜。4.3 与软删除、只读的联动三个拦截器要一起用顺序就有讲究了。我按这个顺序注册options.AddInterceptors( sp.GetRequiredServiceReadOnlyGuardInterceptor(), sp.GetRequiredServiceSoftDeleteInterceptor(), sp.GetRequiredServiceAuditInterceptor());理由很直接。只读校验必须最先执行因为一旦有违规就应该立刻中止不该让后面的拦截器做无用功也不该让软删除去改写一个本来就不该被删的实体。软删除改写放在中间因为它会改变实体的状态和字段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这个格式方便集中管理。三个特性的代码量加起来其实不到五百行但它们带来的约束是长期的。真正难的不是写拦截器而是让团队里所有人都知道这里有护栏别想着绕过去。文档、代码审查、单元测试三样配合才能让这套东西一直有效。
RELATED

相关推荐

代理型AI如何重塑零售媒体生态

代理型AI如何重塑零售媒体生态

1. 零售媒体战局的新变量:代理型AI的崛起沃尔玛近期在技术峰会上透露的战略方向值得玩味——他们正在借鉴亚马逊的零售媒体网络(Retail Media Network)打法,但把筹码押在了"代理型人工智能"这个新兴领域。作为在零售科技…

📅 2026/10/27 2:41:22
学生学籍管理系统数据库课程设计:建表、索引、视图、存储过程与触发器避坑

学生学籍管理系统数据库课程设计:建表、索引、视图、存储过程与触发器避坑

简介:面向计算机专业课程设计与数据库课程实践的学籍管理系统文档,围绕学生基本信息、学籍与成绩数据的查询和维护,给出从需求分析到数据库落地的完整设计思路。内容覆盖教务处管理员与学生两类角色及权限分配,教师、学生、课程、…

📅 2026/10/27 2:41:22
AI模型训练数据预处理与特征工程实践指南

AI模型训练数据预处理与特征工程实践指南

1. AI模型训练数据预处理的核心价值在机器学习项目生命周期中,数据预处理环节往往消耗60%以上的开发时间。去年Kaggle竞赛优胜者调研显示,85%的团队将模型效果提升归因于数据质量的优化。以计算机视觉任务为例,未经处理的原始图像数据通常存在…

📅 2026/10/27 2:36:22
MORE NEWS

更多资讯

📰

数字孪生三维可视化平台选型与实施指南

1. 数字孪生三维可视化平台的核心价值当工业4.0遇上元宇宙,数字孪生技术正在重塑传统行业的运营模式。作为物理世界的数字镜像,一个优秀的三维可视化平台能让你在虚拟环境中实时操控实体设备、预测系统故障、优化生产流程。去年某汽车工厂通过部署数字孪…

📰

从IPDRR到安全能力矩阵:网络安全体系方法论落地

简介:《网络安全体系方法论》是一份面向企业信息安全负责人、安全咨询顾问与运维管理人员的文档资料,聚焦安全建设长期停留在漏洞修补和产品堆叠、难以与业务运营协同的问题,提供从组织、管理到技术的体系化落地参考。文档共1个doc文件&#…

📰

基于OpenCV与EAR的实时人眼、眨眼与闭眼检测

简介:这是一份面向计算机视觉初学者与 Python 实践者的实战型学习资料,围绕用 Python 与 OpenCV 在 Ubuntu 下实现实时人眼识别、眨眼检测与闭眼检测展开,包含可直接运行的源代码与配套教程,帮助读者理解 Haar 级联分类器定位眼部…

📰

CentOS 7 自建 Bitwarden 密码服务器:Docker 部署与 Nginx 反向代理实战

密码管理这件事,我踩过的坑比大多数人吃过的盐都多。早些年用记事本存密码,后来换浏览器自带同步,再后来试过各种在线密码本,直到有一次帮朋友恢复一台被勒索软件加密的机器,发现他所有密码都躺在一个明文Excel里——那…

📰

Copilot会话导出技术解析:浏览器扩展实现原理与实战避坑

1. 这个需求背后的真实痛点:Copilot会话管理的“隐形断层”你有没有试过在Microsoft Copilot里连续聊了七八个不同主题——写周报、改简历、查Python报错、生成PPT大纲、翻译合同条款、润色英文邮件、对比三款NAS设备参数……左侧会话列表密密麻麻,像一排…

📰

AI开发环境配置指南:CUDA与PyTorch版本匹配实战

1. 为什么初学者需要重视环境配置刚接触AI Agent开发的新手常犯的一个错误是直接跳入模型训练环节,而忽略了基础环境的搭建。这种急于求成的做法往往会导致后续出现各种难以排查的问题。我见过太多案例,学员花了两周时间调试代码,最后发现是C…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬