EF Core 拦截器实战:SaveChangesInterceptor、CommandInterceptor 与审计落地

审计不是"给表补几个 CreatedBy 字段",也不是"在业务方法里顺手记日志"。它本质上是系统级可追溯能力,设计目标是让系统在任何写路径下都能稳定回答四个问题:谁发起、改了什么、何时发生、通过哪条链路触发。

真正的难点不在 API 用法,而在系统设计阶段是否把审计定义成基础设施能力。这里聚焦两层落地:SaveChangesInterceptor 负责实体变更审计,CommandInterceptor 负责 SQL 执行审计,两者一起组成可观测、可追溯、可审计的最小闭环。

1. 问题背景:为什么审计必须在系统设计期落地

如果你刚开始做系统设计,通常会先把功能跑通,再逐步补监控、日志和审计。这条路径很正常,但系统从单一写入口演进到多入口后,审计会出现一些典型断层:

  • HTTP 请求有 TraceId,但数据库变更记录无法关联到具体调用链路。
  • Web 接口有审计字段,批处理、后台任务、集成事件消费链路没有统一审计。
  • SQL 慢查询能看到语句本身,但看不到对应业务场景和调用来源。
  • 合规追查时能找到结果,找不到完整过程和责任主体。

这些现象不是某个人"写错了",而是系统设计阶段还没有建立统一审计模型:

  1. 还没先定义统一的审计契约(身份、时间、来源、关联链路)。
  2. 写入路径没有统一审计入口,不同调用通道口径自然会分化。
  3. 变更审计和 SQL 观测没有打通,排障时很难快速复原完整链路。
  4. 只有开发约定,没有系统级自动机制,随着迭代推进就容易出现漏记。

这篇文章要解决的核心问题不是"把审计代码写到哪一层",而是"如何在系统层提供默认生效、可验证、可扩展的审计能力",并且让这套能力能跟着系统规模一起演进。

2. 原理解析:先拆职责,再落地拦截器

如果你是第一次从系统设计角度做审计,最稳妥的方法是先做职责拆分,再做实现落地。这里按"三步法"来理解:先统一实体变更审计,再统一 SQL 观测入口,最后明确拦截器边界。

2.1 第一步:用 SaveChangesInterceptor 统一实体变更审计

SaveChangesInterceptor 适合处理实体状态相关规则,例如:

  • Added 时补 CreatedAt/CreatedBy
  • Modified 时补 UpdatedAt/UpdatedBy
  • 软删除时把 Deleted 改写成 Modified + IsDeleted

这类逻辑和实体状态强相关,放在拦截器里能覆盖所有调用路径,避免每个 Service 重复写一遍。

2.2 第二步:用 CommandInterceptor 统一 SQL 观测入口

DbCommandInterceptor 适合做 SQL 级别的统一观测:

  • 慢 SQL 记录
  • SQL 失败统一日志
  • 参数快照和调用耗时

它不适合做业务决策,但非常适合做"排障入口统一化"。

2.3 第三步:守住边界,避免拦截器承载业务规则

拦截器是横切能力,不是业务规则容器。像"订单状态机是否允许跳转"这类业务校验,仍然应该放在领域或应用服务层。把业务规则塞进拦截器,后期只会让行为变得不可预测。

3. 示例代码:从分散审计到拦截器统一落地

3.1 问题写法:按入口各自补审计,系统层没有统一策略

csharp 复制代码
public sealed class OrderWriteService
{
    private readonly AppDbContext _db;
    private readonly ICurrentUser _currentUser;

    public OrderWriteService(AppDbContext db, ICurrentUser currentUser)
    {
        _db = db;
        _currentUser = currentUser;
    }

    // HTTP 入口:有用户上下文,补了部分审计字段
    public async Task UpdateFromHttpAsync(long id, decimal amount, CancellationToken ct)
    {
        var order = await _db.Orders.FirstAsync(x => x.Id == id, ct);
        order.Amount = amount;
        order.UpdatedAt = DateTimeOffset.UtcNow;
        order.UpdatedBy = _currentUser.UserId ?? "system";
        await _db.SaveChangesAsync(ct);
    }

    // 后台任务入口:没有用户上下文,常见做法是直接跳过审计字段
    public async Task UpdateFromSchedulerAsync(long id, OrderStatus status, CancellationToken ct)
    {
        var order = await _db.Orders.FirstAsync(x => x.Id == id, ct);
        order.Status = status;
        await _db.SaveChangesAsync(ct);
    }

    // 消费者入口:补了 UpdatedBy,但漏了 UpdatedAt
    public async Task UpdateFromEventConsumerAsync(long id, string externalStatus, CancellationToken ct)
    {
        var order = await _db.Orders.FirstAsync(x => x.Id == id, ct);
        order.ExternalStatus = externalStatus;
        order.UpdatedBy = "order-sync-consumer";
        await _db.SaveChangesAsync(ct);
    }
}

这套写法的核心问题不是某个开发者"忘了写",而是审计规则绑定在调用入口。入口一多,规则必然分裂:字段含义不一致、更新时间口径不一致、排障链路也无法统一。

3.2 优化写法一:SaveChangesInterceptor 统一补齐审计字段

先约定实体接口:

csharp 复制代码
public interface IAuditableEntity
{
    DateTimeOffset CreatedAt { get; set; }
    string CreatedBy { get; set; }
    DateTimeOffset? UpdatedAt { get; set; }
    string? UpdatedBy { get; set; }
}

再实现审计拦截器:

csharp 复制代码
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUser _currentUser;
    private readonly TimeProvider _timeProvider;

    public AuditSaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider)
    {
        _currentUser = currentUser;
        _timeProvider = timeProvider;
    }

    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 cancellationToken = default)
    {
        ApplyAudit(eventData.Context);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void ApplyAudit(DbContext? dbContext)
    {
        if (dbContext is null)
        {
            return;
        }

        var now = _timeProvider.GetUtcNow();
        var userId = _currentUser.UserId ?? "system";

        foreach (var entry in dbContext.ChangeTracker.Entries<IAuditableEntity>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedAt = now;
                entry.Entity.CreatedBy = userId;
                entry.Entity.UpdatedAt = now;
                entry.Entity.UpdatedBy = userId;
            }
            else if (entry.State == EntityState.Modified)
            {
                entry.Entity.UpdatedAt = now;
                entry.Entity.UpdatedBy = userId;
            }
        }
    }
}

3.3 优化写法二:CommandInterceptor 统一记录慢 SQL 和失败 SQL

csharp 复制代码
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;

public sealed class AuditCommandInterceptor : DbCommandInterceptor
{
    private readonly ILogger<AuditCommandInterceptor> _logger;
    private static readonly TimeSpan SlowThreshold = TimeSpan.FromMilliseconds(300);

    public AuditCommandInterceptor(ILogger<AuditCommandInterceptor> logger)
    {
        _logger = logger;
    }

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

    public override int NonQueryExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        int result)
    {
        LogIfSlow(command, eventData);
        return base.NonQueryExecuted(command, eventData, result);
    }

    public override object? ScalarExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        object? result)
    {
        LogIfSlow(command, eventData);
        return base.ScalarExecuted(command, eventData, result);
    }

    public override void CommandFailed(DbCommand command, CommandErrorEventData eventData)
    {
        _logger.LogError(
            eventData.Exception,
            "EF SQL failed. CommandText={CommandText}",
            command.CommandText);

        base.CommandFailed(command, eventData);
    }

    private void LogIfSlow(DbCommand command, CommandExecutedEventData eventData)
    {
        if (eventData.Duration < SlowThreshold)
        {
            return;
        }

        _logger.LogWarning(
            "EF slow SQL. DurationMs={DurationMs}, CommandText={CommandText}",
            eventData.Duration.TotalMilliseconds,
            command.CommandText);
    }
}

3.4 注册方式:把拦截器挂到 DbContext 统一生效

csharp 复制代码
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddScoped<AuditCommandInterceptor>();
builder.Services.AddSingleton(TimeProvider.System);

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"));

    options.AddInterceptors(
        sp.GetRequiredService<AuditSaveChangesInterceptor>(),
        sp.GetRequiredService<AuditCommandInterceptor>());
});

如果你希望慢 SQL 日志能快速关联到业务场景,查询时建议配合 TagWith

csharp 复制代码
var order = await _db.Orders
    .TagWith("OrderQuery:GetByOrderNo")
    .FirstOrDefaultAsync(x => x.OrderNo == orderNo, ct);

3.5 多 DbContext / 多租户场景:统一注册与上下文透传

单体示例里只注册一个 AppDbContext,但真实项目常见多个上下文(交易库、账务库、报表库)。如果每个上下文都手写一份注册代码,后续很容易出现"有的上下文挂了审计拦截器,有的没挂"。

更稳妥的做法是把拦截器挂载动作抽成统一扩展方法:

csharp 复制代码
public static class DbContextOptionsBuilderExtensions
{
    public static DbContextOptionsBuilder AddAuditInterceptors(
        this DbContextOptionsBuilder options,
        IServiceProvider serviceProvider)
    {
        options.AddInterceptors(
            serviceProvider.GetRequiredService<AuditSaveChangesInterceptor>(),
            serviceProvider.GetRequiredService<AuditCommandInterceptor>());

        return options;
    }
}

多个上下文复用同一套挂载:

csharp 复制代码
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Main"));
    options.AddAuditInterceptors(sp);
});

builder.Services.AddDbContext<BillingDbContext>((sp, options) =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Billing"));
    options.AddAuditInterceptors(sp);
});

如果是多租户系统,建议在 ICurrentUser 之外再引入 ICurrentTenant,并在 SaveChangesInterceptor 里统一写入 TenantId 或校验租户边界,避免不同入口出现租户字段口径不一致。

4. 总结

EF Core 拦截器的价值,不在于"写得更高级",而在于把重复且容易漏的规则变成基础设施能力。SaveChangesInterceptor 解决实体审计一致性,CommandInterceptor 解决 SQL 观测统一入口。先把这两层根基打牢,后续无论是做审计追踪、慢 SQL 治理,都会方便很多。

实验源码:ef-core/AuditlogsDemo

相关推荐
mingshili8 小时前
[架构设计] 依赖注入优于单例模式
单例模式·架构设计
小邓的技术笔记10 小时前
ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界
架构设计
mingshili10 小时前
[架构设计] pypubsub 底层实现机制与高性能替代方案
python·架构设计
硅基喵1 天前
ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界
asp.net core·架构设计
小邓的技术笔记1 天前
.NET .Result 避坑指南:不同框架下的死锁与线程池饥饿
ef core·工程实践
带娃的IT创业者2 天前
Weclaw 请求路由实战:一个 request_id 如何在 800 个并发连接中精准找到目标浏览器?
python·websocket·fastapi·架构设计·实时通信·openclaw·weclaw
硅基喵2 天前
EF Core 避坑:.Result 在不同框架下的死锁与线程饥饿
ef core·工程实践
硅基喵3 天前
EF Core 慢查询排查实战:TagWith、OpenTelemetry、执行计划,30 分钟定位性能瓶颈
ef core·工程实践
墨10243 天前
当 AI 助手开始管理多个项目:如何把“继续某项目”变成可联动机制
人工智能·ai·项目管理·架构设计·工程实践·openclaw