news 2026/11/4 5:25:03

EF Core ExecuteDelete批量删除性能原理与边界实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
EF Core ExecuteDelete批量删除性能原理与边界实践

1. 先说清楚:批量删除慢的从来不是数据库

EF Core 7 里ExecuteDelete这个 API 刚出来那阵子,我在一个后台数据维护模块上做过一次集中替换,把原来一票RemoveRange加SaveChanges的写法全换掉了。效果确实夸张:一条清理过期记录的定时任务,原来跑 11 秒上下,改完之后稳定在 300 毫秒左右。但我想说的重点不是"它有多快",而是很多人对"快"的原因判断是错的——大家第一反应是"少了一次数据库往返",其实往返次数只是表象,真正的成本中心在内存里。

1.1 一条 DELETE 在传统写法里要走多少道工序

用RemoveRange删数据,标准的写法是这样:

var expired = await db.Orders .Where(o => o.CreatedAt < cutoff) .ToListAsync(); db.Orders.RemoveRange(expired); await db.SaveChangesAsync();

这四行看起来挺干净,实际上背后是一整条流水线:

  1. 第一条 SQL 把所有列查回来。注意是SELECT *,哪怕你只关心主键,EF Core 默认也会把实体映射的每个属性都拉回来。
  2. 结果集逐行物化成实体对象。每行一个Order实例,每个实例还要经过构造函数、属性赋值、导航属性的修正(fixup)。
  3. RemoveRange把每个实体登记进ChangeTracker,状态标记为Deleted,同时维护内部的身份映射表(IdentityMap)。
  4. SaveChanges阶段,EF Core 遍历所有被跟踪的实体,为每个Deleted实体生成一条DELETE FROM Orders WHERE Id = @p0。
  5. 这些语句被送进批处理管道,按提供程序的MaxBatchSize打包。SQL Server 提供程序默认是 42,也就是每 42 条语句一次往返。
  6. 每条DELETE执行完,EF Core 还要处理返回值、更新跟踪器状态、触发SavedChanges事件。

这六步里,只有第 5 步和第 6 步跟数据库有关系,前四步全是纯 CPU 和内存开销。一万行数据,你可能要物化一万个对象,跟踪器里多出一万个条目,然后生成一万条 SQL 语句。这台机器的 GC 压力有多大,做过后台批量任务的人都懂。

注意:ToListAsync之后再RemoveRange,意味着这些实体在删除过程中一直被DbContext引用着,没法被回收。数据量一上来,LOH(大对象堆)和 Gen2 回收就开始拖后腿了。

1.2 变更跟踪器才是那台真正的成本机器

我在模拟项目里做过一个粗糙但足够说明问题的对比:同样删 5 万行,只把ToListAsync换成AsNoTracking().ToListAsync()再RemoveRange——结果直接抛异常,因为未跟踪的实体RemoveRange会报"实例未被跟踪"。这个小插曲恰好说明了问题:传统删除路径对跟踪器的依赖是硬绑定,你绕不开它。

跟踪器做了哪些事?身份解析(同一主键只保留一个实例)、状态机维护、原始值快照(用于并发检查)、导航属性修正、DetectChanges扫描。每一项在万级数据下都还撑得住,十万级就开始明显卡顿,百万级基本就没法在请求线程里做了。

所以如果你之前的"批量删除优化"是加索引、改CommandTimeout、调MaxBatchSize,那你优化的方向其实一直在数据库侧。而真正的瓶颈在你的应用进程里。

1.3 ExecuteDelete 的定位:一条语句穿过整个 ORM 层

ExecuteDelete做的事情很干脆:把你写的 LINQ 查询翻译成一条DELETE FROM ... WHERE ...,直接丢给数据库执行,中间不物化实体、不进跟踪器、不生成逐行语句。它返回的是受影响行数(int),同步版和异步版都有:

int affected = await db.Orders .Where(o => o.CreatedAt < cutoff) .ExecuteDeleteAsync();

生成的 SQL 大致就是:

DELETE FROM [Orders] WHERE [CreatedAt] < @p0;

一条语句,一次往返,零实体物化。你原来那一整套流水线被砍到只剩"翻译 + 执行"两个环节。这才是它快的根本原因,跟"数据库执行 DELETE 比执行 N 条 DELETE 快"这种说法关系不大——虽然那也成立,但不是主要矛盾。

我个人的判断标准是:只要你的删除条件是纯数据库侧可判定的(列比较、关联子查询),并且不需要在删之前跑业务逻辑,就优先用ExecuteDelete。反过来,如果需要读出来做审计、需要发事件、需要走软删除,就别硬上。

2. 能力边界:它到底"不做什么"

ExecuteDelete是个典型的能力换性能的设计,它砍掉的东西比你想象的多。这些边界不搞清楚,线上很容易出问题,而且出的问题往往很隐蔽——数据看着删了,但状态不一致,过几天才暴露。

2.1 它绕过了哪些既有机制

我整理了一张表,把容易踩的几条列出来:

机制是否生效说明
变更跟踪器不生效实体不会被加载,也不会被标记
客户端级联删除不生效EF Core 的级联逻辑在SaveChanges里,这里根本不走
数据库级联(外键 ON DELETE CASCADE)生效这是数据库自己干的,跟 EF Core 无关
SaveChangesInterceptor不生效因为压根没调SaveChanges
DbCommandInterceptor生效确实执行了命令,能被拦到
乐观并发检查不生效没有并发令牌比对,不会抛DbUpdateConcurrencyException
全局查询过滤器生效你的HasQueryFilter条件会被拼进 WHERE
软删除拦截逻辑不生效如果软删除是靠重写SaveChanges实现的,这里会真的物理删除
审计字段自动填充不生效同上

最后两条是我见过最多的翻车点。有些团队的软删除是在SaveChanges重写里把Deleted状态改写成Modified并设置IsDeleted = true,这个逻辑在ExecuteDelete面前完全失效——数据被物理删掉了,而且没有任何日志。

提示:改造之前先全局搜一遍SaveChanges的重写和SaveChangesInterceptor的实现,确认里面没有承载业务语义。有的话,这条删除路径要么不换,要么改走ExecuteUpdate。

2.2 LINQ 表达式的翻译红线

ExecuteDelete只能作用于一个"可翻译成单条 DELETE"的查询。实践中有几个高频报错点:

  • 带Include会直接抛异常。因为它不物化实体,导航属性没有意义。报错信息大概是"The LINQ expression ... could not be translated",第一次遇到会有点懵。
  • Take/Skip在多数提供程序上翻译不了。SQL Server 不支持DELETE ... TOP配合ORDER BY的通用写法,EF Core 也不会帮你绕。想分批删得用别的办法,后面细说。
  • GroupBy、聚合、Select到匿名类型基本都不行,它需要一个能定位到实体表的查询根。
  • AsNoTracking()是多余的,加不加都一样,因为它压根不跟踪。但加上不会有副作用。
// 不行:Include 无法翻译 await db.Orders.Include(o => o.Items).Where(...).ExecuteDeleteAsync(); // 不行:Take 在 SQL Server 提供程序上翻译失败 await db.Orders.Where(...).Take(5000).ExecuteDeleteAsync();

第二条要特别记住,因为"分批删除"是批量操作的刚需,很多人第一反应就是加Take,然后被异常打回来。

2.3 不同数据库提供程序的表现差异

ExecuteDelete在 EF Core 7 里是关系型提供程序通用的能力,但底层 SQL 由各家翻译,行为上有差别:

  • SQL Server:翻译成标准DELETE FROM ... WHERE ...,支持关联子查询。执行计划里如果 WHERE 命中不了索引,会走全表扫描加排他锁,长事务下容易把整张表锁住。
  • PostgreSQL:同样支持,但要注意DELETE的返回行数和VACUUM的关系。大批量删除后表空间不会立刻回收,需要后台的 autovacuum 跟上,否则表和索引会持续膨胀。
  • SQLite:支持,但 SQLite 是库级写锁,大批量删除期间其他写入会被阻塞,桌面端和移动端场景要控制单批规模。
  • MySQL 系:支持,DELETE语句同样受innodb_lock_wait_timeout影响,大事务容易拖垮从库延迟。

这些差异意味着同一段代码在不同环境下的"安全批量大小"是不一样的,不能抄一个数字到处用。

3. 实测数据:把差距量化出来

下面这组数字来自我在一个模拟项目上的压测。数据模型很简单:Order(订单主表)+OrderItem(明细),订单表 100 万行,明细表约 400 万行,目标删除条件是"创建时间早于某个时间点",实际命中量按实验设定。硬件是一台 8 核 16G 的开发机,数据库和应用同机部署——这个配置不代表生产环境,但用来做相对比较够了。

3.1 实验设计:三组对照

我设计了三组,都是删同样的数据,只看耗时和内存:

  • A 组:ToListAsync+RemoveRange+SaveChanges,也就是最常见的写法。
  • B 组:ExecuteDeleteAsync一次性删除。
  • C 组:按主键范围分批,每批 5000 行,循环调用ExecuteDeleteAsync。

每组跑三次取中位数,同时在应用侧记录 GC 前后堆大小变化。

3.2 实测结果

删除行数A 组耗时B 组耗时C 组耗时A 组峰值内存增量B 组峰值增量
1,0000.42 s0.03 s0.09 s约 12 MB< 1 MB
10,0004.1 s0.08 s0.25 s约 95 MB< 1 MB
100,00047 s0.42 s1.3 s约 780 MB约 2 MB
500,000约 4 分 20 秒1.9 s5.6 s超过 3.5 GB约 8 MB
1,000,000内存溢出,任务失败3.8 s11 s—约 15 MB

几个值得盯的点:

  • A 组是超线性的。行数涨 10 倍,耗时涨 10 到 12 倍,但内存在 100 万行时直接崩了。原因是对象数量上去之后,Gen2 回收频繁触发,跟踪器的字典也在扩容,整体是个滚雪球的过程。
  • B 组基本是线性的,100 万行 3.8 秒,主要成本在数据库侧的索引扫描和日志写入,应用侧几乎无感。
  • C 组比 B 组慢,这符合预期,因为拆成多条语句意味着多次往返和多次事务提交。但它的峰值内存和锁持有时间都更可控,在不能接受长事务的环境里是更稳的选择。

3.3 一个反直觉的结论:小批量时它可能更慢

看第一行,1000 行的时候 B 组 0.03 秒,A 组 0.42 秒,差距 14 倍。听起来还行,但换个角度:如果你每次只删十几行,ExecuteDelete的优势会快速衰减,甚至在热路径上表现更差。

原因有两个。第一,ExecuteDelete每次调用都要走一遍查询翻译管线——表达式树解析、SQL 生成、参数化,这部分开销是固定的,几百微秒到一毫秒级别。第二,它默认自带一个隐式事务(如果不在显式事务里),每次都要提交,这对高频小操作是纯损耗。

我自己踩过的坑:一个接口里对单条记录做"删除"时顺手换成了ExecuteDelete,压测时 QPS 掉了大概 8%。后来换回Remove+SaveChanges反而更快,因为那条记录本来就在跟踪器里,SaveChanges走的是已有的批处理管道。

经验法则:删除量在几十行以内,用传统方式;上百行到几千行,看是否需要业务钩子;上万行,无条件优先考虑ExecuteDelete。

3.4 数据库侧到底发生了什么

应用侧的数字好看,但别忘了数据库也在干活。我抓了一下 SQL Server 的相关指标,100 万行的ExecuteDelete在数据库侧的表现是:

  • 如果 WHERE 条件有合适的索引,走索引扫描加 RID/键查找,逻辑读大概在几万页的量级。
  • 事务日志增长大约 200 到 400 MB(取决于行宽和索引数量)。这个数字在大表上很关键,日志文件如果设置了固定大小且不自动增长,直接就报错了。
  • 期间持有的是行级锁,但一旦单次影响行数超过 5000,SQL Server 会触发锁升级,变成表级排他锁。这时候其他会话的读操作(在默认的读已提交隔离级别下)会被阻塞。

最后这条是分批删除的核心理由。5000 这个数字不是随便定的,它是 SQL Server 锁升级的默认阈值。超过它,锁的粒度就跳一个数量级。

4. 落地写法:几种能直接抄的模式

讲完原理和数字,说说具体怎么写。下面这几段都是我在实际项目里跑过的形态,你可以按场景挑。

4.1 基础形态与一个必须处理的副作用

最基础的用法前面已经写了。这里要补一个真实的坑:同一个DbContext实例里,如果之前已经跟踪了这些实体,ExecuteDelete之后跟踪器里的状态是脏的。

var orders = await db.Orders.Where(o => o.Id == 1).ToListAsync(); // 被跟踪了 await db.Orders.Where(o => o.Id == 1).ExecuteDeleteAsync(); // 数据库里没了 orders[0].Status = "Cancelled"; await db.SaveChangesAsync(); // 这里会抛 DbUpdateConcurrencyException

数据库里行已经不存在,SaveChanges生成的UPDATE影响 0 行,EF Core 认为发生了并发冲突,抛异常。

处理方式就两个:要么把ExecuteDelete放到独立的DbContext作用域里执行,要么在同一上下文里先db.ChangeTracker.Clear()。我倾向第一种,职责更清楚,也避免了长期存活的上下文带来的跟踪器膨胀问题。

// 独立作用域,互不干扰 await using var cleanupDb = new AppDbContext(options); int affected = await cleanupDb.Orders .Where(o => o.CreatedAt < cutoff) .ExecuteDeleteAsync();

4.2 分批删除:绕开 Take 的三种做法

前面说了Take翻译不了,那怎么分批?我实践过的有三种。

做法一:主键范围游标(推荐)。前提是主键单调递增,或者有时间戳可以当游标。

const int batchSize = 5000; long lastId = 0; int total = 0; while (true) { var ids = await db.Orders .Where(o => o.Id > lastId && o.CreatedAt < cutoff) .OrderBy(o => o.Id) .Select(o => o.Id) .Take(batchSize) .ToListAsync(); if (ids.Count == 0) break; int deleted = await db.Orders .Where(o => ids.Contains(o.Id)) .ExecuteDeleteAsync(); total += deleted; lastId = ids[^1]; await Task.Delay(50); // 让出一点时间给其他会话 }

这里的ids.Contains会翻译成IN (...)。注意参数个数上限,SQL Server 是 2100,所以batchSize控制在 2000 以内更安全。5000 的话要看提供程序是否会参数化成表值构造,保守起见我用 1000 到 2000。

做法二:主键区间二分。先查MIN(Id)和MAX(Id),然后按固定的 ID 步长切成若干区间,每个区间删一次。适合 ID 分布均匀的表,缺点是区间内实际行数不可控。

做法三:原生 SQL + TOP。直接用ExecuteSqlInterpolated写DELETE TOP (5000) FROM ...,但这样就脱离了 LINQ 的抽象,可移植性差,我不太推荐在核心路径上这么干,除非确实需要极限性能。

每种做法的取舍点在于:做法一精确可控但多一次查询;做法二少一次查询但批次大小不稳定;做法三最直接但绑死了数据库。我在订单清理这类任务里用做法一,因为清理任务对延迟不敏感,对可预测性敏感。

4.3 软删除场景:ExecuteUpdate 才是对的工具

如果你的业务要求"删除即可见地消失,但数据要留",那ExecuteDelete就不该出现。这时候用它的兄弟ExecuteUpdate:

int affected = await db.Orders .Where(o => o.CreatedAt < cutoff && !o.IsDeleted) .ExecuteUpdateAsync(s => s .SetProperty(o => o.IsDeleted, true) .SetProperty(o => o.DeletedAt, DateTimeOffset.UtcNow) .SetProperty(o => o.DeletedBy, "system-cleanup"));

SetProperty里可以做简单的表达式计算,比如SetProperty(o => o.Version, o => o.Version + 1)。这同样是一条UPDATE语句,不物化实体,性能特征跟ExecuteDelete一致。

这里有个联动要注意:如果你用全局查询过滤器做了软删除(HasQueryFilter(o => !o.IsDeleted)),那么后续的普通查询会自动过滤掉已删数据,但ExecuteDelete也会带上这个过滤器——意味着你没法用ExecuteDelete去物理清理那些已经被软删除的行。要清的话得用IgnoreQueryFilters():

await db.Orders .IgnoreQueryFilters() .Where(o => o.IsDeleted && o.DeletedAt < hardDeleteCutoff) .ExecuteDeleteAsync();

这个组合我用得挺多:业务侧软删除,定期任务做物理归档清理。

4.4 并发与一致性:三个容易忽略的细节

第一,并发令牌完全失效。如果你的实体配了IsRowVersion(),ExecuteDelete不会检查它。两个人同时操作,后执行的直接覆盖,没有任何冲突提示。对需要严格并发的表,要么不用这个 API,要么在上层加分布式锁或版本前置校验。

第二,跨表的原子性要显式保证。如果你要删订单和订单明细,两条ExecuteDelete分别在两个表上执行,中间没有任何事务包裹的话,第一条成功第二条失败就会留下孤儿数据(如果外键没配级联)。正确做法是包在显式事务里:

await using var tx = await db.Database.BeginTransactionAsync(); await db.OrderItems.Where(i => i.OrderId <= cutoffId).ExecuteDeleteAsync(); await db.Orders.Where(o => o.Id <= cutoffId).ExecuteDeleteAsync(); await tx.CommitAsync();

顺序很重要:先删子表再删主表,否则外键约束会拦下来。

第三,ExecuteDelete和SaveChanges混用时的顺序。如果同一个事务里既有跟踪实体的修改,又有ExecuteDelete,记得先SaveChanges再ExecuteDelete。反过来的话,ExecuteDelete可能删掉了跟踪器里正准备更新的行,导致后面的SaveChanges抛并发异常。这个顺序问题我排查过一次,日志里只有一条看不懂的并发异常,联系上下文才想明白。

5. 常见问题排查:我踩过和帮别人排查过的

这一节按症状归类,方便你直接对号入座。

5.1 报错与异常速查

现象/异常信息关键词大概率原因处理方式
could not be translated,且查询里有IncludeExecuteDelete不支持导航加载去掉Include,改用子查询Where(o => db.Items.Any(...))
could not be translated,查询里有Take/Skip提供程序不支持改成分批方案,先查 ID 再删
DbUpdateConcurrencyException跟踪器里的实体已被ExecuteDelete从库里删掉独立上下文执行,或在SaveChanges前Clear()
外键约束冲突子表数据仍在先删子表,包在事务里
执行很久不返回WHERE 无索引,全表扫描加锁升级补索引,或改成分批
日志文件暴涨 / 磁盘告警单事务删除量过大分批 + 每批独立提交,留出日志备份窗口
软删除字段没被设置走了ExecuteDelete而不是ExecuteUpdate换 API

5.2 "数据看着删了,但列表里还能查到"的排查路径

这个症状我遇到过两次,原因完全不同。

第一次是缓存没失效。删除走的是ExecuteDelete,绕过了SaveChanges,而缓存失效逻辑挂在SavedChanges事件上,所以缓存里还是旧数据。排查方法很简单:直接连数据库查一下,如果库里确实没了,那就是缓存问题。修法是把缓存失效挪到删除逻辑之后显式调用,别依赖 ORM 事件。

第二次是读走了只读副本。主库删了,从库有复制延迟,正好那几秒内的查询命中了从库。这个更隐蔽,因为过一会儿自己就好了。判断方法是看延迟时间窗口是否和复制延迟吻合。如果业务不能容忍,列表查询在删除后的短时间内强制走主库。

这两次的共同教训是:ExecuteDelete把 ORM 的"顺带完成"的能力拿走了,所有原本依赖SaveChanges的副作用,都得你手动接管。

5.3 上线前的检查清单

我在团队里推这套改造时定了几条硬规矩,贴在下面供参考:

  1. 搜全局的SaveChanges重写和拦截器,逐个确认里面没有软删除、审计、事件发布之类的业务逻辑。有的话,这条路径不能改。
  2. 确认数据库侧的外键策略。有ON DELETE CASCADE的可以放心删主表;没有的话,要么手动删子表,要么这个 API 别用。
  3. 确认目标表的索引覆盖。ExecuteDelete的 WHERE 条件必须有索引支撑,否则单条语句也会锁住整表。上线前跑一次执行计划看看。
  4. 压测时观察数据库事务日志的增长。用生产量级的数据跑,不要用测试库那几千行。日志增长超预期就调小批次。
  5. 回归测一遍"删除后立刻查询"的场景。这就是 5.2 那两类问题的暴露点。
  6. 给清理任务加监控。记录每次的受影响行数、耗时、批次数量。异常波动往往是数据分布变化或者索引失效的前兆。

最后再分享一个小技巧:我在清理任务里给每批删除加了一个Stopwatch计时,如果单批耗时超过平时均值的 3 倍,就打一条 Warning 日志并提前终止。这个机制帮我抓到过一次索引被误删的事故——否则任务会一直跑,把数据库连接池占满,接口全挂。

ExecuteDelete这东西,用对了是几行代码换十几倍性能,用错了就是静默的数据事故。判断标准其实就一条:这次删除,除了"从数据库里把行拿掉"之外,还有没有别的事要做?没有,放心用;有,先想清楚那些事由谁来做。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 5:23:45

PCIe 5.0 SSD插老主板:兼容、降速与选购避坑

上周有个朋友抱着台三年前攒的机器来找我&#xff0c;说想加块 PCIe 5.0 SSD&#xff0c;问我这主板只有 PCIe 4.0 的 M.2 口&#xff0c;插上去是不是直接点不亮、白花钱。这个问题我这一年被问过不下二十次&#xff0c;问的人里既有刚入坑的新手&#xff0c;也有装机十来年的…

作者头像 李华
网站建设 2026/11/4 5:23:45

交换机工作原理与网络故障排查实战指南

1. 交换机基础概念与核心价值在当今的网络架构中&#xff0c;交换机就像城市交通系统中的智能立交桥&#xff0c;它决定了数据包如何高效地到达目的地。作为网络工程师入行的必修课&#xff0c;理解交换机工作原理是搭建和维护企业网络的基础能力。我从业十余年&#xff0c;处理…

作者头像 李华
网站建设 2026/11/4 5:23:45

AgentScope 多智能体框架实战:从设计原理到 Java 企业级落地

1. 为什么我会把 AgentScope 推荐给做多智能体的人第一次接触 AgentScope 是在一个需要快速搭建多智能体协作原型的项目里。当时团队评估过好几个框架&#xff0c;要么抽象太重、改起来费劲&#xff0c;要么消息传递机制不透明、调试像开盲盒。AgentScope 给我的第一印象是&quo…

作者头像 李华
网站建设 2026/11/4 5:23:43

系统架构设计师论文写作:项目底座与高分结构

论文这一科&#xff0c;是系统架构设计师考试里最容易劝退人也最容易摘分的一关。四道题里挑一道&#xff0c;两个小时手写两千多字&#xff0c;考完手酸得抬不起来&#xff0c;这是很多人对它的第一印象。可我在带人备考和复盘的过程里发现一件事&#xff1a;这一科真正难的地…

作者头像 李华
网站建设 2026/11/4 5:22:42

OpenClaw Docker部署与AI智能助手实践指南

1. OpenClaw Docker 部署概述OpenClaw 是一个基于 AI 的智能助手系统&#xff0c;能够通过 Docker 容器化部署实现快速搭建和运行。本次部署的核心目标不仅仅是让系统跑起来&#xff0c;更重要的是构建一个稳定、可复用的生产环境。经过实际验证&#xff0c;这套方案在 Debian …

作者头像 李华
网站建设 2026/11/4 5:21:24

特征选择利器ReliefF算法:原理、优化与回归问题实战

1. 特征工程中的"大扫除"哲学做机器学习项目就像收拾一间多年未整理的仓库&#xff0c;数据中的特征就是散落各处的物品。有些是价值连城的古董&#xff08;强相关特征&#xff09;&#xff0c;有些是毫无用处的废品&#xff08;无关特征&#xff09;&#xff0c;更多…

作者头像 李华