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 | 每次只取一列 |
| Include | 1(或多条 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. 总结
| 环节 | 要点 |
|---|---|
| DbContext | Scoped 短命对象,工作单元入口 |
| 映射 | 约定起步 + Fluent API 补充 |
| 迁移 | 模型驱动 DDL,脚本审阅上线 |
| 查询 | IQueryable 翻译 SQL,AsNoTracking 只读 |
| 加载 | 懒加载埋雷,贪婪加载与投影根治 |
| N+1 | 循环访问导航属性,Include/Select 解决 |
| 异步与并发 | 异步贯穿 + 乐观锁令牌 |
EF Core 把「对象模型与关系模型」的映射成本降到极低,但代价是你必须理解它生成的 SQL。数据访问的性能黑洞(N+1、笛卡尔积、过度跟踪)几乎都源于对执行机制的误解——看清 SQL,才能让 ORM 服务于业务而不是成为隐患。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。