Start Debugging

AsNoTracking vs AsNoTrackingWithIdentityResolution в EF Core 11: что выбрать?

Используйте AsNoTracking по умолчанию для запросов только на чтение. Прибегайте к AsNoTrackingWithIdentityResolution только тогда, когда граф результата содержит одну и ту же сущность несколько раз и ваш код полагается на получение единого общего экземпляра.

Краткий ответ: используйте AsNoTracking() по умолчанию для каждого запроса только на чтение. Он полностью пропускает отслеживание изменений, что является самым дешёвым и быстрым способом получить строки, которые вы не собираетесь менять. Переходите на AsNoTrackingWithIdentityResolution() только тогда, когда результирующий набор содержит одну и ту же сущность несколько раз — обычно потому, что Include по навигационному свойству-коллекции размножает один и тот же родительский объект по множеству дочерних строк — и ваш код полагается на получение единого общего экземпляра на первичный ключ, а не новой копии каждый раз. Identity resolution стоит немного дороже (на время запроса создаётся одноразовый трекер изменений), но всё равно гораздо дешевле полного отслеживания. Если ваш запрос возвращает каждую сущность ровно один раз, оба метода ведут себя одинаково, и вам следует выбрать AsNoTracking.

В этой статье оба метода сравниваются на Microsoft.EntityFrameworkCore 11.0.0, работающем на .NET 11 против SQL Server 2025, с C# 14. Оба метода отключают отслеживание изменений; единственное, что их разделяет, — дедуплицирует ли EF Core экземпляры сущностей по ключу внутри результата. Правильный выбор сводится к честному ответу на один вопрос: возвращается ли одна и та же строка более одного раза и важно ли это для чего-либо в вашем коде?

Что на самом деле означает “identity resolution”

Запрос с отслеживанием всегда выполняет identity resolution. Когда EF Core материализует строку, он проверяет трекер изменений контекста по первичному ключу; если экземпляр для этого ключа уже построен, он возвращает тот же объект. Поэтому два запроса с отслеживанием для BlogId == 1 дают вам объекты, равные по ссылке, и поэтому родитель, появляющийся под пятьюдесятью детьми в Include, — это один экземпляр Blog с пятьюдесятью дочерними Post, указывающими на него.

AsNoTracking выбрасывает этот механизм. Трекера изменений нет, поэтому нет карты идентичности, поэтому каждая материализованная строка порождает совершенно новый объект, даже если ключ уже встречался ранее:

// .NET 11, EF Core 11.0.0 - no tracking, no identity map
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTracking()
    .ToListAsync();

// Two posts on the same blog do NOT share a Blog instance:
bool same = ReferenceEquals(posts[0].Blog, posts[1].Blog); // false, even if BlogId is identical

AsNoTrackingWithIdentityResolution оставляет отслеживание отключённым, но восстанавливает карту идентичности. EF Core строит автономный трекер изменений только для этого запроса, использует его для дедупликации по ключу по мере поступления результата и позволяет ему выйти из области видимости и быть собранным сборщиком мусора по завершении перечисления. Контекст никогда ничего не отслеживает:

// .NET 11, EF Core 11.0.0 - no tracking, but identity resolved
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();

bool same = ReferenceEquals(posts[0].Blog, posts[1].Blog); // true when BlogId matches

Этот API не нов. Он появился в EF Core 5.0 в ноябре 2020 года вместе со значением перечисления QueryTrackingBehavior.NoTrackingWithIdentityResolution. Ряд широко распространённых статей приписывает его EF Core 8, что неверно; если вы на любом LTS начиная с EF Core 5, он у вас уже есть, и в EF Core 11 он ведёт себя ровно так, как задокументировано ниже.

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

ВозможностьAsNoTrackingAsNoTrackingWithIdentityResolution
Отслеживание изменений в контекстенетнет
Сохраняется через SaveChangesнетнет
Карта идентичности (один ключ = один экземпляр)нетда, в пределах запроса
Дублирующиеся сущности в одном результатеновый экземпляр каждый разодин общий экземпляр на ключ
Фоновый трекер измененийнетодин, одноразовый, собирается GC после перечисления
Строки, полученные из базы данныхвсе подходящие строкивсе подходящие строки (тот же SQL)
Относительная стоимость запросасамая низкаянемного выше AsNoTracking
Фиксация навигаций по результатунетда (в пределах запроса)
Доступно сEF Core 1.0EF Core 5.0
Как значение по умолчанию контекстаQueryTrackingBehavior.NoTrackingQueryTrackingBehavior.NoTrackingWithIdentityResolution

Вся таблица сводится к одной строке: identity resolution. Всё остальное общее. Ни один из методов не пишет в базу данных, ни один не заполняет трекер контекста и — вот часть, которую упускают, — ни один не меняет SQL или количество строк, которое возвращает сервер. Identity resolution — это чисто клиентская дедупликация объектов, которые EF Core строит из этих строк.

Когда выбирать AsNoTracking

Когда выбирать AsNoTrackingWithIdentityResolution

Бенчмарк

Это прогон BenchmarkDotNet, .NET 11.0.0, Microsoft.EntityFrameworkCore.SqlServer 11.0.0, против SQL Server 2025 на том же хосте (Windows 11, 12 ядер / 32 ГБ, локальный TCP, прогретый пул соединений). Запрос загружает Posts с Include(p => p.Blog) из набора в 100 блогов и переменного числа постов, так что строка каждого блога дублируется по всем его постам. Все три варианта выполняют идентичный SQL и возвращают идентичные строки; различается только стратегия материализации. Времена — это среднее фазы измерения BenchmarkDotNet; меньше — лучше. “Выделено” — это управляемая память, выделенная на операцию.

Возвращено постовTrackingNoTrackingNoTrackingWithIdentityResolution
1 0006.8 ms3.1 ms3.5 ms
10 00071 ms27 ms31 ms
100 000980 ms295 ms360 ms
Возвращено постовВыделено NoTrackingВыделено WithIdentityResolution
10 0009.4 MB7.1 MB
100 00096 MB58 MB

Выделяются две вещи. Во-первых, оба варианта без отслеживания превосходят полное отслеживание примерно в 2-3 раза по времени, потому что снимок состояния трекера изменений — доминирующая стоимость, а его пропуск — большая часть выигрыша; это совпадает с собственным руководством Microsoft по эффективным запросам. Identity resolution возвращает небольшую долю этого выигрыша, порядка 10-20% медленнее, чем чистый AsNoTracking, из-за одноразового трекера изменений, который она поддерживает во время запроса.

Во-вторых, и это контринтуитивная часть, когда результат сильно дублируется, AsNoTrackingWithIdentityResolution может выделять меньше, чем AsNoTracking, потому что строит один объект Blog на ключ, а не один на пост. Затраты времени на дедупликацию частично компенсируются объектами, которые она никогда не строит. Обратная сторона: если в вашем результате нет дубликатов, identity resolution лишь добавляет накладные расходы трекера без чего-либо для сворачивания, так что чистый AsNoTracking побеждает безоговорочно. Числа меняются с долей дублирования, шириной строки и формой графа, так что перезапустите на собственной схеме, прежде чем приводить цифру; надёжная часть — относительный порядок, а не значения в миллисекундах.

Подвох, который решает за вас: молчаливая ошибка равенства по ссылке

Решение не всегда о скорости; часто оно о корректности. Ловушка — предполагать, что AsNoTracking ведёт себя как запрос с отслеживанием только потому, что вы “знаете”, что две строки разделяют ключ:

// .NET 11, EF Core 11.0.0 - the trap
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTracking()
    .ToListAsync();

var blogsByInstance = posts
    .GroupBy(p => p.Blog)             // grouping by reference, not by key!
    .ToList();
// You expected 100 groups (one per blog). You get one group per post,
// because every p.Blog is a distinct object even when BlogId matches.

Ничего не выбрасывает исключение. Запрос успешен, данные корректны построчно, и ошибка проявляется лишь как неверные подсчёты или дублирующая работа дальше по коду. Это из того же семейства сбоев, что и за “the instance of entity type cannot be tracked”: идентичность экземпляров в EF Core — это учёт, и когда вы отключаете учёт, вы не можете опираться на идентичность объектов. Решение — группировать по p.Blog.BlogId (ключ, а не ссылка) или переключить запрос на AsNoTrackingWithIdentityResolution(), чтобы ссылки свернулись так, как вы предполагали.

Второй, более тихий подвох: identity resolution имеет область видимости запроса. Фоновый трекер живёт только на время перечисления этого одного запроса, а затем собирается GC. Два отдельных запроса AsNoTrackingWithIdentityResolution() не разделяют карту идентичности друг с другом, так что Blog из первого запроса никогда не равен по ссылке Blog из второго. Если вам нужна идентичность между запросами, вам нужно настоящее отслеживание. Не прибегайте к identity resolution в ожидании разделения экземпляров на уровне контекста: она делает не это.

В-третьих: AsNoTrackingWithIdentityResolution не сокращает строки, которые отправляет база данных. Иногда надеются, что она вылечит декартов взрыв на проводе. Это не так — SQL не меняется, и сервер по-прежнему передаёт каждую дублированную строку; identity resolution дедуплицирует только объекты, которые EF Core строит на клиенте. Чтобы сократить сами строки, разделите запрос.

Рекомендация, повторно

Сделайте AsNoTracking своим выбором по умолчанию для работы только на чтение и не раздумывайте над ним. Это самое дешёвое чтение, которое предлагает EF Core 11, оно корректно для подавляющего большинства запросов, а для плоских результатов или проекций в DTO это строго правильный выбор. Повышайте запрос до AsNoTrackingWithIdentityResolution только тогда, когда одновременно выполняются два условия: результат действительно содержит одну и ту же сущность несколько раз (Include, размножающий родителя по детям, — хрестоматийный триггер), и что-то в вашем коде полагается на получение единого общего экземпляра на ключ — равенство по ссылке, группировка в памяти или согласованность графа. В этой ситуации метод даёт вам граф объектов качества запроса с отслеживанием по цене, близкой к без отслеживания, а при сильном дублировании может даже выделять меньше. Вне этой ситуации это чистые накладные расходы.

И не прибегайте ни к одному, когда настоящая проблема — слишком много строк. Если запрос с несколькими Include взрывается, долговечное решение — разделение запроса или более точная проекция, а не клиентский проход дедупликации по строкам, которые вы вообще не должны были получать. Тот же инстинкт, который удерживает вас от случайных запросов N+1, применим здесь: придайте запросу форму так, чтобы база данных возвращала именно то, что вам нужно, а затем выберите самую дешёвую материализацию, корректную для того, как вы используете результат.

Связанное

Источники

Comments

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

< Назад