最近在重构一套老系统时,我必须把审计彻底做完:谁在什么时间把哪张表里的哪条记录改成了什么,以及删除后还能从历史里找回来。只看标题你会觉得这不过是在 SaveChanges 里塞一点逻辑,可真做起来,问题比想象中多得多。有些更新走仓储、有些更新走原生 SQL、有些用的是 ExecuteDelete 之类的批处理入口,如果只靠改业务方法,很容易漏得稀碎。我最后落地的方案,是把 SaveChangesInterceptor、CommandInterceptor 和一套 append-only 审计表组合起来。这套组合能覆盖从实体状态变化到最终 SQL 执行的多个阶段,也是我认为在 .NET 数据访问层里最“值回票价”的一组扩展点。下面把我记录中的关键逻辑、踩过的坑和能直接抄走的实现完整写一遍。
1. 方案选型:为什么是拦截器,而不是改业务代码
1.1 拦截器、触发器、重写 SaveChanges 的取舍
想做审计、软删除和统一字段填充,第一个跳进脑子的方案是重写 DbContext.SaveChanges()。这个方案够直接,但只是在“单个 DbContext 正常触发保存”的路径上有用。项目中一旦出现原生 SQL、存储过程、ExecuteUpdate 这类绕过 ChangeTracker 的入口,重写 SaveChanges 就变盲区了。
我做过一次对比,发现必须按“覆盖范围和改动成本”两个维度权衡:
| 方案 | 覆盖范围 | 改动成本 | 主要风险 |
|---|---|---|---|
| 数据库触发器 | 最广,能覆盖所有入口 | 高,需要数据库 DDL 脚本 | 跨数据库难迁移,审计前后值拼接复杂 |
| 重写 SaveChanges | 仅 EF Core 正常路径 | 低 | 漏掉原生 SQL / 批处理入口 |
| EF Core 拦截器 | EF Core 的 SaveChanges 和 Command 两层 | 中 | 注册生命周期、重入、性能损耗需要控制 |
数据库触发器不是不能用,而是太“底”。对于中小规模团队,业务逻辑和运维脚本还处于快速迭代期,触发器分散在数据库里很难被代码评审、版本控制。每次加字段还得同步检查触发器逻辑,出现线上问题时定位链路也长。对大多数应用来说,EF Core 拦截器是在不引入额外基础设施的前提下,成本最低、覆盖面合理的一层。
1.2 同样叫拦截器,别和 Web 拦截器混为一谈
如果你用过 Spring MVC 拦截器,或者 ASP.NET Core 中间件,会习惯性地想“拦截器是不是能拿到 HTTP 请求”?这个理解放在 EF Core 里会出问题。
EF Core 的 SaveChangesInterceptor 拦的是 DbContext.SaveChanges() 这个生命周期,它能观察到实体状态、属性变化、异常和事务边界;CommandInterceptor 则更进一步,拦的是 EF Core 生成的 DbCommand 在执行之前的创建动作、执行后的事件。换句话说,前者是“实体层面的观察员”,后者是“SQL 命令层面的观察员”,两个都不是 HTTP 管道里的组件。
架构上形成两层之后,我才彻底弄清楚典型场景的归属:记录实体前后值、软删除、自动填充修改时间,应该放在 SaveChangesInterceptor;统计慢 SQL、改写命令、设置超时、拦截危险 SQL,应该放在 CommandInterceptor。如果一开始就把所有逻辑塞进一个拦截器,后面排查起来会很痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SaveChangesInterceptor 实战:统一审计字段与软删除
2.1 核心接口里需要关注的关键方法
SaveChangesInterceptor 的完整接口方法不算少,但实际项目中值得使用的无非是这几个:
SavingChanges/SavingChangesAsync:在真正执行数据库写入之前触发,适合做最后的“状态校正”。SavedChanges/SavedChangesAsync:在保存成功之后触发,适合回填一些跟数据库返回值相关的状态。SaveChangesFailed:保存抛出异常时触发,适合记录失败原因或做告警。
我使用频率最高的是 SavingChangesAsync。它有一个很实用的价值:此时你可以安全地遍历当前上下文的 ChangeTracker,所有待插入、修改、删除的实体都已经被 EF Core 标记好状态,只是还没发送到数据库。业务层的 SaveChanges() 此时还没有真正开启事务,拦截器里做补充操作不会破坏写入顺序。如果对 EF Core 内部执行序列没有把握,建议优先使用异步版本的方法,避免在请求线程上用同步方式阻塞数据库。
2.2 统一补创建人、创建时间和更新时间
先抽象一个基础的审计接口,让所有实体都实现它。
csharp复制public interface IAuditableEntity
{
DateTime CreatedAt { get; set; }
string? CreatedBy { get; set; }
DateTime? UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
bool IsDeleted { get; set; }
}
然后写一个 SaveChangesInterceptor,在 SavingChangesAsync 里遍历所有 IAuditableEntity。
csharp复制public class AuditFieldsSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUser _currentUser;
private readonly TimeProvider _timeProvider;
public AuditFieldsSaveChangesInterceptor(
ICurrentUser currentUser,
TimeProvider timeProvider)
{
_currentUser = currentUser;
_timeProvider = timeProvider;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var currentUserId = _currentUser.UserId;
foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = currentUserId;
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = currentUserId;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = currentUserId;
break;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
一个细节值得单独说:Modified 状态下如果只是修改了更新时间,EF Core 会把所有当前值都当成要更新吗?不一定。如果实体属性没有被显式标记 IsModified = false,默认自动检测会把你赋值过的属性都纳入更新。所以我通常建议在业务层使用 Update 方法时只更新必要属性,或者在实体上使用 [NotMapped] 的属性做区分。这个拦截器只是兜底,不能替代对“用户到底改了什么”的建模。
2.3 用同样的入口做软删除,别在业务里到处改状态
软删除最容易出问题的地方是:每个业务方法都自己写 entity.IsDeleted = true;,然后忘记给查询过滤。正确做法是统一让拦截器处理。
csharp复制case EntityState.Deleted:
if (entry.Entity is IAuditableEntity softDeleteEntity)
{
softDeleteEntity.IsDeleted = true;
softDeleteEntity.UpdatedAt = now;
softDeleteEntity.UpdatedBy = currentUserId;
entry.State = EntityState.Modified;
}
break;
这里有个踩坑经验:一旦把 EntityState.Deleted 改成 Modified,对应实体上的主键不能再被 EF Core 当成“已删除”。如果实体有子表外键关系,EF Core 可能会级联处理子实体,所以软删除统一在拦截器里做没问题,但必须确保所有查询都加了 QueryFilter,否则你会看到“明明数据还在,列表里却永远查不到”。
在 OnModelCreating 里用全局查询过滤器是软删除的标配:
csharp复制modelBuilder.Entity<Order>().HasQueryFilter(x => !x.IsDeleted);
这样拦截器负责把“删除请求”转成“逻辑删除更新”,查询端又统一过滤,业务代码里不再出现跟软删除相关的零散判断。至少在我的项目里,重构前删除逻辑分散在几十个方法里,重构后所有删除语义都收敛在拦截器和查询过滤器里,改动面小了很多。
3. CommandInterceptor:下钻到 SQL 执行层的第二只眼睛
3.1 为什么还需要 CommandInterceptor
SaveChangesInterceptor 再强大,也覆盖不了原生 SQL。比如你用 ExecuteSqlRaw 做批量状态更新,或者用 EF Core 7+ 的 ExecuteUpdate/ExecuteDelete,这些操作不会经过 ChangeTracker,SaveChangesInterceptor 看不到实体差异。如果项目里有这类入口,并且希望做统一监控或拦截,就需要在数据库命令层加一道钩子。
CommandInterceptor 最典型的事件点是:
ReaderExecuting/ReaderExecuted:针对ExecuteReader/ 查询命令。ScalarExecuting/ScalarExecuted:针对ExecuteScalar。NonQueryExecuting/NonQueryExecuted:针对ExecuteNonQuery,也就是增删改。CommandFailed、CommandCanceled之类的事件,用于跟踪失败原因。
实际使用中,我很少只用一个拦截器。字段填充用 SaveChangesInterceptor,命令耗时和危险 SQL 拦截则用 CommandInterceptor,两者通过 DI 注入同一个 DbContext,生命周期互相独立,职责不会纠缠。
3.2 一个直接可用的慢 SQL 记录器
在项目里我监控慢 SQL 的第一版是给所有 Repository 方法加日志,结果发现调用方五花八门,慢慢就漏了。后来改成在 CommandInterceptor 里统一记录,每个 EF Core 生成的 SQL 都会经过这里,不再依赖开发人员的自觉。
csharp复制public class SlowSqlDbCommandInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowSqlDbCommandInterceptor> _logger;
private readonly double _thresholdMs = 500;
public SlowSqlDbCommandInterceptor(ILogger<SlowSqlDbCommandInterceptor> logger)
{
_logger = logger;
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
if (eventData.Duration.TotalMilliseconds > _thresholdMs)
{
_logger.LogWarning("慢查询 {Duration}ms,SQL:{Sql}",
eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return base.ReaderExecuted(command, eventData, result);
}
}
同样的逻辑还要在 NonQueryExecuted 和 ScalarExecuted 里写一遍,因为增删改也可能会慢。如果不想重复代码,可以把“检查耗时”封装成一个私有方法,三个 Executed 方法都调用一次即可。这个实现给我最直接的收益,是定位到了很多“看起来只有几条数据但查询很慢”的 N+1 问题。业务日志里只能看到接口整体耗时,但通过 SQL 层日志能看到具体是哪一条 SQL 拖垮了请求。
3.3 给高危 UPDATE / DELETE 加最后一道保险
我曾经遇到过一次线上事故:同事在管理后台测试一个过滤条件,SQL 里忘了带 WHERE,结果把整张表的状态字段全改成了同一个值。事后我们复盘,发现如果有一个 SQL 层面的“护栏”,这种低级错误是可以提前挡住的。
后来我在 CommandInterceptor 里加了一个只在非生产环境开启的判断:
csharp复制public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result)
{
if (command.CommandType == CommandType.Text)
{
var sql = command.CommandText.TrimStart();
var isUpdateOrDelete =
sql.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase) ||
sql.StartsWith("DELETE", StringComparison.OrdinalIgnoreCase);
if (isUpdateOrDelete && !Regex.IsMatch(sql, @"\bWHERE\b", RegexOptions.IgnoreCase))
{
throw new InvalidOperationException("高危操作被拦截:UPDATE/DELETE 必须包含 WHERE 条件");
}
}
return base.NonQueryExecuting(command, eventData, result);
}
这段代码在真实生产环境不建议直接使用,因为很多合法 SQL 可能通过 CTE、表变量、JOIN 子查询等方式包含高风险写法,单纯靠文本匹配会有误拦。但在开发环境和测试环境里,这种“粗暴护栏”能立刻暴露问题。我当时的目的是防止低级误操作,并不追求可靠解析复杂语义。
3.4 注意 CommandInterceptor 的注册范围与性能
CommandInterceptor 是跟着 DbContextOptionsBuilder 走的,不是在某个 Controller 或 Service 里可以随时开启、随时关闭的局部开关。注册时如果你用的是 AddDbContext,尤其要注意拦截器生命周期。
常见的做法是:
csharp复制services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
serviceProvider.GetRequiredService<AuditFieldsSaveChangesInterceptor>(),
serviceProvider.GetRequiredService<SlowSqlDbCommandInterceptor>());
});
这里的拦截器如果是 Scoped 或 Singleton,取决于它依赖了哪些服务。如果依赖用户上下文(ICurrentUser),拦截器本身通常也要注册成 Scoped,然后在 AddDbContext 的工厂里从 serviceProvider 解析,否则很容易出现“拿到的用户是上一请求”的问题。
不要让 CommandInterceptor 在每次 SQL 执行时做太多同步日志写入。如果每条查询都写文件或写数据库,高并发场景下会让数据库连接被日志拖垮。我在线上环境会对慢 SQL 做阈值和采样,只有超过阈值的命令才记录完整 SQL,其他只记录计数。这是踩过坑之后才学会的取舍。
4. 审计落地:把业务表和审计日志串成一条链路
4.1 审计表设计:append-only 是基本原则
审计日志不是业务数据,它的核心特征是不可变。你不能在运营后台修改一条审计日志,也不应该根据业务对象状态去更新历史记录。所以审计表设计成只追加,不做物理更新和删除。
可以设计一个足够通用的表结构:
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint identity | 主键 |
| TenantId | uniqueidentifier | 租户或业务单位,按系统情况取舍 |
| EntityName | nvarchar(256) | 实体或表名 |
| EntityId | nvarchar(128) | 主键值,统一转字符串 |
| ChangeType | nvarchar(32) | Insert / Update / Delete |
| OldValuesJson | nvarchar(max) | 变更前字段集合 |
| NewValuesJson | nvarchar(max) | 变更后字段集合 |
| ChangedColumns | nvarchar(max) | 本次实际变化的字段列表 |
| OperatorId | nvarchar(128) | 操作人 |
| OperatedAt | datetime2 | 操作时间 |
| IpAddress | nvarchar(64) | 来源 IP |
| RequestId | nvarchar(128) | 请求关联 ID |
索引上,我会至少建一个包含 EntityName + EntityId + OperatedAt 的复合索引。审计查询通常都是“查某条业务记录的所有变更历史”,这个索引能直接命中。如果数据量很大,建议按月份做分区或者定期归档,保证在线审计库不要无限膨胀。
字段里不需要放所有完整对象。很多人喜欢把整行数据序列化放进去,但这样会让表迅速变大。除了一些必须全文留痕的场景,我更推荐只在 NewValuesJson 里保存本次变更的字段,而不是每一列都存一遍。
4.2 在 SavingChanges 里生成审计日志
生产中最实用的实现方式,是在 SaveChangesInterceptor 的 SavingChangesAsync 阶段,把当前 DbContext 里所有新增、修改、删除的实体转换成一条条 AuditLog,并追加到同一个 DbContext。这样业务数据与审计日志会走同一个事务提交:业务成功了,审计一定成功;业务回滚了,审计也不会留下半截数据。
核心思路如下:
csharp复制public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not AppDbContext dbContext)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
var auditLogs = new List<AuditLog>();
foreach (var entry in dbContext.ChangeTracker.Entries())
{
if (entry.Entity is AuditLog || entry.Metadata.IsOwned())
{
continue;
}
switch (entry.State)
{
case EntityState.Added:
auditLogs.Add(CreateAuditLog(entry, "Insert"));
break;
case EntityState.Modified:
var log = CreateAuditLog(entry, "Update");
if (log.ChangedColumns.Count != 0)
{
auditLogs.Add(log);
}
break;
case EntityState.Deleted:
auditLogs.Add(CreateAuditLog(entry, "Delete"));
break;
}
}
if (auditLogs.Count > 0)
{
dbContext.Set<AuditLog>().AddRange(auditLogs);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
CreateAuditLog 的具体逻辑里,重点在于如何取字段值。每个实体条目都有 Properties,我们可以通过 entry.Property(property).OriginalValue 和 CurrentValue 拿到修改前后值。要注意的是,EF Core 只有把某个属性标记为 IsModified = true 时,你才能在 Modified 状态下判断它是否变化。如果业务代码里使用类似 entry.CurrentValues.SetValues() 的方式,可能会把原本没改的字段也标记成已修改,需要结合项目实际调整比较方式。
主键可以这样取:
csharp复制var keyValue = entry.Metadata.FindPrimaryKey()
?.Properties
.Select(p => entry.Property(p.Name).CurrentValue)
.FirstOrDefault();
因为主键可能是 int、Guid、复合主键,所以统一 ?.ToString() 存到 EntityId 里最稳妥。审计日志毕竟只记录变化,主键类型差异不应该影响通用表结构。
4.3 同一个事务还是独立存储
我在这条路上试过两种方案。第一种是把审计日志直接追加到业务 DbContext,也就是上面这一套;第二种是在 SavedChanges 之后,把审计记录同步到另一个独立的 AuditDbContext。
第二种方案的好处是审计库可以与业务库分离,比如业务库是 SQL Server,审计库可以放到另一个实例甚至云上的对象存储。但坏处也很明显:你很难保证业务提交成功之后,审计写入一定成功。如果两个库不在同一个事务里,你会在线上看到“业务数据变了,但审计查不到”的缺口。对绝大多数业务来说,这是不可接受的。
所以我最终的推荐是:先保证审计和业务在同一个本地事务里落地。第一版上线时不要把架构做得太复杂。等到确定审计数据量已经大到影响业务库性能,再引入独立审计库或者消息队列异步归档。审计的准确性比“看起来高大上的分布式方案”重要得多。
4.4 批量更新和原生 SQL 是审计盲区
如果系统里使用了 ExecuteUpdate、ExecuteDelete 或者 ExecuteSqlRaw,SaveChangesInterceptor 是感知不到实体级变化的。这个盲区需要提前设计。
一种处理方式是业务开发人员在这些操作前显式调用一个审计辅助方法,记录“对某张表执行了批量更新操作”,至少把操作人和操作时间记录下来,至于每一条旧值,SQL 层面已经无法回溯,因为批量更新不会先加载旧实体。如果想做更细的追踪,只能用 CommandInterceptor 把 SQL 文本记录下来,再交给 DBA 去反查。但这对普通应用团队太重了,我不会把 SQL 文本解析当成通用审计方案去推广。
我在项目里的原则是:核心交易数据的变更必须通过 SaveChanges 走实体审计,非核心批量操作则允许记录操作日志而不是逐行审计。业务上可以接受“某用户执行了批量更新,影响记录数 N”,但不能接受“连谁执行的都不知道”。这也是为什么 CommandInterceptor 里我会额外记录操作人、时间和命令语句,给重操作留一条追溯线。
5. 常见问题与排查技巧实录
5.1 在拦截器里用另一个 DbContext 写日志导致死锁
刚开始写审计时,我的第一版实现是在 SavingChangesAsync 里 new 了一个新的 AuditDbContext,用 SaveChangesAsync 把审计日志写进数据库。逻辑上感觉没问题,上线后却遇到间歇性死锁。
原因很简单:外层 AppDbContext 已经开始准备事务,内层 AuditDbContext 又尝试连接同一个数据库并开启新事务,在高并发下很容易因为连接池、锁顺序导致性能下降甚至死锁。后来我改成“审计日志和业务日志在同一个 DbContext 里追加”,才彻底解决这类问题。记住拦截器里不是不能开新 DbContext,而是不要在同一事务里开第二个数据库连接,这是在设计阶段就要想清楚的。
5.2 登录用户为空或异步上下文丢失
很多系统在获取当前用户时用的都是 IHttpContextAccessor,它本身是异步安全的。但如果你在拦截器里把 ICurrentUser 通过构造函数注入成 Singleton,就有问题了。
我踩过的坑是:AddDbContext 里使用拦截器工厂,但从根容器解析了 ICurrentUser,导致每个请求刚进来时解析到的都是 null,或者拿到的是上一个请求的用户。正确做法是让拦截器依赖的 ICurrentUser 也是 Scoped,并且在 AddDbContext 的工厂里通过 serviceProvider 解析,这样才能保证每个请求作用域拿到正确的用户信息。
5.3 日志字段过大或包含敏感字段
实体层面做 JSON 序列化很容易把不该记录的字段也写进去。比如用户表里有 PasswordHash,如果用户修改昵称时把整行 CurrentValues 序列化进审计,密码哈希就会被悄悄写进审计日志,一旦审计库泄露,后果不堪设想。
我在项目中通过一个自定义特性来排除敏感字段:
csharp复制[AttributeUsage(AttributeTargets.Property)]
public class AuditIgnoreAttribute : Attribute
{
}
然后在生成 NewValuesJson 或 OldValuesJson 时,先判断属性上是否存在 AuditIgnore 标记。凡是密码、Token、密钥、身份证号这类数据,一律不进入审计记录。如果有需求保留“某个字段被修改过”,可以只记录字段名,不记录具体值,用“脱敏值”代替真实值。这个规则越早定,后面补洞的成本越低。
5.4 拦截器里的错误可能导致业务失败,要区分“审计失败”和“业务失败”
当你把审计日志和业务日志放进同一个 DbContext 后,理论上业务和审计是同生共死的。这时候会发生一个问题:如果审计序列化抛异常,整个业务保存也会失败。
有一次我在审计一个实体时,发现它的某个导航属性被延迟加载,访问 OriginalValue 时触发了额外查询,而那个查询又依赖当前上下文的状态,导致 InvalidOperationException。业务保存直接被拦截器中断了。所以拦截器里的审计逻辑一定要足够“保守”:尽量避免触发延迟加载,尽量只处理标量属性、值对象和已经明确加载的集合。不要让一个非核心的审计动作拖垮主业务链路。如果某条审计日志真的无法生成,我倾向于给系统一个开关:默认打开严格模式,生产环境出现问题时可降级为只记录错误,但业务照常提交。
5.5 调试技巧:在拦截器里临时打印事件顺序
如果你在调多个拦截器协作的问题,可以先不用业务逻辑,只做一个“空拦截器”把所有事件名输出到日志,观察事件发生顺序。比如你会看到一个 SaveChanges 通常会触发 SavingChanges,然后 EF Core 内部再触发 NonQueryExecuting / ReaderExecuting,最后回到 SavedChanges。先确认顺序,再判断你的逻辑应该挂在哪一环,比盲猜高效得多。
我实际排查中经常发现,某些开发人员把 SQL 日志放在 SavingChangesAsync 里,然后抱怨拿不到 SQL 文本——那个阶段命令还没生成,当然拿不到。正确做法是去 CommandInterceptor 的 CommandExecuted 事件里记录。对应关系理清楚了,问题其实就不难。
写在最后的一点体会
这套方案上线之后,我最大的感受不是“拦截器真方便”,而是“横切逻辑必须放在能观察到全貌的位置”。比如创建时间、更新时间和软删除,如果分散在几十个 Service 方法里,每个人都可能漏一行;但集中到 SaveChangesInterceptor 里,它就成了所有实体变更的统一入口。再配合 CommandInterceptor 看 SQL 层,很多线上问题就不再需要靠猜了。
如果现在有人问我审计该怎么落地,我的建议是先画一张入口矩阵:哪些保存操作会走 SaveChanges,哪些会走原生 SQL,哪些会走定时任务。然后对号入座选拦截器。最后你会和我一样发现,EF Core 拦截器不是锦上添花的功能,而是应对数据横切需求时最值得优先考虑的扩展点。
