<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SQL · MIDDLE

Як побачити N+1 з боку бази і чим його лікувати без ORM?

З боку бази N+1 виглядає не як повільний запит, а як один нормалізований запит із величезним calls і мікроскопічним mean_exec_time; лікується збором ключів і другим запитом з IN або = ANY, а JOIN підходить лише там, де він не множить рядки.

Сторінка віддається 400 мс, а найповільніший запит у логах 0.4 мс. Куди подівся час?
У вас немає Debugbar і доступу до коду застосунку. Як довести, що там N+1?
Коли краще один JOIN, а коли два запити з IN?
Чи бувають ситуації, коли N+1 можна залишити як є?
N+1 pg_stat_statements JOIN performance

База не знає слова «N+1». Вона бачить дві сотні коректних запитів по первинному ключу, кожен по 0.1 мс, і жоден з них не потрапить у slow log з типовим long_query_time = 10. Час витрачається не всередині запитів, а між ними: парсинг, планування, мережевий round-trip, обробка результату в драйвері. Двадцять дві мілісекунди сумарного total_exec_time легко перетворюються на 300 мс стінного часу. Розбіжність між «база швидка» і «сторінка гальмує» і є головним симптомом.

У PostgreSQL найдешевше побачити це через розширення pg_stat_statements, яке нормалізує константи до $1 і рахує однакові запити разом. Сортуйте за calls, а не за total_exec_time, і кандидати спливуть одразу: у N+1 співвідношення rows / calls дорівнює одиниці, mean_exec_time мікроскопічний, а calls на два порядки більший, ніж у сусідів. Коли треба прицільно розібрати конкретний сценарій на стенді, вмикайте log_min_duration_statement = 0 на сесію або на роль через ALTER ROLE ... SET, не чіпаючи весь сервер, плюс auto_explain для планів. У MySQL ту саму роль виконує performance_schema.events_statements_summary_by_digest з колонками DIGEST_TEXT і COUNT_STAR, а для повного розбору беруть slow log з long_query_time = 0 і pt-query-digest зверху.

Лікування без жодного ORM повторює те, що всередині робить eager loading. Спершу читаємо батьківські рядки, збираємо унікальні ключі й виконуємо один додатковий SELECT по всьому набору, а зшиваємо результат у PHP через мапу id => рядок. Ключі краще передавати одним параметром-масивом: WHERE id = ANY($1::bigint[]) має незмінний текст запиту, тоді як IN ($1, $2, ..., $57) породжує окремий план і окремий запис у статистиці на кожну довжину списку. У MySQL масиву немає, тому список ріжуть на чанки, тримаючи в голові max_allowed_packet; три запити замість двохсот дають майже весь виграш.

JOIN виглядає елегантнішим рішенням, але годиться не скрізь. Для звʼязку many-to-one (пост і його автор) він ідеальний: один round-trip, дублюється лише кілька колонок автора. У one-to-many кожен батьківський рядок розмножується на кількість дочірніх, і всі його колонки їдуть по мережі стільки ж разів. Приєднайте дві колекції одночасно, і вийде декартів добуток: 10 коментарів на 5 тегів дають 50 рядків на один пост. Тут рятує або окремий запит на кожну колекцію, або агрегація на боці бази через json_agg у PostgreSQL чи JSON_ARRAYAGG у MySQL 5.7+, щоб пост залишався одним рядком. Окремо стоїть «останні три коментарі до кожного поста»: у PostgreSQL це акуратно закриває LEFT JOIN LATERAL з LIMIT усередині, тоді як через ORM вироджується в чесний N+1.

Залишається питання, коли не чіпати. Арифметика проста: вартість дорівнює кількості запитів, помноженій на RTT, плюс час виконання. На локальному сокеті двадцять точкових вибірок по ключу коштують одиниці мілісекунд, і переписувати робочий код заради них немає сенсу, особливо якщо частина значень приходить з кешу. З базою в іншій зоні доступності при RTT 2 мс ті самі двадцять запитів перетворюються на 40 мс, а двісті роблять сторінку непридатною. Буває й зворотний ефект: батч по великому списку ключів іноді змушує планувальник відмовитися від індексу на користь seq scan, і сотня точкових запитів виявляється швидшою. Тому висновок роблять за EXPLAIN (ANALYZE, BUFFERS) і виміряним часом сторінки, а не за самим фактом циклу в коді.

-- 1. Діагностика: шукаємо не повільний запит, а частий
SELECT calls,
       rows / calls                          AS rows_per_call,
       round(mean_exec_time::numeric, 3)     AS mean_ms,
       round(total_exec_time::numeric, 1)    AS total_ms,
       left(query, 60)                       AS query
FROM pg_stat_statements          -- колонки *_exec_time зʼявилися в PostgreSQL 13
ORDER BY calls DESC
LIMIT 5;
-- calls = 200, rows_per_call = 1, mean_ms = 0.11, query = SELECT ... FROM authors WHERE id = $1
-- 22 мс сумарно в базі, але 200 round-trip'ів у застосунку

-- 2. Лікування: два запити замість 1 + N. Спершу батьки
SELECT id, title, author_id
FROM posts
ORDER BY published_at DESC
LIMIT 20;

-- Потім один запит по зібраних унікальних ключах.
-- Масив параметром, а не IN зі змінною кількістю плейсхолдерів
SELECT id, name
FROM authors
WHERE id = ANY($1::bigint[]);
-- у PHP: $byId = array_column($authors, null, 'id'); далі $byId[$post['author_id']]

-- 3. Для many-to-one JOIN дешевший: один round-trip, дублювання лише в name
SELECT p.id, p.title, a.name
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.published_at DESC
LIMIT 20;

-- 4. Для one-to-many JOIN множить рядки, тому агрегуємо в базі
SELECT p.id,
       coalesce(json_agg(json_build_object('id', c.id, 'body', c.body))
                FILTER (WHERE c.id IS NOT NULL), '[]') AS comments
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.id = ANY($1::bigint[])
GROUP BY p.id;
Що N+1 не потрапляє в slow log: кожен окремий запит швидкий, проблема в кількості round-trip'ів, помноженій на RTT.
Конкретні інструменти: pg_stat_statements із сортуванням за calls і співвідношенням rows/calls, log_min_duration_statement = 0 на стенді, auto_explain; у MySQL performance_schema.events_statements_summary_by_digest або slow log з long_query_time = 0 і pt-query-digest.
Що eager loading в ORM це рівно два запити з IN, і те саме робиться руками: зібрати унікальні ключі, один SELECT ... WHERE id = ANY($1), зшити в мапу на боці застосунку.
Розуміння, чому JOIN не універсальна заміна: для one-to-many він множить батьківські рядки, для двох колекцій дає декартів добуток; тоді потрібні json_agg / GROUP_CONCAT або окремий запит на колекцію.
Що при фіксованому малому N з кешем і локальною базою N+1 може бути дешевшим за складний батч-запит, і що рішення приймається за виміряним часом, а не за назвою антипатерну.
Шукати причину в slow log з дефолтним long_query_time = 10 і робити висновок, що з базою все гаразд.
Вішати індекс на зовнішній ключ і вважати проблему розвʼязаною: 200 запитів стануть швидшими, але їх лишиться 200.
Замінювати все одним великим JOIN і отримувати дублювання батьківських рядків та роздутий обсяг передачі.
Збирати список ключів конкатенацією в текст запиту: це і SQL-інʼєкція, і новий текст запиту на кожну довжину списку, який засмічує pg_stat_statements та кеш планів.
Не дедуплікувати ключі перед IN і слати список із тисячами повторів.
Стверджувати, що N+1 буває лише в ORM: цикл із запитом усередині чудово пишеться і на голому PDO.
ПОРАДА

Назвіть діагностичну ознаку одним реченням: у pg_stat_statements шукаємо запит, де calls на два порядки більший за сусідів, mean_exec_time біля 0.1 мс, а rows приблизно дорівнює calls. Це майже завжди вибірка по одному ключу в циклі.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Кожен запит окремо швидкий, тому в slow log він не потрапляє; проблема в кількості викликів, і знайти її можна за calls у pg_stat_statements або за COUNT_STAR у digest-таблиці MySQL.