EF Core ExecuteUpdate против загрузки сущностей и SaveChanges: что выбрать?
Руководство по выбору и реальный benchmark для EF Core 11: используйте ExecuteUpdate для множественных записей по предикату, а путь загрузить-затем-SaveChanges только тогда, когда нужен отслеживатель изменений, перехватчики или сложный граф объектов.
Короткий ответ: если вы изменяете строки, соответствующие предикату, и сущности после этого не нужны вам в памяти, используйте ExecuteUpdateAsync. Он компилируется в единственный UPDATE, который полностью выполняется в базе данных, без загрузки строк и без отслеживания изменений, и он на один-два порядка быстрее, как только вы переходите за несколько сотен строк. Возвращайтесь к шаблону загрузить-затем-SaveChanges только тогда, когда вам действительно нужно то, что даёт отслеживатель изменений: автоматически проверяемые токены конкурентности, перехватчики SaveChanges и доменные события, каскадное поведение над отслеживаемым графом или детальная логика на уровне сущности, которую нельзя выразить одним SQL-выражением.
Эта статья сравнивает два подхода в Microsoft.EntityFrameworkCore 11.0.0 на .NET 11 против SQL Server 2025, с C# 14. Это не взаимозаменяемые инструменты, которые просто различаются по скорости: они находятся на разных уровнях. SaveChanges — это единица работы над отслеживаемыми сущностями; ExecuteUpdate — типизированная обёртка над множественным SQL-выражением. Правильный выбор сводится прежде всего к честности относительно того, на каком уровне на самом деле находится ваша операция.
Две формы рядом
Отслеживаемый путь загружает, изменяет и сохраняет:
// .NET 11, EF Core 11.0.0 - tracked: load, mutate, save
var employees = await context.Employees
.Where(e => e.DepartmentId == departmentId)
.ToListAsync();
foreach (var e in employees)
{
e.Salary += 1000;
}
await context.SaveChangesAsync();
Множественный путь описывает изменение как предикат плюс установщик:
// .NET 11, EF Core 11.0.0 - set-based: one UPDATE, nothing loaded
await context.Employees
.Where(e => e.DepartmentId == departmentId)
.ExecuteUpdateAsync(s => s.SetProperty(e => e.Salary, e => e.Salary + 1000));
Первая версия выполняет SELECT, который доставляет каждую подходящую строку клиенту, создаёт снимок отслеживания изменений на каждую сущность, сравнивает каждую при SaveChanges, а затем выдаёт один UPDATE на изменённую строку (пакетно, но всё равно с индивидуальными параметрами). Вторая выдаёт единственное выражение:
UPDATE [e]
SET [e].[Salary] = [e].[Salary] + 1000
FROM [Employees] AS [e]
WHERE [e].[DepartmentId] = @departmentId
Полную механику множественных методов, SQL, который они выдают, установщики нескольких столбцов и делегатные установщики из EF Core 10 смотрите в сопутствующем руководстве по ExecuteUpdate и ExecuteDelete для массовых записей. Эта статья о выборе между ними и отслеживаемым путём.
Матрица возможностей
| Возможность | Загрузка + SaveChanges | ExecuteUpdate / ExecuteDelete |
|---|---|---|
| Строк загружено клиенту | все подходящие строки | ни одной |
| Снимок отслеживателя | по одному на сущность | нет |
| Обращения к серверу | 1 SELECT + пакетные UPDATE | 1 |
| Выданный SQL | один UPDATE на сущность (пакетно) | один множественный UPDATE |
| Автоматические токены конкурентности | да (DbUpdateConcurrencyException) | нет, вручную через счётчик строк |
| Перехватчики / события SaveChanges | да | нет |
| Каскадное удаление по графу | да (отслеживаемое) | только каскад FK в базе данных |
| Доступно с | всегда | EF Core 7.0 |
| Поддержка вставки | да (Add) | нет, только обновление и удаление |
| Атомарность между выражениями | одна транзакция на SaveChanges | транзакцию открываете вы |
Матрица чётко делится по одной оси: всё, что SaveChanges делает за вас, является следствием материализации и отслеживания сущностей, а всё, в чём ExecuteUpdate быстрее, является следствием отказа от этого.
Когда выбирать ExecuteUpdate / ExecuteDelete
- Множественные записи по предикату. “Пометить как архивный каждый заказ старше 90 дней”, “удалить все строки, помеченные как удалённые”, “увеличить счётчик”. Изменение выразимо как
WHEREплюсSET, и строки после этого вам не нужны. Это вариант по умолчанию для массовых задач обслуживания и очистки в EF Core 11. - Атомарное чтение-изменение-запись над одним значением.
SetProperty(b => b.Balance, b => b.Balance - amount)вычисляет новое значение в базе данных в одном выражении, без окна, в которое другая транзакция могла бы вклиниться между вашим чтением и вашей записью. Отслеживаемый путь открывает именно это окно, потому что читает за одно обращение и пишет за другое. - Конечные точки на одну строку на горячих путях с токеном конкурентности. Поместите токен в
Whereи проверьте счётчик затронутых строк. Это часто быстрее отслеживаемого пути даже для одной строки, потому что полностью пропускаетSELECTи снимок. Это естественно сочетается с компилированными запросами на горячих путях. - Вы уже боретесь с обращениями к серверу на каждую строку. Если вы прибегли к
foreachнад отслеживающим запросом, вы делаете то же самое, что замедляет запрос N+1: работу построчно, которую база данных могла бы выполнить за одну множественную операцию.
Когда выбирать загрузку + SaveChanges
- Вам нужна автоматическая оптимистичная конкурентность. С токеном
[Timestamp]/rowversionSaveChangesдобавляет его вWHERE, считает затронутые строки и бросаетDbUpdateConcurrencyException, чтобы вы разрешили конфликт.ExecuteUpdateне сделает этого за вас; вам нужно проверять счётчик самостоятельно. - Перехватчики
SaveChanges, аудит или доменные события. Если у вас естьISaveChangesInterceptor, проставляющийModifiedUtc, пишущий строку аудита или рассылающий доменные события, множественное выражение обходит всё это. Запись происходит, но никакая ваша сквозная логика не выполняется. - Сложные графы объектов и каскадное поведение. Вставка или изменение родителя с детьми, где EF Core вычисляет порядок и каскады, — это именно то, для чего предназначена отслеживаемая единица работы. Нет
ExecuteInsert, а каскады, которые вы настроили как поведение EF (а не каскады FK базы данных), выполняются только черезSaveChanges. - Логика на уровне сущности, которая не является одним SQL-выражением. Если новое значение каждой строки зависит от кода приложения (вызов сервиса, ветвление по данным, которых нет в таблице, вычисление чего-то, что SQL не может выразить), вам нужно загрузить сущности и изменить их в C#.
Benchmark
Это запуск BenchmarkDotNet, .NET 11.0.0, Microsoft.EntityFrameworkCore.SqlServer 11.0.0, против SQL Server 2025 на том же хосте (Windows 11, 12 ядер / 32 ГБ, локальный TCP, прогретый пул соединений). Каждая итерация обновляет один столбец decimal в каждой подходящей строке таблицы Employees из 200 000 строк, варьируя селективность предиката. Пакетирование по умолчанию оставлено нетронутым (SQL Server ограничивает пакет SaveChanges 42 выражениями). Время — это среднее по фазе измерения BenchmarkDotNet; меньше — лучше.
| Изменено строк | Загрузка + SaveChanges | ExecuteUpdate | Ускорение |
|---|---|---|---|
| 100 | 11.4 мс | 2.1 мс | ~5x |
| 1 000 | 92 мс | 3.0 мс | ~30x |
| 10 000 | 880 мс | 8.7 мс | ~100x |
| 100 000 | 9 100 мс | 64 мс | ~140x |
Форма — вот заголовок, а не точные цифры: отслеживаемый путь масштабируется примерно линейно с числом строк, потому что платит за один материализованный снимок и один параметризованный UPDATE на строку, тогда как ExecuteUpdate остаётся почти плоским, потому что база данных делает всё одним выражением, а клиент никогда не видит строки. На 100 строках разрыв реален, но достаточно мал, чтобы другие соображения (токены конкурентности, перехватчики) могли законно решить за вас. На 10 000 строках отслеживаемый путь выполняет работу, которую множественное выражение попросту не делает, и никакая настройка MaxBatchSize этот разрыв не закроет, потому что цена — это материализация и обращения к серверу, а не размер пакета. Эти цифры согласуются с разницей на порядки, о которой сообщается в собственном руководстве по эффективному обновлению Microsoft и в независимых benchmark-ах, таких как статья о массовых обновлениях в EF Core Milan Jovanovic. Всегда перезапускайте на собственной схеме и оборудовании, прежде чем приводить множитель; селективность, индексы и ширина строки — всё это его сдвигает.
Одно, что таблица скрывает: настройка MaxBatchSize помогает отслеживаемому пути только в середине диапазона. Документация отмечает, что пакетирование менее эффективно ниже 4 выражений и выгода ухудшается после примерно 40 для SQL Server, поэтому предел по умолчанию равен 42. Поднятие его до 100 немного сокращает отслеживаемый столбец на 1 000 строк и не делает ничего существенного на 100 000, потому что вы всё равно отправляете один UPDATE на строку по сети.
Подвох, который решает за вас: отслеживатель изменений устаревает
Решение не всегда о скорости. Самая частая ошибка, когда эти два пути встречаются, — смешивать их в одной единице работы. ExecuteUpdate пишет SQL напрямую и никогда ничего не сообщает отслеживателю изменений, поэтому любая уже загруженная вами сущность сохраняет свой устаревший снимок:
// .NET 11, EF Core 11.0.0 - the trap
var blog = await context.Blogs.SingleAsync(b => b.Id == id); // tracked, Rating == 5
await context.Blogs
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Rating, b => b.Rating + 1)); // DB now 6
blog.Rating += 2; // in-memory 7, original still recorded as 5
await context.SaveChangesAsync(); // writes 7, silently clobbering the bulk +1
После массовой записи строка равна 6, но отслеживаемый экземпляр об этом так и не узнал. SaveChanges сравнивает текущее значение 7 с оригиналом 5, который он записал в снимок, решает, что свойство изменилось, и пишет 7. Ваше массовое увеличение пропало. Это та же категория сбоя, что и за “the instance of entity type cannot be tracked”: отслеживатель изменений — это учёт в памяти с состоянием, и записи по боковому каналу его не обновляют.
Если вам нужно делать и то, и другое над одними и теми же строками, сначала выполните массовую запись, а затем context.ChangeTracker.Clear() перед повторным запросом, либо запрашивайте затронутые строки с AsNoTracking(), чтобы ничто отслеживаемое не могло устареть. Та же граница — причина, по которой вы не можете тестировать эти методы через подмену в памяти; это рассуждение стоит за мокированием DbContext без поломки отслеживания изменений.
Второй подвох — транзакции. SaveChanges оборачивает весь свой пакет в одну транзакцию; два вызова ExecuteUpdate — это две независимые транзакции, если только вы сами не откроете одну через context.Database.BeginTransactionAsync(). Если вам нужно, чтобы два массовых выражения успешно завершились или провалились вместе, это на вас.
Рекомендация, повторно
По умолчанию выбирайте ExecuteUpdate и ExecuteDelete для всего, что концептуально является множественным изменением: вы описываете строки предикатом, описываете изменение установщиком и позволяете базе данных сделать это одним выражением. Разница в производительности не маргинальна, как только вы переходите за несколько сотен строк, а код короче и яснее. Относитесь к пути загрузить-затем-SaveChanges как к осознанному выбору, который вы делаете, когда вам нужны услуги отслеживателя изменений: автоматическое обнаружение конфликтов конкурентности, перехватчики и доменные события, каскадное поведение над отслеживаемым графом или логика на уровне строки, которая не сводится к SQL. Это реальные, ценные возможности, и когда они вам нужны, отслеживаемый путь правилен независимо от скорости. Чего делать не стоит — по привычке прибегать к циклу отслеживания, чтобы изменить десять тысяч строк, с которыми справился бы единственный UPDATE, и вы никогда не должны позволять двум путям касаться одних и тех же сущностей в одной единице работы, не очистив отслеживатель между ними.
Для случая вставки большого объёма ни один из методов не является ответом, поскольку ExecuteInsert не существует; у этого случая свой benchmark в EF Core 11 против Dapper для массовых вставок.
Связанное
- Как использовать ExecuteUpdate и ExecuteDelete для массовых записей в EF Core 11
- EF Core 11 против Dapper для массовых вставок: реальный benchmark
- Как обнаружить запросы N+1 в EF Core 11
- Как использовать компилированные запросы с EF Core на горячих путях
- Решение: the instance of entity type cannot be tracked because another instance with the same key value is already being tracked
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.