База не знає слова «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;
Назвіть діагностичну ознаку одним реченням: у pg_stat_statements шукаємо запит, де calls на два порядки більший за сусідів, mean_exec_time біля 0.1 мс, а rows приблизно дорівнює calls. Це майже завжди вибірка по одному ключу в циклі.