做过几年 .NET 后端的人,大概率会遇到这种需求:记录谁在什么时间改了哪条数据,改之前什么值、改之后什么值,最好还能拼出完整 SQL 用于事后追溯。我最早是在业务方法里手动埋点,每改一条核心数据就调一次审计日志。结果就是所有更新逻辑都塞满了重复代码,漏记几次后,报表和实际数据对不上,排查问题比写功能还累。
这个问题的正解,是 EF Core 的拦截器体系。利用 SaveChangesInterceptor 和 CommandInterceptor 这两个扩展点,可以把“记录变更”从业务代码里彻底拆出来:前者负责在 SaveChanges 前后感知实体的添加、修改、删除,后者负责拦截真正发往数据库的命令,用于 SQL 日志、耗时统计和命令级审计。这篇文章会从原理讲到落地,直接给出可以抄走的代码和设计,适合正在用 EF Core 5 及以上版本,且想规范化审计、统一做数据追踪的后端同学。
1. 拦截器解决什么问题:先看清 EF Core 拦截器家族
1.1 为什么不要再用“硬编码审计”
我见过不少项目把审计日志理解成一个公共方法,比如 AuditHelper.Log(currentUser, oldValue, newValue),然后所有 Service 在写完业务后调用。这个方法本身没毛病,但放进真实业务里会出现三个非常现实的问题。
第一个问题是漏。业务方法一旦多了,一定会有人忘记调用,尤其是接手维护的人,他可能压根不知道这个项目有审计规范。一个没人保证百分百会被调用的公共方法,本质上就不是可靠的审计方案。
第二个问题是“对不上号”。一次 HTTP 请求里可能改了好几个实体,如果每一处都自己记录操作时间和操作人,日志之间没有统一关联键,事后想复现“这个用户这次请求到底动了哪些数据”就非常难。你得在业务层手工维护一个 RequestId 或操作流水号,传着传着就乱了。
第三个问题是入口不统一。后台任务、命令行工具、迁移脚本、定时 Job,都有可能绕过你的 Service 层直接操作 DbContext。只要存在第二条数据写入路径,你在业务层做的审计就只能覆盖一部分场景,最后审计结果和数据库真实变更对不上,反而更容易引发信任危机。
EF Core 拦截器把审计动作收敛到“框架一定会经过的地方”。不管谁调用了 SaveChanges,都会触发 SavingChanges / SavedChanges。只要拦截器挂上去了,入口是否经过 Service 层根本不重要,这是它比硬编码优雅的核心原因。
1.2 SaveChangesInterceptor 和 CommandInterceptor 的分工
很多人第一次接触拦截器时会混淆这两个概念。简单说,SaveChangesInterceptor 工作在“实体变更”层面,CommandInterceptor 工作在“ADO.NET 命令”层面。
SaveChangesInterceptor 能够看到的是当前 ChangeTracker 里的实体状态,知道某一条记录是 Added、Modified 还是 Deleted,能读到每个属性的原始值和新值。这是业务审计最喜欢的模型,因为你不必去解析 SQL,直接拿实体属性就能生成一条结构化的变更日志,比如“订单 10086 的金额由 120 改为 180”。
CommandInterceptor 看到的是底层 DbCommand,也就是真正执行的 INSERT、UPDATE、DELETE、SELECT 文本和参数。它不关心实体关系,只知道有一条 SQL 即将执行或已经执行完。适合用来做慢 SQL 监控、SQL 脱敏存档、强制设置 command timeout、甚至实现简单的读写分离路由。
用生活类比:SaveChangesInterceptor 像超市收银小票,记录顾客买了什么、买了几件;CommandInterceptor 像仓库发货台账,记录哪个包裹几点发出、花了多长时间。审计要追溯业务变更,主力是 SaveChangesInterceptor;要排查数据库性能和 SQL 源头,主力是 CommandInterceptor。
两者并不冲突。我的建议是:只做业务审计时优先 SaveChangesInterceptor;想对运行问题有更多感知时再叠加 CommandInterceptor,不要一开始就两个都上,后面讲审计架构时会说明原因。
1.3 拦截器和过滤器的边界要搞清楚
EF Core 拦截器与 ASP.NET Core 里的中间件、过滤器不是一回事。中间件和过滤器作用在 HTTP 管道上,能拦住请求但拦不住业务代码里的直接数据访问。有人以为写完一个 ActionFilter,顺手把 DbContext.SaveChanges 之后的逻辑塞进去就能完成审计,这是误区。
如果用户的请求本来就是从 Controller 进来的,过滤器确实能记录“谁调用了什么接口”,但它无法得知这一次请求里到底改了哪些实体字段。要拿到这个信息,还是得回到 DbContext 层面。再叠加一个因素:不少项目会有消息消费者、后台调度器,它们不走 HTTP 管道,过滤器根本不会触发。
因此,凡是需要覆盖所有数据变更的,都应该优先在 EF Core 拦截器中实现,而不是在 MVC 过滤器里做。过滤器可以承担接口级访问日志,实体级审计让拦截器做,各管一段,职责才清晰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SaveChangesInterceptor 实战:把审计字段自动补齐
2.1 先看清接口和注册方式
SaveChangesInterceptor 的完整接口包含多个方法,但实际开发中不需要全部实现。EF Core 5 开始提供了抽象基类,帮我们把不关心的方法都写了默认实现,你只需要重写需要的部分。
实际项目中,类似这样的代码就能跑起来:
csharp复制public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var context = eventData.Context;
if (context == null)
{
return base.SavingChanges(eventData, result);
}
// 在这里统一处理待保存的实体
return base.SavingChanges(eventData, result);
}
}
注册方式也很直接。在 AddDbContext 里把拦截器加进 options:
csharp复制services.AddScoped<AuditSav
