EF Core 编译模型 / 模型裁剪:冷启动与查询优化
EF Core 编译模型 / 模型裁剪:冷启动与查询优化 🚀
适配 .NET 8/9 与 ABP vNext。交付:一键启用脚本、可复现基准、N+1 审计拦截器、预热 SOP、回滚开关。
关键词:dotnet ef dbcontext optimize
、UseModel(IModel)
、--precompile-queries
、DbCommandInterceptor
、Warm-up
📚 目录
- EF Core 编译模型 / 模型裁剪:冷启动与查询优化 🚀
- 1. 背景与目标 🎯
- 模型构建与编译模型对比
- 2. 概念速览与硬性限制 ⚠️
- 是否应启用编译模型 🧭
- 3. 一键启用与回滚 🛠️
- 3.1 生成编译模型(CLI)
- 3.2 在 ABP 中挂载(含回滚开关)
- 3.3 健康检查端点(与脚本配套,**新增**)
- 4. 模型“裁剪” ✂️
- 5. 预热(Warm-up)SOP 🔥
- 预热顺序
- 6. N+1 审计拦截器 🕵️♂️
- 6.1 功能与判定逻辑
- 6.2 可配置与注册
- 6.3 参考实现
- N+1 审计工作流(示意)
- 7. 基准设计 📈
- 7.1 评测维度
- 7.2 目录结构
- 7.3 脚本(关键摘录)
- 基准跑法
- 8. 查询优化附录:Single vs. Split 🔍
- 9. 风险清单与对策 🧩
1. 背景与目标 🎯
EF Core 在首次使用某个 DbContext
类型(如首次查询或首次保存)时会构建元模型(实体/关系/约束/转换等)。在上百/上千实体的大模型或云原生冷启动场景,这一步会显著抬高首条查询延迟。
Compiled Models:将模型预生成为源码并编译,运行时直接加载,明显缩短首启/首查成本;EF Core 9 另有预编译查询(实验),进一步削减查询编译开销(建议灰度启用并可回滚)。
模型构建与编译模型对比
2. 概念速览与硬性限制 ⚠️
- Compiled Models(编译模型)
通过dotnet ef dbcontext optimize
生成模型代码;运行时调用DbContextOptionsBuilder.UseModel(IModel)
后,EF Core 不会再执行OnModelCreating
(但生成编译模型的 CLI 过程仍会跑一次OnModelCreating
产出代码)。 - EF9 自动发现(可选)
当DbContext
与编译模型在同一程序集,EF9 可自动发现编译模型;也可显式.UseModel(...)
覆盖。
硬性限制(启用前必须满足) 🧱
- ❌ 不支持全局查询过滤器(
HasQueryFilter(...)
,常用于多租户/软删); - ❌ 不支持 lazy-loading 或 change-tracking 代理;
- ❌ 不支持自定义
IModelCacheKeyFactory
(如需变体,请编译多套模型并在启动时选择); - 🔁 模型变更后需手动再生成编译模型。
设计提醒:若你依赖全局查询过滤器,请不要启用编译模型;或迁移为查询层注入条件、数据库视图/RLS 等替代手段后再考虑编译模型。
是否应启用编译模型 🧭
3. 一键启用与回滚 🛠️
3.1 生成编译模型(CLI)
# 1) 安装/更新 EF CLI
dotnet tool update -g dotnet-ef# 2) 在包含 DbContext 的项目根执行(替换上下文/命名空间)
dotnet ef dbcontext optimize \--context MyAppDbContext \--output-dir CompiledModels \--namespace MyApp.CompiledModels# (可选,EF Core 9+ 实验)预编译查询
dotnet ef dbcontext optimize \--context MyAppDbContext \--output-dir CompiledModels \--namespace MyApp.CompiledModels \--precompile-queries
✅ 建议将
optimize
纳入 CI;如生成文件有变更,要求提交;否则跳过。
3.2 在 ABP 中挂载(含回滚开关)
// MyCompany.MyProject.EntityFrameworkCore/MyProjectEntityFrameworkCoreModule.cs
public override void ConfigureServices(ServiceConfigurationContext context)
{// 推荐姿势:ABP 模块读取配置var cfg = context.Configuration; // 或 context.Services.GetConfiguration()var useCompiled = cfg.GetValue<bool>("Ef:CompiledModelEnabled");// 单例注册 N+1 审计拦截器(实现见后文)context.Services.AddSingleton<NPlusOneInterceptor>();// 注册预热后台任务context.Services.AddHostedService<DbWarmupService>();Configure<AbpDbContextOptions>(options =>{options.Configure(opts =>{if (useCompiled){// 设置 UseModel 后,运行时不再执行 OnModelCreatingopts.DbContextOptions.UseModel(MyApp.CompiledModels.MyAppDbContextModel.Instance);}// ABP 模板通常默认 Split;如需覆盖可保留显式设置opts.UseSqlServer(o =>o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));// 注入拦截器opts.DbContextOptions.AddInterceptors(context.Services.GetRequiredService<NPlusOneInterceptor>());});});
}
🔄 回滚:
Ef:CompiledModelEnabled=false
时不调用UseModel(...)
,即可一键回退到运行时模型。
3.3 健康检查端点(与脚本配套,新增)
// Program.cs (Minimal API 或 WebHost)
app.MapGet("/health/db", async (MyAppDbContext db, CancellationToken ct) =>
{// 轻量只读查询,确保连接/池/查询编译就绪var _ = await db.Set<Tenant>().AsNoTracking().Take(1).ToListAsync(ct);return Results.Ok(new { ok = true });
});
4. 模型“裁剪” ✂️
- 上下文拆分:按子域/读写拆分巨型
DbContext
,分别生成并挂载编译模型。 - 忽略无用映射:
modelBuilder.Ignore<T>()
清理过期/测试实体;减少 Shadow Property 与复杂转换。 - 约定最小化:更多使用显式配置(
IEntityTypeConfiguration<T>
),降低约定扫描工作量。 - 查询层裁剪:避免全局
AutoInclude
;多集合 Include 易笛卡尔膨胀时,改用投影或SplitQuery 控制行数/内存。
5. 预热(Warm-up)SOP 🔥
-
应用启动预热(推荐)
使用BackgroundService/IHostedService
执行一次最小只读查询:- 先
db.Model.GetEntityTypes()
⇒ 构建模型(无 I/O); - 再执行轻量 SELECT ⇒ 建立连接/初始化池/编译查询。
- 先
-
按需预热
对热点小表(配置/字典)执行一次 SELECT→缓存;结合 ABP Background Worker 定时刷新。 -
容器场景
用 Readiness Probe 等待预热完成再接流量,减少冷启抖动。
最小实现:
public sealed class DbWarmupService : BackgroundService
{private readonly IServiceProvider _sp;public DbWarmupService(IServiceProvider sp) => _sp = sp;protected override async Task ExecuteAsync(CancellationToken token){using var scope = _sp.CreateScope();var db = scope.ServiceProvider.GetRequiredService<MyAppDbContext>();// 1) 构建模型(无 I/O)_ = db.Model.GetEntityTypes();// 2) 轻量查询(触发连接/查询编译等)_ = await db.Set<Tenant>().AsNoTracking().Take(1).ToListAsync(token);}
}
预热顺序
6. N+1 审计拦截器 🕵️♂️
6.1 功能与判定逻辑
- 捕获 SQL:基于
DbCommandInterceptor
,覆盖ReaderExecuting
与ScalarExecuting
(读取型命令); - 作用域聚合:以
Activity.Id
(或自定义 TraceId)聚合同一请求窗口内的 SQL; - 模板归一化:将不同提供程序的参数名统一替换为
:p
(兼容@p0/$1/?pX
等); - 滑动窗口:在可配置窗口(默认 2s)内统计“同构 SELECT 模板”的出现频次;
- 阈值告警:频次达到阈值(默认 10)判定为“疑似 N+1”;
- 内存上限:为“每作用域模板数”与“作用域总数”设置上限并自动清理,防止高并发泄露。
6.2 可配置与注册
public sealed class NPlusOneOptions
{public TimeSpan WindowTtl { get; set; } = TimeSpan.FromSeconds(2);public int TemplateThreshold { get; set; } = 10;public int MaxTemplatesPerScope { get; set; } = 2000;public int MaxScopes { get; set; } = 1000;public HashSet<string> TemplateWhitelist { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}// appsettings.json
// "NPlusOne": { "WindowTtl": "00:00:02", "TemplateThreshold": 10, ... }// Module.cs
context.Services.Configure<NPlusOneOptions>(context.Configuration.GetSection("NPlusOne"));
context.Services.AddSingleton<NPlusOneInterceptor>();
6.3 参考实现
public sealed class NPlusOneInterceptor : DbCommandInterceptor
{private readonly ILogger<NPlusOneInterceptor> _logger;private readonly NPlusOneOptions _opt;private readonly ConcurrentDictionary<string, SlidingWindow> _windows = new();public NPlusOneInterceptor(ILogger<NPlusOneInterceptor> logger,IOptions<NPlusOneOptions> opt){ _logger = logger; _opt = opt.Value; }public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result){ Audit(command, eventData); return base.ReaderExecuting(command, eventData, result); }public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result){ Audit(command, eventData); return base.ScalarExecuting(command, eventData, result); }public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result){ /* 非查询通常不记为 N+1;需要时可开启 */ return base.NonQueryExecuting(command, eventData, result); }private void Audit(DbCommand cmd, CommandEventData e){if (!cmd.CommandText.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) return;var template = Normalize(cmd);if (_opt.TemplateWhitelist.Contains(template)) return;var scopeKey = Activity.Current?.Id ?? e.Context?.GetHashCode().ToString() ?? "unknown";if (_windows.Count > _opt.MaxScopes) CleanupExpiredScopes();var win = _windows.GetOrAdd(scopeKey, _ => new SlidingWindow(_opt.WindowTtl, _opt.MaxTemplatesPerScope));var count = win.Increment(template);if (count == _opt.TemplateThreshold)_logger.LogWarning("Possible N+1 detected (scope={Scope}): {Template}", scopeKey, template);if (win.IsExpired) _windows.TryRemove(scopeKey, out _);}private void CleanupExpiredScopes(){foreach (var kv in _windows)if (kv.Value.IsExpired) _windows.TryRemove(kv.Key, out _);}private static string Normalize(DbCommand cmd){var sql = cmd.CommandText;foreach (DbParameter p in cmd.Parameters)sql = Regex.Replace(sql, $@"\b{Regex.Escape(p.ParameterName)}\b", ":p");return Regex.Replace(sql, @"\s+", " ").Trim();}private sealed class SlidingWindow{private readonly TimeSpan _ttl;private readonly int _maxKeys;private DateTime _start = DateTime.UtcNow;public bool IsExpired => DateTime.UtcNow - _start > _ttl;private readonly ConcurrentDictionary<string, int> _counts = new();public SlidingWindow(TimeSpan ttl, int maxKeys){ _ttl = ttl; _maxKeys = Math.Max(100, maxKeys); }public int Increment(string key){if (IsExpired) { _counts.Clear(); _start = DateTime.UtcNow; }if (_counts.Count > _maxKeys) _counts.Clear(); // 上限溢出时直接重置窗口return _counts.AddOrUpdate(key, 1, (_, c) => c + 1);}}
}
N+1 审计工作流(示意)
7. 基准设计 📈
7.1 评测维度
- 时间:首条查询(冷启动)、第 2~10 次查询(热身收敛);
- 资源:进程私有内存峰值、GC 次数;
- 规模:小/中/大 三档模型(约 20/200/800+ 实体);
- 变体:① Plain(未启用
UseModel
);② Compiled(启用UseModel
);③ Compiled+PreQ(--precompile-queries
,EF9 实验)。
7.2 目录结构
efcore-compiledmodel-lab/src/AbpHost.Plain/ # 未启用编译模型AbpHost.CompiledModel/ # 启用 UseModel(...)Shared/ # 实体/迁移/种子scripts/optimize.ps1 # 生成编译模型/预编译查询run-cold.ps1 # 冷启动首条计时(独立进程 + 健康检查重试)run-hot.bench.cs # BenchmarkDotNet 稳态测试docs/results.md # 表格/箱线图/ECDF
7.3 脚本(关键摘录)
scripts/optimize.ps1
param([string]$Context = "MyAppDbContext",[string]$Namespace = "MyApp.CompiledModels",[string]$Project = "../src/AbpHost.CompiledModel/AbpHost.CompiledModel.csproj",[switch]$PrecompileQueries
)$pp = $PrecompileQueries.IsPresent ? "--precompile-queries" : ""
dotnet tool update -g dotnet-ef
dotnet ef dbcontext optimize `--project $Project `--context $Context `--output-dir CompiledModels `--namespace $Namespace `$pp
scripts/run-cold.ps1
(独立进程 + 显式端口 + 健康检查重试)
param([int]$TimeoutSec = 30,[int]$IntervalMs = 250
)dotnet build ../src/AbpHost.Plain -c Release | Out-Null
dotnet build ../src/AbpHost.CompiledModel -c Release | Out-Nullfunction Wait-Healthy([string]$url, [int]$timeoutSec, [int]$intervalMs) {$deadline = (Get-Date).AddSeconds($timeoutSec)while ((Get-Date) -lt $deadline) {try {$resp = Invoke-WebRequest $url -MaximumRedirection 0 -TimeoutSec 5if ($resp.StatusCode -eq 200) { return $true }} catch { Start-Sleep -Milliseconds $intervalMs }}return $false
}function Test-App([string]$proj, [string]$url, [string]$bind) {$p = Start-Process "dotnet" -ArgumentList "run --project $proj -c Release --urls $bind" -PassThru$sw = [System.Diagnostics.Stopwatch]::StartNew()if (-not (Wait-Healthy $url $using:TimeoutSec $using:IntervalMs)) {$sw.Stop()Stop-Process -Id $p.Id -Forcethrow "Health check timeout: $url"}$sw.Stop()Stop-Process -Id $p.Id -Forcereturn $sw.ElapsedMilliseconds
}"Plain`t$(Test-App ../src/AbpHost.Plain http://localhost:5000/health/db http://localhost:5000)"
"Compiled`t$(Test-App ../src/AbpHost.CompiledModel http://localhost:5001/health/db http://localhost:5001)"
基准跑法
8. 查询优化附录:Single vs. Split 🔍
-
默认是 Single;当多集合 Include 可能导致笛卡尔膨胀时,使用 Split(全局或局部)可降低内存与传输行数,但代价是更多往返。
-
可在全局设置:
optionsBuilder.UseSqlServer(o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
或在特定查询上用:
AsSplitQuery()
/AsSingleQuery()
切换。 -
ABP 提示:部分模板默认启用 Split;如需回到 Single,请在配置或具体查询中显式切换。
9. 风险清单与对策 🧩
-
全局查询过滤器:与编译模型不兼容 → 改为查询层注入/视图/RLS;或保持 Plain。
-
代理/动态模型:懒加载/变更跟踪代理与自定义
IModelCacheKeyFactory
均不支持 → 评估需求,必要时多套编译模型或回退。 -
预编译查询(实验):灰度 + 回滚;注意构建时间与产物体积变化。
-
额外优化(新增动作示例):开启 DbContext Pooling 进一步降低上下文构建成本:
// 非 ABP 最简示例:在 Program.cs builder.Services.AddDbContextPool<MyAppDbContext>(opt => {opt.UseSqlServer(cs => cs.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));// opt.EnableThreadSafetyChecks(false); // 如确知无并发误用(谨慎) });
Pooling 与数据库连接池不同层;适合追求低延迟的高并发场景。