EF Core 数据访问

系统讲解 EF Core 的 DbContext 与 DbSet、实体映射约定与迁移流程,深入变更跟踪、懒加载与 N+1 问题的根源,并给出异步查询与并发控制的最佳实践。

1. DbContext 与 DbSet

一句话总结: DbContext 是 EF Core 的工作单元入口,DbSet 代表一张表的查询与写入能力,一个上下文类就是一个数据库会话。

DbContext 是 EF Core 的核心对象:它持有数据库连接、跟踪实体的变更、生成并执行 SQL。DbSet<T> 则是对应实体的「表句柄」,查询、添加、删除都从它开始。

public class ShopDbContext(DbContextOptions<ShopDbContext> options)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<OrderLine> OrderLines => Set<OrderLine>();
}

// 注册:Scoped 生命周期,每个请求一个上下文
builder.Services.AddDbContext<ShopDbContext>(options =>
    options.UseSqlServer(builder.Configuration
        .GetConnectionString("Default")));

// 注入使用
public class OrderService(ShopDbContext db)
{
    public async Task<List<Order>> GetRecentAsync() =>
        await db.Orders.OrderByDescending(o => o.CreatedAt)
                       .Take(10)
                       .ToListAsync();
}
成员职责
DbContext连接管理、变更跟踪、SQL 生成
DbSet<T>实体的查询入口与增删改入口
SaveChangesAsync把跟踪的变更一次性写库
ModelBuilder实体映射配置(Fluent API)

避坑: DbContext 是短命对象,应该 Scoped 注册、按请求或工作单元创建。把它做成 Singleton 或长期持有,连接池、跟踪缓存、并发写都会失控。

2. 实体映射与约定

一句话总结: EF Core 通过约定自动把 C# 类映射成表结构,Data Annotation 与 Fluent API 用于补充约定覆盖不了的映射细节。

约定(Convention)让普通 POCO 直接变表:类名→表名、属性→列、主键命名 Id 或 {类名}Id 自动识别。需要更多控制时用 Fluent API 显式配置。

public class Order
{
    public int Id { get; set; }                    // 约定:主键
    public int CustomerId { get; set; }            // 约定:外键
    public Customer? Customer { get; set; }        // 导航属性
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public List<Order> Orders { get; set; } = [];  // 一对多
}

// Fluent API 显式配置:索引、精度、外键行为
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        entity.HasIndex(o => o.CustomerId);
        entity.Property(o => o.Total).HasPrecision(18, 2);
        entity.HasOne(o => o.Customer)
              .WithMany(c => c.Orders)
              .HasForeignKey(o => o.CustomerId)
              .OnDelete(DeleteBehavior.Restrict);
    });
}
映射手段场景
约定默认命名与主键识别
Data Annotation单个属性约束([Required] 等)
Fluent API复杂关系、索引、精度、级联
Owned Type值对象映射
Value Converter枚举↔字符串、DateTime↔UTC

一句话: 约定的价值是「零配置起步」,但复杂业务必须依赖 Fluent API 把关系与约束说清楚。导航属性 + 外键属性显式配对,是避免 EF 猜错关系的最佳习惯。

3. 迁移机制

一句话总结: 迁移把实体模型的变更翻译成数据库结构变更,代码先行让 Schema 与模型始终同步,迁移文件即团队协作的数据库版本记录。

EF Core 用迁移(Migration)把模型变化转成 SQL DDL。流程是「改模型 → Add-Migration → Update-Database」,迁移文件生成后应纳入版本控制,上线时按序执行。

# 生成迁移(dll 需能加载模型)
dotnet ef migrations add AddOrderIndex
# 本地应用
dotnet ef database update
# 生成 SQL 脚本(上线审阅用)
dotnet ef migrations script --from 0 --output up.sql
// 生成的迁移快照:Up 与 Down 成对
public partial class AddOrderIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateIndex(
            name: "IX_Orders_CustomerId",
            table: "Orders",
            column: "CustomerId");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropIndex(
            name: "IX_Orders_CustomerId",
            table: "Orders");
    }
}
迁移命令作用
dotnet ef migrations add生成新迁移文件
dotnet ef database update应用到数据库
dotnet ef migrations script输出 SQL 脚本
dotnet ef database drop删除数据库(开发用)
dotnet ef migrations remove撤销未应用的迁移

避坑: 迁移里若有数据回填(重命名列、填默认值),手写 migrationBuilder.Sql(...) 时要用 SQL 而非 C#,且必须验证 Down 可回滚。生产环境的 Schema 变更应该走「生成脚本 + 审阅 + 灰度执行」,而不是直接 database update。

4. 查询与变更跟踪

一句话总结: LINQ 查询被翻译成 SQL 执行,默认启用变更跟踪让实体进入「被观察」状态,SaveChanges 把增量变更写回数据库。

EF Core 查询本质是 IQueryable → SQL。查询出的实体默认被跟踪:修改属性后调用 SaveChangesAsync,EF 对比快照生成 UPDATE。AsNoTracking 用于只读查询,省掉跟踪开销。

// 可组合查询:先过滤后固化(IQueryable 翻译成 SQL)
var query = db.Orders
    .Include(o => o.Customer)
    .Where(o => o.Total > 100 && o.CreatedAt > since);

var list = await query.ToListAsync();

// 只读查询:AsNoTracking 省跟踪开销
var readonlyList = await db.Orders
    .AsNoTracking()
    .Where(o => o.Status == "Paid")
    .ToListAsync();

// 变更跟踪 + 保存
var order = await db.Orders.SingleAsync(o => o.Id == 1);
order.Total = 150m;                       // 标记为 Modified
await db.SaveChangesAsync();              // 生成 UPDATE
查询模式行为适用
默认跟踪实体被观察,Save 写回读改写场景
AsNoTracking不跟踪,只读列表/报表
Include贪婪加载导航防 N+1
Projection(Select 新形状)只取需要的列减少传输
AsSplitQuery多 Include 拆多条查询避免笛卡尔积

避坑: 跟踪实体长期不释放(例如塞进静态缓存),跟踪器会越攒越多。只读场景一律 AsNoTracking;需要把结果跨请求缓存时,务必投影成 DTO 而非返回跟踪实体。

5. 懒加载与显式加载

一句话总结: 懒加载在首次访问导航属性时才查询数据库,配置简单但最容易诱发 N+1;显式加载和贪婪加载才是可控的选择。

懒加载需要代理(UseLazyLoadingProxies)且导航属性标记 virtual。它看起来方便,却把数据库访问「隐藏」在属性访问里——一旦循环中访问导航属性,就是灾难性的 N+1。

// 启用懒加载代理
builder.Services.AddDbContext<ShopDbContext>(options =>
    options.UseLazyLoadingProxies()
           .UseSqlServer(connectionString));

// 导航属性必须是 virtual 才能生成代理
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public virtual Customer? Customer { get; set; }   // virtual
}

// 显式加载:需要时明确查询
var order = await db.Orders.SingleAsync(o => o.Id == 1);
await db.Entry(order).Reference(o => o.Customer).LoadAsync();
加载方式查询时机N+1 风险
懒加载访问导航属性时高
贪婪加载(Include)主查询一并带出低
显式加载(LoadAsync)手动触发可控
投影(Select DTO)主查询限定列低

一句话: 懒加载是「看着省事、实则埋雷」。默认关闭、显式选用贪婪加载或投影,是 EF Core 社区的共识——数据库访问次数必须在代码里看得见,而不是藏在属性 getter 后面。

6. 性能与 N+1 问题

一句话总结: N+1 是懒加载或循环查询导航属性导致的 1 条主查询 + N 条附加查询,贪婪加载与投影是根治它的两大武器。

N+1 是最典型的 ORM 性能事故:查询 N 个订单,循环里访问 order.Customer 又触发 N 次查询,总请求数变成 N+1。数据库往返是毫秒级成本,N 一大就会拖垮接口。

// 反面:循环触发懒加载 → N+1
var orders = await db.Orders.Take(100).ToListAsync();
foreach (var o in orders)
{
    Console.WriteLine(o.Customer?.Name);   // 每次访问都发一条 SQL
}

// 正面:贪婪加载一次带出
var orders2 = await db.Orders
    .Include(o => o.Customer)
    .Take(100)
    .ToListAsync();

// 更优:投影只取需要的列
var dtos = await db.Orders
    .Select(o => new { o.Id, o.Total, CustomerName = o.Customer!.Name })
    .Take(100)
    .ToListAsync();
手段查询数数据量
懒加载N+1每次只取一列
Include1(或多条 SplitQuery)可能列冗余
投影1最小化

避坑: 用日志观察实际 SQL 数量(LogTo 输出每条 SQL),N+1 立刻现形。另外多个 Include 一对多关系会产生笛卡尔积,行数相乘——AsSplitQuery() 拆成多条查询可规避。

7. 异步操作与并发控制

一句话总结: 异步 API 贯穿 EF 查询避免阻塞线程,并发控制用并发令牌把「乐观锁」落地,SaveChanges 冲突时以异常形式显形。

EF Core 全链路异步:ToListAsync、SaveChangesAsync 等。并发控制方面,[ConcurrencyCheck] 或 [Timestamp](rowversion)标记并发令牌字段,更新时 WHERE 子句带上原值,冲突抛 DbUpdateConcurrencyException。

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int Stock { get; set; }
    public byte[] RowVersion { get; set; } = [];   // 并发令牌
}

// Fluent 配置行版本
entity.Property(p => p.RowVersion).IsRowVersion();

// 冲突处理
try
{
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // 冲突:重新读取或提示用户重试
    var entry = ex.Entries.Single();
    var dbValues = await entry.GetDatabaseValuesAsync();
    Console.WriteLine($"库存已被改为 {dbValues?.GetValue<int>("Stock")}");
}
并发策略机制适用
乐观锁(令牌)更新时校验版本Web 高并发默认选择
悲观锁(事务 + 行锁)数据库锁强一致低频场景
最后写入胜出不校验可接受覆盖的场合

避坑: 乐观锁冲突是业务事件而非程序 bug——正确姿势是把冲突反馈给用户让其重试或合并,而不是静默吞掉。事务中嵌套异步需注意 TransactionScope 与连接复用的交互,尽量保持一个请求一个事务。

8. 总结

环节要点
DbContextScoped 短命对象,工作单元入口
映射约定起步 + Fluent API 补充
迁移模型驱动 DDL,脚本审阅上线
查询IQueryable 翻译 SQL,AsNoTracking 只读
加载懒加载埋雷,贪婪加载与投影根治
N+1循环访问导航属性,Include/Select 解决
异步与并发异步贯穿 + 乐观锁令牌

EF Core 把「对象模型与关系模型」的映射成本降到极低,但代价是你必须理解它生成的 SQL。数据访问的性能黑洞(N+1、笛卡尔积、过度跟踪)几乎都源于对执行机制的误解——看清 SQL,才能让 ORM 服务于业务而不是成为隐患。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 消息与后台任务
  2. 缓存与并发控制
  3. 测试体系:xUnit 与 Moq