
这篇文章讨论一个问题:一次写入请求从实体变更到数据库落盘,中间到底发生了什么,哪里最容易慢,以及应该怎么定位。
问题背景
真实场景:订单系统在白天吞吐稳定,凌晨高峰出现周期性尖峰。接口平均耗时变化不大,但 P95 从 80ms 抬到 420ms,数据库 CPU 也出现波峰。
排查后发现:
- 应用层每次批量处理 2000 条数据
- 循环中频繁触发变更检测
- 每条数据都单独
SaveChanges - 最终生成大量碎片化 SQL 往返
这类问题看起来像"数据库有点拉,扛不住了",实际上很多成本来自应用侧写入链路组织不当。
原理解析
ChangeTracker 负责记录变更
EF Core 需要知道哪些实体是新增、修改、删除,这些状态都由 ChangeTracker 管理。默认情况下,EF Core 会在多个时机触发 DetectChanges,例如:
- 调用
SaveChanges/SaveChangesAsync - 访问部分跟踪集合
- 某些
Add/Attach组合场景
如果一个 DbContext 里挂了大量实体,频繁 DetectChanges 会带来明显 CPU 开销。
SaveChanges 并不是"一条语句"
一次 SaveChanges 大致会经历这些步骤:
- 执行变更检测,确定实体状态
- 生成修改命令(insert/update/delete)
- 按提供程序规则组装批次(batch)
- 开启事务并执行命令
- 成功后调用
AcceptAllChanges
性能问题通常出在 2-4 步:命令太碎、批次太小、往返太多、事务范围不合理。
SQL Batch 决定数据库往返次数
以 SQL Server 为例,EF Core 会尽可能把多条 DML 合并到一个批次中执行,但合并能力受多种因素影响:
- 提供程序特性
- 参数数量上限
- 命令类型是否可并行拼接
- 配置的批次大小(
MaxBatchSize)
如果批量导入被写成"每条一存",数据库会收到大量短 SQL,吞吐会明显下降。
AcceptAllChanges 是可控的
默认 SaveChanges 成功后会立即 AcceptAllChanges,将实体状态重置为 Unchanged。在高频批处理场景,可以使用:
csharp
await db.SaveChangesAsync(acceptAllChangesOnSuccess: false, ct);
db.ChangeTracker.AcceptAllChanges();
这样可以把提交和状态确认拆开,给你更细粒度的异常处理空间。
示例代码
先定义一个简化实体:
csharp
public sealed class InventoryLog
{
public long Id { get; set; }
public string Sku { get; set; } = string.Empty;
public int Delta { get; set; }
public DateTime CreatedAtUtc { get; set; }
}
DbContext 配置日志和批大小:
csharp
public sealed class AppDbContext : DbContext
{
public DbSet<InventoryLog> InventoryLogs => Set<InventoryLog>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(
"Server=.;Database=DemoDb;Trusted_Connection=True;TrustServerCertificate=True",
sql => sql.MaxBatchSize(128))
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information);
}
}
先看一个常见低效写法:
csharp
public static async Task InsertLogsBadAsync(AppDbContext db, IReadOnlyList<InventoryLog> logs, CancellationToken ct)
{
foreach (var log in logs)
{
db.InventoryLogs.Add(log);
await db.SaveChangesAsync(ct);
}
}
问题很直接:每条都触发一次检测、一次事务、一次数据库往返。
改成批处理写法:
csharp
public static async Task InsertLogsBetterAsync(AppDbContext db, IReadOnlyList<InventoryLog> logs, CancellationToken ct)
{
const int chunkSize = 500;
var original = db.ChangeTracker.AutoDetectChangesEnabled;
try
{
db.ChangeTracker.AutoDetectChangesEnabled = false;
for (var i = 0; i < logs.Count; i += chunkSize)
{
var chunk = logs.Skip(i).Take(chunkSize).ToList();
db.InventoryLogs.AddRange(chunk);
db.ChangeTracker.DetectChanges();
await db.SaveChangesAsync(acceptAllChangesOnSuccess: false, ct);
db.ChangeTracker.AcceptAllChanges();
db.ChangeTracker.Clear();
}
}
finally
{
db.ChangeTracker.AutoDetectChangesEnabled = original;
}
}
这段代码做了四件关键事:
- 关闭自动检测,避免高频重复扫描
- 分块提交,控制单次事务与参数规模
- 手动确认状态,异常时更可控
- 每批清理跟踪器,防止上下文越跑越重
如果你要快速观察当前跟踪压力,可以加一个轻量诊断:
csharp
public static void DumpTrackerStats(DbContext db)
{
var entries = db.ChangeTracker.Entries().ToList();
Console.WriteLine($"Tracked: {entries.Count}");
Console.WriteLine($"Added: {entries.Count(e => e.State == EntityState.Added)}");
Console.WriteLine($"Modified: {entries.Count(e => e.State == EntityState.Modified)}");
Console.WriteLine($"Deleted: {entries.Count(e => e.State == EntityState.Deleted)}");
}
压测结果(AB 实测)
我用单机服务和数据库,用 AB 模式完整跑了一轮,对照组先跑,再跑实验组。核心数据如下:
| 指标 | 数值 |
|---|---|
| 总请求数 | 5190 |
| 吞吐 RPS | 8.23 |
| 成功吞吐 RPS | 8.07 |
| 错误率 | 1.93% |
| 端到端耗时 P95 | 944 ms |
| 成功路径 P95 | 861 ms |
| 成功路径平均 | 480 ms |
| 失败路径 P95 | 30079 ms |
| 失败-超时次数 | 100 |
| AB 对比 - bad P95 | 30079 ms |
| AB 对比 - better P95 | 861 ms |
| AB 对比 - P95 改善 | 97.14% |
这组数据已经很有说服力:
better在同样压测条件下,慢请求耗时明显更稳,P95 从 30s 档位直接回到 1s 内。- 错误主要是超时型失败,不是业务逻辑错误,这通常意味着写入链路组织方式把数据库往返拖慢了。
- 成功路径平均 480ms,但整体平均被 30s 级别超时样本拉高,说明少量慢失败对整体体验影响非常大。
一句话总结:这次 AB 结果证明,优化写入组织方式(分块 + 减少无效变更检测)比单纯堆数据库资源更直接。
实践建议
一个请求一个 DbContext,别跨批次复用
这个点看着普通,但特别容易被忽略。DbContext 活得越久,里面挂着的实体越多,SaveChanges 就越容易变慢,偶发抖动也会变多。
批量写入先分块,别一口气全塞进去
可以先从每批 200~1000 条开始压测,再按你们库的参数上限、锁等待情况慢慢调。别追求一步到位,先跑稳再提速。
总结
EF Core 写入性能不是一个"开关问题",而是一条链路问题。
从 ChangeTracker 到 SQL Batch,每一层都有成本。把检测频率、批次粒度、上下文生命周期控制好,往往比盲目换 ORM 更能稳定地提升吞吐和慢请求耗时。