EF Core 实现原理
EF Core 架构
EF Core 的源码可以按职责切成四层
┌─────────────────────────────────────────────┐
│ 你的代码:DbContext / LINQ / SaveChanges │ ← 门面层(Facade)
├─────────────────────────────────────────────┤
│ 查询管线 & 保存管线(QueryCompiler / │ ← 核心层(与数据库无关)
│ ChangeTracker / IdentityMap) │
├─────────────────────────────────────────────┤
│ 关系型抽象层(EFCore.Relational): │ ← 关系层(SQL 方言无关)
│ SQL 表达式树、Migration 模型差异、命令批处理 │
├─────────────────────────────────────────────┤
│ Provider 插件(Npgsql / SqlServer / MySql) │ ← 方言层(可替换)
│ SQL 生成器、类型映射、DDL 翻译器 │
└─────────────────────────────────────────────┘
查询管线:LINQ 是如何变成 SQL 的
var orders = db.Orders
.Where(o => o.Price > 100)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToList();
这段代码的执行过程,比你看到的异步得多
① IQueryable 阶段
db.Orders.Where(...) 只是往表达式树(Expression Tree)上挂节点,
没有访问数据库,没有枚举任何数据。
② 编译阶段(QueryCompiler.Compile)
遇到终结符(ToList / FirstOrDefault / Count...)才真正开始。
表达式树被编译成一个委托,首次编译的结果会被缓存——
所以同一个 LINQ 查询第二次执行会快得多。
③ 翻译阶段(逐方法翻译器)
内置 200+ 个翻译器:Where→WHERE,OrderBy→ORDER BY,
Take→LIMIT/TOP……每个 LINQ 方法各自对应一个翻译规则。
翻译不了的直接抛 InvalidOperationException。
④ 方言生成(Provider 的 QuerySqlGenerator)
抽象 SQL 表达式 → 具体方言:
- SQL Server: SELECT TOP (20) ...
- PostgreSQL: SELECT ... LIMIT 20
⑤ 执行与物化(Materializer)
IDataReader 逐行读取 → 按"塑形(Shaping)"规则拼成对象图。
Include 不是 JOIN 完再拼装,而是在 reader 层面按层级物化。
为什么同样的 LINQ 生成不同的 SQL
把连接串从 UseNpgsql 换成 UseSqlServer,同一段 LINQ 会生成不同的 SQL(LIMIT vs TOP、引号风格、类型名)。这不是 EF "不稳定",而是方言层在正常工作——核心层保证语义,Provider 层保证方言。
保存管线:ChangeTracker 状态机
SaveChanges 不是一个方法,是一整套结算流程,SaveChanges() 做的事远比"执行 INSERT/UPDATE"多:
① 发现变更(DetectChanges)
默认在 SaveChanges 时自动触发。
方式 A【快照比对】:Add/Attach 时为每个实体存一份原始值副本,
保存时逐属性 Diff——简单粗暴,内存开销 O(实体数 × 属性数)。
方式 B【变更代理】:动态生成实体子类,属性 setter 里埋钩子,
赋值瞬间自动标脏——零比对开销,但要求实体属性必须是 virtual。
② 状态打标
每个实体被打上 Added / Modified / Deleted / Unchanged 之一。
③ 拓扑排序
按外键依赖排执行顺序:先插主表行,再插子表行;
删除则相反。这保证约束不会在执行中途被破坏。
④ 命令批处理
同类操作合并进一批 SQL(默认一批最多 N 条命令),
而不是每个实体一次往返。
⑤ 事务包裹
所有命令在同一个事务里执行,中途失败整体回滚。
⑥ AcceptAllChanges
成功后把所有实体状态重置为 Unchanged,快照刷新。
此后 DbContext 中所有实体都是"干净"的。
快照比对的成本,以及绕过它的方式
快照模式意味着:每多跟踪一个实体,就多一份属性副本 + 一次逐属性比较。这就是"循环 Add 一万条然后 SaveChanges 很慢"的原理性原因——不是 SQL 慢,是状态机慢。
两个绕开方案如下
// 方案 1:批量插入不走跟踪(.NET 7+)
await db.Orders.ExecuteInsertAsync(new Order { ... }); // 每个实体仍是一次往返
// 真正的大批量:干脆绕开 EF,用 Dapper 或原生 SQL 一次 INSERT 多行
// 方案 2:ExecuteUpdate / ExecuteDelete——不加载实体、不改状态机
await db.Orders
.Where(o => o.Status == "Cancelled")
.ExecuteDeleteAsync(); // 一条 DELETE,零实体物化
模型层:约定 + 显式配置 → IModel
模型是"构建一次、内存缓存"的元数据。
DbContext 首次使用时,模型按以下顺序构建:
扫描 DbSet<> 与实体类
↓
① 约定(Conventions)先跑
例:发现 Order.CustomerId 属性 + Customer 类型的导航属性
→ 自动推断出"CustomerId 是外键"并建立关系
例:Id / OrderId 结尾的属性 → 自动推断为主键
↓
② 显式配置(Fluent API / Data Annotations)后跑,覆盖约定
modelBuilder.Entity<Order>().HasKey(o => o.OrderNo);
↓
③ 产出 IModel——不可变的模型对象图,缓存复用
约定先行 + 配置覆盖,是 EF "约定优于配置"哲学的实现方式。你可以在 OnModelCreating 里用 modelBuilder.HasAnnotation(...) 或调试器查看 db.Model 来检查最终生效的模型。
Migration 的 ModelSnapshot 存的就是 IModel 的序列化结果。理解了这一层,就能理解为什么 Snapshot 冲突是"模型 diff 的基准被改了"——而不是简单的文本冲突。
Provider 插件机制:方言差异如何被吸收
核心层只定义接口,各数据库 Provider 实现它们:
| 接口 | 职责 | 谁实现 |
|---|---|---|
IDatabaseProvider |
判定当前数据库类型 | 各 Provider |
IQuerySqlGenerator |
SQL 表达式 → 方言 SQL 字符串 | 各 Provider |
IRelationalTypeMappingSource |
CLR 类型 ↔ 数据库类型(如 string ↔ nvarchar vs text) |
各 Provider |
IMigrationsSqlGenerator |
Migration 操作 → 方言 DDL | 各 Provider |
IUpdateSqlGenerator |
INSERT/UPDATE/DELETE SQL 生成 | 各 Provider |
基于此,有两个推论
-
dotnet ef命令行必须能拿到你的 Provider 引用——因为设计时服务(生成迁移、生成 SQL 脚本)要运行这些翻译器。这就是"为什么迁移命令必须在项目目录里执行"的原理性原因。 -
Provider 可以扩展核心能力。Npgsql 的
HasPostgresArray()、HasPostgresRange()、JSONB 映射,都是往翻译器和类型映射里注册新规则的结果。核心层没写死的东西,方言层可以自己加上。
如何实现 Migration
EF Core 的 Migration(迁移)其实核心思路很简单:对比"你代码里的模型"和"数据库现在的结构",生成让数据库变成目标结构的 SQL 脚本。整个流程可以拆成几个环节:
一、模型快照:一切对比的基准
EF 在你的项目中维护两类关键文件:
- 迁移文件(Migrations/xxxx_InitialCreate.cs):每次执行 Add-Migration(或 dotnet ef migrations add)时生成,包含一个 Up() 方法(执行什么操作)和一个 Down() 方法(回滚这些操作)。
- ModelSnapshot.cs:记录你当前代码模型的完整状态(表、列、索引、外键、关系等),相当于一份"地图"。
二、生成迁移:模型差异 → 操作序列
执行 Add-Migration 时,EF 做的事情本质上是:
读取 ModelSnapshot(旧模型)
对比当前代码中的 DbContext/实体类(新模型)
→ 计算两者差异(diff)
→ 生成一组 MigrationOperation 对象
→ 写成 Up()/Down() 方法代码
→ 用新模型覆盖 ModelSnapshot
比如你给实体加了一个 public string Email { get; set; },diff 结果就是"表 Users 新增列 Email,类型 nvarchar",于是 Up() 里是 migrationBuilder.AddColumn<string>(name: "Email", table: "Users")。
三、执行迁移:三种方式,殊途同归
生成了迁移文件不代表数据库变了,还需要"应用"。EF 提供三种途径,底层都用同一个 MigrationsSqlGenerator 把操作翻译成数据库方言的 SQL:
Database.Migrate()(代码内调用):程序启动时自动检查并执行未应用的迁移,常用于开发/测试dotnet ef database update(CLI):手动执行dotnet ef migrations script:只生成 SQL 脚本不执行,交给 DBA 审查后手动跑(生产环境推荐这个)
关键问题是"EF 怎么知道哪些迁移已经执行过?"——它在数据库里建一张 __EFMigrationsHistory 表,每应用一个迁移就插入一条记录(迁移名 + 版本号)。执行时对比代码里的迁移列表和这张表,找出缺失的按顺序补上。
四、回滚与脚本化
因为每个迁移都有 Down(),所以可以 dotnet ef database update <上一个迁移名> 倒退回去。
容易困惑的几个点
- Snapshot 是手动还是自动? 自动。每次
Add-Migration都会更新它,这也是团队协作冲突的重灾区——多人同时改了模型再合并,Snapshot 冲突需要手工解决。 - 为什么不直接改数据库? 迁移文件是可版本控制、可审查、可回滚的。直接
ALTER TABLE无法追溯,也无法在多台环境(开发/测试/生产)间保持一致。 - EF 不是"记住"每次结构,而是"记住当前状态"。 它不是靠积累一堆增量,而是靠 snapshot 记录终点,靠 history 表记录进度。