Start Debugging

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 для массовых записей. Эта статья о выборе между ними и отслеживаемым путём.

Матрица возможностей

ВозможностьЗагрузка + SaveChangesExecuteUpdate / ExecuteDelete
Строк загружено клиентувсе подходящие строкини одной
Снимок отслеживателяпо одному на сущностьнет
Обращения к серверу1 SELECT + пакетные UPDATE1
Выданный SQLодин UPDATE на сущность (пакетно)один множественный UPDATE
Автоматические токены конкурентностида (DbUpdateConcurrencyException)нет, вручную через счётчик строк
Перехватчики / события SaveChangesданет
Каскадное удаление по графуда (отслеживаемое)только каскад FK в базе данных
Доступно свсегдаEF Core 7.0
Поддержка вставкида (Add)нет, только обновление и удаление
Атомарность между выражениямиодна транзакция на SaveChangesтранзакцию открываете вы

Матрица чётко делится по одной оси: всё, что SaveChanges делает за вас, является следствием материализации и отслеживания сущностей, а всё, в чём ExecuteUpdate быстрее, является следствием отказа от этого.

Когда выбирать ExecuteUpdate / ExecuteDelete

Когда выбирать загрузку + SaveChanges

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; меньше — лучше.

Изменено строкЗагрузка + SaveChangesExecuteUpdateУскорение
10011.4 мс2.1 мс~5x
1 00092 мс3.0 мс~30x
10 000880 мс8.7 мс~100x
100 0009 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 для массовых вставок.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад