惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 最新话题
N
News | PayPal Newsroom
S
Security Affairs
W
WeLiveSecurity
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Webroot Blog
Webroot Blog
Spread Privacy
Spread Privacy
A
Arctic Wolf
T
Troy Hunt's Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cybersecurity and Infrastructure Security Agency CISA
T
Threat Research - Cisco Blogs
V2EX - 技术
V2EX - 技术
Scott Helme
Scott Helme
Google Online Security Blog
Google Online Security Blog
Schneier on Security
Schneier on Security
F
Fortinet All Blogs
U
Unit 42
爱范儿
爱范儿
腾讯CDC
S
Security @ Cisco Blogs
PCI Perspectives
PCI Perspectives
Hacker News - Newest:
Hacker News - Newest: "LLM"
Apple Machine Learning Research
Apple Machine Learning Research
C
CERT Recently Published Vulnerability Notes
Security Latest
Security Latest
Y
Y Combinator Blog
S
Schneier on Security
Cisco Talos Blog
Cisco Talos Blog
T
The Blog of Author Tim Ferriss
Hugging Face - Blog
Hugging Face - Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
IT之家
IT之家
K
Kaspersky official blog
Security Archives - TechRepublic
Security Archives - TechRepublic
博客园 - 聂微东
Cloudbric
Cloudbric
V
V2EX
H
Help Net Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
小众软件
小众软件
TaoSecurity Blog
TaoSecurity Blog
T
Tor Project blog
G
Google Developers Blog
人人都是产品经理
人人都是产品经理
GbyAI
GbyAI
MyScale Blog
MyScale Blog

博客园 - 邓磊Lei

为什么 Go 一个 HTTP 服务可以同时处理数万连接 gin怎么处理一次http请求的 我的经验:git提交信息分别用什么icon C# 也能像 Python 一样写脚本 | .NET 10 构建基于文件的应用 .NET 10 使用 Microsoft.AspNetCore.OpenApi 实现 API 版本管理 ASP.NET Core 内存缓存实战:一篇搞懂该怎么配、怎么避坑 Microsoft Agent Framework + Kimi API 实战:控制台应用跑通单次与多轮 Agent 对话 开发实战:asp.net core + ef core 实现动态可扩展的分页方案 聊聊 ASP.NET Core 中间件和过滤器的区别 Python 入门:从“其他语言”到 Pythonic 思维的完整迁移手册 .NET 进阶之路:异步、并发与内存管理的系统性认知 Redis:延迟双删的适用边界与落地细节 Serilog:从结构化日志认知到 .NET 工程落地 EF Core 原生 SQL 实战:FromSql、SqlQuery 与对象映射边界 EF Core 拦截器实战:SaveChangesInterceptor、CommandInterceptor 与审计落地 ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界 .NET .Result 避坑指南:不同框架下的死锁与线程池饥饿 EF Core 慢查询排查实战:TagWith、OpenTelemetry、执行计划,30 分钟定位性能瓶颈 EF Core 并发冲突实战:乐观锁、RowVersion 与 DbUpdateConcurrencyException 怎么处理 ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎么落地 C# 异步编程深水区:Task、ValueTask、线程池饥饿与背压设计 EF Core 查询性能黑洞:Include、投影与跟踪策略的边界 从 IApplicationBuilder 到 RequestDelegate:ASP.NET Core 请求管线的性能与可观测性实战 如何在 rider 中配置 SonarQube
EF Core 写入链路深拆:从 ChangeTracker 到 SQL Batch 的性能诊断与优化
邓磊Lei · 2026-03-12 · via 博客园 - 邓磊Lei

image
这篇文章讨论一个问题:一次写入请求从实体变更到数据库落盘,中间到底发生了什么,哪里最容易慢,以及应该怎么定位。

问题背景

真实场景:订单系统在白天吞吐稳定,凌晨高峰出现周期性尖峰。接口平均耗时变化不大,但 P95 从 80ms 抬到 420ms,数据库 CPU 也出现波峰。

排查后发现:

  • 应用层每次批量处理 2000 条数据
  • 循环中频繁触发变更检测
  • 每条数据都单独 SaveChanges
  • 最终生成大量碎片化 SQL 往返

这类问题看起来像“数据库有点拉,扛不住了”,实际上很多成本来自应用侧写入链路组织不当。

原理解析

ChangeTracker 负责记录变更

EF Core 需要知道哪些实体是新增、修改、删除,这些状态都由 ChangeTracker 管理。默认情况下,EF Core 会在多个时机触发 DetectChanges,例如:

  • 调用 SaveChanges / SaveChangesAsync
  • 访问部分跟踪集合
  • 某些 Add / Attach 组合场景

如果一个 DbContext 里挂了大量实体,频繁 DetectChanges 会带来明显 CPU 开销。

SaveChanges 并不是“一条语句”

一次 SaveChanges 大致会经历这些步骤:

  1. 执行变更检测,确定实体状态
  2. 生成修改命令(insert/update/delete)
  3. 按提供程序规则组装批次(batch)
  4. 开启事务并执行命令
  5. 成功后调用 AcceptAllChanges

性能问题通常出在 2-4 步:命令太碎、批次太小、往返太多、事务范围不合理。

SQL Batch 决定数据库往返次数

以 SQL Server 为例,EF Core 会尽可能把多条 DML 合并到一个批次中执行,但合并能力受多种因素影响:

  • 提供程序特性
  • 参数数量上限
  • 命令类型是否可并行拼接
  • 配置的批次大小(MaxBatchSize

如果批量导入被写成“每条一存”,数据库会收到大量短 SQL,吞吐会明显下降。

AcceptAllChanges 是可控的

默认 SaveChanges 成功后会立即 AcceptAllChanges,将实体状态重置为 Unchanged。在高频批处理场景,可以使用:

await db.SaveChangesAsync(acceptAllChangesOnSuccess: false, ct);
db.ChangeTracker.AcceptAllChanges();

这样可以把提交和状态确认拆开,给你更细粒度的异常处理空间。

示例代码

先定义一个简化实体:

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 配置日志和批大小:

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);
    }
}

先看一个常见低效写法:

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);
    }
}

问题很直接:每条都触发一次检测、一次事务、一次数据库往返。

改成批处理写法:

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;
    }
}

这段代码做了四件关键事:

  • 关闭自动检测,避免高频重复扫描
  • 分块提交,控制单次事务与参数规模
  • 手动确认状态,异常时更可控
  • 每批清理跟踪器,防止上下文越跑越重

如果你要快速观察当前跟踪压力,可以加一个轻量诊断:

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 更能稳定地提升吞吐和慢请求耗时。