Эндпоинт на запросе LINQ куда медленнее своего SQL — найдите причину
Эндпоинт возвращает 50 заказов с их строками и работает около 900 мс, тогда как эквивалентный SQL, запущенный руками по той же базе, отрабатывает за 6 мс. Объём данных не вырос, больше ничего не менялось. Ниже — лог команд EF Core, снятый для одного вызова этого эндпоинта.
Executed DbCommand (4ms) [Parameters=[], CommandType='Text']
SELECT o."Id", o."CustomerId", o."CreatedAt" FROM "Orders" AS o LIMIT 50
Executed DbCommand (3ms) [Parameters=[@__p_0='1'], CommandType='Text']
SELECT l."Id", l."OrderId", l."Sku", l."Qty" FROM "Lines" AS l WHERE l."OrderId" = @__p_0
Executed DbCommand (3ms) [Parameters=[@__p_0='2'], CommandType='Text']
SELECT l."Id", l."OrderId", l."Sku", l."Qty" FROM "Lines" AS l WHERE l."OrderId" = @__p_0
... ещё 48 команд, отличающихся лишь @__p_0 = 3 .. 50 ...
Определите причину и скажите, как бы вы это исправили.
В логе один запрос за 50 заказов и затем по запросу на строки каждого заказа — 51 обращение к базе. Это N+1 из-за навигации, разрешаемой лениво внутри цикла; каждая команда быстрая, поэтому дело не в SQL, а в их количестве. Загрузите строки тем же запросом через Include или проекцию Select, отключите lazy loading и добавьте AsNoTracking, чтобы снять стоимость отслеживания на эндпоинте только для чтения.
- ✗Читать быстрые отдельные команды как доказательство здоровья запроса, игнорируя их количество
- ✗Винить отсутствующий индекс, когда время стоит именно число обращений к базе
- ✗Считать, что нетранслируемый
Whereтихо вычислится на клиенте, хотя с 3.0 он бросает исключение
- →Какую категорию логов или диагностику вы включите первой, чтобы это увидеть?
- →Сколько ещё стоит отслеживание, когда N+1 устранён, и как вы это измерите?
Что в логе
Executed DbCommand (4ms) SELECT ... FROM "Orders" AS o LIMIT 50
Executed DbCommand (3ms) SELECT ... FROM "Lines" AS l WHERE l."OrderId" = @__p_0 -- @__p_0 = 1
Executed DbCommand (3ms) SELECT ... FROM "Lines" AS l WHERE l."OrderId" = @__p_0 -- @__p_0 = 2
... ещё 48 таких же команд ...
Диагноз
Каждая команда сама по себе быстрая — 3-4 мс. Виновата не форма SQL, а их количество: один запрос за список заказов плюс по одному запросу на строки каждого из 50 заказов — 51 обращение к базе вместо одного. Это классический N+1.
Признак в логе однозначен: команды идентичны по форме и отличаются лишь параметром @__p_0, который пробегает идентификаторы родителей. Так выглядит навигация, разрешаемая лениво внутри цикла: код обходит заказы и на каждом трогает order.Lines, а прокси в этот момент идёт в базу.
Что это не является:
- ⚠️ Не отсутствующий индекс — при сканировании таблицы одна команда не укладывалась бы в 3 мс, а суммарное время всё равно определяется 51 круговым обходом.
- ⚠️ Не вычисление на клиенте — с версии 3.0
EF Coreбросает исключение на нетранслируемыйWhere, а не тихо тянет таблицу в память. - ⚠️ Не split query — он порождает фиксированное число запросов (по одному на коллекцию), а не по запросу на каждого родителя.
Исправление
// ❌ было: навигация разрешается лениво в цикле → 1 + 50 запросов
var orders = await db.Orders.Take(50).ToListAsync();
foreach (var o in orders)
total += o.Lines.Sum(l => l.Qty); // каждый доступ — поход в базу
// ✅ стало: связанные данные тянутся тем же запросом, без отслеживания
var orders = await db.Orders
.AsNoTracking() // снимаем снимок и identity resolution
.Include(o => o.Lines) // строки приходят одним обращением
.Take(50)
.ToListAsync();
Ещё дешевле — проекция только по нужным колонкам, без материализации сущностей:
var rows = await db.Orders
.AsNoTracking()
.Take(50)
.Select(o => new OrderView(o.Id, o.Lines.Sum(l => l.Qty)))
.ToListAsync();
И отключите прокси lazy loading в API — иначе N+1 вернётся при первом же новом цикле.