Skip to content
Go back

EF Core Interceptors 完全指南:审计字段、软删除和慢查询日志

EF Core 的 Interceptor 是“数据库调用的中间件”——它挂在 EF Core 的执行管线里,可以在 SaveChanges 之前自动填充审计字段、在 SQL 执行之后标记慢查询、甚至直接拦截并取消某个操作。

这篇文章覆盖 EF Core 10 的 7 种 Interceptor 接口、注册方式、最常见的审计字段和软删除实现、作用域服务注入的正确姿势,以及容易被忽略的几个坑。完整代码已在作者 GitHub 上可运行。

Interceptor 是什么

每次 EF Core 做一件有意义的事——打开连接、构建 SQL、执行 SaveChanges——它都给已注册的 Interceptor 一个机会,在操作之前和之后运行你的代码。

和普通日志不同的是,Interceptor 可以改变结果。日志只能观察;Interceptor 可以修改 SQL、取消命令、替换 EF Core 看到的结果、或者吞掉异常。这种能力让它们成为横切数据库关注点的天然工具,但也意味着用之前要想清楚。

一个 Interceptor 运行在基础设施层,低于业务逻辑。它会在每一次 SaveChanges 时触发——不管调用来自 Controller、后台任务还是单元测试。这对审计字段是正确的行为,但对只应该在特定场景下触发的业务规则来说是错误的选择。

EF Core 10 的 7 种 Interceptor

Interceptor挂载点典型场景
ISaveChangesInterceptorSaveChanges 前后审计字段、软删除、领域事件、发件箱模式
IDbCommandInterceptorSQL 命令执行前后慢查询日志、添加 Query Hint、修改 SQL
IDbConnectionInterceptor连接打开和关闭按租户切换连接串、获取 Access Token
IDbTransactionInterceptorBegin、Commit、Rollback自定义事务日志
IMaterializationInterceptor查询结果构造实体实例设置非映射属性、向实体注入服务
IQueryExpressionInterceptorLINQ 表达式树编译前向所有查询注入排序或过滤
IIdentityResolutionInterceptor跟踪实体主键冲突合并两个同主键的已跟踪实例

几个关键的实现细节:

注册 Interceptor

OnConfiguring 中注册:

public class AppDbContext : DbContext
{
    private static readonly SlowQueryInterceptor _slowQuery = new();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_slowQuery);
}

对无状态的 Interceptor 用 static readonly 单例,避免每次创建新实例。

在 ASP.NET Core 中用 AddDbContext 注册:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connStr).AddInterceptors(new SlowQueryInterceptor()));

一个有用的细节:即使你用了 AddDbContextOnConfiguring 仍然会执行,两者都会被调用。所以 OnConfiguring 适合放“无论上下文怎么构建都要生效”的配置,包括 Migration 时 Design-Time Factory 创建的上下文。

审计字段:SaveChangesInterceptor

这是 Interceptor 最常见的用途——每个实体自动填上 CreatedAtUtcUpdatedAtUtcCreatedByUpdatedBy,不需要在每个 Handler 里手动写。

先定义两个标记接口:

public interface IAuditable
{
    DateTime CreatedAtUtc { get; set; }
    string? CreatedBy { get; set; }
    DateTime? UpdatedAtUtc { get; set; }
    string? UpdatedBy { get; set; }
}

public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAtUtc { get; set; }
}

Interceptor 继承 SaveChangesInterceptor,覆写 SavingChangesSavingChangesAsync

public sealed class AuditableInterceptor(ICurrentUser currentUser)
    : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        ApplyAudit(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        ApplyAudit(eventData.Context);
        return base.SavingChangesAsync(eventData, result, ct);
    }

    private void ApplyAudit(DbContext? context)
    {
        if (context is null) return;
        context.ChangeTracker.DetectChanges();
        var now = DateTime.UtcNow;
        var user = currentUser.UserId ?? "system";

        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedAtUtc = now;
                entry.Entity.CreatedBy = user;
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.UpdatedAtUtc = now;
                entry.Entity.UpdatedBy = user;
            }
        }

        foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>())
        {
            if (entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAtUtc = now;
            }
        }
    }
}

第二层循环是软删除:当实体被标记为 Deleted 时,把状态改回 Modified 并设置 IsDeleted = true。EF Core 会发 UPDATE 而不是 DELETE,行留在表里。

记得在 OnModelCreating 中配全局查询过滤器隐藏已删除行:

modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);

为什么必须同时覆写同步和异步方法

很多人只覆写了 SavingChangesAsync,然后某个代码路径调了同步的 SaveChanges()——审计字段静默丢失,没有任何错误。EF Core 调用的是你没覆写的方法,也就是基类的空操作。规则:要么两个都覆写,要么明确禁止其中一种并抛出异常。

注入当前用户(作用域服务)

AuditableInterceptor 需要 ICurrentUser——一个从 HTTP 请求中读取当前用户的作用域服务。Interceptors 和作用域服务不会自动配合。

错误做法(大量博客文章会这样写):

// 错误——单例 Interceptor 不能依赖作用域服务
builder.Services.AddSingleton<AuditableInterceptor>();

单例创建一次永久存活,无法持有每次请求不同的 ICurrentUser

正确做法:把 Interceptor 注册为 Scoped,然后通过 AddDbContext(sp, options) 重载解析:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddScoped<AuditableInterceptor>();

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
    options.UseNpgsql(connStr)
        .AddInterceptors(sp.GetRequiredService<AuditableInterceptor>()));

因为 AddDbContext 把上下文和 options 注册为 Scoped,这个 lambda 每次请求用请求自己的 IServiceProvider 执行,所以 GetRequiredService 返回的 Interceptor 携带了当前请求的 ICurrentUser。生命周期对齐。

ICurrentUser 实现本身很普通:

public sealed class CurrentUser(IHttpContextAccessor accessor) : ICurrentUser
{
    public string? UserId =>
        accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
}

注意:如果用了 AddDbContextPool 而不是 AddDbContext,Options 只构建一次并在池中共享,Scoped 的 Interceptor 无法按请求解析。用 Pooling 时保持 Interceptor 无状态,在方法内部通过 eventData.Context 获取作用域服务。

慢查询日志:DbCommandInterceptor

DbCommandInterceptor 工作在 SQL 层面,适合做诊断。CommandExecutedEventData 携带了 Duration

public sealed class SlowQueryInterceptor(
    ILogger<SlowQueryInterceptor> logger)
    : DbCommandInterceptor
{
    private const int SlowThresholdMs = 500;

    public override DbDataReader ReaderExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result)
    {
        Log(command, eventData);
        return base.ReaderExecuted(command, eventData, result);
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken ct = default)
    {
        Log(command, eventData);
        return base.ReaderExecutedAsync(command, eventData, result, ct);
    }

    private void Log(DbCommand cmd, CommandExecutedEventData data)
    {
        if (data.Duration.TotalMilliseconds >= SlowThresholdMs)
        {
            logger.LogWarning(
                "Slow query {ElapsedMs}ms: {Sql}",
                data.Duration.TotalMilliseconds, cmd.CommandText);
        }
    }
}

超过 500ms 的查询会自动出现在日志里,带 SQL 和耗时。这比等用户抱怨页面慢快得多。

取消操作:InterceptionResult.Suppress()

每个拦截方法返回 InterceptionResult。原样返回则 EF Core 正常执行,但你可以调 Suppress() 告诉 EF Core 停下。

一个实用例子是消除删除时的并发异常。两个请求几乎同时删除同一行,第二个请求会报并发冲突——但行已被删了,最终状态正确:

public InterceptionResult ThrowingConcurrencyException(
    ConcurrencyExceptionEventData eventData,
    InterceptionResult result)
{
    if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        return InterceptionResult.Suppress();
    return result;
}

还有一个兄弟方法 SuppressWithResult,它取消操作并给 EF Core 返回一个你提供的替代结果。

什么时候不该用 Interceptor

你想做正确工具为什么不用 Interceptor
打审计字段SaveChangesInterceptor这就是标准场景
软删除SaveChangesInterceptor标准场景,配 Query Filter
隐藏已删除行全局查询过滤器Interceptor 管写不管读
保存后发邮件/事件Domain Events / MediatR业务逻辑不属于基础设施层
按租户过滤查询全局查询过滤器比改表达式树简单安全
校验请求或整形响应Middleware 或 FilterInterceptor 永远看不到 HTTP
慢查询日志DbCommandInterceptor标准场景

30 秒决策法则:只有在行为是横切的、位于基础设施层(不是业务规则)、且必须对每次保存或命令都触发时,才用 Interceptor。审计字段三项全满足。“注册后发欢迎邮件”三项全不满足。

容易踩的坑

如果你关注 .NET 开发、EF Core 实践和工程经验,可以关注 Aide Hub。这里会继续分享能落地的教程和代码示例。

参考


Tags


Previous

Serilog 在 .NET 中的完整指南:从安装到生产级结构化日志

Next

单体架构在 C# 中的完整指南:从传统分层到模块化单体