Код с Entity Framework Core выглядит прекрасно: запросы читаются как обычные предложения, связки между таблицами описаны навигационными свойствами, а SQL среда генерирует сама. На машине разработчика с базой из двух десятков строк всё летает. На продакшене с миллионом записей та же страница вдруг отдаёт ответ за десять секунд, а SQL сервер держит загрузку процессора у ста процентов. Классическая причина в таких историях не медленная база, а количество запросов, которые приложение честно отправляет по одному. Разбор ниже о том, где элегантность LINQ превращается в проблему N+1, почему lazy loading в EF Core выключен по умолчанию и какие приёмы возвращают странице нормальную скорость.
Элегантный LINQ в коде и его скрытая цена в SQL
Красота начинается с честного примера. Выборка новых заказов с номером и суммой читается без единого комментария:
var orders = await db.Orders
.Where(o => o.Status == OrderStatus.New)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderListItem
{
Number = o.Number,
Total = o.Lines.Sum(l => l.Price * l.Quantity)
})
.ToListAsync();
Среда превращает конструкцию в один SQL запрос с фильтром, сортировкой и агрегатной функцией. LINQ строится деревьями выражений, дерево переводится провайдером в SQL, и рука разработчика не опускается ниже уровня языка. Ловушка в другом: LINQ выглядит как код, а работает как удалённый вызов. Каждое обращение к навигационному свойству, не попавшему в SQL, означает новый визит к базе. Отложенное исполнение добавляет скрытности: запрос молчит до материализации, а навигационные свойства молчат ещё дольше.
История умолчаний здесь поучительна. До EF Core 3.0 провайдер мог тихо исполнить часть выражения в памяти, если не справлялся с переводом в SQL, запрос оставался рабочим, но растолстевшим. Начиная с 3.0 неявная клиентская оценка запрещена, неподдерживаемый фрагмент выражения бросает исключение при сборке запроса. Границу перевода задают явно: методы до AsEnumerable уезжают в SQL, всё после исполняется в памяти процесса.
Проблема N+1 запросов на живом примере цикла
Дальше происходит классика жанра. На экран нужен список заказов с именами клиентов, и код печатается почти сам:
foreach (var order in orders)
{
Console.WriteLine($"{order.Number}: {order.Customer.Name}");
}
Если навигационное свойство Customer не загружено заранее, включённый lazy loading молча подставляет запрос при первом обращении. Цикл на сто заказов посылает к базе сто один SELECT: один за списком, по одному за каждый заказ. На локальной машине с миллисекундной задержкой это незаметно, сто лишних кругов стоят сто миллисекунд. На продакшене с сетевой задержкой в пять миллисекунд и конкурентной нагрузкой те же круги стоят уже полсекунды чистого ожидания, плюс лишние планы выполнения и давление на пул соединений. Красота LINQ сохраняется, счёт выставляется отдельно.
В журнале продакшена проблема видна как повтор:
SELECT o.Number, o.CustomerId FROM Orders AS o WHERE o.Status = 2
SELECT c.* FROM Customers AS c WHERE c.Id = 42
SELECT c.* FROM Customers AS c WHERE c.Id = 43
SELECT c.* FROM Customers AS c WHERE c.Id = 44
Одна форма строки, растущее число повторов. Каждая строка журнала это круг по сети, разбор команды и лишний визит в пул соединений.
Тот же эффект легко получить и без lazy loading: цикл с обращением к GetByIdAsync внутри тела тоже честно называется N+1, только автор писал его руками. Лечится такой код одинаково: загрузка связей заранее или переход на проекцию целиком.
Есть и третья разновидность, самая коварная: сериализатор. Формировщик JSON ответа обегает навигационные свойства объекта, включённый lazy loading подтягивает по клиенту на каждый заказ, а при глубокой цепочке и по заказу на каждого клиента. Один эндпоинт списка разворачивается в двести запросов, и ни один цикл в коде этого не показывает.
Lazy loading в EF Core и его выключенное по умолчанию состояние
В Entity Framework 6 ленивая загрузка работала из коробки: навигационное свойство с ключевым словом virtual подхватывало связанные записи автоматически. Команда EF Core сознательно сменила умолчание: начиная с версии 2.1 lazy loading включается явно, отдельным пакетом и одной настройкой контекста, а рекомендуемым путём остаётся жадная загрузка:
var orders = await context.Orders
.Include(o => o.Customer)
.AsNoTracking()
.ToListAsync();
Причин выключенного состояния две. Первая: ленивая загрузка делает круги к базе невидимыми, код выглядит одинаково и при загрузке всего списка, и при ста запросах. Вторая: DbContext не потокобезопасен и живёт недолго, обращение к навигационному свойству после освобождения контекста падает исключением, а объект-обёртка может тихо держать ссылку на давно закрытый контекст. Альтернатива, внедряемый загрузчик ILazyLoader, чуть честнее, но проблему невидимых кругов не снимает. Выглядит внедряемый загрузчик так:
public class Order
{
private ILazyLoader _loader;
private Customer _customer;
public Customer Customer
{
get => _loader.Load(this, ref _customer);
set => _customer = value;
}
}
Виртуальность свойств не нужна, сущность управляет загрузкой сама, но круги к базе остаются такими же невидимыми, поэтому в код ревью сочетание lazy loading и сериализации в одном эндпоинте ловят первым.
Include против проекции и выборка только нужных колонок
Первый ответ на N+1 известен каждому. Include добавляет связанные сущности в тот же SQL запрос через соединение:
var orders = await db.Orders
.Include(o => o.Customer)
.ToListAsync();
У Include есть своя слабость. Если у заказа две коллекции, линии и теги, два Include умножают строки: каждая строка повторяется для каждой комбинации связей, и сто заказов по пять линий превращаются в раздутый межстрочный повтор из сотен комбинаций. С EF Core 5 появился AsSplitQuery, который разбивает выборку на несколько SQL запросов вместо одного гигантского соединения:
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Lines).ThenInclude(l => l.Part)
.AsSplitQuery()
.ToListAsync();
Разделённые запросы решают умножение строк, но добавляют оговорку: между отдельными SQL запросами данные могут измениться, для строгой согласованности нужен уровень изоляции или одна выборка. Для экранов чтения есть путь ещё лучше, проекция. Выборка через Select забирает только нужные колонки, без создания отслеживаемых сущностей и без лишних соединений:
var rows = await db.Orders
.Where(o => o.Status == OrderStatus.New)
.Select(o => new OrderRow
{
Number = o.Number,
CustomerName = o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity)
})
.ToListAsync();
Один запрос, три колонки, ноль отслеживания. Правило простое: Include нужен там, где сущности пойдут в изменение, проекция там, где их покажут. Загруженные через Include сущности попадают в отслеживание, и правки уходят в базу при SaveChangesAsync:
var order = await db.Orders
.Include(o => o.Lines)
.FirstAsync(o => o.Id == id);
order.Status = OrderStatus.Shipped;
await db.SaveChangesAsync();
Отслеживание стоит памяти и сравнения снимков, поэтому на экранах без изменения его отключают.
Один запрос вместо сотен при помощи Contains и пакетных операций
Не всякий N+1 лечится Include. Если заказы уже получены из кеша или шины, а имена клиентов нужны из базы, правильный ход один: собрать идентификаторы и сходить за ними одним визитом:
var customerIds = orders.Select(o => o.CustomerId).Distinct().ToList();
var customers = await db.Customers
.Where(c => customerIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
Среда переводит Contains в оператор IN, и вместо ста запросов летит один. Для массовых изменений с EF Core 7 доступны ExecuteUpdate и ExecuteDelete, которые заменяют цикл из тысячи построчных команд одной пакетной операцией:
await db.Orders
.Where(o => o.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync();
Отчёты считают в базе, а не в памяти. Группировка с агрегатом переводится в SQL целиком:
var totals = await db.Orders
.GroupBy(o => o.Customer.Name)
.Select(g => new { Name = g.Key, Amount = g.Sum(o => o.Total) })
.ToListAsync();
Попытка материализовать список и группировать его на клиенте даёт тот же N+1 по смыслу, только с лишним перекачиванием таблицы. Горячие повторяющиеся запросы дополнительно убираются скомпилированными запросами через EF.CompileAsyncQuery, дерево выражений строится один раз и кешируется:
var newOrderNumbers = EF.CompileAsyncQuery(
(AppDbContext ctx) => ctx.Orders
.Where(o => o.Status == OrderStatus.New)
.Select(o => o.Number));
Наблюдение за SQL от ToQueryString до перехватчика команд
Понять, что страница разговаривает с базой слишком много, помогают встроенные механизмы. Метод расширения ToQueryString печатает готовый SQL до выполнения запроса:
var query = db.Orders.Where(o => o.Status == OrderStatus.New);
Console.WriteLine(query.ToQueryString());
В продакшене строят перехватчик команд, который считает визиты к базе в пределах одного HTTP запроса:
public class QueryCounterInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
Counter.Increment(eventData.Context.ContextId);
return base.ReaderExecuting(command, eventData, result);
}
}
Строка в логе вида "запрос к заказам, 47 обращений за запрос" экономит недели догадок, потому что стреляет в момент инцидента, а не на код ревью. Грубую картину даёт и обычный журнал: категория DbLoggerCategory.Database.Command пишет текст каждой команды, фильтр по ней в тестовом прогоне мгновенно показывает повторяющиеся формы. Ещё один производственный сигнал, тихий рост времени ответа p95 при росте таблицы без изменений в коде: объём множит цену каждого лишнего круга. Логирование SQL включается одной настройкой при регистрации контекста, а отдельный флаг показывает параметры запросов и потому годится только для тестового окружения.
Правила гигиены DbContext под реальной нагрузкой
Несколько привычек держат слой данных быстрым без подвигов. Чтение без изменения сопровождается AsNoTracking или проекцией. Контекст живёт коротко, в границах одного HTTP запроса, и никогда не делится между потоками. Списки всегда пагинируются с явной сортировкой, без OrderBy пропуск строк через Skip непредсказуем:
var page = await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Skip(offset).Take(50)
.ToListAsync();
Пул контекстов переиспользует настроенные экземпляры вместо сборки нового на каждый запрос:
builder.Services.AddDbContextPool<AppDbContext>(
options => options.UseSqlServer(connectionString));
Фоновые задачи берут фабрику контекстов AddDbContextFactory и создают короткоживущий экземпляр на порцию работы, а таймаут тяжёлого отчёта задают явно, стандартные тридцать секунд SqlClient подходят не всем сценариям: