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

от автора

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

Сегодня разбираем один из самых коварных способов убить базу — плохие 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 и получить полезный гайд.


ссылка на оригинал статьи https://habr.com/ru/articles/920004/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *