Entity Framework Core 作为 .NET 生态中最主流的 ORM,一些开发者停留在 DbSet.Add()、SaveChanges()、Include() 这些基础 API 上。但当项目规模扩大、并发上升、业务变复杂之后,EF Core 提供的一系列高级特性才真正开始发挥价值——无论是性能优化、多租户隔离,还是审计追踪、并发控制。
本文整理了 14 个实用的 EF Core 高级功能,每个都配有示例代码。示例需要结合具体的实体、数据库提供程序和 EF Core 版本调整后再使用。
在模型级别自动给所有查询附加 WHERE 条件,最常见的场景是软删除和多租户隔离。
public class Order
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public int TenantId { get; set; }
}
public class AppDbContext : DbContext
{
private readonly int _currentTenantId;
public AppDbContext(DbContextOptions options, ITenantProvider tenantProvider)
: base(options)
{
_currentTenantId = tenantProvider.GetCurrentTenantId();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted && o.TenantId == _currentTenantId);
}
}
需要绕过过滤器时可以用 IgnoreQueryFilters()。这个 API 会同时绕过软删除和租户过滤器,必须只允许经过严格授权的后台操作使用,否则可能造成跨租户数据泄露:
var allOrders = await context.Orders
.IgnoreQueryFilters()
.ToListAsync();
对高频执行的查询,EF.CompileQuery 可以缓存表达式树的解析和 SQL 翻译过程,减少重复编译开销。
private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById =
EF.CompileAsyncQuery((AppDbContext context, int orderId) =>
context.Orders.FirstOrDefault(o => o.Id == orderId));
// 调用
var order = await GetOrderById(context, 1001);
包含多个 Include 的一对多查询容易产生笛卡尔积爆炸,AsSplitQuery() 把一条大 JOIN 拆成多条独立 SQL。
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
注意:拆分查询会发送多条 SQL。默认情况下,多条查询之间可能观察到不同的数据快照;如果必须保证一致性,需要根据数据库能力显式使用合适的事务隔离级别。同时还要权衡额外的网络往返,不要只因为查询包含
Include就默认启用。
var readOnlyOrders = await context.Orders
.AsNoTracking()
.Where(o => o.Status == OrderStatus.Completed)
.ToListAsync();
// 需要按主键去重、避免同一实体重复实例化时
var ordersWithIdentityResolution = await context.Orders
.Include(o => o.Items)
.AsNoTrackingWithIdentityResolution()
.ToListAsync();
// 混合 LINQ 与原始 SQL
var highValueOrders = await context.Orders
.FromSqlInterpolated($"SELECT * FROM Orders WHERE Amount > {1000}")
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
// EF Core 7+ 批量更新/删除,不需要先加载到内存
await context.Orders
.Where(o => o.Status == OrderStatus.Cancelled)
.ExecuteDeleteAsync();
await context.Orders
.Where(o => o.CreatedAt < DateTime.UtcNow.AddYears(-1))
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.IsArchived, true));
Owned Entity Types 可以把地址等依赖对象映射到宿主实体的表中。它们仍然属于 EF 的实体类型并参与变更跟踪;如果使用 EF Core 8+,更纯粹的值对象也可以考虑 Complex Types。
public class Order
{
public int Id { get; set; }
public Address ShippingAddress { get; set; } = null!;
}
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress, a =>
{
a.Property(p => p.Street).HasColumnName("ShippingStreet");
a.Property(p => p.City).HasColumnName("ShippingCity");
});
}
属性不出现在 C# 类里,只存在于 EF 模型和数据库表中——很适合配合审计字段,避免污染领域模型。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property<DateTime>("CreatedAt");
modelBuilder.Entity<Order>()
.Property<string>("CreatedBy");
}
// 读写影子属性
context.Entry(order).Property("CreatedAt").CurrentValue = DateTime.UtcNow;
var createdAt = context.Entry(order).Property<DateTime>("CreatedAt").CurrentValue;
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 枚举存成字符串,而不是默认的 int
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>();
// 自定义转换器:落库前加密,读出后解密
modelBuilder.Entity<Customer>()
.Property(c => c.PhoneNumber)
.HasConversion(
plain => EncryptionHelper.Encrypt(plain),
cipher => EncryptionHelper.Decrypt(cipher));
// JSON 列(EF Core 7+,具体能力取决于数据库提供程序)
// ToJson() 适用于 owned entity,而不是普通的 List<string>。
modelBuilder.Entity<Order>()
.OwnsOne(o => o.Metadata, b => b.ToJson());
}
这里的 Metadata 应该是一个拥有实体,例如包含 Source、Campaign 等属性的对象;如果 Tags 是 List<string>,应根据数据库提供程序选择数组映射、值转换器或单独的实体表。
public abstract class Payment
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
public class CreditCardPayment : Payment
{
public string CardNumberLast4 { get; set; } = string.Empty;
}
public class AlipayPayment : Payment
{
public string AlipayAccount { get; set; } = string.Empty;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// TPH(单表继承,默认策略):所有子类共用一张表,靠 Discriminator 列区分
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("PaymentType")
.HasValue<CreditCardPayment>("CreditCard")
.HasValue<AlipayPayment>("Alipay");
// TPT(每个类型一张表,EF Core 5+):每个子类单独建表,通过外键关联基类表
// modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
// modelBuilder.Entity<AlipayPayment>().ToTable("AlipayPayments");
}
public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = default!;
}
try
{
order.Amount = 200;
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues is null)
{
// 记录已被删除
}
else
{
// 读取数据库最新值,和当前值进行合并;这里只更新 OriginalValues,
// 并不会自动完成冲突解决或重试。
entry.OriginalValues.SetValues(databaseValues);
// 根据业务决定如何合并 CurrentValues,然后再次 SaveChangesAsync()
}
}
比起重写 SaveChanges,拦截器更适合横切关注点,和依赖注入配合也更自然。
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
public AuditSaveChangesInterceptor(ICurrentUserService currentUser)
{
_currentUser = currentUser;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);
var now = DateTime.UtcNow;
var userId = _currentUser.UserId;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Property(nameof(IAuditable.CreatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditable.CreatedBy)).CurrentValue = userId;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
entry.Property(nameof(IAuditable.UpdatedBy)).CurrentValue = userId;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
// 注册
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});
DbCommandInterceptor 还能拦截原始 SQL 执行,方便做慢查询日志或 SQL 审计。慢查询应该在命令执行完成后判断,不能在 ReaderExecutingAsync 中读取耗时:
public class SlowQueryLoggingInterceptor : DbCommandInterceptor
{
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Duration > TimeSpan.FromSeconds(1))
{
Log.Warning("慢查询: {Sql}", command.CommandText);
}
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString,
sqlOptions => sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null)));
// 显式事务必须用 ExecutionStrategy 包裹,否则重试会失败。
// 委托可能被重复执行,其中的业务操作必须具备幂等性。
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await context.Database.BeginTransactionAsync();
try
{
context.Orders.Add(newOrder);
await context.SaveChangesAsync();
context.Inventory.Update(inventoryItem);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
不要把发送邮件、发布消息等不可回滚的外部副作用直接放进可重试委托中;应使用 Outbox 等方案。更复杂的场景还应考虑“事务已经提交,但客户端未收到结果”这种不确定状态。
按用户所在地区或租户动态选择数据库连接,常见于分库分区场景。
public class TenantDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly ITenantProvider _tenantProvider;
private readonly IConfiguration _configuration;
public TenantDbContextFactory(ITenantProvider tenantProvider, IConfiguration configuration)
{
_tenantProvider = tenantProvider;
_configuration = configuration;
}
public AppDbContext CreateDbContext()
{
var region = _tenantProvider.GetCurrentRegion(); // 如 "cn-east", "us-west"
var connectionString = _configuration.GetConnectionString($"Db_{region}");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.Options;
return new AppDbContext(options);
}
}
不建议直接在 OnModelCreating 里按请求租户动态调整 Schema:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().ToTable("Orders", schema: _tenantProvider.GetSchemaName());
}
EF Core 会缓存模型,OnModelCreating 不会为每个请求重新执行。这样可能导致第一个租户生成的 Schema 被后续租户复用。若确实需要按租户使用不同 Schema,必须实现按租户区分的 IModelCacheKeyFactory,并额外处理迁移、模型缓存数量和连接生命周期。实践中,按租户切换数据库连接通常更简单;单库多租户则更适合使用 TenantId 列配合全局查询过滤器。
高并发 API 场景下,复用 DbContext 实例可以明显减少每次请求创建/销毁的开销。
services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString), poolSize: 128);
注意:池化的 DbContext 实例会被复用,构造函数中的请求级初始化不会在每次从池中取出时重新执行。EF Core 没有通用的
DbContext.Initialize()回调。应避免在池化 Context 中保存请求级状态;如果必须设置租户,应由请求管道在每次解析后显式设置,并确保所有自定义状态都被重置。也可以改用IDbContextFactory创建短生命周期 Context。
public class AppDbContext : DbContext
{
public int CurrentTenantId { get; private set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
// 每次从池中取出后,由中间件显式调用;不要依赖构造函数设置请求级租户。
public void SetTenant(int tenantId)
{
CurrentTenantId = tenantId;
ChangeTracker.Clear(); // 清理上一次请求残留的追踪状态
}
}
// 中间件里
app.Use(async (context, next) =>
{
var dbContext = context.RequestServices.GetRequiredService<AppDbContext>();
var tenantId = context.User.GetTenantId();
dbContext.SetTenant(tenantId);
await next();
});
这 14 个功能大致可以归为四类:
| 类别 | 功能 | 解决的问题 |
|---|---|---|
| 查询性能 | 全局过滤器、编译查询、拆分查询、NoTracking、批量操作 | 减少不必要的开销、避免笛卡尔积 |
| 建模 | 拥有类型、影子属性、值转换器、继承映射 | 让领域模型更贴近业务而不被数据库结构绑架 |
| 并发事务 | 乐观锁、拦截器、执行策略 | 数据一致性与云环境下的瞬时故障容错 |
| 工程化 | 动态连接切换、DbContext 池化 | 多租户架构、高并发下的资源复用 |
实践中最值得优先掌握的是全局查询过滤器 + 拦截器这对组合——前者解决多租户/软删除的一致性问题,后者解决审计字段和横切逻辑。两者结合可以覆盖很多企业级项目的通用需求,但仍需配合权限校验、测试和生产监控。
暂无评论,快来抢沙发吧!