Суть Читай реальные ORM-циклы, batch-функцию DataLoader, query log и fan-out-хендлер — предскажи число round-trip и выбери фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
N+1 диагностируется чтением кода и логов, а не догадками. Прочитай сниппет или трейс, посчитай round-trip’ы, которые он выпустит, затем выбери фикс, к которому сениор тянется первым.
Цель
Отработать цикл, который ты гоняешь в каждом инциденте N+1: прочитать hot path, посчитать round-trip’ы и взять структурный фикс под форму данных — до того, как трогать индексы или железо.
Сниппет 1 — невинный цикл
const orders = await Order.findAll({ where: { userId } }); // 50 строкfor (const order of orders) { const customer = await order.getCustomer(); // один запрос на order render(order, customer.name);}
Викторина
Completed
На 50 заказах сколько запросов это выпустит, и какой единственный фикс с наибольшим рычагом?
Heads-up Lazy getCustomer() внутри цикла выпускает запрос на каждой итерации; ORM не батчит, если не объявить relationship заранее через include/preload.
Heads-up Индекс ускоряет каждый lookup, но ты всё равно платишь 50 round-trip'ов latency. Фикс — меньше trip'ов через один IN-запрос, а не быстрее отдельные.
Heads-up Отсрочка рендера ничего не меняет в моменте выпуска запросов. 50 customer-запросов триггерятся lazy-доступом внутри цикла независимо от тайминга рендера.
Этот DataLoader батчит корректно, но имеет баг корректности, который вскроет load test. В чём он?
Heads-up IN-запрос в порядке, и DataLoader сериализует батч. Дефект — контракт порядка результатов: выходы должны позиционно совпадать с входными ids.
Heads-up Наоборот — loader'ы на уровне модуля утекают кэш между request'ами. Баг в ЭТОМ сниппете — неупорядоченный возврат, а не scope создания.
Heads-up load() возвращает Promise, который фреймворк резолверов await'ит; возврат его из резолвера корректен. Баг — неверно упорядоченный результат батча.
Сниппет 3 — query log
Started GET "/dashboard"User Load (0.4ms) SELECT * FROM users WHERE id = 42Project Load (0.6ms) SELECT * FROM projects WHERE user_id = 42 LIMIT 50Task Load (0.3ms) SELECT * FROM tasks WHERE project_id = 1Task Load (0.3ms) SELECT * FROM tasks WHERE project_id = 2... (ещё 48 строк Task Load)Comment Load (0.2ms) SELECT * FROM comments WHERE task_id = 1... (ещё 243 строки Comment Load)Completed 200 OK in 980ms (Views: 250ms | ActiveRecord: 689ms)
Викторина
Completed
Читая этот лог, что происходит и какой точный фикс?
Heads-up 0.2 мс на запрос — быстро. 689 мс — это сумма ~300 round-trip'ов, а не медленные отдельные запросы. Фикс — меньше запросов через вложенный eager loading.
Heads-up Views — 250 мс, но ActiveRecord — 689 мс на 300+ запросах, доминирующая цена. Рычаг — убить вложенный N+1, а не тюнинг шаблонов.
Heads-up Уровней два: tasks на project И comments на task. includes(:tasks) сам по себе оставляет ~245 comment-запросов; нужен includes(tasks: :comments).
Эти четыре вызова сервисов независимы (ни один не потребляет результат другого). Какой wall-clock сейчас и какой фикс?
Heads-up Последовательный await блокирует каждый вызов до резолва предыдущего. Они идут последовательно на ~120 мс; надо диспетчерить их вместе через Promise.all, чтобы перекрыть.
Heads-up Это четыре разных сервиса без общего backend для батчинга. Фикс для независимых удалённых вызовов — параллельный dispatch, а не батчинг запросов.
Heads-up Скорость протокола даёт инкрементальный выигрыш; вызовы остаются последовательными. Параллелизация схлопывает sum(latencies) в max(latencies) — структурный фикс.
Итог
Каждый N+1 читается в коде или логах: lazy relation внутри цикла — это 1+N запросов, фиксится eager loading’ом; batch-функция DataLoader обязана вернуть результаты, позиционно выровненные по входным ids; query log с повторяющимися похожими SELECT’ами вскрывает вложенные уровни N+1, фиксящиеся вложенным preload; а независимые последовательные вызовы сервисов схлопываются с sum в max через параллельный dispatch. Сначала посчитай round-trip’ы, подбери фикс под форму данных, затем перечитай лог, чтобы подтвердить падение счётчика.