这几年用 EF Core 做业务系统,踩过最多的坑其实不是 LINQ 写不对,也不是迁移冲突,而是“谁在什么时候改了什么”。线上数据被改错了,翻遍日志找不到操作人,只能对着数据库日志干瞪眼。后来我把 EF Core 拦截器彻底玩明白之后,这套问题才算真正解决——SaveChangesInterceptor 负责在数据提交前后偷看 ChangeTracker,DbCommandInterceptor 负责在 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 中的 Filter | Action 方法前后 | 登录校验、模型处理 |
| 数据访问层 | EF Core 的 SaveChangesInterceptor、DbCommandInterceptor | SaveChanges、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/ThrowingConcurrencyExceptionAsync:EF 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执行前,主键属性里的值是一个负的临时值(比如-2147482647),EF 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 InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> 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 Dictionary<string, object?>(); var newValues = new Dictionary<string, 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)把审计实体也标记为 Added,EF 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.AddScoped<ICurrentUser, CurrentUser>(); builder.Services.AddScoped<AuditSaveChangesInterceptor>(); builder.Services.AddDbContext<AppDbContext>((sp, options) => { var interceptor = sp.GetRequiredService<AuditSaveChangesInterceptor>(); 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 ILogger<SlowQueryCommandInterceptor> _logger; public SlowQueryCommandInterceptor(ILogger<SlowQueryCommandInterceptor> 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 InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> 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实体,表结构设计其实很讲究。我推荐的字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint 自增 | 主键 |
| TableName | nvarchar(120) | 被修改的表名 |
| Operation | nvarchar(20) | Added / Modified / Deleted |
| EntityId | nvarchar(50) | 主键值,新增数据事后回填 |
| OldValuesJson | nvarchar(max) | 修改前数据 JSON |
| NewValuesJson | nvarchar(max) | 修改后数据 JSON |
| ChangedColumns | nvarchar(max) | 发生变更的列名,逗号分隔 |
| OperatorId | nvarchar(50) | 操作人 ID |
| OperatorName | nvarchar(120) | 操作人姓名 |
| ClientIp | nvarchar(50) | 客户端 IP |
| CreatedAt | datetime2 | 操作时间 |
索引方面,高频查询通常是“某张表最近有哪些改动”“某个操作人最近改了什么”,所以至少建两个复合索引:
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 里标准的做法是注入IHttpContextAccessor:
public 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 或者项目引用共享。审计逻辑是所有业务系统的公共关注点,重复写三遍就会有人开始“精简”,一精简就容易把关键逻辑剪掉。抽成公共组件,统一维护一次,比什么都强。