Привет, Хабр!
Сегодня разбираем один из самых коварных способов убить базу — плохие JOIN
'ы. Казалось бы, простое дело: связать пару таблиц — и вперёд. Но если в ON
засунуть LOWER(email)
, забыть про индексы или перепутать LEFT JOIN
с INNER
— сервер мигом начнет дышать на ладан.
В каждой секции:
Пример, который бьет по продакшену.
Что именно ломается.
Конкретный рефакторинг + индексы + что логировать.
Когда все‑таки можно нарушать и не стыдиться.
Все примеры — 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 и получить полезный гайд.