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

基于此,有两个推论

  1. dotnet ef 命令行必须能拿到你的 Provider 引用——因为设计时服务(生成迁移、生成 SQL 脚本)要运行这些翻译器。这就是"为什么迁移命令必须在项目目录里执行"的原理性原因。

  2. 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:

  1. Database.Migrate()(代码内调用):程序启动时自动检查并执行未应用的迁移,常用于开发/测试
  2. dotnet ef database update(CLI):手动执行
  3. dotnet ef migrations script:只生成 SQL 脚本不执行,交给 DBA 审查后手动跑(生产环境推荐这个)

关键问题是"EF 怎么知道哪些迁移已经执行过?"——它在数据库里建一张 __EFMigrationsHistory 表,每应用一个迁移就插入一条记录(迁移名 + 版本号)。执行时对比代码里的迁移列表和这张表,找出缺失的按顺序补上。

四、回滚与脚本化

因为每个迁移都有 Down(),所以可以 dotnet ef database update <上一个迁移名> 倒退回去。

容易困惑的几个点

  • Snapshot 是手动还是自动? 自动。每次 Add-Migration 都会更新它,这也是团队协作冲突的重灾区——多人同时改了模型再合并,Snapshot 冲突需要手工解决。
  • 为什么不直接改数据库? 迁移文件是可版本控制、可审查、可回滚的。直接 ALTER TABLE 无法追溯,也无法在多台环境(开发/测试/生产)间保持一致。
  • EF 不是"记住"每次结构,而是"记住当前状态"。 它不是靠积累一堆增量,而是靠 snapshot 记录终点,靠 history 表记录进度。