首页 每日推荐 SaveChanges 一万行?换批量操作快 10 倍 - 码农刚子

SaveChanges 一万行?换批量操作快 10 倍 - 码农刚子

大家好,我是码农刚子。今天聊 EF Core 的批量操作,一次改一万行,别再一条条 SaveChanges 了。

批量改状态、批量软删除、批量调价——这几件事在 ERP 里是家常便饭。订单结账要批量改状态,商品下架要批量软删除,调价活动要批量改价格。

一开始我写的都是最直觉的版本:查出来、改内存、SaveChanges。几十行数据的时候没问题,直到有一次要做历史数据迁移,一次改十万行。

页面卡了四分钟,内存直接飙到 1.2G。那次之后我才认真去看 EF Core 的批量 API。


01 先说传统写法慢在哪

传统写法:

// 把所有过期订单改成已关闭
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 发给数据库。

批量写法:

// 批量改状态:一条 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 是这样的:

UPDATE Orders
SET Status = 3, UpdatedAt = @p0
WHERE ExpireAt < @p1 AND Status = 1;
-- 一条。就一条。
           SaveChanges(传统)              ExecuteUpdate(批量)
                  |                                |
          数据库里 1 万行                    数据库里 1 万行
                  |                                |
        1 万实体进内存(带追踪)             翻译成一条 UPDATE
                  |                                |
          生成 1 万条 UPDATE                数据库直接执行
                  |                                |
        事务提交 · 慢且占内存              完成 · 快且几乎不占内存

03 到底快多少

我拿秦巴牧云的历史订单表测过一次,一万行改状态,本机数据库,两个写法各跑三遍取中位数:

指标SaveChangesExecuteUpdate
发出的 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。

多条批量操作包事务:

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。

原文连接:SaveChanges 一万行?换批量操作快 10 倍 - 码录集