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();这四行看起来挺干净,实际上背后是一整条流水线:
- 第一条 SQL 把所有列查回来。注意是
SELECT *,哪怕你只关心主键,EF Core 默认也会把实体映射的每个属性都拉回来。 - 结果集逐行物化成实体对象。每行一个
Order实例,每个实例还要经过构造函数、属性赋值、导航属性的修正(fixup)。 RemoveRange把每个实体登记进ChangeTracker,状态标记为Deleted,同时维护内部的身份映射表(IdentityMap)。SaveChanges阶段,EF Core 遍历所有被跟踪的实体,为每个Deleted实体生成一条DELETE FROM Orders WHERE Id = @p0。- 这些语句被送进批处理管道,按提供程序的
MaxBatchSize打包。SQL Server 提供程序默认是 42,也就是每 42 条语句一次往返。 - 每条
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,000 | 0.42 s | 0.03 s | 0.09 s | 约 12 MB | < 1 MB |
| 10,000 | 4.1 s | 0.08 s | 0.25 s | 约 95 MB | < 1 MB |
| 100,000 | 47 s | 0.42 s | 1.3 s | 约 780 MB | 约 2 MB |
| 500,000 | 约 4 分 20 秒 | 1.9 s | 5.6 s | 超过 3.5 GB | 约 8 MB |
| 1,000,000 | 内存溢出,任务失败 | 3.8 s | 11 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,且查询里有Include | ExecuteDelete不支持导航加载 | 去掉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 上线前的检查清单
我在团队里推这套改造时定了几条硬规矩,贴在下面供参考:
- 搜全局的
SaveChanges重写和拦截器,逐个确认里面没有软删除、审计、事件发布之类的业务逻辑。有的话,这条路径不能改。 - 确认数据库侧的外键策略。有
ON DELETE CASCADE的可以放心删主表;没有的话,要么手动删子表,要么这个 API 别用。 - 确认目标表的索引覆盖。
ExecuteDelete的 WHERE 条件必须有索引支撑,否则单条语句也会锁住整表。上线前跑一次执行计划看看。 - 压测时观察数据库事务日志的增长。用生产量级的数据跑,不要用测试库那几千行。日志增长超预期就调小批次。
- 回归测一遍"删除后立刻查询"的场景。这就是 5.2 那两类问题的暴露点。
- 给清理任务加监控。记录每次的受影响行数、耗时、批次数量。异常波动往往是数据分布变化或者索引失效的前兆。
最后再分享一个小技巧:我在清理任务里给每批删除加了一个Stopwatch计时,如果单批耗时超过平时均值的 3 倍,就打一条 Warning 日志并提前终止。这个机制帮我抓到过一次索引被误删的事故——否则任务会一直跑,把数据库连接池占满,接口全挂。
ExecuteDelete这东西,用对了是几行代码换十几倍性能,用错了就是静默的数据事故。判断标准其实就一条:这次删除,除了"从数据库里把行拿掉"之外,还有没有别的事要做?没有,放心用;有,先想清楚那些事由谁来做。