PostgreSQL CookbookАналитикаLATERAL-join: top-N и убийца N+1
0 / 63 (0%)

LATERAL-join: top-N и убийца N+1

Профиль клиента в приложении Brew рисует блок «3 крупнейших заказа». Сначала бэкенд тянул список клиентов одним запросом, а потом в цикле, на каждого клиента, отправлял в базу ещё один запрос за его тремя заказами. Тысяча клиентов на экране «топ-покупателей» — тысяча и один поход в Postgres. Этот паттерн зовётся N+1, и на проде он выглядел так: страница профиля грузилась секунду, дашборд с сотней клиентов — почти минуту, пул соединений захлёбывался, а половина латентности уходила просто на сетевые round-trip между сервисом и базой. Хотелось другого: один запрос, который сам, внутри базы, посчитает топ-3 сразу для всех клиентов.

Сделать это в лоб мешает одно правило SQL. И ровно его снимает LATERAL.

Почему обычный подзапрос в FROM не справится

Подзапрос в FROM вычисляется независимо. Он не видит соседние таблицы из того же FROM — формально это отдельный «производный набор строк», и Postgres обязан уметь посчитать его сам по себе, до всякого соединения. Поэтому написать «возьми три заказа вот этого клиента» прямо в FROM нельзя: имени c.id там просто не существует, подзапрос про него ничего не знает.

LATERAL снимает этот запрет. Подзапрос справа от JOIN LATERAL получает право ссылаться на колонки таблиц слева от него. Если думать процедурно, это буквально тело цикла «для каждой строки слева»: Postgres проходит по клиентам и для каждого исполняет правый подзапрос, подставив текущий c.id. Та самая идея, которой N+1 страдал в приложении, переезжает внутрь базы — и становится одним планом запроса вместо тысячи round-trip.

Top-N на группу одним запросом

Когда подзапросу разрешено видеть левую строку, top-3 на клиента пишется прямолинейно: для каждого c берём его заказы (WHERE o.customer_id = c.id), сортируем по убыванию суммы и оставляем LIMIT 3. Поменяй число в LIMIT — получишь top-5 или top-1; структура запроса не меняется.

Здесь же кроется обобщение DISTINCT ON из 04-04. Там мы брали «одну строку на группу» — по сути top-1. LATERAL — это тот же приём, у которого top-1 просто частный случай LIMIT 1: LIMIT 1 даёт «лучший/последний на группу», LIMIT 3 — top-3, и переключение между ними стоит ровно одну цифру. Где DISTINCT ON упирается в потолок (только одна строка), LATERAL спокойно отдаёт сколько нужно.

LEFT против CROSS: что делать с беззаказными

В наших данных есть Карина — клиент без единого заказа. Для неё правый подзапрос вернёт пустоту. И тут важна разновидность соединения. CROSS JOIN LATERAL требует совпадения — если правая часть пуста, левая строка выпадает, Карина исчезнет из профилей. LEFT JOIN LATERAL (...) ON true ведёт себя как нормальный LEFT JOIN: левая строка сохраняется, а колонки правого подзапроса становятся NULL. Условие ON true тут — формальность: вся логика выбора уже сидит внутри подзапроса (в его WHERE), джойну соединять не по чему, поэтому он склеивает «как есть».

NULL на стороне Go неудобен для чистого string, поэтому в SQL стоит coalesce(..., '—'): Карина в выводе получает прочерки, а не пропадает.

Тот же цикл «на каждую строку слева», но в двух местах — снаружи по сети и внутри базы:

plaintext
N+1 в приложении (по сети):              LATERAL (один план внутри базы):
  1 запрос → список клиентов               FROM customers c
  затем на КАЖДОГО ещё запрос:             LEFT JOIN LATERAL (top-3 ... c.id) ON true
    Алиса  → SELECT top-3 (Алиса)  ┐         для каждой левой строки c:
    Борис  → SELECT top-3 (Борис)  ├─ ×1000    Алиса  → 520, 450, 300
    Карина → SELECT top-3 (Карина) ┘           Борис  → 480, 250
  = 1000+1 round-trip                          Карина → (пусто) → '—'
                                             = 1 round-trip, 1 проход

Идея «тело цикла для каждой строки слева» одна и та же; разница — где он крутится. Какой JOIN LATERAL взять и чем он отличается от соседей:

ПриёмСтрок на группуБеззаказная КаринаКогда брать
N+1 в приложениисколько угоднозависит от коданикогда (тысяча round-trip)
DISTINCT ON (04-04)ровно 1сохраняет (через LEFT JOIN)чистый top-1
CROSS JOIN LATERALtop-N (LIMIT n)роняет (нет совпадения)top-N, беззаказные не нужны
LEFT JOIN LATERAL … ON truetop-N (LIMIT n)сохраняет (NULL'—')top-N и сохранить всех

Что показывает наш код

Сердце урока — query.sql. Запрос TopOrdersPerCustomer берёт всех клиентов слева и для каждого исполняет правый подзапрос, который видит c.id:

sql
SELECT
    c.name,
    coalesce(t.rn::text, '')    AS rn,
    coalesce(t.cents::text, '') AS cents,
    coalesce(t.day::text, '')   AS day
FROM lat_customers_lab c
LEFT JOIN LATERAL (
    SELECT row_number() OVER (ORDER BY o.cents DESC, o.id) AS rn, o.cents, o.day
    FROM lat_orders_lab o
    WHERE o.customer_id = c.id
    ORDER BY o.cents DESC, o.id
    LIMIT 3
) t ON true
ORDER BY c.id, t.rn;

Ссылка WHERE o.customer_id = c.id — это и есть LATERAL: подзапрос t тянется к колонке левой таблицы. LIMIT 3 режет результат до top-3 на клиента, LEFT ... ON true сохраняет Карину, coalesce превращает её NULL в '—'. Второй запрос, BiggestOrderPerCustomer, — тот же скелет с LIMIT 1: самый крупный заказ на клиента, в точности случай DISTINCT ON.

cmd/demo/main.go — тонкий: pgxpooldb.New → два типизированных вызова → tabwriter. Никакой логики выбора в Go нет, она вся в SQL.

Запуск

sh
docker compose up -d
make lecture L=08-analytical-and-lateral/08-05-lateral-joins T=db-reset
make lecture L=08-analytical-and-lateral/08-05-lateral-joins

T=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита те же шаги короче: make db-reset, затем make run.

plaintext
1) Top-3 заказа на клиента (LEFT JOIN LATERAL, один запрос):
КЛИЕНТ  #  сумма  день
Алиса   1  520    2025-03-03
Алиса   2  450    2025-03-02
Алиса   3  300    2025-03-01
Борис   1  480    2025-03-02
Борис   2  250    2025-03-01
Карина  —  —      —
   → Карина без заказов сохранена ('—'); у Алисы 4 заказа, top-3 отсёк самый дешёвый (280).
 
2) Самый крупный заказ на клиента (LATERAL c LIMIT 1):
КЛИЕНТ  сумма  день
Алиса   520    2025-03-03
Борис   480    2025-03-02
Карина  —      —

У Алисы четыре заказа, и LIMIT 3 честно отрезал самый дешёвый (280). Борис со своими двумя заказами влез целиком. Карина без заказов осталась в обоих списках с прочерками — это работа LEFT JOIN LATERAL ... ON true.

Заборчик

  • Без индекса под условие корреляции (o.customer_id) LATERAL — это N полных сканов таблицы заказов вместо одного. Та же ловушка N+1, только теперь спряталась внутри базы и снаружи не видна.
  • Для чистого top-1 часто короче и понятнее DISTINCT ON из 04-04. LATERAL достаёт его именно тогда, когда строк на группу нужно больше одной.
  • Помни про разновидность джойна: CROSS JOIN LATERAL роняет строки без совпадения — чтобы «сохранить всех», нужен LEFT JOIN LATERAL ... ON true.
  • Не путай LATERAL с коррелированным подзапросом в SELECT: тот обязан вернуть ровно одну строку или скаляр, а LATERAL спокойно отдаёт много строк на левую запись.
  • План и стоимость такого запроса — это уже модуль 06; правильный индекс под корреляцию на проде подберёт твой DBA.

Что забрать с собой

LATERAL — это подзапрос в FROM, которому разрешили видеть левую строку, то есть «тело цикла для каждой строки слева» прямо в SQL. Он решает top-N на группу одним запросом и тем убивает N+1 из приложения: тысяча round-trip превращается в один план. LEFT JOIN LATERAL (...) ON true сохраняет беззаказных клиентов (Карину) — CROSS JOIN LATERAL бы их уронил. По сути это обобщение DISTINCT ON из 04-04: LIMIT 1 — «лучший/последний», LIMIT 3 — top-3, разница в одной цифре. И не забудь про индекс под условие корреляции, иначе N+1 просто переедет внутрь базы.

Мы научились разворачивать каждую строку в её собственный набор. Дальше нужно обратное движение — свернуть строки в итоги, причём сразу по нескольким разрезам одним проходом. В 08-06 этим займутся grouping sets, rollup и cube: одна агрегация, считающая суммы по дням, по клиентам и общий итог одновременно.

·Модуль 09

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Аналитика / LATERAL-join: top-N и убийца N+1