做过几年 .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<AuditSaveChangesInterceptor>();
services.AddDbContext<AppDbContext>((sp, options) =>
{
var auditInterceptor = sp.GetRequiredService<AuditSaveChangesInterceptor>();
options
.UseSqlServer(connectionString)
.AddInterceptors(auditInterceptor);
});
这里有一个容易踩坑的点。如果拦截器需要获取当前登录用户,千万不要把它注册成 Singleton,也别在构造函数里一次性注入用户信息。拦截器本身在 DbContext 的生命周期内被调用,最好使用 Scoped 生命周期,这样才能保证同一个请求内的 DbContext 拿到的是当前用户。如果不这么做,你可能会遇到一个诡异问题:所有审计日志里的操作人都是第一个访问系统的人。这个问题在生产环境里非常隐蔽,因为它不会报错,只会让数据失真。
2.2 自动写入创建人、修改人、创建时间
最常见的需求不是记录明细,而是给每张表补充“创建人、创建时间、修改人、修改时间”四个字段。很多团队的做法是让实体继承一个 BaseEntity,然后每次在业务里手动赋值,这个小动作非常容易漏。
其实可以在 SavingChanges 阶段根据实体的 State 自动填值。以下是一个可用的实现:
csharp复制public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUser _currentUser;
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 now = DateTimeOffset.UtcNow;
var userId = _currentUser.Id;
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditEntity)
{
continue;
}
switch (entry.State)
{
case EntityState.Added:
entry.Property(nameof(IAuditEntity.CreatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditEntity.CreatedBy)).CurrentValue = userId;
break;
case EntityState.Modified:
entry.Property(nameof(IAuditEntity.UpdatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditEntity.UpdatedBy)).CurrentValue = userId;
break;
}
}
return base.SavingChanges(eventData, result);
}
}
这个写法为什么放在拦截器里而不是在 SaveChanges 方法重写?因为拦截器可以被多个 DbContext 复用,比如你有 OrderDbContext、LogDbContext,只要注册时挂上,就不需要每个 DbContext 都重写一遍。它也不关心调用方是 Controller、Job 还是命令工具,只要入口是 EF Core,就会被补上。
需要注意“修改时间”的处理。很多项目用的是 DateTime 而不是 DateTimeOffset,这时候要约定好统一使用本地时间还是 UTC。我建议统一使用 UTC 存储,展示层再转本地时区,不然服务器时区变了,审计时间线就会乱掉。
2.3 SavingChanges 和 SavedChanges 不要误用
SavingChanges 触发时事务还没提交,实体状态都在内存里,适合做“变更前准备”。而 SavedChanges 只有在保存成功后才触发,适合做保存成功后的后续动作,比如清理缓存、发送事件、记录一条保存成功的摘要。
如果你想在审计日志中记录“保存是否成功”,可以在 SavingChanges 阶段写入一条状态为“执行中”的日志,然后在 SavedChanges 阶段把状态更新成“成功”,在 SaveChangesFailed 阶段把状态更新成“失败”。这种模式接近 Saga 的思想,能帮你把失败操作也纳入审计范围。
但这里有个很关键的前提:如果审计日志和业务数据放在同一个数据库,并且使用同一个事务,那么 SaveChangesFailed 时业务事务可能会回滚,审计日志也会跟着回滚,最终什么都记不下来。所以“记录失败操作”这个需求如果很强烈,建议业务事务和审计事务分开,或者把审计日志写入独立数据库。不然你只能记到 SavingChanges 阶段收到的事件和异常信息,没法记到回滚后的结果。
说到这必须强调一个设计点:普通业务审计不要在产品初期做成“全字段、每次保存都记快照”。我先解释一下原因,后面第 4 部分会展开给出一套可以落地的方案。业务表少的时候全量审计没问题,一旦表超过几十张、核心操作每天几十万次,审计表膨胀速度会超出预期。更合理的策略是先按表分级,对资金、合同、用户等核心表做全量字段审计,对普通配置表只记录操作动作和时间。
3. CommandInterceptor 实操:从 SQL 日志到慢查询治理
3.1 拦截的是哪个节点
CommandInterceptor 的核心思想是:每一次数据库命令执行前、执行后都会回调你注册的方法。EF Core 把命令分成三类:NonQuery(执行 UPDATE/INSERT/DELETE、存储过程等)、Scalar(执行 SELECT COUNT(*) 这类返回单值的查询)、Reader(执行返回结果集的查询)。
对应的拦截器里名字非常直观:NonQueryExecuting / NonQueryExecuted、ScalarExecuting / ScalarExecuted、ReaderExecuting / ReaderExecuted,每个都有同步和异步版本。如果你是做 SQL 审计日志,最关心的是命令文本和参数,可以在执行前记录;如果你更关心耗时和是否慢查询,通常在执行后的回调里拿数据更准确。
这里有一个新手特别容易犯的认知错误:以为 ReaderExecutingAsync 执行完,SQL 就跑完了。其实 Executing 阶段只是拦截器给了你一个“阻止执行”的机会,真正的数据库调用发生在拦截器返回之后。所以不要在 Executing 方法里用 Stopwatch 计时,你测到的时间只是拦截器前处理的时间,不是 SQL 执行耗时。要测真实耗时,请使用 ReaderExecuted 这类执行后回调,或者直接依赖事件参数中 EF Core 已经统计好的执行耗时。
3.2 实现一个轻量 SQL 审计拦截器
下面这个示例会在每次 Reader 命令执行完成后,把 SQL 文本、耗时、参数写到一个后台队列里,由独立消费者写入日志库。注意我故意没有在拦截器内部直接写文件或写数据库,原因是性能,后面会细说。
csharp复制public sealed class SqlAuditCommandInterceptor : DbCommandInterceptor
{
private readonly IAuditQueue _auditQueue;
public SqlAuditCommandInterceptor(IAuditQueue auditQueue)
{
_auditQueue = auditQueue;
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
_auditQueue.Enqueue(new SqlAuditRecord
{
CommandText = command.CommandText,
Parameters = command.Parameters
.Cast<DbParameter>()
.ToDictionary(p => p.ParameterName, p => p.Value),
DurationMs = eventData.Duration.TotalMilliseconds,
ExecutedAt = DateTimeOffset.UtcNow
});
return base.ReaderExecuted(command, eventData, result);
}
}
把这个拦截器看作数据库命令的统一闸口,凡是 EF Core 发出去的查询命令都会经过这里。如果哪一天 DBA 说“某个 SQL 把数据库打满了”,你不再需要靠猜,直接查 SQL 审计表就能看到具体的调用频率、参数和耗时分布。
实际用下来有几点体会:
第一,不要把所有 SQL 都落库。正常业务产生的查询量远大于写操作,如果每条 SELECT 都留档,日志量会非常恐怖。建议只记录执行时间超过阈值的慢 SQL,比如默认 500 毫秒;或者只记录非查询类写操作,这些写操作通常对应业务变更。把 SQL 审计做成“全量 SQL 日志”极容易成为拖垮业务的第二个隐患。
第二,SQL 里的参数值可能是敏感字段,比如身份证号、手机号、密码哈希甚至明文。记录到日志之前要统一脱敏。按参数名正则匹配,遇到 password、token、mobile、idcard 等关键字就直接替换成 ***。这既是对用户的保护,也是降低日志系统被拖库后的危害面。
第三,拦截器里不要直接执行耗时操作。如果每个查询都同步写文件或同步写数据库,你的接口响应时间会额外增加不少。正确做法是扔到内存队列或 Channel 里,由后台任务批量写入。这个模式简单说就是“拦截器只生产,消费者负责存储”,能有效把 SQL 审计对业务请求的影响压到最低。
3.3 更进一步的读写分离与命令级兜底
再往深走一步,CommandInterceptor 还能做更多事。比如项目早期没接读写分离中间件时,可以在这个拦截器里根据命令文本判断是不是写操作,然后动态更换数据库连接。判断逻辑很简单:
csharp复制foreach (var keyword in new[] { "INSERT", "UPDATE", "DELETE" })
{
if (command.CommandText.StartsWith(keyword, StringComparison.OrdinalIgnoreCase))
{
// 切换到主库连接
}
}
但这个方法只能解决最简单情况,遇到以 WITH 开头或包含注释的 SQL 就会误判。真实项目做读写分离,我还是建议用专门的数据访问中间件,拦截器更适合做“最后一道命令级管控”,比如不允许某些危险 SQL 执行、强制所有命令的超时时间。
有一个特性容易被忽略:EF Core 7 里的 ExecuteUpdate 和 ExecuteDelete 是直接拼接 SQL 执行的,它们不会经过 ChangeTracker,因此不会触发 SaveChangesInterceptor。如果业务中有批量更新需求又要求审计,这会是一个大坑。你只能在 CommandInterceptor 里看到这条 UPDATE SQL,但无法根据 SQL 还原出所有被修改行的字段旧值,还是得依赖数据库触发器等方案才能做到完整审计。这也是为什么真正的审计架构要先把边界划清楚。接下来我们聊聊怎么落地一套相对完整的审计体系。
4. 审计落地的架构拆解:从“一条日志”到“append-only”
4.1 审计表怎么建才不后悔
先给结论:审计表应该设计成 append-only,只插入,不修改,不删除。
很多项目一开始把审计日志和业务日志混在一起,随手删数据也不在意。可一旦审计服务于对账、合规或安全事故溯源,任何形式的修改都意味着数据不可信。技术实现上,最简单的一种方式是,数据库账号不要给审计表 UPDATE/DELETE 权限,只保留 INSERT 和 SELECT。应用层也可以加一道拦截,根据实体类型判断如果目标是审计表就禁止更新。
建表时字段可以围绕三个维度设计:
- 操作来源维度:操作人、租户、来源 IP、请求追踪 ID;
- 操作内容维度:实体名、主键、操作类型、变更前快照、变更后快照、变更字段列表;
- 发生时间维度:操作时间、事务 ID、是否需要回放排序。
下面这个表结构是我在多套系统里反复调过之后的版本,虽然不是万能,但可以作为起点:
csharp复制public sealed class AuditLog
{
public long Id { get; set; }
public Guid RequestId { get; set; }
public string OperatorId { get; set; }
public string EntityName { get; set; }
public string Operation { get; set; }
public string EntityKey { get; set; }
public string OldValuesJson { get; set; }
public string NewValuesJson { get; set; }
public string ChangedColumns { get; set; }
public DateTimeOffset OperatedAt { get; set; }
}
每次操作都带上 RequestId 非常重要。它把一次请求里的多个实体变更串在一起,你可以从“某用户在某次请求中改了哪些数据”这个维度做查询,而不是只看到零散的字段级审计。这个字段在 HTTP 请求进来时生成,然后通过 ICurrentUser 或操作上下文传递到拦截器。
4.2 实体变更快照:before/after 怎么生成
记录实体变更最核心的技术动作是生成字段级的 before/after。很多人会想到序列化当前实体对象,但这有两个问题:第一,实体对象里可能包含导航属性,序列化时会带出关联数据,造成日志臃肿;第二,修改操作需要拿到“原始值”而不只是“当前值”。所以正确做法是遍历 ChangeTracker 里的 PropertyEntry,而不是直接序列化实体。
可以用 JsonSerializer 手动构造一个字典来保存变更。示例思路如下:
csharp复制var oldValues = new Dictionary<string, object?>();
var newValues = new Dictionary<string, object?>();
var changedColumns = new List<string>();
foreach (var property in entityEntry.Properties)
{
if (property.Metadata.IsPrimaryKey())
{
entityKey = property.CurrentValue?.ToString();
continue;
}
if (entityEntry.State == EntityState.Modified && property.IsModified)
{
oldValues[property.Metadata.Name] = property.OriginalValue;
newValues[property.Metadata.Name] = property.CurrentValue;
changedColumns.Add(property.Metadata.Name);
}
}
这段代码里有个陷阱:property.IsModified 只有在修改状态下才有明确语义,Added 状态下所有属性都算“新值”,Deleted 状态下大部分属性都被标记为修改。因此不同操作类型要分开处理,不要试图用一套逻辑覆盖所有状态。
Added 操作只记录当前值,OriginalValue 基本没意义;Deleted 操作只记录原始值,CurrentValue 可能已经被置空;Modified 操作才需要完整比较。框架层做审计时最容易踩的坑就是把所有状态混在一起生成 JSON,导致“新增的数据审计里全是空旧值”之类的问题。
关于导航属性和复杂类型,我的建议是审计阶段默认不追踪。实体审计的重点是行级别的属性变化,关联变化可以通过对另一方实体的审计去体现。如果强行把整个对象图序列化进去,审计日志的可读性会严重下降,还会把订单和用户等多个维度的数据黏在一起,后续查询和归档都很难办。
敏感字段处理起来也要谨慎。字段名包含 Password、Token 的,直接不记录具体值;大段的 description、remark 可以做截断;二进制字段千万不能直接入库。这些规则可以通过约定实现,比如字段名带 AuditIgnore 特性的直接跳过。架构上做一次,后面新表接入就不用逐字段配置了。
4.3 与事务的关系以及性能取舍
审计日志与业务数据是否使用同一事务,是一个必须尽早拿主意的问题。
如果放在同一个事务里,业务成功审计成功,业务回滚审计也回滚,数据一致性最完美。但这也意味着审计写入失败会导致整个业务操作失败。举个例子,你只是想修改商品名称,审计表恰好遇到锁或磁盘满,结果商品更新也失败了。这在业务上不一定能接受。
如果你把审计日志独立提交,业务可以不受影响,但会引入双写一致性问题:业务提交成功、审计写入失败,事后就查不到这次变更。通常我建议分阶段处理:系统初期、审计量可控时,使用同一事务保证强一致;审计量上来后,改为同步写业务库、异步归档到分析库,并且通过后台补偿任务检查缺失记录。
性能方面的取舍也很明显。每次 SaveChanges 都生成两张 JSON 快照会产生额外开销,如果核心表每一行都存一次 before/after,审计库的体量会比业务库大好几倍。我踩过的最深一个坑就是上线初期“有变更就全字段审计”,结果一个订单列表分页更新操作直接导致审计库容量翻了几倍,最后不得不花一个周末写归档任务清理数据。
合理做法是给审计规则加“开关”。比如只审计订单、支付、合同、用户等核心资产表;配置表的变更只记录“谁在什么时候改了什么”,不记录完整字段快照。审计方案最重要的不是算法复杂度,而是让日志在半年后仍然能被有效查询。你可以为不同类型设置保留期,普通操作日志保留 180 天,资金相关日志保留更长时间并做冷存储归档。技术上再完美,如果没有可落地的数据治理策略,审计库迟早变成一个吃磁盘的怪兽。
5. 常见问题与排查技巧实录
5.1 拦截器没生效,先查这三处
很多人写完拦截器发现事件没有触发,第一反应是代码写错了,但绝大多数时候是没挂上去。注册环节是最容易出问题的地方。
第一个排查点是看 AddInterceptors 到底写在了哪个 builder 上。要确认它在 DbContextOptionsBuilder 上,而不是只写在别的配置对象里。如果你是在最终 UseSqlServer(...); 之后调用 AddInterceptors,顺序上没问题,但如果传入的是 new 出来的拦截器实例,并且这个实例依赖 Scoped 服务,那么务必通过 provider 重载解析,否则生命周期不对。
第二个排查点是确认拦截器的程序集真的被加载了。如果你的拦截器类在单独的类库中,而 DbContext 项目没有引用该类库,自然不会被扫描到。这种问题编译期不报错,运行时只有自己进调试器才能发现。
第三个排查点是 EF Core 版本是否过旧。老的 EF6 时代没有完整的拦截器抽象体系,很多人查资料会看到 IDbCommandInterceptor 但找不到 SaveChangesInterceptor。如果你用的是 EF Core 5 以下版本,建议先升级,或者改用 DbContext 重写 SaveChanges 的方式缓解。
5.2 在拦截器里写审计日志,结果写成了递归
新手最容易出现的问题是在 SavingChanges 回调中,往当前 DbContext 添加了审计实体。如果你没有排除审计表自身的审计逻辑,那么添加 AuditLog 这个动作又会触发 SaveChanges,又进入 SavingChanges,然后再次添加审计日志,形成无限递归。实际表现通常是栈溢出或数据库连接被打爆。
解决办法有两个方向:第一,在拦截器开头判断实体类型,凡是审计相关的实体都直接放行;第二,把写审计日志放到 SavedChanges 成功回调中,用一个新的 DbContextScope 或独立事务写入,这样和当前 SaveChanges 流程隔离,不会产生递归。
这里补充一个细节。如果你确实希望在 SavingChanges 阶段把审计日志加入同一个事务,一定注意不能在这个阶段去遍历审计实体本身。我习惯在 SavingChanges 里先做一次快速过滤:
csharp复制if (entry.Entity is IAuditLogEntity)
{
continue;
}
判断条件越靠前,性能损耗越小。这个操作看似简单,但漏掉之后排查成本很高,值得在写代码时先固化约定。
5.3 异步 SaveChanges 和后台队列容易冲突
写异步方法时,如果拦截器内部的逻辑没有使用异步版本,比如你在 SaveChangesAsync 流程里只实现了同步 SavingChanges,虽然大多数情况下也能被调用,但某些情况下线程上下文的处理会变得不自然。
另一种冲突来自后台队列。假如你在拦截器里把 SQL 日志放入内存队列,但队列消费线程还没来得及写入数据库,进程就崩溃了,内存里的日志会一起丢失。做日志类功能时,很多人会忽略“进程崩溃”这个场景。线上环境建议使用持久化队列或直接落盘后再异步传输,这样能保证相当程度的可靠性。如果只是排查慢查询,内存队列就够了,不必追求百分百不丢;如果是合规审计,必须考虑持久化。
另外还要考虑事务内执行 SQL 却被 CommandInterceptor 记录的问题。业务回滚后,CommandInterceptor 已经看到了命令,但命令最终没有形成持久化结果。因此,CommandInterceptor 日志里的 SQL 不能直接等同于数据库事实,它只代表“曾经尝试执行过”。要拿到“最终生效的数据”还是要依赖 SaveChangesInterceptor 或者在业务事务提交后再生成审计。
5.4 问题速查表
以下是我在实际项目中沉淀下来的一份速查表,按频率排列,几乎每个团队都会遇到其中至少一条:
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 拦截器完全没有触发 | 没有注册或注册方式不对 | 确认 AddInterceptors 是否挂到 options |
| 操作人永远是第一个人 | 拦截器被注册成了 Singleton | 改成 Scoped,并通过 provider 解析 |
| 保存时报递归错误 | 审计日志又触发了同一套审计逻辑 | 过滤掉审计实体类型 |
| 审计日志偶尔少一条 | 异步队列在进程退出时丢失 | 使用持久化队列或补偿任务 |
| 修改记录里旧值全是 null | 在 SaveChanges 前没先 DetectChanges | 覆盖 SavingChanges 前先调用 DetectChanges 或确保状态准确 |
| ExecuteUpdate 没有审计记录 | ExecuteUpdate 不走 ChangeTracker | 用 CommandInterceptor 看 SQL 或改用先查后改 |
| 审计表越来越大 | 全量字段全量记录,未归档 | 做规则开关、分表归档、冷存储 |
5.5 另一个容易忽略的点:列级代码生成的干扰
虽然新版 EF Core 整体比较稳定,但使用拦截器后仍然要和 ChangeTracker.AutoDetectChangesEnabled 的设置对齐。如果业务代码在批量更新时把 AutoDetectChangesEnabled 设成了 false,那么 SaveChanges 时一些修改可能没有被正确标记,你在拦截器里读取 IsModified 时会发现字段不全。批量场景下,建议在拦截器里不依赖自动检测,而是显式在进入拦截器后判断是否需要调用 context.ChangeTracker.DetectChanges()。
不过 DetectChanges 是一个开销不小的操作,如果性能敏感,得靠业务代码层面明确标记实体状态,而不是大量依赖 save 前的自动检测。这里没有银弹,需要你根据实际表规模和行数做取舍。核心原则是,审计逻辑绝不能改变业务的正常行为,如果因为审计逻辑影响了性能或抛异常,就要考虑把它做成旁路系统。
6. 写在最后:一点关于审计功能的个人建议
拦截器写起来并不难,难的是搞清楚“为什么要记录”和“记录之后怎么用”。从我做过的项目看,审计功能最怕的不是复杂,而是无人维护。上线时记录得再多,如果半年后没人知道这份日志能回答什么问题,它就只是又一张吃磁盘的表。
所以我一直坚持一个进入任何新项目都有效的原则:先给核心资产表做审计,做的时候顺手把操作来源细节配齐,把查询 API 给到运维和业务人员,让审计日志能在事故当天就被用起来。等这个模式跑顺了,再逐步扩展到更多表,比一开始就想做一个全覆盖的大而全系统要稳妥得多。如果你正在设计 EF Core 的审计方案,希望上面的踩坑记录能帮你少走一些弯路。
