Средний ~55 мин чтения

JOIN: все виды и практика

Урок 7 из 10 в курсе SQL с нуля: полный курс

JOIN: все виды и практика

JOIN — оператор SQL, соединяющий строки двух или более таблиц по условию. Существует несколько типов JOIN, каждый с чёткой семантикой: INNER JOIN возвращает только строки с совпадением в обеих таблицах, LEFT JOIN — все строки левой таблицы плюс совпадения из правой (NULL если не найдено), RIGHT JOIN — зеркало LEFT, FULL OUTER JOIN — все строки обеих таблиц, CROSS JOIN — декартово произведение. В реальных приложениях 90% JOIN — это INNER и LEFT. JOIN — ключевой инструмент для работы с нормализованной схемой: вместо хранения избыточных данных в одной таблице, данные разбиты на таблицы и соединяются JOIN при запросе. Умение правильно написать JOIN на нескольких таблицах и понять его план выполнения — обязательный навык для любого backend-разработчика.

Почему это важно: N+1 проблема — один из самых частых антипаттернов в backend-приложениях — напрямую связана с неправильным использованием JOIN. Django ORM: Post.objects.all() потом for post in posts: post.author.name — это 1 запрос для постов + N запросов для авторов. При 100 постах — 101 запрос, 300мс. С SELECT posts.*, users.name FROM posts JOIN users ON posts.author_id = users.id — 1 запрос, 5мс. Неверный тип JOIN может привести к потере строк (INNER JOIN вместо LEFT) или дублированию (JOIN к таблице с несколькими совпадениями без GROUP BY или DISTINCT). Понимание алгоритмов JOIN (Hash Join, Nested Loop, Merge Join) позволяет объяснить почему конкретный запрос медленный и как его исправить через индексы или изменение запроса.

Главная идея

JOIN соединяет таблицы по условию ON (обычно равенство FK = PK). Тип JOIN определяет судьбу строк без совпадения: INNER отбрасывает их с обеих сторон, LEFT оставляет все строки левой (NULL справа), RIGHT — правой, FULL OUTER — обеих. Self-join соединяет таблицу с самой собой — для иерархических данных (сотрудник → менеджер). LATERAL JOIN (correlated subquery в FROM) выполняет подзапрос для каждой строки основной таблицы — мощный инструмент для 'топ-N для каждой группы'. Алгоритм выполнения JOIN: Nested Loop (итерация по каждой строке внешней таблицы с lookup по индексу внутренней), Hash Join (хеш-таблица из меньшей, scan по большей), Merge Join (обе таблицы отсортированы по ключу JOIN).

Как это выглядит на практике

  1. INNER JOIN — только строки с совпадением в обеих таблицах. SELECT u.full_name, o.id AS order_id, o.total_amount FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.status = 'completed'. Результат: только пользователи которые сделали хотя бы один выполненный заказ. Пользователи без заказов не попадают в результат.
  2. LEFT JOIN — все строки левой таблицы + совпадения из правой. SELECT u.full_name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.full_name ORDER BY order_count DESC. Пользователи без заказов попадут с order_count = 0 (COUNT(o.id) = 0 при NULL). Если бы был INNER JOIN — таких пользователей не было бы.
  3. Антисоединение — LEFT JOIN + WHERE right IS NULL: SELECT u.id, u.full_name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL. Возвращает пользователей БЕЗ заказов. Это эффективнее чем NOT IN или NOT EXISTS в большинстве случаев.
  4. Несколько JOIN последовательно: SELECT o.id, u.email, u.full_name, p.name AS product, oi.quantity, oi.price_at_purchase FROM orders o JOIN users u ON o.user_id = u.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON oi.product_id = p.id WHERE o.status = 'pending' ORDER BY o.created_at DESC. Четыре таблицы соединяются последовательно — получаем полную информацию о заказе.
  5. Self-join для иерархии: SELECT e.full_name AS employee, m.full_name AS manager, m.full_name IS NULL AS is_top_manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id ORDER BY m.full_name NULLS FIRST, e.full_name. Таблица соединяется сама с собой: e — сотрудники, m — их менеджеры. LEFT JOIN сохраняет топ-менеджеров (у которых нет manager_id).
  6. FULL OUTER JOIN для сравнения двух наборов: SELECT COALESCE(a.category, b.category) AS category, a.count_2023, b.count_2024 FROM (SELECT category, COUNT(*) FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2023 GROUP BY category) a FULL OUTER JOIN (SELECT category, COUNT(*) FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2024 GROUP BY category) b ON a.category = b.category. Показывает категории из обоих годов, включая те что были только в одном.
  7. JOIN с агрегацией — подсчёт связанных записей: SELECT p.id, p.name, COUNT(r.id) AS review_count, ROUND(AVG(r.rating), 2) AS avg_rating FROM products p LEFT JOIN reviews r ON r.product_id = p.id GROUP BY p.id, p.name HAVING AVG(r.rating) >= 4 OR COUNT(r.id) = 0 ORDER BY avg_rating DESC NULLS LAST.
  8. LATERAL JOIN — для 'последнего заказа каждого пользователя': SELECT u.id, u.full_name, last_order.id AS last_order_id, last_order.created_at FROM users u LEFT JOIN LATERAL (SELECT id, created_at, total_amount FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1) AS last_order ON true. LATERAL выполняет подзапрос для каждой строки users.
  9. JOIN без ON (неявный CROSS JOIN) — декартово произведение: SELECT d.name AS day, t.name AS time_slot FROM days d CROSS JOIN time_slots t ORDER BY d.id, t.id. Если days = 7 строк, time_slots = 8 строк — результат 56 строк. Используется для генерации расписаний, матриц, всех комбинаций.
  10. N+1 проблема и её устранение: Django ORM без .select_related() делает SELECT * FROM posts, потом для каждого поста SELECT * FROM users WHERE id = post.author_id. 101 запрос вместо одного. Решение в Django: Post.objects.select_related('author') — добавляет JOIN. В SQL: SELECT posts.*, users.full_name FROM posts JOIN users ON posts.author_id = users.id.
  11. Влияние индексов на план JOIN: Nested Loop использует индекс на inner table — без индекса на orders.user_id при JOIN с users каждая строка users вызывает seq scan orders. Hash Join строит хеш-таблицу из меньшей таблицы в work_mem — эффективен для больших таблиц без индексов. EXPLAIN ANALYZE показывает тип JOIN: 'Hash Join', 'Nested Loop', 'Merge Join'.
  12. Условия фильтрации в ON vs WHERE: при LEFT JOIN условие в WHERE исключает строки после JOIN (превращает LEFT в INNER!). SELECT u.*, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'completed'. Условие status в ON оставляет всех пользователей, у незаполненных заказов o.total = NULL. В WHERE: WHERE o.status = 'completed' отфильтрует пользователей без completed заказов — станет INNER JOIN де-факто.

Примеры кода

Все типы JOIN на одном примере

-- Тестовые данные
-- users: Alice(1), Bob(2), Charlie(3)
-- orders: заказы для Alice(1→u1), Alice(2→u1), Bob(3→u2)
-- Charlie не делал заказов

-- INNER JOIN: только пары где оба существуют
SELECT u.full_name, o.id AS order_id
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Alice | 1
-- Alice | 2
-- Bob   | 3
-- Charlie: отсутствует (нет заказов)

-- LEFT JOIN: все пользователи + их заказы
SELECT u.full_name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- Alice   | 1
-- Alice   | 2
-- Bob     | 3
-- Charlie | NULL  ← без заказа, но в результате

-- Антисоединение: пользователи БЕЗ заказов
SELECT u.full_name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- Charlie

-- FULL OUTER JOIN: все строки обеих таблиц
SELECT u.full_name, o.id AS order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Alice   | 1
-- Alice   | 2
-- Bob     | 3
-- Charlie | NULL

INNER JOIN — пересечение. LEFT JOIN — все слева + совпадения справа. LEFT JOIN + WHERE right IS NULL — только строки без совпадения (антисоединение). FULL OUTER — все из обеих таблиц.

JOIN с агрегацией — реальные запросы для дашборда

-- Статистика пользователей: количество и сумма заказов
SELECT
  u.id,
  u.full_name,
  u.email,
  COUNT(o.id)          AS total_orders,
  COALESCE(SUM(o.total_amount), 0) AS total_spent,
  MAX(o.created_at)    AS last_order_date
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
  AND o.status != 'cancelled'
WHERE u.is_active = true
GROUP BY u.id, u.full_name, u.email
HAVING COUNT(o.id) > 0 OR u.created_at > now() - interval '7 days'
ORDER BY total_spent DESC
LIMIT 50;

-- Продукты с количеством отзывов и средним рейтингом
SELECT
  p.id,
  p.name,
  p.price,
  COUNT(r.id)                    AS review_count,
  ROUND(AVG(r.rating)::NUMERIC, 2) AS avg_rating,
  COUNT(r.id) FILTER (WHERE r.rating = 5) AS five_star_count
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY p.id, p.name, p.price
ORDER BY avg_rating DESC NULLS LAST, review_count DESC;

LEFT JOIN с GROUP BY — стандартный паттерн для агрегации связанных данных. COALESCE(SUM(...), 0) — возвращает 0 для пользователей без заказов вместо NULL. FILTER (WHERE ...) — условная агрегация только по 5-звёздочным отзывам.

N+1 проблема и JOIN-решение

-- ПРОБЛЕМА: N+1 запросов в коде приложения
-- Псевдокод: 1 + N запросов
-- articles = SELECT * FROM articles LIMIT 20;  -- 1 запрос
-- for article in articles:
--   author = SELECT * FROM users WHERE id = article.author_id  -- 20 запросов!
-- Итого: 21 запрос вместо 1

-- РЕШЕНИЕ: один JOIN вместо N+1
SELECT
  a.id,
  a.title,
  a.published_at,
  u.id         AS author_id,
  u.full_name  AS author_name,
  u.avatar_url AS author_avatar
FROM articles a
JOIN users u ON u.id = a.author_id
WHERE a.is_published = true
ORDER BY a.published_at DESC
LIMIT 20;

-- Для нескольких связей — несколько JOIN
SELECT
  a.id, a.title,
  u.full_name                         AS author,
  COUNT(c.id)                         AS comment_count,
  COUNT(l.id)                         AS like_count,
  MAX(c.created_at)                   AS last_comment_at
FROM articles a
JOIN users u   ON u.id = a.author_id
LEFT JOIN comments c ON c.article_id = a.id
LEFT JOIN likes    l ON l.article_id = a.id
WHERE a.is_published = true
GROUP BY a.id, a.title, u.full_name
ORDER BY a.published_at DESC
LIMIT 20;

Один JOIN-запрос вместо N+1 = одна round-trip к БД, один план выполнения, минимальный сетевой трафик. При использовании ORM: Django .select_related('author'), ActiveRecord .includes(:author), SQLAlchemy joinedload(Article.author).

Что происходит под капотом

  • PostgreSQL имеет три алгоритма JOIN: Nested Loop (перебор каждой строки внешней таблицы с поиском в внутренней через индекс; эффективен при маленькой внутренней таблице или хорошем индексе), Hash Join (хеш-таблица из меньшей таблицы в work_mem, scan по большей; эффективен при больших таблицах без индексов), Merge Join (обе стороны отсортированы по ключу JOIN; эффективен когда оба side уже отсортированы или их сортировка дешева). Планировщик выбирает алгоритм на основе статистики размеров и наличия индексов.
  • Nested Loop + Index Scan: для каждой строки из внешней таблицы PostgreSQL делает Index Scan по внутренней. Стоимость: O(outer_rows * log(inner_rows)). Это лучший алгоритм если outer_rows маленький (например, WHERE user_id = 42 возвращает 1 строку) или если есть хороший индекс на inner таблице. Без индекса Nested Loop становится O(outer * inner) — очень медленно.
  • Hash Join: строит хеш-таблицу из меньшей таблицы в work_mem (размер хеш-таблицы ограничен work_mem). Затем сканирует большую таблицу и делает хеш-lookup. Стоимость: O(N + M). Если хеш-таблица не помещается в work_mem — spill на диск (Batches > 1 в EXPLAIN). Увеличение work_mem SET work_mem = '256MB' может избежать spill.
  • Порядок JOIN: PostgreSQL переставляет JOIN в оптимальном порядке (join reordering). Для > 8 JOIN-ов (join_collapse_limit = 8) планировщик использует эвристики. join_collapse_limit = 1 отключает переупорядочивание — полезно для отладки. geqo_threshold — порог для генетического алгоритма оптимизации при очень многих JOIN.
  • ON vs WHERE для LEFT JOIN: условие в ON применяется до JOIN (фильтрует строки правой таблицы перед соединением), условие в WHERE применяется после JOIN (к результирующим строкам). При LEFT JOIN условие на правую таблицу в WHERE превращает его в INNER JOIN де-факто: строки с NULL справа отфильтровываются. Условие в ON оставляет строки с NULL — нужные NULL для 'нет записи'.
  • LATERAL JOIN: позволяет подзапросу ссылаться на столбцы из предыдущих таблиц в FROM. JOIN LATERAL (SELECT ... WHERE t.id = outer.id ...) AS sub ON true. PostgreSQL выполняет подзапрос для каждой строки основной таблицы. Это позволяет 'топ-N на группу' запросы без window functions. Аналог APPLY в SQL Server.
  • N+1 проблема — когда для каждой строки основного запроса выполняется дополнительный запрос. 100 постов → 100 SELECT для авторов = 101 запрос. Один JOIN заменяет всё это. N+1 невидимо в разработке (данных мало), появляется в продакшене (много данных). Django ORM: .select_related() для FK, .prefetch_related() для M2M. EXPLAIN ANALYZE на объектах ORM — обязательная практика.
  • Bitmap Index Scan для JOIN: при Hash Join PostgreSQL иногда делает BitmapAnd/BitmapOr из нескольких Index Scan для одной стороны JOIN — собирает bitmap страниц, потом читает их разом. Это эффективнее Sequential Scan если selectivity высокая, но дешевле Index Scan если selectivity низкая.
  • Индекс на FK-столбце дочерней таблицы: без CREATE INDEX ON orders(user_id) JOIN users u JOIN orders o ON u.id = o.user_id будет seq scan по orders для каждого пользователя при Nested Loop. Это частая причина медленных JOIN. Правило: каждый FK требует индекс на дочерней стороне.
  • Параллельный Hash Join: PostgreSQL 9.6+ поддерживает параллельные JOIN. При достаточном parallel_workers и без parallel_safe ограничений — Hash Join может выполняться параллельно несколькими workers. В EXPLAIN видно 'Gather' node над Parallel Hash Join. Для аналитических запросов параллелизм может ускорить в 2-8 раз.

Типичные ошибки и заблуждения

  • «JOIN всегда медленнее двух отдельных SELECT». Это ошибка. При правильных индексах один JOIN быстрее двух SELECT. Причина: один round-trip к БД, один план выполнения, планировщик оптимизирует ORDER JOIN. Два SELECT создают N+1 проблему: нужен отдельный запрос для каждой связанной записи. JOIN + индекс = быстрее + меньше кода.
  • «INNER и JOIN (без ключевого слова) — разные операторы». JOIN без типа = INNER JOIN. Это синтаксический сахар. Результат одинаковый. Большинство разработчиков пишут просто JOIN для INNER JOIN и LEFT JOIN для LEFT OUTER JOIN.
  • «RIGHT JOIN нужен часто». RIGHT JOIN почти всегда можно переписать как LEFT JOIN с обменом таблиц местами. SELECT * FROM orders o RIGHT JOIN users u ON u.id = o.user_id = SELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id. LEFT JOIN более читаем — 'основная' таблица всегда слева.
  • «JOIN создаёт дубли только при ошибке». JOIN с таблицей где несколько строк совпадают — умножает строки. SELECT * FROM users u JOIN orders o ON u.id = o.user_id — Alice с 3 заказами появится 3 раза. Это корректное поведение JOIN, не баг. Для 'один пользователь — один ряд' нужен GROUP BY или EXISTS.
  • «Условие WHERE на правую таблицу в LEFT JOIN оставляет LEFT JOIN семантику». Нет. SELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'completed' — это де-факто INNER JOIN: пользователи без completed заказов отфильтруются WHERE (их o.status = NULL, а NULL = 'completed' = NULL). Для фильтрации правой таблицы с сохранением LEFT JOIN семантики — условие в ON: ON u.id = o.user_id AND o.status = 'completed'.
  • «CROSS JOIN — ошибка или случайность». CROSS JOIN имеет легитимные применения: генерация расписаний (SELECT day, hour FROM days CROSS JOIN hours), матрицы продукт × регион, все возможные комбинации параметров для тестирования. Результат CROSS JOIN = |left| × |right| строк. 1000 × 1000 = 1 000 000 строк — следи за размерами.
  • «FULL OUTER JOIN часто нужен». FULL OUTER JOIN нужен редко — обычно для 'reconciliation' (сравнения двух наборов данных, поиска расхождений между ними). В обычном OLTP-коде — почти никогда. Если часто видишь FULL OUTER JOIN в коде — возможно, схема данных требует пересмотра.

Ключевые выводы

  • INNER JOIN возвращает только строки с совпадением в обеих таблицах; строки без совпадения теряются.
  • LEFT JOIN возвращает все строки левой таблицы; строки без совпадения справа — NULL справа.
  • LEFT JOIN + WHERE right.id IS NULL — антисоединение: только строки левой таблицы БЕЗ совпадения в правой.
  • Условие фильтрации правой таблицы в WHERE при LEFT JOIN превращает его в INNER JOIN — ставь условие в ON.
  • JOIN умножает строки если в правой таблице несколько совпадений — это нормально, но нужен GROUP BY или DISTINCT.
  • Индекс на FK-столбце дочерней таблицы критичен — без него Nested Loop делает seq scan для каждой строки.
  • N+1 проблема — каждый N+1 цикл запросов в ORM — заменяй одним JOIN.

Термины урока

INNER JOIN: пересечение двух наборов по условию ON; строки без совпадения с обеих сторон не включаются.
LEFT JOIN: все строки левой таблицы; строки без совпадения в правой дают NULL для всех правых столбцов.
FULL OUTER JOIN: все строки обеих таблиц; NULL там, где нет совпадения с другой стороны.
CROSS JOIN: декартово произведение; все комбинации строк обеих таблиц; результат = |left| × |right|.
Self-join: таблица соединяется сама с собой через псевдоним; для иерархий и отношений внутри одной таблицы.
LATERAL JOIN: подзапрос в FROM ссылается на столбцы предыдущих таблиц; выполняется для каждой строки.
Nested Loop: алгоритм JOIN через вложенный цикл с Index Scan; эффективен при малом outer и хорошем индексе inner.
Hash Join: алгоритм JOIN через хеш-таблицу; эффективен для больших таблиц без индексов.
N+1 проблема: антипаттерн — один запрос для списка + N запросов для связанных данных; устраняется JOIN или eager loading.
Антисоединение: LEFT JOIN + WHERE right.id IS NULL — строки левой таблицы, не имеющие совпадений в правой.

Связь с работой backend-разработчика

Главное практическое правило JOIN: каждый FK в схеме должен иметь индекс на дочерней таблице. CREATE INDEX ON orders(user_id) — без этого каждый JOIN к users будет seq scan по orders. Для диагностики N+1: включи логирование SQL в ORM (ActiveRecord: Rails.logger, Django: settings.LOGGING, SQLAlchemy: echo=True) и смотри сколько одинаковых запросов с разными id генерируется. Более 3-5 одинаковых шаблонных запросов — признак N+1. Паттерн решения в ORM: eager loading (select_related в Django, includes в ActiveRecord, joinedload в SQLAlchemy). Для ручного SQL — объединяй в один JOIN-запрос.

Мини-разбор реальной ситуации

Мобильное приложение для чтения статей: лента статей загружается 3 секунды. Backend-разработчик подключает django-debug-toolbar и видит: 1 запрос на список статей + 50 запросов на авторов + 50 запросов на теги + 50 запросов на количество лайков = 151 SQL-запрос для одной страницы. Каждый запрос ~5-20мс, итого 3 секунды. Первое исправление: добавляет .select_related('author') в queryset — убирает 50 N+1 для авторов. Второе: добавляет .prefetch_related('tags') — убирает 50 N+1 для тегов. Третье: для лайков пишет один аннотированный запрос: Article.objects.annotate(like_count=Count('likes')). Результат: 3 запроса вместо 151, время загрузки: 3000мс → 80мс. Урок: мониторинг SQL-запросов в разработке обязателен — каждый N+1 невидим локально, виден только под нагрузкой или с реальными данными.

Что запомнить

  • INNER JOIN теряет строки без совпадения — используй LEFT JOIN если все строки левой таблицы важны.
  • WHERE на правой таблице при LEFT JOIN = INNER JOIN — условие правой таблицы ставь в ON.
  • Индекс на FK-столбце дочерней таблицы обязателен — без него JOIN работает через seq scan.
  • N+1: включи SQL-логирование в ORM и считай одинаковые шаблонные запросы.
  • JOIN умножает строки при множественных совпадениях — проверяй количество строк в результате, добавляй GROUP BY или EXISTS.

Итог

JOIN — фундаментальный инструмент для работы с нормализованными данными. Знание типов JOIN, их семантики и алгоритмов выполнения позволяет писать корректные и эффективные запросы, устранять N+1 проблемы и понимать что показывает EXPLAIN ANALYZE. Правильный JOIN + правильный индекс превращают страницу приложения из медленной в мгновенную.