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

В этой статье давайте посмотрим, можно ли реально обойдись без join и в каких ситуациях? А также 4 способа, которые кратно ускорят ваши запросы.

Выбрасывайте join на помойку

Немного кликбейтный заголовок, но я постарался отразить свою позицию, что всегда лучше без join, чем с ним. Если конечно можно сделать без этого самого join. И я ни в коем случае не говорю, что от них вовсе стоит отказаться.

Разберем сперва более детально, почему они стоит аккуратно к ним относиться.

  1. Алгоритмическая сложность. Если отсутствуют индексы на таблице, скорее всего потребуется полное сканирование таблицы, а также потребуется время на построение хэш-таблиц для сравнения ключей, по которым идем join (hash join)

  2. Запись на диск. Если соединенных данных много и они не помещаются в оперативную память, будет происходит запись «не влезающих в память» данных на диск, а операция чтения и записи (I/O) еще одна весьма затратная операция.

  3. Увеличение строк. Если у вас join «многие-ко-многим» или вы попросту допустили ошибку и получили дубли, строк может стать гораздо больше, чем было изначально, что может заметно снизить скорость работы.

  4. Сетевые задержки. Если приходится соединять данные из различных баз данных или нод.

Способ 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 в телеграм.