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(..., '—'): Карина в выводе получает прочерки, а не пропадает.
Тот же цикл «на каждую строку слева», но в двух местах — снаружи по сети и внутри базы:
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 LATERAL | top-N (LIMIT n) | роняет (нет совпадения) | top-N, беззаказные не нужны |
LEFT JOIN LATERAL … ON true | top-N (LIMIT n) | сохраняет (NULL → '—') | top-N и сохранить всех |
Что показывает наш код
Сердце урока — query.sql. Запрос TopOrdersPerCustomer берёт всех клиентов
слева и для каждого исполняет правый подзапрос, который видит c.id:
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 — тонкий: pgxpool → db.New → два типизированных вызова →
tabwriter. Никакой логики выбора в Go нет, она вся в SQL.
Запуск
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-joinsT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита те же
шаги короче: make db-reset, затем make run.
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: одна
агрегация, считающая суммы по дням, по клиентам и общий итог одновременно.