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 он ведёт себя ровно так, как задокументировано ниже.
Матрица возможностей
| Возможность | AsNoTracking | AsNoTrackingWithIdentityResolution |
|---|---|---|
| Отслеживание изменений в контексте | нет | нет |
Сохраняется через SaveChanges | нет | нет |
| Карта идентичности (один ключ = один экземпляр) | нет | да, в пределах запроса |
| Дублирующиеся сущности в одном результате | новый экземпляр каждый раз | один общий экземпляр на ключ |
| Фоновый трекер изменений | нет | один, одноразовый, собирается GC после перечисления |
| Строки, полученные из базы данных | все подходящие строки | все подходящие строки (тот же SQL) |
| Относительная стоимость запроса | самая низкая | немного выше AsNoTracking |
| Фиксация навигаций по результату | нет | да (в пределах запроса) |
| Доступно с | EF Core 1.0 | EF Core 5.0 |
| Как значение по умолчанию контекста | QueryTrackingBehavior.NoTracking | QueryTrackingBehavior.NoTrackingWithIdentityResolution |
Вся таблица сводится к одной строке: identity resolution. Всё остальное общее. Ни один из методов не пишет в базу данных, ни один не заполняет трекер контекста и — вот часть, которую упускают, — ни один не меняет SQL или количество строк, которое возвращает сервер. Identity resolution — это чисто клиентская дедупликация объектов, которые EF Core строит из этих строк.
Когда выбирать AsNoTracking
- Простые списки только на чтение и DTO. Таблица, ответ API, отчёт. Вы запрашиваете, проецируете или сериализуете — и готово. Нет причин платить за карту идентичности, когда вы никогда не сравниваете экземпляры. Это правильный выбор по умолчанию для подавляющего большинства чтений, и он естественно сочетается со скомпилированными запросами на горячих путях.
- Запросы, возвращающие каждую сущность ровно один раз. Плоский
context.Customers.Where(...)без ветвящегосяIncludeне может породить дубликат, поэтому identity resolution не сделает ничего, кроме добавления накладных расходов. То же самое верно, когда вы проецируете в анонимный тип или DTO, не содержащий экземпляров сущностей: там EF Core вообще не выполняет отслеживание, с оператором или без него. - Большие результаты, которые вы обрабатываете построчно в потоке. Когда вы итерируете с
IAsyncEnumerable<T>и отбрасываете каждый элемент после обработки, у вас никогда нет двух экземпляров одновременно, поэтому дедупликация ничего не даёт, а дополнительный трекер изменений — это чистые затраты. - Вы оптимизируете узкий путь чтения.
AsNoTracking— это нижняя граница. Если вы установили значение по умолчанию контекста вNoTracking, чтобы сделать каждое чтение дешёвым по умолчанию, оставляйте отдельные запросы на нём, если только какому-то конкретно не нужна identity resolution.
Когда выбирать AsNoTrackingWithIdentityResolution
Includeпо навигационному свойству-коллекции, где родители повторяются. Загрузка заказов с их клиентом или постов с их блогом заставляет одну и ту же родительскую строку возвращаться по разу на каждого ребёнка. Без identity resolution вы получаете отдельный объектCustomer/Blogна каждого ребёнка, что и тратит память, и ломает любой код, который проходит поorder.Customerв ожидании общего объекта. Это канонический случай, ради которого существует метод.- Ваш код полагается на равенство по ссылке или дедупликацию в памяти. Если вы строите
Dictionary<Blog, ...>с ключом по экземпляру, группируете по ссылке или меняете связанную сущность в памяти, ожидая, что все ссылки увидят изменение,AsNoTrackingмолча подведёт вас, потому что каждая “одинаковая” сущность — это другой объект. Identity resolution восстанавливает гарантию единственного экземпляра, которую вы получили бы от запроса с отслеживанием, без затрат на отслеживание. - Вы отключили отслеживание глобально, но всё ещё нуждаетесь в согласованном графе. Когда значение по умолчанию контекста —
NoTracking, а одному чтению нужен дедуплицированный граф объектов,AsNoTrackingWithIdentityResolution()— это подключение на уровне отдельного запроса. Вам не нужно откатываться до полного отслеживания, чтобы получить согласованный граф. - Вы столкнулись с декартовым взрывом и хотите меньше объектов в памяти.
Includeнескольких коллекций может резко умножить строки. Правильное основное решение обычно — разделение запроса, чтобы избежать декартова взрыва, но когда вы оставляете один запрос, identity resolution хотя бы сворачивает дублирующиеся родительские объекты до одного экземпляра каждый.
Бенчмарк
Это прогон 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; меньше — лучше. “Выделено” — это управляемая память, выделенная на операцию.
| Возвращено постов | Tracking | NoTracking | NoTrackingWithIdentityResolution |
|---|---|---|---|
| 1 000 | 6.8 ms | 3.1 ms | 3.5 ms |
| 10 000 | 71 ms | 27 ms | 31 ms |
| 100 000 | 980 ms | 295 ms | 360 ms |
| Возвращено постов | Выделено NoTracking | Выделено WithIdentityResolution |
|---|---|---|
| 10 000 | 9.4 MB | 7.1 MB |
| 100 000 | 96 MB | 58 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, применим здесь: придайте запросу форму так, чтобы база данных возвращала именно то, что вам нужно, а затем выберите самую дешёвую материализацию, корректную для того, как вы используете результат.
Связанное
- EF Core ExecuteUpdate vs загрузка сущностей и SaveChanges: что выбрать?
- Как использовать разделение запроса, чтобы избежать декартова взрыва в EF Core 11
- Как обнаружить запросы N+1 в EF Core 11
- Как использовать скомпилированные запросы с EF Core на горячих путях
- Как мокать DbContext, не ломая отслеживание изменений
- Fix: 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.