Как стать автором
Обновить
636.45
OTUS
Развиваем технологии, обучая их создателей

Плохие JOIN’ы: приемы, которые (нечаянно) кладут прод

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров9.1K

Привет, Хабр!

Сегодня разбираем один из самых коварных способов убить базу — плохие JOIN'ы. Казалось бы, простое дело: связать пару таблиц — и вперёд. Но если в ON засунуть LOWER(email), забыть про индексы или перепутать LEFT JOIN с INNER — сервер мигом начнет дышать на ладан.

В каждой секции:

  1. Пример, который бьет по продакшену.

  2. Что именно ломается.

  3. Конкретный рефакторинг + индексы + что логировать.

  4. Когда все‑таки можно нарушать и не стыдиться.

Все примеры — PostgreSQL, но подавляющее большинство выводов одинаково валит MySQL и SQL Server.

Cartesian product без ON: «SELECT * FROM users, payments»

SELECT  u.id, p.amount
FROM    users u, payments p;      -- упс

На тестовом кластере: users — 1 млн строк, payments — 2 млн. Итог: 2 000 000 000 rows в hash join»е, 9 GB temp на диске. Бру‑таль‑но.

Оптимизатор не нашёл условиях соединения и сделал чистый CROSS JOIN, а мы даже не заметили: в старом ANSI-89 синтаксисе запятая — это именно он. Результат растёт мультипликативно.

Всегда пишем явный JOIN … ON, даже если кажется, что «и так понятно». В PostgreSQL ≥ 15 можно включить standard_conforming_strings и FROM … JOIN, чтобы linters ловили запятую‑JOIN. Если действительно нужен декартов продукт — пишите CROSS JOIN и ставьте комментарий почему.

JOIN по функции (LOWER(email))

SELECT  u.id, s.id
FROM    users u
JOIN    subscriptions s
        ON LOWER(u.email) = LOWER(s.email);

По плану — Seq Scan на обеих таблицах, 12 с вместо 40 мс.

Любая функция в ON скрывает колонку за черным ящиком — оптимизатор не может воспользоваться индексом и идёт в full scan.

Заводим functional index:

CREATE INDEX CONCURRENTLY idx_users_email_lower ON users (LOWER(email));

и такой же на subscriptions. Если база не умеет функциональные индексы (Hello, старый MySQL) — держим нормализованное поле email_lower и триггер на обновление. Валидируем данные заранее (email → lowercase) и соединяемся голыми колонками.

LEFT JOIN + WHERE column IS NOT NULL: превращение в INNER JOIN

SELECT  o.id, r.id
FROM    orders o
LEFT JOIN refunds r ON r.order_id = o.id
WHERE   r.id IS NOT NULL;         -- Моментально «съедает» все NULL'ы

Если хотели сохранить строки без возврата — увы, они исчезли; индекс на orders тоже не нужен, потому что фильтр идёт ПОСЛЕ join»а.

Фильтр WHERE r.id IS NOT NULL убирает все строки, где refunds не нашелся — получается обычный INNER JOIN, только с лишним шагом. План раздут, время — вдвое хуже.

Пишите прямо INNER JOIN (оптимизатор отблагодарит). Если правда нужен LEFT JOIN, переносим фильтр в ON:

LEFT JOIN refunds r 
       ON r.order_id = o.id
      AND r.processed = TRUE;

Это сохраняет «левую» часть.

Несогласованные типы (INT vs TEXT)

SELECT  c.id, o.id
FROM    customers  c         -- id VARCHAR
JOIN    orders     o         -- customer_id INT
        ON c.id = o.customer_id;

20 млн строк, но план упорно читает orders по PK, а customers — seq scan: индекс не пригодился.

Сначала СУБД приводит младший тип к старшему по precedence; здесь — orders.customer_id::text. Индекс на orders.customer_id игнорируется. В MS SQL эта же проблема вызывает «implicit conversion» warning.

Приводим оба поля к одному типу в схеме, а не в запросе. В крайнем случае создаём computed/virtual column и индексируем её. CI‑проверка: simple script, который ищет :: или CAST( внутри JOIN … ON.

OR в ON-условии: оптимизатор разводит руками

SELECT  *
FROM    payments p
JOIN    invoices i
      ON (i.id = p.invoice_id OR i.external_id = p.invoice_external_id);

План показывает два seq scan + nested loop, даже при индексах.

OR делает условие неконъюнктивным; оценка селективности падает, индексы часто игнорируются.

Поэтому разбиваем на UNION ALL двух запросов, где каждое условие — в отдельном JOIN. Или ставим partial indexes и используем UNION. В PostgreSQL иногда помогает ENABLE_SEQSCAN = off для теста — если видите 10х ускорение, знаете, где копать.

Не-саргабельные выражения (DATE(created_at) = …)

SELECT  *
FROM    logs l
JOIN    users u ON u.id = l.user_id
WHERE   DATE(l.created_at) = CURRENT_DATE;   -- ах да, надо же «только за сегодня»

Каждый вызов DATE() ‑ того же порядка, что FULL SCAN: 6 сек вместо 50 мс.

Функция над колонкой — тот же чёрный ящик, что и в кейсе #2: индекс теряется, plan — seq scan на 14 ГБ.

Переписываем диапазоном

WHERE l.created_at >= CURRENT_DATE
  AND l.created_at <  CURRENT_DATE + INTERVAL '1 day';

Ставим expression index CREATE INDEX … (DATE(created_at)) — но тогда успокаиваем DBA и объясняем, почему так надо.

Join без индексов: «слепой merge-join»

SELECT  *
FROM      big_a a
JOIN      big_b b ON b.a_id = a.id;

С обеих сторон full scan + sort + merge, время растёт O(n log n).

Без b‑tree под ключом a_id оптимизатор вынужден сортировать оба результата. В MySQL EXPLAIN покажет type: ALL = full scan.

Фиксим:

CREATE INDEX CONCURRENTLY idx_big_b_a_id ON big_b (a_id);

Не забываем про analyze, иначе статистика старая и PG продолжит seq scan потому что думает, что это дёшево.

CROSS APPLY / LATERAL как цикл «for each row»

SELECT  u.id,
        l.last_login
FROM    users u
CROSS APPLY (
   SELECT  last_login
   FROM    logins
   WHERE   user_id = u.id
   ORDER BY created_at DESC
   LIMIT 1
) l;

Для 500 k пользователей — 500 k * (индекс‑lookup + sort), latency — минуты.

CROSS APPLY (SQL Server) или LATERAL (Postgres) запускает подзапрос для каждой строки исходной таблицы. Если внутри сортировка/агрегация — получаем N раз.

Реписываем на оконные функции:

SELECT DISTINCT ON (u.id)
       u.id,
       l.last_login
FROM   users u
JOIN   logins l ON l.user_id = u.id
ORDER  BY u.id, l.created_at DESC;

Или агрегация + join:

WITH last AS (
    SELECT user_id, MAX(created_at) AS last_login
    FROM   logins
    GROUP  BY user_id
)
SELECT u.id, last.last_login
FROM   users u
LEFT JOIN last ON last.user_id = u.id;

JOIN к материализованному виду-чудовищу

SELECT  *
FROM    orders o
JOIN    sales_report_monthly m
          ON m.customer_id = o.customer_id;

sales_report_monthly — это SELECT … GROUP BY month, 150 GB, no indexes.

Часто выносят логику в view или CTE, а потом соединяют без ограничений. Оптимизатор разворачивает view как есть, и join происходит поверх огромного, уже агрегированного датасета.

Материализуем результат (Materialized View) + индекс. Или инлайн‑агрегация: считаем GROUP BY на минимальном подмножестве, потом join. Не боимся временных таблиц, они часто дешевле многократного пересчёта.

Fan-out join: many-to-many + отсутствие DISTINCT

SELECT  p.id, t.tag
FROM    products p
JOIN    products_tags pt ON pt.product_id = p.id
JOIN    tags t           ON t.id = pt.tag_id;

В семантике это ок, но фронт рассчитывает получить по одному тегу, а вместо этого — тысячи дубликатов, JSON‑ответ пухнет.

Каждый join множит строки; без "UNIQUE" в products_tags или DISTINCT после join получаем лавину. Плюс — order by после join сортирует уже раздутую выборку.

Ставим UNIQUE (product_id, tag_id) — плюс DB сама гарантирует консистентность. На стороне SQL — SELECT p.id, ARRAY_AGG(t.tag) … GROUP BY p.id (Postgres) или JSON_ARRAYAGG в MySQL 8.

В тяжелых случаях — денормализованное поле «tags jsonb» + триггер на обновление.


Вывод

JOIN — это не просто «склеить таблички». Это контракт между вами и оптимизатором. Нарушаем контракт — платим ресурсами.

Берегите индексы, не прячьте ключи в функциях, проверяйте типы и не бойтесь EXPLAIN ANALYZE.


Если вы сталкиваетесь с проблемами производительности из‑за неэффективных JOIN‑ов, приглашаем вас на открытый урок «SQL: Оконные функции — когда GROUP BY уже не хватает», который пройдет 2 июля в 20:00. Узнайте, как избежать ошибок, которые могут «сломать» ваш продакшн.

Также пройдите вступительное тестирование, чтобы оценить свой уровень знаний SQL и получить полезный гайд.

Теги:
Хабы:
+24
Комментарии11

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS