Entity Framework Core 是一个强大而灵活的对象关系映射 (O/RM) 框架。遵循最佳实践可以帮助你构建高性能、可维护且可靠的应用程序。
1. DbContext 生命周期管理
使用短生命周期
DbContext 被设计为轻量级、一次性的工作单元。
- Web 应用: 在 ASP.NET Core 中,默认使用作用域 (Scoped) 生命周期。即每个 HTTP 请求创建一个新的 DbContext 实例。这是推荐的方式,通过 services.AddDbContext<YourDbContext>(...) 注册。
- 其他应用: 对于非 Web 应用(如控制台、WPF、WinForms),通常在需要执行数据库操作的业务单元(例如一个方法或一个 using 块)内创建和释放 DbContext 实例。
- 示例 (非 Web):
using (var context = new YourDbContext())
{
// 执行数据库操作
var blogs = context.Blogs.ToList();
// ...
context.SaveChanges();
} // context 在这里被 Dispose
避免长生命周期
不要将 DbContext 注册为单例 (Singleton)。这会导致内存泄漏(因为 DbContext 会跟踪越来越多的实体)、数据陈旧以及并发问题。
使用 DbContext 池 (可选)
对于高吞吐量场景,可以使用 AddDbContextPool<YourDbContext>(...)。它通过重置状态来复用 DbContext 实例,可以减少实例化的开销。但要注意,如果 DbContext 的构造函数或 OnConfiguring 中有状态管理,可能会有问题。
2. 高效查询 (核心性能)
只查询所需数据
- Select() 投影: 使用 LINQ 的 Select() 方法只选择你需要的列,而不是加载整个实体。这可以显著减少网络流量和内存占用。
var blogTitles = await context.Blogs
// 只选择 Title 列
.Select(b => b.Title)
.ToListAsync();
- DTO 投影: 将查询结果直接投影到数据传输对象 (DTO)。
var blogDtos = await context.Blogs
.Select(b => new BlogDto
{
Id = b.BlogId, Name = b.Title
})
.ToListAsync();
使用AsNoTracking()进行只读查询
如果你只是读取数据并且不打算修改它们(即不需要 SaveChanges()),请使用 .AsNoTracking()。这会禁用变更跟踪,显著提高查询性能并减少内存使用。
var blogs = await context.Blogs
.AsNoTracking()
.ToListAsync();
避免 N+1 问题
当加载关联数据时,注意避免 N+1 查询(查询主实体,然后为每个主实体单独查询关联实体)。
- 预加载 (Eager Loading): 使用 Include() 和 ThenInclude() 一次性加载所需的关联数据。适用于关联数据量可控的情况。
var blogsWithPosts = await context.Blogs
// 加载 Posts
.Include(b => b.Posts)
// 加载 Post 的 Author
.ThenInclude(p => p.Author)
.ToListAsync();
- 显式加载 (Explicit Loading): 在需要时手动加载关联数据。
var blog = await context.Blogs.FindAsync(1);
if (blog != null)
{
// 手动加载 Posts
await context.Entry(blog)
.Collection(b => b.Posts)
.LoadAsync();
}
- 投影 (Projection): 通过 Select() 选择所需的主实体和关联实体数据,通常性能最好。
过滤和排序在数据库端执行
尽量在 ToList()、ToArray()、FirstOrDefault() 等“执行”查询的方法之前应用 Where(), OrderBy(), Skip(), Take() 等操作。这样可以让数据库执行过滤和排序,而不是在内存中处理大量数据。
// 好: 数据库执行过滤和排序
var recentBlogs = await context.Blogs
.Where(b =>
b.PublishedOn > DateTime.UtcNow.AddDays(-7)
)
.OrderByDescending(b => b.PublishedOn)
.Take(10)
.ToListAsync();
// 差: 先加载所有数据到内存,再过滤排序 (性能极差!)
// var allBlogs = await context.Blogs.ToListAsync();
// var recentBlogs = allBlogs.Where(...) /* ... */;
异步操作
优先使用异步方法 (ToListAsync(), FirstOrDefaultAsync(), SaveChangesAsync(), FindAsync(), etc.)。这可以释放当前线程,提高应用程序的响应性和吞吐量,尤其是在 Web 应用中。
警惕客户端评估
尽量避免 LINQ 查询在客户端(应用程序内存中)进行评估,因为这可能导致加载整个表到内存中。检查日志以确保查询在数据库端正确翻译和执行。
分页
使用 Skip() 和 Take() 实现高效的分页,确保 OrderBy() 在 Skip() 之前。
使用ExecuteUpdateAsync和ExecuteDeleteAsync(EF Core 7+)
对于批量更新或删除操作,这两个方法直接在数据库中执行,绕过了变更跟踪器,性能极高。
// 示例:批量更新
await context.Blogs
.Where(b => b.Rating < 3)
.ExecuteUpdateAsync(setters => setters
.SetProperty(b => b.IsArchived, true)
);
// 示例:批量删除
await context.Posts
.Where(p => p.IsSpam)
.ExecuteDeleteAsync();
3. 变更跟踪与保存
理解变更跟踪
DbContext 默认会跟踪从数据库查询出来的实体。当你修改实体属性时,DbContext 会记录这些变化。调用 SaveChanges() 或 SaveChangesAsync() 时,EF Core 会检测这些变化并生成相应的 INSERT, UPDATE, DELETE SQL 语句。
批量操作
如上所述,对于大量实体的更新或删除,优先考虑
ExecuteUpdateAsync/ExecuteDeleteAsync (EF Core 7+)。如果版本较低或操作复杂,可能需要分批次处理 (SaveChanges() 多次) 或使用原生 SQL 或 Dapper 等库。
附加 (Attach) 和更新
如果你有一个已存在于数据库但当前未被跟踪的实体(例如,从 Web 请求反序列化而来),并想更新它:
- 方法一 (推荐): 先查询,再更新。
var existingBlog = await context.Blogs
.FindAsync(blogDto.Id);
if (existingBlog != null)
{
// 只更新需要的属性
existingBlog.Title = blogDto.Title;
// ...
await context.SaveChangesAsync();
}
- 方法二 (Attach + Mark Modified): 如果不想先查询,可以附加实体并标记其状态。
// 可能只有部分属性
var blogToUpdate = new Blog {
BlogId = blogDto.Id,
Title = blogDto.Title
};
// 将实体标记为 Modified,会更新所有列
context.Blogs.Update(blogToUpdate);
// 或者更精细地控制:
// context.Attach(blogToUpdate);
// context.Entry(blogToUpdate)
// .Property(b => b.Title).IsModified = true;
await context.SaveChangesAsync();
注意:context.Update() 会将实体的所有属性标记为已修改,导致生成更新所有列的 SQL,即使某些属性值没有变。
4. 数据模型设计
遵循约定
EF Core 有很多约定(如 Id 或 ClassNameId 作为主键,导航属性自动识别关系等)。遵循约定可以减少配置代码。
使用 Fluent API 或 Data Annotations
- Fluent API: 在 OnModelCreating 方法中使用,提供最强大、最灵活的配置方式,将配置与实体类分离。推荐用于复杂的或共享的模型配置。
- Data Annotations: 直接在实体类属性上使用特性 (Attributes),如 [Key], [Required], [MaxLength]。简单直观,但会使实体类与 EF Core 配置耦合。
配置索引
为经常用于查询条件 (WHERE) 或排序 (ORDER BY) 的列创建索引,以提高查询性能。使用 Fluent API 的 HasIndex() 方法。
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.HasIndex(b => b.Url) // 为 Url 列创建索引
.IsUnique(); // 可以指定唯一索引
}
值转换器 (Value Converters)
用于将属性类型在存入数据库时进行转换(例如,将 enum 存为字符串,或加密数据)。
影子属性 (Shadow Properties)
不在实体类中定义的属性,但存在于 EF Core 模型中(例如 LastUpdated 时间戳),可以通过 ChangeTracker API 访问。
5. 数据库迁移 (Migrations)
使用 Code-First Migrations
这是管理数据库架构变更的标准方式。
保持迁移的原子性和可审查性
每次迁移应对应一组相关的、逻辑上的变更。生成迁移后,务必审查生成的 C# 代码和 SQL 脚本。
迁移策略
- 开发环境: 可以使用 Update-Database (Package Manager Console) 或 dotnet ef database update (CLI)。
- 生产环境:生成 SQL 脚本: 使用 Script-Migration (PMC) 或 dotnet ef migrations script (CLI) 生成 SQL 脚本,然后由 DBA 审查并在合适的维护窗口执行。这是最安全的方式。应用程序启动时迁移 (谨慎使用): 调用 dbContext.Database.Migrate()。简单但不推荐用于生产,特别是在多实例部署时可能导致并发问题或启动延迟。如果使用,确保有适当的锁定机制。迁移包 (Migration Bundles) (EF Core 6+): 生成包含迁移逻辑的可执行文件,可以在部署过程中独立运行。
处理数据迁移
如果架构变更需要移动或转换现有数据,可能需要在迁移中编写原始 SQL (migrationBuilder.Sql("..."))。
6. 并发控制
理解乐观并发
EF Core 默认使用乐观并发。它假设并发冲突很少发生。在 SaveChanges() 时,如果检测到数据在你读取后已被其他人修改,会抛出
DbUpdateConcurrencyException。
使用并发令牌
配置一个属性作为并发令牌(通常是 rowversion/timestamp 类型的列,或任何你选择的属性)。EF Core 会在 UPDATE/DELETE 语句的 WHERE 子句中包含这个令牌的原始值。
// 使用 Data Annotation
public class Blog
{
public int BlogId { get; set; }
// ...
// 对于 SQL Server,映射到 rowversion
[Timestamp]
public byte[] RowVersion { get; set; }
}
// 或者使用 Fluent API
modelBuilder.Entity<Blog>()
.Property(b => b.RowVersion)
.IsRowVersion(); // 或 .IsConcurrencyToken();
处理并发异常
捕获
DbUpdateConcurrencyException 并采取适当的策略(例如,通知用户数据已更新,提供合并选项,或强制覆盖)。
7. 日志与错误处理
配置日志
在开发过程中启用详细日志记录,特别是 SQL 语句的日志,可以帮助诊断性能问题和理解 EF Core 的行为。使用
Microsoft.Extensions.Logging。
// 在 appsettings.Development.json 或代码中配置
services.AddDbContext<YourDbContext>(options =>
options.UseSqlServer(connectionString)
// 输出到控制台
.LogTo(Console.WriteLine, LogLevel.Information)
// 开发环境可开启敏感数据日志
.EnableSensitiveDataLogging());
处理特定异常
准备好处理常见的 EF Core 异常,如 DbUpdateException (保存失败,可能包含内部数据库错误) 和
DbUpdateConcurrencyException (并发冲突)。
8. 测试
- 使用内存数据库 (谨慎): Microsoft.EntityFrameworkCore.InMemory 提供一个内存数据库提供程序,可用于单元测试或快速集成测试。但它不是关系数据库,行为(如事务、约束、某些 LINQ 翻译)可能与真实数据库不同。不推荐用它来测试数据库相关逻辑的正确性。
- 使用 SQLite (内存模式): SQLite 可以在内存模式下运行,它是一个真正的关系数据库,比 InMemory 提供程序更接近真实场景,但仍与 SQL Server/PostgreSQL 等有差异。
- 使用测试数据库 (推荐): 为了进行可靠的集成测试,最好针对与生产环境相同类型(或非常相似)的数据库进行测试。可以使用本地数据库实例、Docker 容器 (如 Testcontainers) 或专门的测试数据库。确保测试之间的数据隔离。
- 仓库模式/服务层: 可以将 DbContext 封装在仓库 (Repository) 或服务层之后,这有助于解耦和模拟依赖项进行单元测试。
遵循这些最佳实践可以帮助你更有效地使用 EF Core,构建出性能优良、易于维护的应用程序。记住,没有绝对的“银弹”,具体实践需要根据项目需求和场景进行调整。