N+1, JOIN и prepared statements

О лекции

N+1 — это N сетевых кругов вместо одного запроса, а не «сложная сортировка в планировщике». JOIN или батч-preload лечат чтение. Prepared statement снимает parse на горячем пути и не заменяет индекс.

PostgreSQL postgres собеседование

N+1 — это N сетевых кругов вместо одного запроса. Классика ORM: загрузили список заказов одним SELECT, затем в цикле SELECT * FROM users WHERE id = ? на каждый заказ. Сложность здесь — O(n) round-trip по сети, не O(n) CPU внутри Postgres. Каждый круг занимает слот пула на время ожидания.

Пример. 20 заказов на странице. Один SELECT заказов плюс 20 SELECT пользователей. 21 круг по сети. Каждый запрос дешёвый. Страница медленная. Это N+1, не «сложная сортировка».

Симптомы: в pg_stat_statements тысячи одинаковых коротких запросов, в трейсе веер SQL из одного HTTP-хендлера, P99 растёт линейно с размером списка. EXPLAIN одного запроса из цикла вы в проде «как проблему» не увидите — каждый по отдельности дешёвый.

Лечение — не «выключить ORM», а перестать ходить в БД в цикле. Один JOIN, один WHERE id = ANY($1), dataloader, preload. Выбор между JOIN и двумя запросами уже про размер payload, не про саму болезнь.

JOIN против двух запросов

Один JOIN, который дублирует шапку заказа на каждую позицию, нормален, пока строка не раздувается в сто раз. Тогда лучше два батча: сначала ids, потом ANY по пользователям. Главное — не цикл на N. Индексы на внешние ключи обязательны, иначе JOIN сам станет Seq Scan и вы «вылечите» N+1 ценой полного скана.

SELECT o.id, o.total, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= $1;

-- вместо цикла SELECT * FROM users WHERE id = ?
SELECT id, email
FROM users
WHERE id = ANY($1::bigint[]);

Один JOIN вместо цикла по пользователям. Альтернатива — батч ANY по массиву id, если JOIN раздувает payload.

В Go это pgx.Array или pq.Array, не склейка id в строку. Склейка ломает prepared statements и открывает путь к сюрпризам с типами. Лимит на размер массива и плана всё равно существует: батчи по сотням, не по миллиону id за раз.

Prepared statements

Разбор SQL (parse, rewrite, plan) на коротких запросах часто дороже bind+execute. Драйвер вроде pgx готовит statement на соединении и дальше шлёт только параметры. Плюс — меньше CPU на parse и стабильный план. Минус — план может «залипнуть» под первые параметры: generic plan хуже custom. PostgreSQL умеет переключаться, но сюрпризы бывают.

Это не замена пулу, а отдельная тема. Prepared statement живёт на серверном соединении. Transaction pooling в PgBouncer без поддержки prepared ломает схему: снова parse на каждый запрос или ошибка. N+1 при этом никуда не девается — вы просто быстрее выполняете каждый из тысячи кругов.

Типичная ошибка

Лечить N+1 повышением isolation или «добавим индекс». Индекс ускорит каждый из N запросов и сделает проблему менее заметной, пока список не вырастет. Смотреть нужно на число round-trip и на pg_stat_statements, не на Big O сортировки в памяти приложения.

На собеседовании

N+1 — про сеть и пул, не про асимптотику сортировки. Сравните «один JOIN» и «два батча ANY». В проде вы увидите тысячу одинаковых запросов в pg_stat_statements, а не один плохой EXPLAIN. Prepared statement снимает parse, не заменяет индекс и плохо дружит с session-less пулом.