大家好,我是码农刚子。今天聊 EF Core 的批量操作,一次改一万行,别再一条条 SaveChanges 了。
批量改状态、批量软删除、批量调价------这几件事在 ERP 里是家常便饭。订单结账要批量改状态,商品下架要批量软删除,调价活动要批量改价格。
一开始我写的都是最直觉的版本:查出来、改内存、SaveChanges。几十行数据的时候没问题,直到有一次要做历史数据迁移,一次改十万行。
页面卡了四分钟,内存直接飙到 1.2G。那次之后我才认真去看 EF Core 的批量 API。
01 先说传统写法慢在哪
传统写法:
csharp
// 把所有过期订单改成已关闭
var expired = await db.Orders
.Where(o => o.ExpireAt < DateTime.Now && o.Status == 1)
.ToListAsync(); // ① 全部加载进内存
foreach (var o in expired)
o.Status = 3; // ② 在内存里逐个改
await db.SaveChangesAsync(); // ③ 生成 N 条 UPDATE
一万行数据,这段代码干了三件重活:
第一,把一万个实体全部读进内存。 EF Core 默认带追踪(tracking),每个实体都要建一份快照用于比对变更。一万个订单实体,光内存就得几十兆。
第二,生成一万条 UPDATE 语句。 SaveChanges 会为每个改动的实体生成一条独立的 UPDATE。一万条 SQL 往返数据库,光是网络往返就是灾难。
第三,这整件事还包在一个事务里。 一万条语句一个事务,锁持有时间长,并发一上来就是锁等待。
说白了,我们只想让数据库执行一句 UPDATE ... WHERE ...,却绕了一大圈,把数据搬到内存里又搬回去。
02 ExecuteUpdate 一条 SQL 解决
EF Core 7 开始提供了 ExecuteUpdateAsync 和 ExecuteDeleteAsync。它不加载实体,直接把你写的 LINQ 翻译成一条 SQL 发给数据库。
批量写法:
csharp
// 批量改状态:一条 SQL 搞定
var affected = await db.Orders
.Where(o => o.ExpireAt < DateTime.Now && o.Status == 1)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, 3)
.SetProperty(o => o.UpdatedAt, DateTime.Now));
// 批量删除:同样一条 SQL
var deleted = await db.Logs
.Where(l => l.CreatedAt < DateTime.Now.AddYears(-1))
.ExecuteDeleteAsync();
返回值是受影响的行数,可以直接拿来给用户提示。
生成的 SQL 是这样的:
sql
UPDATE Orders
SET Status = 3, UpdatedAt = @p0
WHERE ExpireAt < @p1 AND Status = 1;
-- 一条。就一条。
SaveChanges(传统) ExecuteUpdate(批量)
| |
数据库里 1 万行 数据库里 1 万行
| |
1 万实体进内存(带追踪) 翻译成一条 UPDATE
| |
生成 1 万条 UPDATE 数据库直接执行
| |
事务提交 · 慢且占内存 完成 · 快且几乎不占内存
03 到底快多少
我拿秦巴牧云的历史订单表测过一次,一万行改状态,本机数据库,两个写法各跑三遍取中位数:
| 指标 | SaveChanges | ExecuteUpdate |
|---|---|---|
| 发出的 SQL 条数 | 10000 条 | 1 条 |
| 耗时 | 约 3.5 秒 | 约 0.2 秒 |
| 应用侧内存增长 | 约 80MB | 几乎为 0 |
数据量越大,差距越夸张。因为传统写法是 O(N) 的 SQL 条数,批量写法永远是 1 条。十万行的时候,传统写法我跑出过四分钟。
说明:上面的数字是我本机跑的量级参考,不是基准测试。你的库、索引、网络不一样,绝对值会变,但「一条 SQL vs 一万条 SQL」这个量级差是稳定的。
04 五个坑,用之前先知道
批量操作快是快,但它绕过了 EF Core 的一整套管线。这几个坑我第一次用的时候全踩了一遍。
坑一:拦截器、审计字段、全局过滤器都不生效。 ExecuteUpdate 直接发 SQL,不走 SaveChanges 管线。你重写的 SaveChanges 拦截器、自动填充 UpdatedAt / UpdatedBy 的逻辑,在这里一条都不会执行。审计字段必须在 SetProperty 里手动带上。
坑二:不会更新 RowVersion。 如果你的表用了 RowVersion 做并发令牌,批量更新不会带上版本检查,也不会把版本号推进去。等于绕过了一次并发保护。批量操作和乐观锁,用之前得想清楚优先级。
坑三:不能跨表,也不能 Include。 ExecuteUpdate 只能更新当前实体自己的字段,没法通过导航属性顺手改关联表。要跨表就得拆成多条批量操作,或者退回原生 SQL。
坑四:事务边界要自己管。 每次 ExecuteUpdateAsync 调用自己提交一次。如果你连着做三条批量操作,想保证要么全成功要么全回滚,得显式包一个事务。
坑五:版本要够新。 这两个 API 是 EF Core 7 才有的。项目还在 6.0 的话,要么升级,要么用第三方库(比如 EFCore.BulkExtensions),要么退回原生 SQL。
多条批量操作包事务:
csharp
await using var tx = await db.Database
.BeginTransactionAsync();
await db.Orders
.Where(o => o.Status == 1 && o.ExpireAt < now)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, 3));
await db.OrderItems
.Where(i => i.Order.Status == 3)
.ExecuteDeleteAsync();
await tx.CommitAsync(); // 要么全成,要么全滚
05 什么时候用哪个
| 场景 | 用哪个 |
|---|---|
| 大批量、同一套改法、不涉及关联表 | ExecuteUpdate / ExecuteDelete |
| 需要审计拦截器自动填字段 | SaveChanges(或手动补字段) |
| 表上有 RowVersion 并发令牌 | SaveChanges |
| 要顺手改关联表的数据 | SaveChanges 或原生 SQL |
| 只改几十行 | 随意,差异感知不到 |
批量 API 不是用来替换 SaveChanges 的,它是补上一个 SaveChanges 天生做不好的场景:大批量、同质、无副作用的写操作。这种场景下,把数据搬进内存再搬回去,纯属多余。
一句话总结 :大批量同质更新用
ExecuteUpdate/ExecuteDelete,一条 SQL 换掉一万条;但审计字段、RowVersion、跨表更新都得自己补,多条批量操作要显式包事务。
这条你之前用过吗?评论区说说你的场景。
转发给还在用 SaveChanges 一条条改的同事,少走我们趟过的弯路。
关注 CSharp精选营,每周二四 get 能直接抄的 C# 实战。
下一篇聊:这 8 个 NuGet 包,项目直接起飞。
关于作者
码农刚子,六年 ERP / 制造业系统开发,专注 C# / .NET / Blazor。