awesome-everything EN
↑ Обратно к восхождению

Производительность

N+1: одна логическая операция, много round-trip''''ов

Суть Один экран рендерится в 50 запросов к БД, потому что ORM загружает каждую связанную строку по требованию. Фикс — не быстрее запрос, а меньше запросов.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 10 min

Страница медленная — 800 мс. CPU flame graph в порядке, широких функций нет. Свен открывает DB query log и считает 51 запрос на один page load. Профайлер не врал; bottleneck не в одной функции. Он распределён по 51 маленькому round-trip’у.

Что такое N+1

Пользователь просит «покажи список заказов с именами клиентов». ORM выполняет один запрос на orders, потом по одному на каждый order для fetch клиента — 1 + N запросов. На 50 заказах — 51 DB round-trip на один рендер.

Каждый round-trip платит сетевой latency (1–5 мс intra-DC, 10–50 мс cross-region) плюс query-overhead. Страница, которая должна делаться за 20 мс, делается за 500 мс — и инженер не найдёт проблему в CPU-профиле, потому что цена распределена по многим быстрым вызовам. Ни одна функция не медленная.

Почему ORM производит N+1 по умолчанию

Object-relational mapper’ы выставляют модель, где каждый объект имеет relationships, доступные как обычные свойства. order.customer читается как обычный атрибут; под капотом ORM триггерит запрос, если customer ещё не загружен. Это называется lazy loading — удобно, не нужно объявлять заранее, что нужно, — но это источник N+1.

Цикл по 50 заказам, чтение order.customer.name на каждом — ORM стреляет 50 запросами без того, чтобы ты написал 50 запросов.

Метафора кладовой

Готовишь пасту, нужны чеснок, масло, соль и базилик:

  • Вариант A (batch): пойти в кладовую один раз, взять все четыре, вернуться. Один trip.
  • Вариант B (N+1): пойти за чесноком, вернуться; пойти за маслом, вернуться; пойти за солью, вернуться; пойти за базиликом, вернуться. Четыре trip’а.

Та же работа — четыре trip’а против одного. Паттерн N+1 — вариант B. Фикс — вариант A: сократить прогулки.

Единица цены — round-trip: время от момента, когда код просит данные, до момента, когда данные в руках. RTT, query parse, planner overhead, lock acquisition, result encoding — всё платится за каждый round-trip. Один запрос на 100 строк платит раз; 100 запросов платят 100 раз.

Конкретный пример

// Код ORM, который выглядит безобидно:
const orders = await Order.findAll({ where: { userId } });
for (const order of orders) {
  const customer = await order.getCustomer(); // один запрос на каждый order
  render(order, customer);
}

// Query log:
// SELECT * FROM orders WHERE user_id = 42 LIMIT 50      — 1 запрос
// SELECT * FROM customers WHERE id = 1                   — 1 запрос
// SELECT * FROM customers WHERE id = 2                   — 1 запрос
// ...
// SELECT * FROM customers WHERE id = 50                  — 1 запрос
// Итого: 51 запрос × ~15 мс RTT = 765 мс

Фикс в Prisma и большинстве ORM — одно слово, include:

const orders = await prisma.order.findMany({
  where: { userId },
  include: { customer: true },
});
// Теперь: 2 запроса — один на orders, один на всех customers через IN (...)
// Итого: ~20 мс
ПодходЗапросыВремя (intra-DC, 15 мс RTT)
Lazy (N+1)51~765 мс
Eager / include2~30 мс

Вложенный N+1: проблема множится

Дашборд рендерит команды → проекты → участников. Наивный ORM:

  • 1 запрос на команды
  • N запросов на проекты (один на команду)
  • N×M запросов на участников (один на проект)

С 10 командами × 5 проектов × 8 участников: 1 + 10 + 50 = 61 запрос. Фикс nested eager loading: 3 запроса. Рендер падает с 1,5 с до 80 мс.

Почему это работает

Имя N+1 отражает worst case: 1 запрос на parent-коллекцию плюс 1 на parent = 1 + N. Термин вошёл в мейнстрим около 2003–2005 с подъёмом ORM вроде Active Record (Rails 2004) и Hibernate (Java). Форма повторяется в каждом новом data-access framework’е: Prisma добавил include в 2020, каждый современный ORM поставляет его.

Викторина

Страница медленная, но CPU-профиль не показывает широких функций. DB query log показывает сотни маленьких запросов. Самая вероятная причина?

Викторина

Почему получение 100 строк ОДНИМ запросом бьёт получение 100 строк 100 отдельными запросами?

Расставь шаги по порядку

Расставь четыре частых паттерна фикса N+1 от простейшего к самому гибкому:

  1. 1 JOIN — забрать parent и child одним запросом через SQL join
  2. 2 IN (...) — первый запрос даёт parent ID, второй фетчит всех children через WHERE child_id IN (parents)
  3. 3 Eager loading / preload — директива ORM, превращающая lazy-доступ в один batch-запрос
  4. 4 Batch loader (DataLoader) — собрать все child ID по всему запросу, выстрелить один батч, когда event loop yield'ит
Закончи аналогию

Вставь пропуск: паттерн N+1 — это N+1 _______ — каждый сам по себе быстрый, но их сумма и делает страницу медленной.

Вспомните перед уходом
  1. 01
    В одном абзаце: объясни, почему N+1 трудно найти CPU-профилем и куда смотреть вместо него.
  2. 02
    Что такое lazy loading и почему он производит N+1 по умолчанию?
Итог

Проблема N+1 превращает одну логическую операцию в 1 + N DB round-trip’ов, загружая каждую связанную строку лениво. На 50 элементах — 51 запрос; при 15 мс RTT каждый — 765 мс вместо 30 мс. Корень — lazy loading, дефолт ORM, триггерящий запрос при каждом обращении к связанному свойству. Фикс структурный: использовать eager-load директиву (Rails .includes, Django .select_related, SQLAlchemy selectinload, Prisma include), чтобы сказать ORM, что нужно заранее. CPU-профиль не покажет этот bottleneck; query log покажет.

Связанные уроки
встречается в159
Продолжить восхождение ↑Семейства фиксов: JOIN, IN, preload и DataLoader
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.