Как писать эффективные SQL-запросы под доменную модель: меньше JOIN’ов, больше ясности
Разберём технику проектирования запросов от сущностей к данным: фильтрация, агрегации, ограничение полей и предсказуемые результаты. Покажем, как избегать дублирования строк и “случайных” декартовых произведений.
Содержание
Как писать эффективные SQL-запросы под доменную модель: меньше JOIN’ов, больше ясности
Производительность SQL-запросов часто обсуждают как проблему оптимизатора: «настройте индексы», «проверьте план», «перепишите под конкретную СУБД». Это важно, но есть ещё один слой — проектирование запроса исходя из доменной модели.
Если запрос рождается от структуры таблиц, он легко превращается в цепочку JOIN’ов ради JOIN’ов: появляются дубли строк, “случайные” декартовы произведения, непредсказуемые агрегаты и необходимость гадать, почему результат выглядит иначе, чем ожидалось.
Если же запрос проектируется от сущностей предметной области — с чёткими границами «что именно мы считаем», «какую сущность возвращаем» и «какие атрибуты нужны» — то SQL становится и быстрее, и понятнее. Он реже требует тяжёлых исправлений и проще проверяется на корректность.
Ниже — техника, которую удобно применять в реальных проектах: строим запрос по шагам от сущности к данным, минимизируем лишние JOIN’ы, контролируем кардинальность и гарантируем предсказуемость результатов.
От сущности к данным: базовая схема мышления
Доменная модель (агрегаты, сущности, связи, ограничения) подсказывает, какие строки являются “единицей результата”. Это ключевой момент: пока вы не определили единицу — вы будете вынуждены «чинить» SQL по факту.
“Единица результата” (grain)
Примеры:
- Запрос возвращает список заказов: grain — заказ.
- Запрос возвращает статистику по пользователям: grain — пользователь.
- Запрос возвращает таблицу позиций в заказе: grain — позиция (order_item).
Если grain задан, можно безопасно:
- фильтровать по тем атрибутам, которые относятся к соответствующей сущности,
- агрегировать так, чтобы не перемешать разные уровни детализации,
- ограничивать набор полей без лишних связей.
Если grain не задан, JOIN’ы неизбежно “размножат” строки и агрегаты начнут считать не то.
Семантические уровни: фильтрация → агрегация → форма результата
Для большинства доменных запросов полезно придерживаться порядка:
- Фильтрация на уровне сущности, которая задаёт grain.
- Агрегации на уровне той же сущности или дочерней (например, суммы по позициям заказа).
- Ограничение полей (SELECT-list) и приведение к форме результата.
- Проверка кардинальности: не получили ли мы неожиданное увеличение строк.
- (Опционально) Подключение “описательных” данных (справочники, статусы) — уже после того как основной набор строк сформирован.
Эта схема часто позволяет обходиться меньшим количеством JOIN’ов и избегать повторяющегося дублирования.
Меньше JOIN’ов: когда они действительно нужны
JOIN — мощный инструмент, но он не бесплатен: он влияет на кардинальность, может требовать дополнительных индексов, а главное — усложняет доказательство корректности.
Виды JOIN’ов по их роли
Условно JOIN’ы можно разделить на несколько категорий:
- JOIN для фильтрации
Например, фильтруем заказы по статусу. Здесь JOIN может быть необходим. - JOIN для расширения результата атрибутами
Например, добавляем имя клиента к заказу. Можно часто заменить на более аккуратный JOIN или подзапрос. - JOIN для агрегаций
Например, считаем сумму по позициям. Тут JOIN почти неизбежен, но его нужно “закрепить” к grain. - JOIN для “косметики”, которая не нужна прямо сейчас
Частая причина лишних JOIN’ов: запрос тянет половину справочников только потому, что они “могут пригодиться”.
Техника проектирования от сущностей помогает определить, какие категории действительно нужны на данном шаге. Обычно “косметика” и часть справочных JOIN’ов подключаются позже — когда кардинальность уже зафиксирована.
JOIN как источник декартовых произведений
Декартово произведение случается, когда вы соединяете таблицы без корректного условия кратности, или когда логическая модель допускает множественные связи, а вы ожидаете “одно значение”.
Пример: если у заказа может быть несколько платежей, а вы JOIN’ите orders → payments и считаете сумму, легко получить размножение позиций заказа: сумма позиций умножится на число платежей.
Это не ошибка оптимизатора — это ошибка конструкции запроса: вы не учли уровни детализации.
Проектирование от доменной модели: пошаговая техника
Рассмотрим подход на примере типичных сущностей: customers, orders, order_items, payments, order_status_history.
Шаг 1. Определите grain результата
Предположим, задача: вывести список заказов с итоговой суммой и датой последнего статуса.
Единица результата: order.
Значит, финальный SELECT должен возвращать по одной строке на заказ.
Шаг 2. Сначала — фильтрация заказов
Допустим, вы хотите заказы за период и только определённого типа.
SELECT o.id, o.created_at
FROM orders o
WHERE o.created_at >= DATE '2026-01-01'
AND o.created_at < DATE '2026-02-01'
AND o.type = 'retail';
На этом этапе ещё не трогаем позиции, платежи и историю статусов. Мы фиксируем набор заказов, к которому будем применять дальнейшие вычисления.
Почему это важно: если фильтровать “после” JOIN’ов, можно размножить строки и неявно получить иной набор агрегатов.
Шаг 3. Агрегируйте “дочерние” данные в отдельном шаге
Итоговая сумма заказа зависит от order_items — дочерней сущности к заказу.
Чтобы не размножить строки в финальном результате, агрегируйте позиции до JOIN с заказом.
WITH items_sum AS (
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM order_items oi
GROUP BY oi.order_id
)
SELECT
o.id,
o.created_at,
COALESCE(s.order_total, 0) AS order_total
FROM orders o
LEFT JOIN items_sum s
ON s.order_id = o.id;
Преимущества:
- финальный grain остаётся по заказам;
- агрегат рассчитан один раз;
LEFT JOINбезопасен: если позиций нет, сумма будет0.
Заметьте: здесь JOIN нужен, но он контролируемый — это JOIN агрегата, у которого одна строка на order_id.
Шаг 4. Получите “последнее состояние” без разведения строк
История статусов — классический источник размножения. Если order_status_history содержит много строк на заказ, нельзя просто JOIN’ить её и ожидать «одна запись = текущее состояние».
Корректный подход: выделить последнюю запись (по changed_at, например) в отдельном CTE.
WITH items_sum AS (
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM order_items oi
GROUP BY oi.order_id
),
last_status AS (
SELECT
osh.order_id,
osh.status_code,
osh.changed_at,
ROW_NUMBER() OVER (PARTITION BY osh.order_id ORDER BY osh.changed_at DESC) AS rn
FROM order_status_history osh
)
SELECT
o.id,
o.created_at,
COALESCE(s.order_total, 0) AS order_total,
ls.status_code AS current_status,
ls.changed_at AS status_changed_at
FROM orders o
LEFT JOIN items_sum s
ON s.order_id = o.id
LEFT JOIN last_status ls
ON ls.order_id = o.id
AND ls.rn = 1;
Здесь тоже JOIN работает с “схлопнутыми” данными: last_status возвращает одну строку на заказ.
Важный нюанс: ROW_NUMBER() гарантирует предсказуемость при наличии нескольких одинаковых changed_at — но тогда нужен дополнительный критерий сортировки (например, id).
Шаг 5. Ограничьте поля и не тащите лишнее
Если интерфейс показывает только id, created_at, order_total и current_status, не выбирайте лишние колонки из таблиц, которые вы JOIN’ите только “на всякий случай”.
Это не просто про производительность: это про ясность. Чем меньше колонок, тем проще проверить кардинальность и корректность.
Шаг 6. Проверьте кардинальность на каждом шаге
Практика: на каждом CTE проверяйте, что у него ожидаемая “единица”.
Например, items_sum должен иметь максимум одну строку на order_id. Если вдруг у вас там будет много строк на order_id, значит группа по неправильному набору полей или логика неверна.
Проверка:
SELECT order_id, COUNT(*)
FROM items_sum
GROUP BY order_id
HAVING COUNT(*) > 1;
В идеале результат пуст.
Избегаем дублирования строк: типовые сценарии и анти-паттерны
Анти-паттерн 1: JOIN дочерних таблиц “в лоб” и агрегирование поверх
Частая ошибка выглядит так:
- JOIN
orders→order_items - JOIN
orders→payments - SUM по
order_items
Проблема: если платежей несколько, строки order_items умножаются на число платежей, и сумма становится больше реальной.
Правильный вариант — агрегировать дочерние сущности в отдельных CTE и затем JOIN’ить агрегаты к заказу, как в примере выше.
Анти-паттерн 2: Смешение уровней детализации в одной SELECT-агрегации
Допустим, вы хотите вывести по пользователю:
- общее число заказов,
- общую сумму,
- и при этом показываете
order_date(как будто он один).
Если вы SELECT’ите order_date, но группируете по пользователю, нужно решать семантику: какой order_date? первый? последний? средний?
Логика должна соответствовать доменной задаче. Если нужно “последний заказ пользователя” — это отдельная сущность результата (grain = user_last_order), а не просто атрибут в user-агрегации.
Анти-паттерн 3: “Текущий статус” через простой JOIN
Если вы JOIN’ите историю статусов без ограничения на последнюю строку, вы получите несколько строк на заказ. Иногда это “проглатывается” DISTINCT, но это не исправление, а маскировка.
Корректный подход — выбрать одну запись на заказ (через ROW_NUMBER() или агрегирование по максимальной дате с последующим связыванием).
Фильтрация и ограничение полей: предсказуемые результаты вместо сюрпризов
Фильтрация “внутри” правильного grain
Правило:
- Если условие относится к сущности результата — применяйте его до JOIN к дочерним таблицам.
- Если условие относится к дочерним таблицам — сначала ограничьте дочерние данные, затем агрегируйте.
Например, нужно считать сумму заказа только по позициям с конкретной категорией. Тогда вы фильтруете order_items внутри items_sum, а не на уровне итогового заказа.
WITH items_sum AS (
SELECT
oi.order_id,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM order_items oi
WHERE oi.category_code = 'electronics'
GROUP BY oi.order_id
)
SELECT
o.id,
COALESCE(s.order_total, 0) AS electronics_total
FROM orders o
LEFT JOIN items_sum s
ON s.order_id = o.id;
Так вы получаете предсказуемую семантику: сумма — “по отфильтрованным позициям”, а не “по всем позициям, но только если где-то было условие”.
Ограничение полей в SELECT-list
Часто код разрастается из-за привычки выбирать “всё, что есть”. Для аналитики это приводит к:
- лишней сериализации и памяти,
- сложнее проверять корректность,
- JOIN’ы начинают тащить дополнительные колонки, чтобы “не переписывать позже”.
Ограничивайте поля на этапе проектирования: сначала grain и ключи, потом нужные атрибуты.
Ограничение JOIN’ов через EXISTS и полу-соединения
Иногда JOIN нужен только для фильтрации существования, а не для получения данных. В таком случае можно использовать EXISTS, чтобы избежать размножения строк.
Пример: выбрать заказы, где есть хотя бы один платеж со статусом failed.
Небрежный подход может сделать много строк:
SELECT o.id
FROM orders o
JOIN payments p ON p.order_id = o.id
WHERE p.status = 'failed';
Если на заказ есть несколько failed-платежей, запрос вернёт дубликаты o.id.
Исправление через DISTINCT не всегда лучший вариант: он может быть дорогим и скрывает проблему.
Предпочтительнее:
SELECT o.id
FROM orders o
WHERE EXISTS (
SELECT 1
FROM payments p
WHERE p.order_id = o.id
AND p.status = 'failed'
);
EXISTS сохраняет grain и делает намерение явным: “факт существования”, а не “сбор строк для дальнейшей агрегации”.
Предсказуемые результаты: как формализовать правила
Чтобы SQL был не только быстрым, но и стабильным при изменениях схемы, полезно формализовать правила.
1) Каждая CTE соответствует одному смыслу и grain
items_sum: сумма по заказу (одна строка наorder_id).last_status: последнее состояние (одна строка наorder_id).filtered_orders: набор заказов по датам и базовым условиям.
Если CTE смешивает смыслы, вы получаете “магическую” зависимость результата от данных, а не от логики запроса.
2) У агрегаций и оконных функций — явная цель
GROUP BYдолжен соответствовать grain результата агрегации.ROW_NUMBER()должен иметь определённый порядок (и при необходимости tie-breaker).
3) SELECT-list отражает пользовательский контракт
Если потребитель API или отчёт ожидает набор полей “на заказ”, то и SELECT должен быть “на заказ”. Лишние атрибуты из дочерних таблиц будут вызывать либо дубли, либо неявные агрегаты (и тогда семантика расплывается).
Практический мини-чеклист перед запуском запроса
- Что является единицей результата? (заказ? позиция? пользователь?)
- Где применяются фильтры? До JOIN’ов или внутри соответствующих CTE?
- Есть ли дочерние JOIN’ы, которые могут размножить строки?
Если да — агрегируйте дочерние таблицы заранее или используйтеEXISTS. - Нет ли смешения уровней детализации? (атрибуты дочерней сущности в агрегате родительской)
- Выбран ли “текущий” элемент истории корректно? (последняя запись, а не произвольная)
- Ожидаемая кардинальность каждой CTE подтверждается? (быстрые проверки
COUNT/GROUP BY) - SELECT-list минимален и соответствует контракту?
Этот чеклист часто экономит больше времени, чем попытки “угадать” по плану выполнения.
Когда всё-таки нужны “много JOIN’ов”
Иногда проектирование “от сущностей” приводит не к минимальному числу JOIN’ов, а к более структурированным JOIN’ам: к JOIN’ам агрегатов, справочников и “схлопнутых” CTE. Число JOIN’ов может остаться высоким, но проблема дубликатов и декартовых произведений уходит.
Критерий качества здесь не в том, чтобы убрать каждое соединение, а в том, чтобы:
- соединения были семантически обоснованы,
- grain не разрушался,
- агрегаты считались на правильном уровне.
Если запрос стал понятнее — значит вы нашли правильную точку.
Как это освоить на практике
Чаще всего инженеры сталкиваются с доменной моделью уже в готовом виде, но не умеют превращать её в грамотный SQL. Хороший способ — идти от малого: научиться писать CTE для агрегатов, выбирать “последнее состояние” и заменять JOIN на EXISTS там, где нужен только фильтр существования.
Если вы начинаете с нуля или возвращаетесь к базе, полезным стартом может быть курс «SQL – для начинающих!» — как способ упорядочить фундаментальные конструкции, на которых дальше легче применять описанную технику.
Выводы
Эффективные SQL-запросы под доменную модель — это не магия оптимизатора и не бесконечная настройка индексов. Это дисциплина проектирования: вы выбираете единицу результата, фильтруете на нужном уровне, агрегируете дочерние сущности в “схлопнутом” виде, а справочные данные и “последние состояния” подключаете контролируемыми способами.
Практический итог:
- меньше JOIN’ов “в лоб” → меньше размножения строк;
- больше агрегатов и оконных функций, сделанных осознанно → предсказуемая семантика;
- меньше SELECT “на всякий случай” → запросы легче читать и проверять;
- результаты становятся стабильными
Комментарии
Пока нет комментариев