У меня есть несколько статей, в которых я рассказываю о различных особенностях работы коллекций в .NET, например эта и эта. Мы с вами разбирали, как лучше искать в коллекциях, спорили о преимуществах Any и Count в борьбе за тысячные доли секунды. Но все усилия, затраченные на эти микрооптимизации, могут пойти крахом из-за одного неудачного запроса в базу или другой сервис.
На этот раз я предлагаю рассмотреть проблему N+1 запроса.
Что это такое
Проблема пришла к нам из мира ORM, где lazy-loading легко может создать нам проблем:
// 1 запрос — получим список из N блогов var blogs = await _context.Blogs.ToListAsync(); foreach (var blog in blogs) { // Ещё N запросов, где мы считаем количество постов в каждом блоге Console.WriteLine(blog.Posts.Count); } // Всего 1+N запросов!
Но и без lazy-loading можно натворить делов:
public async Task<List<User>> GetUsers(Guid[] ids) { var list = new List<User>(); foreach (var id in ids) { var user = await _context.Users.FirstOrDefaultAsync(o => o.Id == id); if (user != null) { list.Add(user); } } return list; }
И проблемы, порожденные ORM мы решаем средствами ORM. Например, в Entity Framework (EF) используем метод Include:
// 1 запрос — получим список из N блогов и всех постов var blogs = await _context.Blogs.Include(o => o.Posts).ToListAsync(); foreach (var blog in blogs) { // Не будет лишних запросов Console.WriteLine(blog.Posts.Count); } // 1 запрос, чтобы выбрать все данные
Но такое решение имеет ограниченную применимость. Используя Include, мы говорим EF вобрать данные одним запросом, для чего он использует JOIN. А если представить, что у нас есть 20 блогов, а в каждом по 100 постов, то в результате получим выборку из 20 x 100 = 2000 строк, потому что будет дублирование родительской сущности для каждой дочерней. Хотя уникальных данных у нас всего на 120 строк. А представьте, что может быть, если мы добавим еще Include и выберем еще вложенные сущности?
var blogs = await _context.Blogs.Include(o => o.Posts).Include(b => b.Tags).ToListAsync();
Эта ситуация называется декартов взрыв (или картезианский взрыв) — когда количество строк в итоговой выборке растет лавинообразно с добавлением нового измерения через Include.
Для решения этой проблемы добавили новую возможность разделения на независимые запросы через метод AsSplitQuery:
// 2 запроса — получить список блогов и список всех постов var blogs = await _context.Blogs .Include(o => o.Posts) .AsSplitQuery() .ToListAsync(); foreach (var blog in blogs) { // Не будет лишних запросов Console.WriteLine(blog.Posts.Count); } // 2 запроса, чтобы выбрать все данные
В этом варианте не будет лишних JOIN, которые кратно увеличивают размер результата. Но будет дополнительный запрос, что скорее всего не проблема.
Но если нам нужна не вся информация из вложенных сущностей, то оптимальным будет сделать проекцию только необходимых данных:
// 1 запрос только с необходимыми данными var blogs = await _context.Blogs .Select(o => new { o.Id, o.Name, PostsCount = o.Posts.Count }).ToListAsync(); foreach (var blog in blogs) { // Не будет лишних запросов Console.WriteLine(blog.PostsCount); }
За пределами ORM
Описанное выше — частая ситуация при использовании ORM. За это их часто и не любят - слишком уж умные они и могут подставить на ровном месте. Или сильно ограничивают их использование за различными обертками, типа репозиториев. Мы можем написать код, который выберет нам только нужные данные оптимальным способом и не будет никаких проблем. Так ведь?
Ну конечно же нет. Независимо от того, используем мы ORM или пишем голые SQL-запросы, мы всегда можем нарваться на проблему N+1 запроса. Если совсем упростить, то выглядит это примерно так:
public void ProcessOrders(IEnumerable<int> orderIds) { foreach (var id in orderIds) { // N запросов на каждый заказ var order = _orderRepository.GetById(id); Process(order); } }
Совершенно не важно, какую инфраструктуру мы используем для доступа к данным — проблема, очевидно, не в ней, а бизнес-логике, которая её использует.
И здесь в большинстве случаев корректным решением будет реализация batch-запроса:
public void ProcessOrders(IEnumerable<int> orderIds) { // 1 запрос на все заказы var orders = _orderRepository.GetByIds(orderIds); foreach (var order in orders) { Process(order); } }
Один раз получить все необходимые данные в память и работать с ними.
Эта проблема меня давно беспокоит, поскольку, как я писал в начале, один такой запрос стоит тысячи небольших оптимизаций. Хотелось автоматизировать поиск подобных проблем, потому что такие сценарии ещё проще пропустить на ревью — контекст распределён по нескольким классам и файлам. Но, тем не менее, мне удалось реализовать достаточно качественный поиск таких паттернов, который я добавил в свою диагностику CI0011: Potential N+1 Query Problem.
Я тестировал её на нескольких реальных проектах Контура и я хочу поделиться примерами с вами.
Нестареющая классика
Этот и подобные ему примеры натолкнули меня на реализацию. Уж слишком часто они попадаются. Не подумайте, я не хочу обвинить разработчиков в лени, некомпетентности. Я только хочу показать, что тут может быть проблема. А даже если её нет, то мы можем сделать ту же работу меньшими усилиями, сэкономив память, время.
Это классический пример, от которого я отталкивался — у нас есть какой-то список идентификаторов и мы запрашиваем данные поодиночке.
public void Execute() { foreach (var formId in formWithDisabledRCsDataReader.Read().ToArray()) { var form = formReader.Read<CabinetReqForm>(formId); SendNotification(form); } }
Обновляем по одному
Ещё один наглядный пример, когда нам нужно что-то сделать со списком сущностей. Берём их поочередно из хранилища, как-то проверяем и обновляем.
foreach (var accountId in searchResult.Value.Accounts.Select(it => it.Id)) { var account = await this.accountsRepository.GetAsync(accountId, token); var isConfigured = await this.IsAccountConfiguredAsync(account, token); await this.accountsRepository.UpdateIsConfiguredAsync(accountId, isConfigured, actionInfo, token); }
А что будет, если мы обработаем не все аккаунты в одной операции? Допустимо ли это? Здесь напрашивается batch-операция.
LINQ скрывает сложность
LINQ — мощный инструмент и помощник .NET-разработчику. Он значительно упрощает нам жизнь и ускоряет разработку. Но, как и всегда, нужно быть внимательным:
public FileMeta[] GetFileMetas(Guid[] fileIds) { return fileIds.Select(fileId => { var fileReference = cryptoRepository.Read<FileReference>(fileId); return cryptoRepository.Read<FileMeta>(fileReference.FileMetaId); }).ToArray(); }
Мы не видим здесь циклов в явном виде, всё выглядит достойно, пока мы не заглянем внутрь lambda-выражения, в котором несколько раз обращаемся к двум репозиториям, чтобы собрать список файлов.
Кручу, верчу…
private IEnumerable<Guid> ParseRCsFromPartnerRoles(IEnumerable<string> partnerRoles) => partnerRoles .Select(roles => roles.Split('@')[0]) .Distinct() .Select(code => rcHandler.Find(code)) .Where(rc => rc != null) .Select(rc => rc.Id) .ToArray();
Заметили потенциальную проблему за обилием LINQ-методов?
private bool WasVerifiedByOperator(File file, IEnumerable<DocumentVerificationInfo> documentVerifications) => documentVerifications .Where(v => v.OperatorTaskId.HasValue) .Select(v => v.OperatorTaskId.Value) .Select(taskId => operatorTaskHandler.Read(taskId)) .Where(task => task.DocType == file.DocType) .Select(task => task.VerifiableFileId) .Select(fileId => fileReader.ReadIncludingDeleted(fileId)) .Any(f => f.FileDescriptorId == file.FileDescriptorId);
А тут? Такой код можно быстро написать, и он будет делать ровно то, что нужно. Но в любой момент это может стать проблемой. А еще он заканчивается методом Any, который благодаря своей ленивости будет перебирать данные до первого совпадания и в одних случая может закончиться после одного запроса, а вдругих — после сотни.
Как работает диагностика
Я попытался воплотить в этой диагностике тот же подход, что и сам использую на ревью:
Находим использование цикла или сложного LINQ-выражения.
Находим использование переменной цикла внутри тела. Например, переменной
fileиз примера выше.Находим использование этой переменной внутри определённых методов и классов, которые похожи на хранилища. Сейчас это методы, которые начинаются со слов
Read,Find,Get,TryRead,TryGet,TryFindв классах (или интерфейсах), которые заканчиваются наRepository,Reader,Writer,Handler— это наши кандидаты.Отсеиваем методы, которые содержат в своём названии слова
Batch,Bulk,Rangeили принимают коллекцию как параметр.И по оставшимся методам генерируем диагностику с уровнем
Warn. С очень большой вероятностью мы столкнулись с потенциальной проблемой N+1 запроса в хранилище.
А не слишком ли много условностей?
Может показаться, что диагностика недостаточно хороша, т.к. опирается на какие-то магические строки и найдёт мне только вызов fileRepository.Get, а вот fileManager.Fetch явно пропустит. Но нужно вспомнить, что мы сильно повысили уровень абстракции. Такие понятия, как репозиторий, хранилище — это не конструкции языка. .NET ничего не знает о том, что скрывается за этими названиями, и только мы, разработчики, придаём им смысл. Поэтому, внедрение такой абстрактной диагностики может потребовать кастомизации. И у вас есть такая возможность.
Например, вы можете переопределить список суффиксов для методов, которые хотите анализировать, и добавить туда, в том числе, обновление данных. Для этого нужно добавить в .editorconfig следующее:
dotnet_diagnostic.CI0011.data_access_method_prefixes = Get, Fetch, Update, Delete
Включить обработку не только репозиториев, но и каких-то клиентов:
dotnet_diagnostic.CI0011.data_access_type_substrings = Repository, SpamClient, MyServiceClient
Ну и можно завести свой список для batch-методов, чтобы исключить ложные срабатывания:
dotnet_diagnostic.CI0011.bulk_method_substrings = Batch, Bulk, Multi, Collection
Также по умолчанию отключена обработка тестовых методов. Но и её можно включить:
dotnet_diagnostic.CI0011.analyze_test_methods = true
Думаю, таких возможностей хватит для большинства проектов.
А как же автоматизация?
К сожалению, такого рода проблемы не исправляются механической заменой одного метода на другой. Нового метода может вообще не существовать или требуется более тщательный рефакторинг, поэтому автоматических исправлений тут нет. Но мой опыт мне говорит, что 90% случаев достаточно просто разрешаются.
Если понравилось
Библиотека с диагностиками выложена в Nuget. Актуальная версия — 0.3.1.
Можно подключить целиком на все проекты, положив в корень файл Directory.build.props с содержимым:
<Project> <ItemGroup> <PackageReference Include="Collections.Analyzer" Version="0.3.1" /> </ItemGroup> </Project>
Всего же вам будет доступно 11 диагностик, уровнем важности которых (severity) можно управлять через .editorconfig:
[*.cs] dotnet_diagnostic.CI0010.severity = none dotnet_diagnostic.CI0011.severity = suggestion
Я постарался сделать диагностики максимально полезными, без лишнего шума. Но шум всё равно может быть из-за каких-то особенностей проекта. Точечно подавить диагностику можно через команды компилятору:
#pragma warning disable CI0011 var rcUserPermissions = cachedRCUserPermissionHandler.GetAvailableRCIds(rcUser.Id) #pragma warning restore CI0011
Комментарии (4)

Kyoki
27.08.2026 06:56Один раз получить все необходимые данные в память и работать с ними.
Без оговорок плохой совет. А если все необходимые данные это несколько гигабайт? А если данные в конце списка могут устареть пока вы начало обрабатываете? А если ...

srogatnev Автор
27.08.2026 06:56Конечно, при должном усердии можно придумать еще пару десятков условии, на котором это всё сломается. Поэтому важно учитывать контекст. Контекст конкретного примера, конкретного приложения и ситуации. Т.е. где-то руководствоваться здравым смыслом. И если совет приводит к тому, что приложение грузит гигабайты данных в память, то он вам очевидно не подходит. Мне как-то не хочется добавлять все эти условности и дисклеймеры перед каждым абзацем.
Верю, что разработчики смогут корректно трактовать эти советы.
Gromilo
Проблема понятная, а как красиво объединять коллекции?
Я как не пробовал, а в итоге всё равно получается "загружаем словарь и таскам по маперам".
Типичная реализация: получаем нужные ид (может быть больше 1 истончика), загружаем данные, складываем в словарь, используем в мапинге.
Типа такого
Делал что-то GraphQL подобное, но оказалось слишком сложно для использования и проще словари таскать :(
srogatnev Автор
Понятие "красиво" довольно субъективно. Наверное, c точки зрения какой-то лаконичности кода это выглядит не так изящно, как несколько LINQ-методов вызванных один за другим. Но с точки зрения читабельности и надежности - это для меня очень хорошее решение: в нем нет сложных запросов, нет непредсказуемых join-ов на стороне БД. Так что для меня такой код будет красивым. Там, как и всегда, могут получиться какие-то крайние случаи, когда в метода маппинга нужно добавить 10 словарей. Но это не массовое явление обычно.