EFCore最佳实践模式Skill efcore-patterns

此技能提供了Entity Framework Core的核心最佳实践模式,包括默认无跟踪、查询分割、迁移管理、专用迁移服务等,帮助开发人员优化性能、避免常见陷阱,并提高数据库操作的效率和可靠性。关键词:Entity Framework Core, EF Core, 性能优化, 数据库迁移, .NET开发, 后端开发

后端开发 0 次安装 0 次浏览 更新于 3/22/2026

名称: efcore-patterns 描述: Entity Framework Core 最佳实践,包括默认无跟踪、导航集合的查询分割、迁移管理、专用迁移服务以及要避免的常见陷阱。 许可证: MIT 元数据: 版本: “1.0.0” 域: 语言 触发词: C#, .NET, ASP.NET Core, Entity Framework, EF Core 角色: 专家 范围: 实现 输出格式: 代码 相关技能: csharp-developer, dotnet-core-expert, dotnet-ddd, testcontainers-integration-tests

Entity Framework Core 模式

何时使用此技能

使用此技能时:

  • 在新项目中设置 EF Core
  • 优化查询性能
  • 管理数据库迁移
  • 将 EF Core 与 .NET Aspire 集成
  • 调试更改跟踪问题
  • 高效加载多个导航集合(查询分割)

核心原则

  1. 默认无跟踪 - 大多数查询是只读的;需要时再启用跟踪
  2. 永远不要手动编辑迁移 - 始终使用 CLI 命令
  3. 专用迁移服务 - 将迁移执行与应用程序启动分离
  4. 执行策略用于重试 - 处理瞬时数据库故障
  5. 显式更新 - 当无跟踪时,显式标记实体进行更新

模式 1:默认无跟踪

配置您的 DbContext 默认禁用更改跟踪。这提高了读密集型工作负载的性能。

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
        // 默认禁用更改跟踪,以提高只读查询的性能
        // 对于需要跟踪更改的查询,请显式使用 .AsTracking()
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

当无跟踪激活时

只读查询正常工作:

// ✅ 快速读取 - 无跟踪开销
var orders = await dbContext.Orders
    .Where(o => o.Status == OrderStatus.Pending)
    .ToListAsync();

写入需要显式处理:

// ❌ 错误 - 实体未跟踪,SaveChanges 什么都不做
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 什么都没发生!

// ✅ 正确 - 显式标记实体进行更新
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // 将整个实体标记为已修改
await dbContext.SaveChangesAsync();

// ✅ 也正确 - 在查询中使用 AsTracking()
var order = await dbContext.Orders
    .AsTracking()
    .FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // 工作!

何时使用跟踪

场景 使用跟踪? 原因
在 UI 中显示数据 只读,无更新
API GET 端点 返回数据,无突变
更新单个实体 是或显式 Update() 需要保存更改
具有导航的复杂更新 跟踪处理关系
批量操作 否 + ExecuteUpdate 更高效

显式添加/更新模式

public class OrderService
{
    private readonly ApplicationDbContext _db;

    // 创建 - 始终使用 Add(无论跟踪如何都有效)
    public async Task<Order> CreateOrderAsync(Order order)
    {
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();
        return order;
    }

    // 更新 - 显式标记为已修改
    public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
    {
        var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
            ?? throw new NotFoundException($"订单 {orderId} 未找到");

        order.Status = newStatus;
        order.UpdatedAt = DateTimeOffset.UtcNow;

        // 显式标记为已修改,因为 DbContext 默认使用无跟踪
        _db.Orders.Update(order);
        await _db.SaveChangesAsync();
    }

    // 删除 - 附加并移除
    public async Task DeleteOrderAsync(Guid orderId)
    {
        var order = new Order { Id = orderId };
        _db.Orders.Remove(order);
        await _db.SaveChangesAsync();
    }
}

模式 2:永远不要手动编辑迁移

关键: 始终使用 EF Core CLI 命令来管理迁移。永远不要:

  • 手动编辑迁移文件(除了在 Up()/Down() 中的自定义 SQL)
  • 直接删除迁移文件
  • 重命名迁移文件
  • 在项目之间复制迁移

创建迁移

# 创建新迁移
dotnet ef migrations add AddCustomerTable \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

# 使用特定的 DbContext(如果您有多个)
dotnet ef migrations add AddCustomerTable \
    --context ApplicationDbContext \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

移除迁移

# 移除最后一个迁移(如果尚未应用)
dotnet ef migrations remove \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

# 永远不要这样做:
# rm Migrations/20240101_AddCustomerTable.cs  # ❌ 错误!

应用迁移

# 应用所有待处理的迁移
dotnet ef database update \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

# 应用到特定迁移
dotnet ef database update AddCustomerTable \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

# 回滚到先前迁移
dotnet ef database update PreviousMigrationName \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

生成 SQL 脚本

# 为所有迁移生成 SQL 脚本
dotnet ef migrations script \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api \
    --output migrations.sql

# 生成幂等脚本(可安全运行多次)
dotnet ef migrations script \
    --idempotent \
    --project src/MyApp.Infrastructure \
    --startup-project src/MyApp.Api

模式 3:使用 Aspire 的专用迁移服务

使用专用迁移服务将迁移执行与主应用程序分离。这确保:

  • 迁移在应用程序启动前完成
  • 关注点分离
  • 测试环境中的受控种子数据

项目结构

src/
├── MyApp.AppHost/           # Aspire 编排
├── MyApp.Api/               # 主应用程序
├── MyApp.Infrastructure/    # DbContext 和迁移
└── MyApp.MigrationService/  # 专用迁移运行器

MigrationService Program.cs

using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;

var builder = Host.CreateApplicationBuilder(args);

// 添加 Aspire 服务默认值
builder.AddServiceDefaults();

// 添加 PostgreSQL DbContext
var connectionString = builder.Configuration.GetConnectionString("appdb")
    ?? throw new InvalidOperationException("未找到连接字符串 'appdb'。");

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseNpgsql(connectionString, npgsqlOptions =>
        npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));

// 添加迁移工作器
builder.Services.AddHostedService<MigrationWorker>();

var host = builder.Build();
host.Run();

MigrationWorker.cs

public class MigrationWorker : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IHostApplicationLifetime _hostApplicationLifetime;
    private readonly ILogger<MigrationWorker> _logger;

    public MigrationWorker(
        IServiceProvider serviceProvider,
        IHostApplicationLifetime hostApplicationLifetime,
        ILogger<MigrationWorker> logger)
    {
        _serviceProvider = serviceProvider;
        _hostApplicationLifetime = hostApplicationLifetime;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("迁移服务启动中...");

        try
        {
            using var scope = _serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            await RunMigrationsAsync(dbContext, stoppingToken);

            _logger.LogInformation("迁移服务成功完成。");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "迁移服务失败:{Error}", ex.Message);
            throw;
        }
        finally
        {
            // 迁移完成后停止应用程序
            _hostApplicationLifetime.StopApplication();
        }
    }

    private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
    {
        // 使用执行策略处理瞬时故障
        var strategy = dbContext.Database.CreateExecutionStrategy();

        await strategy.ExecuteAsync(async () =>
        {
            var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);

            if (pendingMigrations.Any())
            {
                _logger.LogInformation("正在应用 {Count} 个待处理迁移...",
                    pendingMigrations.Count());

                await dbContext.Database.MigrateAsync(ct);

                _logger.LogInformation("迁移已成功应用。");
            }
            else
            {
                _logger.LogInformation("无待处理迁移。数据库已是最新。");
            }
        });
    }
}

AppHost 配置

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");

// 迁移首先运行,然后退出
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
    .WaitFor(db)
    .WithReference(db);

// API 等待迁移完成
var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WaitForCompletion(migrations)  // 关键:等待迁移完成
    .WithReference(db);

模式 4:用于瞬时故障的执行策略

始终为可能瞬时失败的操作使用 CreateExecutionStrategy()

public async Task UpdateWithRetryAsync(Guid id, Action<Order> update)
{
    var strategy = _dbContext.Database.CreateExecutionStrategy();

    await strategy.ExecuteAsync(async () =>
    {
        var order = await _dbContext.Orders
            .AsTracking()
            .FirstOrDefaultAsync(o => o.Id == id);

        if (order is null) return;

        update(order);
        await _dbContext.SaveChangesAsync();
    });
}

重要: 您不能将 CreateExecutionStrategy() 与用户启动的事务一起使用。如果需要带重试的事务:

var strategy = _dbContext.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    // 事务必须在策略回调内部
    await using var transaction = await _dbContext.Database.BeginTransactionAsync();

    try
    {
        // ... 您的操作 ...
        await _dbContext.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
});

模式 5:使用 ExecuteUpdate/ExecuteDelete 进行批量操作

对于批量操作,使用 EF Core 7+ 的 ExecuteUpdateAsyncExecuteDeleteAsync 而不是加载实体:

// ❌ 慢 - 将所有实体加载到内存中
var expiredOrders = await _db.Orders
    .Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
    .ToListAsync();

foreach (var order in expiredOrders)
{
    order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();

// ✅ 快 - 单个 SQL UPDATE 语句
await _db.Orders
    .Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, OrderStatus.Expired)
        .SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));

// ✅ 快 - 单个 SQL DELETE 语句
await _db.Orders
    .Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
    .ExecuteDeleteAsync();

常见陷阱

1. 忘记在无跟踪时更新

// ❌ 静默失败 - 实体未跟踪
var customer = await _db.Customers.FindAsync(id);
customer.Name = "新名称";
await _db.SaveChangesAsync(); // 什么都不做!

// ✅ 显式更新
var customer = await _db.Customers.FindAsync(id);
customer.Name = "新名称";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();

2. N+1 查询问题

// ❌ N+1 查询 - 每个订单一个查询
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
    var orders = customer.Orders; // 懒加载触发查询
}

// ✅ 急切加载 - 单个查询
var customers = await _db.Customers
    .Include(c => c.Orders)
    .ToListAsync();

3. 多个 DbContext 实例的跟踪冲突

// ❌ 跟踪冲突 - 实体被不同上下文跟踪
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // 可能抛出异常或行为意外

// ✅ 使用单个上下文或先分离
_db1.Entry(order1).State = EntityState.Detached;

4. 不一致地使用异步

// ❌ 在异步上下文中的阻塞调用
var orders = _db.Orders.ToList(); // 阻塞线程

// ✅ 全程异步
var orders = await _db.Orders.ToListAsync();

5. 在循环中查询

// ❌ 每次迭代查询
foreach (var orderId in orderIds)
{
    var order = await _db.Orders.FindAsync(orderId);
    // 处理订单
}

// ✅ 单个查询
var orders = await _db.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync();

DI 中的 DbContext 生命周期

ASP.NET Core(默认作用域)

// 作用域 = 每个 HTTP 请求一个实例
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseNpgsql(connectionString));

后台服务(创建作用域)

public class MyBackgroundService : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // ✅ 为每个工作单元创建作用域
        using var scope = _serviceProvider.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        // ... 使用 dbContext ...
    }
}

参与者 / 长寿命对象(工厂模式)

public class OrderActor : ReceiveActor
{
    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;

    public OrderActor(IDbContextFactory<ApplicationDbContext> dbFactory)
    {
        _dbFactory = dbFactory;

        ReceiveAsync<GetOrder>(async msg =>
        {
            // 为每个操作创建新上下文
            await using var db = await _dbFactory.CreateDbContextAsync();
            var order = await db.Orders.FindAsync(msg.OrderId);
            Sender.Tell(order);
        });
    }
}

// 注册
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
    options.UseNpgsql(connectionString));

模式 6:查询分割以防止笛卡尔爆炸

当您通过 Include() 加载多个导航集合时,EF Core 会生成单个查询,可能导致笛卡尔爆炸。如果您有 10 个订单,每个订单有 10 个项目,您会得到 100 行,而不是 10 + 10。

全局配置(推荐用于大多数情况)

在 DbContext 配置中全局启用查询分割:

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseNpgsql(connectionString, npgsqlOptions =>
        {
            npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
        }));

每查询覆盖

当您知道单查询更高效时使用:

// 当您知道结构已充分理解时使用单查询
var orders = await dbContext.Orders
    .Include(o => o.Items)
    .Include(o => o.Payments)
    .AsSingleQuery()  // 覆盖全局分割行为
    .ToListAsync();

权衡

行为 优点 缺点
SplitQuery 无笛卡尔爆炸,适合大集合 多次往返,潜在一致性问题
SingleQuery 单次往返,事务一致性 多个集合时笛卡尔爆炸

推荐: 默认全局使用 SplitQuery,对于已知单查询更好的特定查询使用 AsSingleQuery()

何时首选 SingleQuery

  • 小且已充分理解的导航图(2-3 层)
  • 总是需要所有相关数据的查询
  • 往返成本低于笛卡尔爆炸的性能关键路径

何时首选 SplitQuery

  • 大或不可预测的导航图
  • 多对多关系
  • 加载可能不需要所有集合的查询

使用 EF Core 测试

内存提供程序(仅用于单元测试)

// 仅用于简单单元测试 - 不匹配真实数据库行为
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
    .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
    .Options;

using var context = new ApplicationDbContext(options);

使用 TestContainers 的真实数据库(集成测试)

请参阅 testcontainers-integration-tests 技能进行适当的数据库测试。

// 在容器中使用真实 PostgreSQL
var container = new PostgreSqlBuilder()
    .WithImage("postgres:16-alpine")
    .Build();

await container.StartAsync();

var options = new DbContextOptionsBuilder<ApplicationDbContext>()
    .UseNpgsql(container.GetConnectionString())
    .Options;