上个月要把一批历史订单的状态字段刷一遍,12 万行左右。第一版我按老习惯写完 SaveChanges,跑了四十多分钟还没跑完,只能停掉。后来把能想到的几种写法都在同一个库上试了一遍,这里记下过程和数字。都是我自己机器上的结果,换环境肯定不一样,看趋势就行。
先交代环境:SQL Server 2022 Developer Edition 跑在本地 Docker 里,4 核 8 G;应用是 .NET 9,EF Core 9.0.2(7 月重测时的版本,5 月第一次测还是 8.0.6)。目标表 OrderHistory 12 万行,要做的事是按 CreatedAt 把 Status 从 3 改成 7,同时刷一遍 UpdatedAt。用的是本地还原的三个月前的备份,没有并发写入干扰,这一点后面还要提。
一、SaveChanges 慢在哪
第一版代码长这样,相信不少人也是这么写的:
var rows = await db.OrderHistories
.Where(x => x.CreatedAt < deadline && x.Status == 3)
.ToListAsync(ct);
foreach (var row in rows)
{
row.Status = 7;
row.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
慢不在 SQL。开 Profiler 看一眼就明白:每个实体各发一条 UPDATE,12 万行就是 12 万次往返,本地网络再快也顶不住。更麻烦的是 ChangeTracker,每加一个实体都要做一次变更检测,实体越多单次开销越大,跑到最后几万行时明显比开头慢。
另外 ToListAsync 把 12 万行全读进内存本身也不便宜,光这一步就占了差不多 20 秒,吃掉 600 多 MB 内存。所以即便不做批量更新,改成只取主键、按主键分批处理,也能省下一部分,只是省不出数量级的差距。
把 AutoDetectChangesEnabled 设成 false,循环里每 500 行手动调一次 DetectChanges,SaveChanges 改成每批一次,这一版跑完是 241 秒,比 312 秒快了两成多。但业务要求这次刷数据在十分钟内完成,这条路还是走不通,只能换写法。
二、ExecuteUpdate
EF Core 7 开始提供 ExecuteUpdate / ExecuteDelete,生成的是一条 UPDATE ... SET ... WHERE,不需要把实体读进内存,也就没有变更追踪那一整套开销。
var affected = await db.OrderHistories
.Where(x => x.CreatedAt < deadline && x.Status == 3)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.Status, 7)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
返回值是影响的行数,可以直接打日志。分批就在 Where 里加主键范围,我用的是主键范围而不是 Skip,因为 Skip 在数据量大时自己也有成本。
EF Core 8 允许在 SetProperty 里引用同一实体的其他字段,比如把 UpdatedAt 设成 CreatedAt;EF Core 9 又放宽了对 JSON 列和部分复杂类型的限制。这两个版本差异我都是从生成 SQL 的变化里看出来的,没有逐条对照 release notes,可能有遗漏。
官方文档里有一句话我一开始没读进去:「ExecuteUpdate 是立即执行的,不依赖 SaveChanges。」我第一版代码在它后面又写了一遍 SaveChangesAsync,白等了几秒,还一度以为是 ExecuteUpdate 本身慢。
三、实测数字
12 万行那一档,取三次运行的中位数:
- SaveChanges 逐条提交:312 秒
- 关掉自动检测后:241 秒
- 原生 SQL 拼批(ExecuteSqlRaw + CASE WHEN):19 秒
- ExecuteUpdate 分批:11 秒
这里有个很明显的拐点:从每批 100 行加到 500 行,时间直接掉了一半多;再往上加到 2 000 行只又省了几秒;到 10 000 行基本没变化,但单次事务持锁的时间明显变长,我在另一个窗口查同一张表时能感觉到卡。最后取了每批 1 000 行,纯粹因为失败重跑时一次只丢一千行,心里踏实。
事务范围那一组也值得记:把每批一个事务改成全程一个事务,时间从 11 秒降到 9 秒,但日志增长和锁的持有时间都明显更差。我最后选了每批一个事务——这次刷数据允许中途重跑,不允许长时间堵住别的查询。
ExecuteUpdate 不走 ChangeTracker,不会触发你在 SavingChanges 里写的审计逻辑。我们有一张表靠这个事件记录操作人,第一次用 ExecuteUpdate 刷数据时完全没留痕,后来是补写的日志。
四、我最后选了哪个
公司那套系统现在跑的是 ExecuteUpdate 加每批 1 000 行,外面套循环,每批各一个事务。原生 SQL 那版其实更快,但字段名和枚举值要写死在字符串里,改表结构时我担心会漏,最后没用——为 2 秒的差距去承担这个风险,我觉得不划算。
顺便记下几个坑:ExecuteUpdate 不触发 SavingChanges 事件;批量操作前先确认目标表上有没有触发器,有的话分批也救不了;还有提前看一眼事务日志所在磁盘还剩多少空间,我第一次一次性提交 12 万行,日志涨了 6 个多 G,就是在这儿翻的车。
ExecuteDelete 用起来同样顺手。上个月清过期日志,我把它替掉了一段「先查出来再 RemoveRange」的旧代码,从 40 多秒降到不到 1 秒。不过它会绕过实体上配置的级联删除,外键得自己先处理干净,不然会留下孤儿数据。
还有一个我没测的地方:并发写入。生产上刷数据的时候这张表是停写的,所以没有这个顾虑;如果你的表一直在被写,上面所有数字都只能当参考,而且分批的必要性会更高。这块我没有结论,如果谁做过类似的测试,欢迎写信告诉我。
(2026-07 补充:这次重测还发现,ExecuteUpdate 与原生 SQL 的差距从 5 月那次的 8 秒缩到 3 秒以内,但顺序没变,最终选择也不用改。9 的批处理阈值似乎比 8 更激进一点,这点我只是从执行计划里看出来的,没有深挖,不确定是否准确。)