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

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

Комментарии (9)


  1. petropavel
    24.06.2025 21:20

    Это во многом недостатки оптимайзера.

    JOIN по функции (LOWER(email)) — оптимайзер должен сам сообразить, что если строки сравниваются без учёта регистра, то LOWER в таком контексте это no-op. В случае UPPER в MariaDB кстати индексы будут использоваться (MDEV-31496), но в случае LOWER — ещё нет, есть над чем работать.

    LEFT JOIN + WHERE column IS NOT NULL: превращение в INNER JOIN — "План раздут, время — вдвое хуже", да нифига. В MySQL уже лет двадцать (и, очевидно, в MariaDB) как план честно превращается в INNER JOIN и будет обычный INNER JOIN, а не "выполняем LEFT JOIN и выбрасываем лишние строки", будет именно что выполняться как INNER. Кстати IS NOT NULL это ещё просто заметить, и более невинно выглядещее условие WHERE column>10, например, тоже превращает OUTER в INNER. Оптимайзер не отблагодарит, он даже не заметит разницы.

    OR в ON-условии — оптимайзер в MySQL/MariaDB не разводит руками, а использует index merge.

    Не-саргабельные выражения (DATE(created_at) = …) — где не "саргабельные", а в MariaDB вполне себе (MDEV-8320)

    JOIN к материализованному виду-чудовищу — почти десять лет (MDEV-9197) как в MariaDB оптимизатор умеет проталкивать такие условия внутрь представления до материализации, и тогда GROUP BY будет считаться только по небольшому подмножеству действительно нужных строк. Автоматически.

    Вывод: когда оптимайзер слабый, приходится оптимизировать руками.


    1. slonopotamus
      24.06.2025 21:20

      Я бы и в случае постгреса не очень доверял этой статье, потому что планы запросов нам не показали. Версия постгреса кстати тоже не указана. По-хорошему надо идти и всё перепроверять.


      1. senchik
        24.06.2025 21:20

        "В нашем клубе, джентльменам принято верить на слово" (с) А вообще на Хабре всë нужно проверять, тут истина с заблуждениями и предубеждениями рука об руку идут) Но статья в любом случае интересна)


  1. Akina
    24.06.2025 21:20

    Итог: 2 000 000 000 rows в hash join»е

    Три нуля потеряли...

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

    Ну или GENERATED COLUMN. В зависимости от СУБД - либо STORED, либо VIRTUAL, но индексированное. И связываем по такому полю.

    Хотя правильное решение при нежелании создавать подходящий индекс - не использовать LOWER(), а указать требуемый (регистронезависимый) COLLATE.

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

    На самом деле пункт должен быть шире: любой WHERE condition_by_column, за исключением column IS NULL.

    В скобках отмечу, что обычно планировщики достаточно умны, чтобы определить, что LEFT JOIN тут не нужен, и выполнить INNER JOIN. Более того, встречался со случаями, когда использование LEFT JOIN в подобном запросе давал более производительный план, чем INNER JOIN - но это действительно экзотика, связанная обычно с достаточно странной (и, как правило, давно не актуализировавшейся) статистикой данных.

    Или ставим partial indexes и используем UNION.

    Лучше всё же UNION ALL в любом случае, иначе получаем дополнительную и нафиг ненужную сортировку. Просто в одном из подзапросов пишем дополнительное условие: ON condition1 UNION ALL ON condition2 AND NOT condition1 - это будет тем быстрее, чем больше выходной набор.

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

    Ну вообще-то уже был пункт JOIN по функции (LOWER(email)), который именно об этом. Какой смысл делить на два пункта только потому, что non-SARGable условие располагается в разных секциях запроса?

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

    Во-первых, опечатка.

    Во-вторых - и это главное,- покажите мне там оконную функцию. В упор не вижу.

    А ещё - DISTINCT ON есть чисто Постгрессовская конструкция. И, покопавшись в его документации, я так и не нашёл нигде явного указания, что при использовании DISTINCT ON + ORDER BY в выходной набор попадёт именно топ-запись по указанной сортировке. А без такой гарантии запрос превратится в тыкву... может, я плохо искал? ткните пальцем, пожалуйста, в соотв. пункт документации.


    1. erogov
      24.06.2025 21:20

      https://postgrespro.ru/docs/postgresql/17/sql-select#SQL-DISTINCT

      SELECT DISTINCT ON ( выражение [, ...] ) сохраняет только первую строку из каждого набора строк, для которого данное выражение даёт одинаковые значения. Выражения DISTINCT ON обрабатываются по тем же правилам, что и выражения ORDER BY (см. выше). Заметьте, что «первая строка» каждого набора непредсказуема, если только не применяется предложение ORDER BY, определяющее, какие строки должны быть первыми.

      Об этом речь?


      1. Akina
        24.06.2025 21:20

        Это я видел. Но меня лично крайне смущает использованная формулировка, что в переводе. что в оригинале. Она похожа больше не на "это однозначно так", а на "реализация такова, что это так", прям не документация, а словно бабки вечерком на лавочке обсудили. Да, этим можно пользоваться на практике, но вот по моментам с формулировками, подобными этой, постоянно приходится держать в голове, что это current implementation и при любом обновлении может поломаться.


        1. erogov
          24.06.2025 21:20

          Не, это by design, а не current implementation. Сохраняет первую строку, первая строка определяется по правилам `ORDER BY`, а если это предложение не указано, но ССЗБ. И пример там дальше приведен совершенно однозначный.


          1. Akina
            24.06.2025 21:20

            Да верю я, что это определяется текущей реализацией, оторую никто не станет менять. Ну типа как порядок вычисления полей в MS Access или MySQL, которые вычисляются строго в порядке текста выходного набора. Там это определяется именно кодом обработки.

            Просто бывали прецеденты. Вот все же убеждены, что при простом SELECT * FROM table; данные будут отдаваться в порядке, определяемом первичным ключом, и логика получения данных сервером из хранилища для отдачи клиенту этого требует - а, оказывается, это ни разу не догма, пусть и отклонение встречается весьма редко, а достоверно его воспроизвести вообще без шансов. Или при неполной группировке, буде она допускается сервером, вот тоже все убеждены, что негруппируемые поля будут взяты из одной записи - ан нет. И опять - крайне редко и в принципе невоспроизводимо. Причём если в первом случае a ещё могу придумать причину (горячий покрывающий таблицу вторичный индекс, к примеру), то вот во втором случае мысль просто буксует в попытке придумать механизм.


  1. M-M-I
    24.06.2025 21:20

    WHERE l.created_at >= CURRENT_DATE

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

    Есть какое то объяснение, зачем дополнительное сравнение для СЕГОДНЯШНЕЙ даты создания?

    Кроме подключений к базам данных из будущего