尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

EF Core拦截器实战:用SaveChangesInterceptor和DbCommandInterceptor实现数据审计与SQL监控

EF Core拦截器实战:用SaveChangesInterceptor和DbCommandInterceptor实现数据审计与SQL监控 这几年用 EF Core 做业务系统踩过最多的坑其实不是 LINQ 写不对也不是迁移冲突而是“谁在什么时候改了什么”。线上数据被改错了翻遍日志找不到操作人只能对着数据库日志干瞪眼。后来我把 EF Core 拦截器彻底玩明白之后这套问题才算真正解决——SaveChangesInterceptor 负责在数据提交前后偷看 ChangeTrackerDbCommandInterceptor 负责在 SQL 命令级别做监控和干预两者组合起来一套可落地的审计方案就出来了。这篇文章不打算讲官方文档里已经有的接口清单而是从实战角度拆解四个问题EF Core 拦截器到底是什么、SaveChangesInterceptor 怎么写才能不踩坑、DbCommandInterceptor 能帮我们干什么、以及一套审计方案完整落地时要考虑哪些边角料。适合正在用 EF Core 做业务系统、被审计需求或者 SQL 监控需求折磨过的 .NET 开发。先把话说在前面文章里的“审计”指的是数据审计Audit Trail也就是记录数据行的增删改。如果你搜过 seay 源代码审计系统这类安全工具那是源代码层面的东西跟咱们要聊的数据变更审计完全是两条路别混。1. 先把 EF Core 拦截器的位置搞清楚1.1 一次 SaveChanges 调用EF Core 内部发生了什么很多人把 DbContext 当成一个“黑盒数据库连接器”其实 EF Core 的架构是分层的最外层是你写的 LINQ 查询或者实体状态变更中间是 EF Core 的查询管道和状态管理器最底层才是 ADO.NET 的 DbCommand、DbConnection 这些传统对象。以一次SaveChanges()为例内部大致会经历这几步调用ChangeTracker.DetectChanges()把实体属性的改动同步到状态管理器。根据实体状态Added、Modified、Deleted生成对应的 Insert、Update、Delete 命令。将需要执行的命令交给数据库执行涉及事务时开启事务。执行完毕后根据返回结果更新实体的临时值比如自增主键。处理并发令牌冲突、级联删除等后续逻辑。拦截器就是嵌在这些关键节点上的钩子。EF Core 从 3.0 开始正式提供IInterceptor体系到 5.0 左右基本成熟现在官方把拦截器分成几个明确的接口分别对应不同的生命周期节点。1.2 EF Core 官方拦截器家族一览目前实际用得上的拦截器接口主要有这几个ISaveChangesInterceptor在SaveChanges/SaveChangesAsync前后触发是做审计、自动填充审计字段、软删除过滤的黄金位置。IDbCommandInterceptor在 SQL 命令创建、执行、失败等节点触发适合做慢查询监控、SQL 改写、统一超时设置。IDbConnectionInterceptor拦截连接打开、关闭、异常等事件。IDbTransactionInterceptor拦截事务开始、提交、回滚等事件。IMaterializationInterceptor实体从数据库结果集实例化时触发可以做字段脱敏、默认值填充。日常开发里前两个用得最多。后面三个偏向基础设施等你对 EF Core 的事件模型熟悉了再碰也不迟。微软已经帮你准备好了几个抽象基类SaveChangesInterceptor、DbCommandInterceptor、DbConnectionInterceptor等继承它们然后按需重写方法比直接实现接口舒服得多。1.3 拦截器、过滤器、中间件别再傻傻分不清热门搜索里经常有人问“拦截器和过滤器的区别”“springmvc 拦截器和 filter 区别”这里顺带把概念理清楚。它们的核心区别是所在的层级不同层级代表技术触发时机典型用途HTTP 管道最外层ASP.NET Core 中间件、Servlet Filter请求进入/离开应用时编码、CORS、日志、鉴权MVC 控制器层Spring MVC HandlerInterceptor、ASP.NET Core 中的 FilterAction 方法前后登录校验、模型处理数据访问层EF Core 的 SaveChangesInterceptor、DbCommandInterceptorSaveChanges、SQL 执行前后审计、SQL 监控、软删除HTTP 客户端axios 拦截器、原生 fetch 封装前端请求发出/响应返回注入 Token、统一错误处理比如 Spring MVC 的 HandlerInterceptor 拦的是 Controller 的请求生命周期ASP.NET Core 的 Middleware 拦的是 HTTP 管道axios 拦截器管的是浏览器到后端这条链路它们都碰不到数据库。而 EF Core 拦截器是离数据库最近的一层甚至能直接改 SQL 命令。前端那边“用原生 fetch 封装拦截器还是 axios 拦截器”的争论跟咱们聊的 EF Core 拦截器不在一个维度理解层级关系后就不会再混淆。2. SaveChangesInterceptor审计日志最该下手的地方2.1 先搞清楚这些方法什么时候被调用SaveChangesInterceptor抽象类的核心方法有一组对称的生命周期SavingChanges/SavingChangesAsync在真正执行数据库命令之前调用。SavedChanges/SavedChangesAsync在所有命令成功执行之后调用。SaveChangesFailed/SaveChangesFailedAsync在保存失败时调用。ThrowingConcurrencyException/ThrowingConcurrencyExceptionAsyncEF Core 7 新增在并发冲突异常抛出前触发。方法都分同步和异步两套这点至关重要业务代码里调用的是同步SaveChanges()Interceptor 里只会触发同步的SavingChanges调SaveChangesAsync()则触发异步版本。如果你只重写了异步方法而业务里全是同步调用拦截器看起来就是“不生效”。反过来也一样别笑这个坑我见过不止一次。SavingChanges能拿到DbContextEventData里面有Context属性通过它可以拿到当前 DbContext 实例和它的ChangeTracker。审计的核心素材全在 ChangeTracker 里。2.2 怎么从 ChangeTracker 里把变更信息挖干净ChangeTracker 里每个被跟踪的实体对应一个EntityEntry它暴露了三个关键对象CurrentValues实体当前值。OriginalValues从数据库加载时的原始值或者上次保存后的值。DatabaseValues数据库中当前的值只有在使用Reload或并发处理时才有意义。对于不同状态取值策略完全不同Added 状态没有原始值应该记录CurrentValues。Deleted 状态CurrentValues已经被清空或无效要读OriginalValues。Modified 状态需要逐个属性判断IsModified只记录真正发生变化的属性否则一次 Update 会把所有字段都写进审计干扰排障。还有一个非常容易忽略的细节临时值。新增实体时如果主键是数据库自增IDENTITY在SaveChanges执行前主键属性里的值是一个负的临时值比如-2147482647EF Core 用它来维持对象间的引用关系。此时如果直接把主键值写进审计日志记录的就是一个没用的负数。后面专门讲怎么处理。遍历属性时有个技巧property.Metadata.IsPrimaryKey()可以判断是否主键列主键单独取出来存成EntityId字段其他列按新旧值分别收集。注意property.IsTemporary需要排除因为它只是 EF Core 内部用的占位值。2.3 一个可以直接抄的审计拦截器我直接给一套经过实战验证的实现。核心思路是在SavingChanges阶段把审计日志实体加到同一个 DbContext 里让审计数据和业务数据在同一个事务、同一次SaveChanges中提交这样两边永远一致。public class AuditSaveChangesInterceptor : SaveChangesInterceptor { private readonly ICurrentUser _currentUser; private readonly JsonSerializerOptions _jsonOptions new() { ReferenceHandler ReferenceHandler.IgnoreCycles, DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull }; private readonly List(object Entity, AuditLog Audit) _pendingAdded new(); public AuditSaveChangesInterceptor(ICurrentUser currentUser) { _currentUser currentUser; } public override InterceptionResultint SavingChanges( DbContextEventData eventData, InterceptionResultint result) { var context eventData.Context; if (context null) { return base.SavingChanges(eventData, result); } // 先拍快照避免遍历过程中修改集合 var entries context.ChangeTracker.Entries().ToList(); foreach (var entry in entries) { // 审计表自身的变更不再递归产生审计 if (entry.Entity is AuditLog) { continue; } if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) { continue; } var auditLog new AuditLog { TableName entry.Metadata.GetTableName(), Operation entry.State.ToString(), OperatorId _currentUser.UserId, OperatorName _currentUser.UserName, ClientIp _currentUser.IpAddress, CreatedAt DateTime.UtcNow }; if (entry.Metadata.FindPrimaryKey() is { } pk) { auditLog.EntityId entry.Property(pk.Properties[0].Name).CurrentValue?.ToString(); } var oldValues new Dictionarystring, object?(); var newValues new Dictionarystring, object?(); foreach (var property in entry.Properties) { if (property.Metadata.IsPrimaryKey()) { continue; } switch (entry.State) { case EntityState.Added when !property.IsTemporary: newValues[property.Metadata.Name] property.CurrentValue; break; case EntityState.Deleted: oldValues[property.Metadata.Name] property.OriginalValue; break; case EntityState.Modified when property.IsModified: oldValues[property.Metadata.Name] property.OriginalValue; newValues[property.Metadata.Name] property.CurrentValue; break; } } auditLog.OldValuesJson JsonSerializer.Serialize(oldValues, _jsonOptions); auditLog.NewValuesJson JsonSerializer.Serialize(newValues, _jsonOptions); auditLog.ChangedColumns string.Join(,, newValues.Keys); if (entry.State EntityState.Added) { _pendingAdded.Add((entry.Entity, auditLog)); } context.Add(auditLog); } return base.SavingChanges(eventData, result); } }有几个细节说下第一context.Add(auditLog)把审计实体也标记为 AddedEF Core 会把它跟业务数据一起打包进本次 SaveChanges 的命令批处理里事务一致不用额外维护事务。第二entry.Metadata.GetTableName()拿到的是数据库表名而不是实体类名。如果你的实体用了 Table 特性或 ToTable 映射这个值才是审计时候真正关心的。第三_pendingAdded里存的是新增实体和对应审计日志的配对关系用来解决自增主键的问题下面马上说。2.4 Added 实体的主键问题与事后回填刚才提到自增主键在保存前是临时负值。如果此时把auditLog.EntityId写成这个负值审计记录里就出现一堆-2147482647没法定位数据行。解决办法是在SavedChanges阶段做一次回填。此时业务实体已经被 EF Core 更新为真实主键我们可以通过之前缓存的实体引用找到对应的AuditLog把EntityId补成真实值。public override int SavedChanges( SaveChangesCompletedEventData eventData, int result) { base.SavedChanges(eventData, result); var context eventData.Context; if (context null || _pendingAdded.Count 0) { return result; } var needSave false; foreach (var (entity, audit) in _pendingAdded) { var entry context.Entry(entity); var pk entry.Metadata.FindPrimaryKey(); if (pk ! null) { var keyValue entry.Property(pk.Properties[0].Name).CurrentValue; audit.EntityId keyValue?.ToString(); needSave true; } } if (needSave) { // 第二次 SaveChanges只更新 AuditLog 表 context.SaveChanges(); } _pendingAdded.Clear(); return result; }这里要注意两点第二次SaveChanges()会再次触发拦截器但因为业务实体此时都是 Unchanged 状态而 AuditLog 实体被我们的第一段代码过滤掉了所以不会无限递归。如果业务系统的主键是客户端生成的 Guid保存前就已经有值就不存在临时主键问题_pendingAdded那套逻辑可以整个省略。依赖注入时审计拦截器不要用AddSingleton注册。因为它依赖ICurrentUser而后者通常依赖IHttpContextAccessor这个 scoped 服务生命周期不匹配会导致解析异常或拿到空用户。正确姿势是 scoped 注册然后从AddDbContext的服务提供器里取builder.Services.AddHttpContextAccessor(); builder.Services.AddScopedICurrentUser, CurrentUser(); builder.Services.AddScopedAuditSaveChangesInterceptor(); builder.Services.AddDbContextAppDbContext((sp, options) { var interceptor sp.GetRequiredServiceAuditSaveChangesInterceptor(); options.UseSqlServer(builder.Configuration.GetConnectionString(Default)) .AddInterceptors(interceptor); });3. DbCommandInterceptor在 SQL 命令层面做监控3.1 这个拦截器能拦到什么DbCommandInterceptor管的是更底层的东西——数据库命令。只要你用的是关系型数据库EF Core 最终都会把 LINQ 或者实体状态翻译成 SQL 命令这些命令在真正交给 ADO.NET 执行前后都会经过这个拦截器。按执行结果的类型方法分成三组ReaderExecuting/ReaderExecuted执行查询返回DbDataReader对应查询操作。ScalarExecuting/ScalarExecuted执行返回单值的结果比如Count()、Any()、Sum()。NonQueryExecuting/NonQueryExecuted执行返回影响行数的命令比如 Insert 和 Update。每组都有对应的 Async 版本。EF Core 7 之后官方还引入了更统一的CommandExecuting/CommandExecuted方法用一套方法覆盖所有命令类型旧的三分法依然保留兼容。如果你的项目是 EF Core 7 以上建议优先用新方法代码更精简语义更清晰。这些方法都能拿到CommandEventData或CommandExecutedEventData里面有几个非常有用的属性Command当前执行的DbCommand可以看CommandText和Parameters。Context当前的DbContext没有的话说明是 EF Core 内部命令。Duration命令执行耗时CommandExecutedEventData上。CommandSource命令来源可以是LinqQuery、FromSqlQuery、SaveChanges、BulkUpdate等用来区分查询和写操作很有用。3.2 慢查询监控实战一个特别常见的需求是慢查询日志。EF Core 官方日志里本来有Microsoft.EntityFrameworkCore.Database.Command的耗时信息但日志级别通常控制得比较粗而且格式是给框架用的不够直观。自己写拦截器可以完全控制日志内容、阈值和过滤规则。public class SlowQueryCommandInterceptor : DbCommandInterceptor { private static readonly TimeSpan SlowQueryThreshold TimeSpan.FromSeconds(2); private readonly ILoggerSlowQueryCommandInterceptor _logger; public SlowQueryCommandInterceptor(ILoggerSlowQueryCommandInterceptor logger) { _logger logger; } public override DbDataReader ReaderExecuted( DbCommand command, CommandExecutedEventData eventData, DbDataReader result) { WriteSlowLogIfNeeded(command, eventData); return base.ReaderExecuted(command, eventData, result); } public override object ScalarExecuted( DbCommand command, CommandExecutedEventData eventData, object result) { WriteSlowLogIfNeeded(command, eventData); return base.ScalarExecuted(command, eventData, result); } public override int NonQueryExecuted( DbCommand command, CommandExecutedEventData eventData, int result) { WriteSlowLogIfNeeded(command, eventData); return base.NonQueryExecuted(command, eventData, result); } private void WriteSlowLogIfNeeded(DbCommand command, CommandExecutedEventData eventData) { if (eventData.Duration SlowQueryThreshold) { return; } _logger.LogWarning( 慢查询 {Duration}ms来源 {Source}SQL: {Sql}, eventData.Duration.TotalMilliseconds, eventData.CommandSource, command.CommandText); } }注意eventData.Duration是框架帮你计时的不需要自己再开 Stopwatch。如果你要统计的是“从发起到拿到结果”的完整时间Duration已经覆盖了命令发送到响应返回的区间足够用了。这个拦截器注册成单例就行因为它只依赖ILogger。每次查询都会触发它逻辑必须保持轻量日志异步写入这种优化到业务量大起来再考虑也不迟。3.3 修改命令参数和超时的玩法DbCommandInterceptor不只是能看还能改。最常用的两个场景是统一设置命令超时和修改 SQL 命令文本。比如某个老系统里供应商提供的数据库偶尔会慢默认 30 秒超时不够用你又不想在所有查询上加大CommandTimeout可以通过拦截器按命令来源有选择地调整public override InterceptionResultDbDataReader ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResultDbDataReader result) { if (eventData.CommandSource CommandSource.LinqQuery command.CommandTimeout 60) { command.CommandTimeout 60; } return base.ReaderExecuting(command, eventData, result); }再比如某些特定的查询想强制走索引提示或者给表加WITH (NOLOCK)前提是你能接受脏读也可以在ReaderExecuting里改command.CommandText。不过这一步要非常谨慎SQL 文本是 EF Core 根据查询管道拼接出来的直接改字符串很容易破坏参数占位符。我一般只建议做简单替换或者附加提示复杂的 SQL 改写宁可改成FromSqlRaw自己控制。拦截器修改命令文本时如果要追加提示用CommandInitializing事件更合适它在命令初始化阶段触发参数都设置完毕改CommandText相对安全。但是请注意测试一定要覆盖所有可能的查询形状不然一个线上事故就够你喝一壶。4. 审计方案完整落地从拦截器到能查的日志4.1 审计日志表怎么设计前面代码里已经用到了AuditLog实体表结构设计其实很讲究。我推荐的字段如下字段类型说明Idbigint 自增主键TableNamenvarchar(120)被修改的表名Operationnvarchar(20)Added / Modified / DeletedEntityIdnvarchar(50)主键值新增数据事后回填OldValuesJsonnvarchar(max)修改前数据 JSONNewValuesJsonnvarchar(max)修改后数据 JSONChangedColumnsnvarchar(max)发生变更的列名逗号分隔OperatorIdnvarchar(50)操作人 IDOperatorNamenvarchar(120)操作人姓名ClientIpnvarchar(50)客户端 IPCreatedAtdatetime2操作时间索引方面高频查询通常是“某张表最近有哪些改动”“某个操作人最近改了什么”所以至少建两个复合索引CREATE INDEX IX_AuditLog_TableName_CreatedAt ON AuditLog(TableName, CreatedAt DESC); CREATE INDEX IX_AuditLog_OperatorId_CreatedAt ON AuditLog(OperatorId, CreatedAt DESC);OldValuesJson和NewValuesJson用 JSON 而不是拆成一张多行的明细表主要是权衡。拆成明细表查询方便但审计写入变成了 1 对 N 的插入事务和性能成本都会上升。实际项目里大部分审计查询都是“看某一行记录前后变成了什么”JSON 已经足够而且序列化实现起来简单得多。4.2 用户、IP、时间上下文怎么拿审计日志必须带上“谁干的”才有意义。ASP.NET Core 里标准的做法是注入IHttpContextAccessorpublic class CurrentUser : ICurrentUser { private readonly IHttpContextAccessor _httpContextAccessor; public CurrentUser(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor httpContextAccessor; } public string? UserId _httpContextAccessor.HttpContext? .User.FindFirst(ClaimTypes.NameIdentifier)?.Value; public string? UserName _httpContextAccessor.HttpContext? .User.FindFirst(ClaimTypes.Name)?.Value; public string? IpAddress _httpContextAccessor.HttpContext? .Connection.RemoteIpAddress?.ToString(); }注意一个问题SaveChangesInterceptor里拿IHttpContextAccessor是拿HttpContext的实时值还是在构造函数里注入整个ICurrentUser行为完全不同。用构造函数注入 scoped 的ICurrentUser它能从当前请求作用域里拿到正确的用户信息推荐这么做。如果是后台任务、消息队列消费者这类没有 HttpContext 的场景ICurrentUser返回空是正常的。审计代码要接受这一点不能因为拿不到用户就把整次保存搞崩。这时候可以设计一个SystemUser之类的默认值比如OperatorName System。4.3 事务一致性怎么保证审计和业务数据的一致性是审计方案能不能落地的关键。两种主流做法各有取舍第一种是本文前面展示的“加入同一个 DbContext 的 SaveChanges 批处理”。好处是天然在同一个事务里要么都成功要么都失败不会出现“业务改了但审计没记”的情况。缺点是一次 SaveChanges 的命令批会变大批量操作场景下需要注意命令参数数量后面会讲。第二种是“显式事务 两次 SaveChanges”。如果业务强求新增记录的主键必须立刻出现在审计里且不想用事后再回填的方式可以在外面手动开事务await using var transaction await context.Database.BeginTransactionAsync(); try { context.Add(order); await context.SaveChangesAsync(); // 此时 order.Id 已经生成 context.Add(new AuditLog { TableName Orders, Operation Added, EntityId order.Id.ToString(), // ... }); await context.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); }这种做法代码侵入性强每个需要审计的业务方法都要自己包事务但控制粒度最细。我个人的经验是大多数业务系统用第一种简单、统一、不容易漏需要精确主键且审计量不大的核心表单走第二种高并发写入场景可以考虑把审计丢到异步队列接受“最终一致”这在后面性能部分展开。4.4 性能与存储的四个注意点审计方案上线之前这四件事一定要想清楚一是序列化开销。每写一行业务数据就要序列化两个字典字段多的表序列化成本不低。解决办法是按需裁剪无意义的字段比如ConcurrencyStamp、Version可以在拦截器里配置忽略列表不进入审计内容。JsonSerializerOptions里设置IgnoreCycles防止循环引用导致抛异常。二是大批量变更的参数上限问题。SQL Server 单个命令的参数上限是 2100 个。一个SaveChanges里如果有几百个实体变更再加上每个实体对应一条 AuditLog同一个批处理里参数很容易爆掉。审计量大的场景建议聚合一次SaveChanges只生成少数几条审计记录把变更明细组装成数组存进NewValuesJson而不是一实体一审计行。三是大字段和二进制内容。byte[]、string大字段直接序列化会让审计表膨胀得飞快。我一般会在拦截器里做长度判断超过比如 500 字符的字段只存截断摘要或者存字段哈希确有必要再去查原文。四是数据保留策略。审计日志只增不减跑个一年就能上千万条。设计之初就要想好归档方案按月/按年分区或者定期把旧数据导到数据仓库后删除。别忘了在AuditLog表上加CreatedAt索引否则按时间查审计会全表扫描。5. 实战踩坑与排查记录5.1 拦截器没有生效的几种原因经验里最灵异的“拦截器没生效”绝大多数不是框架问题而是下面几个低级错误注册遗漏拦截器必须在AddDbContext的options里通过AddInterceptors注册。有些人把拦截器加进了 DI 容器却忘了在 options 里引用它自然不会执行。生命周期不匹配用AddSingleton注册的拦截器依赖了 scoped 的IHttpContextAccessor运行时报错或者拿到的永远是 null。同步异步错位只重写了SavingChangesAsync业务却调用同步SaveChanges或者反过来。两个方法是独立触发的。没有调用base方法重写时如果直接return result而不是return base.XXX(...)某些情况下 EF Core 的内部处理会被跳过导致奇怪行为。除非你明确知道自己在干什么否则保持调用base。排查顺序我建议先断点打在构造函数里确认拦截器有没有被创建再看方法名的同步异步是否对上最后检查 options 里的注册代码。九成问题都能在这里面找到。5.2 审计日志把主流程拖垮的问题审计逻辑写在SavingChanges里意味着它和业务数据在同一条关键路径上。审计相关的异常会直接导致业务保存失败这是设计上要接受的——如果业务要求“审计失败不能影响主流程”那就不能在同一批里写审计得改成异步队列或者独立事务。性能问题出现在两个地方一是每行变更都要JsonSerializer.Serialize二是所有审计实体参与同一个批处理。实测下来几十行内的变更影响很小但批量导入几百上千行时审计可能让单次 SaveChanges 耗时长出一倍。这时候优先考虑聚合写入或者把审计从关键路径上拆出去。还有个容易忽略的隐患如果审计表也参与了ChangeTracker的跟踪那么在调试、热更新、后台任务等场景下审计实体可能被误当成普通业务实体处理。所以审计逻辑里一定要做好entry.Entity is AuditLog的过滤这是防止递归和误判的第一道防线。5.3 重试、异步、多线程的坑使用 SQL Server 执行策略EnableRetryOnFailure时第一次保存遇到瞬态故障框架会自动重试整个操作。如果拦截器在SavingChanges阶段做了带副作用的操作发消息、调接口、写外部存储重试会导致副作用重复执行。这一点务必记住拦截器里不要做任何有副作用的外部调用只做内存计算和数据库操作。异步死锁问题也很典型。在SavingChanges的同步方法里调用异步方法取结果.Result或.Wait()在带 SynchronizationContext 的环境下极易死锁。正确做法是同步方法里只做同步事异步方法里用await。还有多线程问题如果审计拦截器是单例注册又用了实例字段存_pendingAdded并发请求下这个字典就成性能瓶颈和错误源了。我们的方案里拦截器是 scoped每个请求作用域一个实例实例字段才安全。5.4 常见问题速查表现象可能原因解法拦截器完全不触发没在 options 里 AddInterceptors检查注册代码确认有AddInterceptors只触发部分方法同步/异步方法重写错位按业务调用方式重写对应版本拿不到用户信息拦截器是单例依赖 scoped 服务改成 scoped 注册审计记录里主键是负数Added 自增主键临时值未回填在 SavedChanges 里回填真实主键审计数据丢失审计实体被过滤或事务未提交检查过滤逻辑使用同一事务提交一次保存命令参数超限大批量变更 一实体一审计行聚合审计记录减少参数数量业务失败但审计已写入审计单独事务先提交改为同一事务或接受最终一致执行策略重试导致副作用重复拦截器里做了外部调用移除副作用逻辑最后再补一个经验审计这块做多了我最大的体会是拦截器用不用、怎么用取决于你要解决的问题边界。SaveChangesInterceptor 解决的是“应用层知道自己在改什么”DbCommandInterceptor 解决的是“数据库真正执行了什么”。两者配合既能知道业务实体层面的增删改又能看到底层 SQL 的真实行为排查线上问题的时候这两层信息对照着看命中率极高。如果你刚准备在自己的项目里落地这套方案我的建议是先用一个最简单的SaveChangesInterceptor只记录表名、操作类型和时间跑通注册和调用链路再慢慢把新旧值、用户信息、主键回填这些细节加进去。步子迈太大出问题的时候反而不容易定位。另外如果你们团队的项目不止一个建议把这套拦截器抽成独立的类库通过 NuGet 或者项目引用共享。审计逻辑是所有业务系统的公共关注点重复写三遍就会有人开始“精简”一精简就容易把关键逻辑剪掉。抽成公共组件统一维护一次比什么都强。
返回列表