Все мы прекрасно знаем, join – одна из самых важных вещей в любой базе данных, но в то же время одна из самых ресурсозатратных операций. Да что уж там, в мире аналитики (OLAP) десятилетиями адепты различных подходов моделирования данных все спорят без остановки что лучше: нормализация (то есть много join) или денормализация (то есть мало join). Но все понимают, если можно без join, лучше без. Проблема в том, что они нужны практически всегда.
В этой статье давайте посмотрим, можно ли реально обойдись без join и в каких ситуациях? А также 4 способа, которые кратно ускорят ваши запросы.

Выбрасывайте join на помойку
Немного кликбейтный заголовок, но я постарался отразить свою позицию, что всегда лучше без join, чем с ним. Если конечно можно сделать без этого самого join. И я ни в коем случае не говорю, что от них вовсе стоит отказаться.
Разберем сперва более детально, почему они стоит аккуратно к ним относиться.
Алгоритмическая сложность. Если отсутствуют индексы на таблице, скорее всего потребуется полное сканирование таблицы, а также потребуется время на построение хэш-таблиц для сравнения ключей, по которым идем join (hash join)
Запись на диск. Если соединенных данных много и они не помещаются в оперативную память, будет происходит запись «не влезающих в память» данных на диск, а операция чтения и записи (I/O) еще одна весьма затратная операция.
Увеличение строк. Если у вас join «многие-ко-многим» или вы попросту допустили ошибку и получили дубли, строк может стать гораздо больше, чем было изначально, что может заметно снизить скорость работы.
Сетевые задержки. Если приходится соединять данные из различных баз данных или нод.
Способ 1 — Materialized view (предварительное вычисление тяжёлых JOIN’ов)
Весьма банальное решение, но упомянуть о нем я не мог.
Проблема:
Если каждый запрос считает, на��ример, суммы заказов за последние 30 дней, база постоянно соединяет таблицы orders, order_items, users и products и выполняет агрегацию. Понятное дело это медленно. Если мы делаем подобные расчеты часто, почему бы их не переиспользовать?
Решение:
Предварительно вычислить агрегат и сохранить его в материализованном представлении (materialized view). Запросы читают уже готовые данные, а обновление представления происходит по расписанию или с помощью инкрементального обновления.
Пример для PostgreSQL:
-- создаём материализованное представление с агрегатом CREATE MATERIALIZED VIEW mv_user_monthly AS SELECT u.id AS user_id, sum(o.amount) AS total FROM users u JOIN orders o ON o.user_id = u.id WHERE o.created_at >= current_date - interval '30 days' GROUP BY u.id; -- обновление при необходимости REFRESH MATERIALIZED VIEW mv_user_monthly; -- чтение готовых данных SELECT user_id, total FROM mv_user_monthly WHERE total > 100;
Правда у такого конечно же есть своя цена - усложнение процесса обновления данных.
Совет: в некоторых случаях используйте REFRESH CONCURRENTLY, если доступ к данным мат. представления должен быть постоянным.
Когда использовать:
Дорогие агрегаты или JOIN’ы, которые могут обновляться с определённым интервалом.
Дашборды или отчеты, которые часто запрашивают одни и те же расчеты
Когда НЕ использовать:
Когда нужны реальные данные в реальном времени, которые должны отражать каждый новый запрос или запись сразу
Когда данных слишком много и лучше воспользоваться инкрементальным обновлением
Способ 2 — Все записать в JSON
Как бы тут меня не закидали тухлыми яйцами, но я считаю, весьма полезный способ, как для OLTP-нагрузок, так и для справочных данных в OLAP. И как бы JSON не хаяли адепты нормализации, я лично очень часто встречаю подобную реализацию на проектах.
Проблема:
Вы часто соединяете таблицы products , categories, suppliers и другие небольшие справочные таблицы. Для того, чтобы провести анализ по продуктам. То есть приходится соединять множество справочных таблиц с большими таблицами, чтобы вытащить справочную информацию.
Решение:
Включить небольшую справочную информацию напрямую в строку продукта, например в jsonb или отдельные поля.
При необходимости индексировать извлечённые атрибуты для быстрого поиска.
Пример для PostgreSQL:
ALTER TABLE products ADD COLUMN cat jsonb; UPDATE products p SET cat = jsonb_build_object('id', c.id, 'name', c.name) FROM categories c WHERE p.category_id = c.id; CREATE INDEX idx_products_cat_name ON products ((cat->>'name')); -- чтение SELECT id, cat->>'name' AS category FROM products WHERE id = $1;
И тут конечно есть цена - при обновлении какого-либо ключа в json нужно полностью обновлять данный json или смириться с eventual consistency (когда данные время от времени не согласованы, но мы это принимаем).
Когда использовать:
Маленькие справочные таблицы, которые редко меняются.
API, ориентированные на чтение, где одна строка содержит все необходимые данные.
Когда не использовать:
Атрибуты с высокой кардинальностью, которые сильно увеличивают размер строки.
Способ 3 — Покрывающие индексы
В данном способе не совсем про отказ от join-ов. Написал про нее, потому что штука весьма полезная, но про далеко не все знают.
Сперва немного теории. Чем отличается покрывающий индекс от обычного?
Покрывающий индекс — это индекс, который включает не только ключи поиска, но и все колонки, необходимые для выполнения конкретного запроса.
Пример:
Обычный индекс:
CREATE INDEX idx_orders_status ON orders(status);
Если запрос выбирает id и total по status, база после поиска по индексу всё равно идёт к основной таблице за этими колонками
Покрывающий индекс:
CREATE INDEX idx_orders_cover ON orders(status) INCLUDE(id, total);
Теперь запрос SELECT id, total FROM orders WHERE status = 'paid'; полностью обслуживается через индекс (index-only scan), без дополнительных обращений к таблице.
Получается, мы не обращаемся к самой таблице, читаем только индекс и за счет этого кратно ускоряем само чтение. Весьма полезно, особенно когда к вашей таблице часто обращаются (даже в join-е), чтобы забрать определенные столбцы.
Когда использовать:
Очень частые запросы с простыми фильтрами и небольшим числом возвращаемых колонок.
Когда таблица большая, и полный scan слишком дорог.
Когда не использовать:
Если запрос возвращает много колонок, которые не помещаются в индекс — индекс станет слишком «тяжёлым» и неэффективным.
Способ 4 — Кеширование на стороне приложения (Redis или in-memory)
Проблема:
Каждый запрос часто обращается к маленькой, но часто используемой справочной таблице, например к профилю пользователя или настройкам организации. JOIN или lookup в базе повторяется при каждом запросе, что нагружает БД.
Решение:
Сохранять маленькую справочную информацию в быстром key-value хранилище (Redis) или в памяти приложения.
В «горячем» пути читать кеш. При отсутствии кэша обращаться к базе и обновлять кеш.
Пример на Python + Redis:
def get_user(uid): key = f"user:{uid}" data = r.hgetall(key) if not data: data = db.one("SELECT id,name,region FROM users WHERE id = %s", uid) r.hset(key, mapping=data) return data # потом используем get_user вместо JOIN с users orders = db.all("SELECT id,total FROM orders WHERE user_id = %s", uid)
Когда использовать:
Маленькие таблицы, часто читаемые, которые редко изменяются.
Когда сетевой доступ к Redis быстрее, чем повторный JOIN или холодное чтение из БД.
Когда не использовать:
Для чувствительных данных, которые должны быть консистентны при каждом чтении, если нет строгого механизма обновления кеша.
Заключение
Подведем итоги
Как выбирать между четырьмя паттернами
Если нужен агрегат, используйте материализованное представление.
Если нужны несколько маленьких справочных полей, денормализуйте или храните в
JSONB.Если часто читаете определенные столбцы по определенным фильтрам, используйте покрывающий индекс.
Если нужен один маленький справочный объект на запрос, кешируйте в Redis.
Как говорится, join-ов боятся в лес не ходить. Конечно, от них не откажешься да и не за чем. Слишком уж они удобный инструмент. Но иногда лучше рассмотреть альтернативные варианты.
Пишу про базы данных и data engineering в телеграм.
