$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогКак писать эффективные SQL-запросы под доменную модель: меньше JOIN’ов, больше ясности

Как писать эффективные SQL-запросы под доменную модель: меньше JOIN’ов, больше ясности

$ sudo teach IT
·25 июля 2026 г.·10 мин·63
Как писать эффективные SQL-запросы под доменную модель: меньше JOIN’ов, больше ясности

Разберём технику проектирования запросов от сущностей к данным: фильтрация, агрегации, ограничение полей и предсказуемые результаты. Покажем, как избегать дублирования строк и “случайных” декартовых произведений.

Содержание
От сущности к данным: базовая схема мышления“Единица результата” (grain)Семантические уровни: фильтрация → агрегация → форма результатаМеньше JOIN’ов: когда они действительно нужныВиды JOIN’ов по их ролиJOIN как источник декартовых произведенийПроектирование от доменной модели: пошаговая техникаШаг 1. Определите grain результатаШаг 2. Сначала — фильтрация заказовШаг 3. Агрегируйте “дочерние” данные в отдельном шагеШаг 4. Получите “последнее состояние” без разведения строкШаг 5. Ограничьте поля и не тащите лишнееШаг 6. Проверьте кардинальность на каждом шагеИзбегаем дублирования строк: типовые сценарии и анти-паттерныАнти-паттерн 1: JOIN дочерних таблиц “в лоб” и агрегирование поверхАнти-паттерн 2: Смешение уровней детализации в одной SELECT-агрегацииАнти-паттерн 3: “Текущий статус” через простой JOINФильтрация и ограничение полей: предсказуемые результаты вместо сюрпризовФильтрация “внутри” правильного grainОграничение полей в SELECT-listОграничение JOIN’ов через EXISTS и полу-соединенияПредсказуемые результаты: как формализовать правила1) Каждая CTE соответствует одному смыслу и grain2) У агрегаций и оконных функций — явная цель3) SELECT-list отражает пользовательский контрактПрактический мини-чеклист перед запуском запросаКогда всё-таки нужны “много JOIN’ов”Как это освоить на практикеВыводы

Производительность SQL-запросов часто обсуждают как проблему оптимизатора: «настройте индексы», «проверьте план», «перепишите под конкретную СУБД». Это важно, но есть ещё один слой — проектирование запроса исходя из доменной модели.

Если запрос рождается от структуры таблиц, он легко превращается в цепочку JOIN’ов ради JOIN’ов: появляются дубли строк, “случайные” декартовы произведения, непредсказуемые агрегаты и необходимость гадать, почему результат выглядит иначе, чем ожидалось.

Если же запрос проектируется от сущностей предметной области — с чёткими границами «что именно мы считаем», «какую сущность возвращаем» и «какие атрибуты нужны» — то SQL становится и быстрее, и понятнее. Он реже требует тяжёлых исправлений и проще проверяется на корректность.

Ниже — техника, которую удобно применять в реальных проектах: строим запрос по шагам от сущности к данным, минимизируем лишние JOIN’ы, контролируем кардинальность и гарантируем предсказуемость результатов.


От сущности к данным: базовая схема мышления

Доменная модель (агрегаты, сущности, связи, ограничения) подсказывает, какие строки являются “единицей результата”. Это ключевой момент: пока вы не определили единицу — вы будете вынуждены «чинить» SQL по факту.

“Единица результата” (grain)

Примеры:

  • Запрос возвращает список заказов: grain — заказ.
  • Запрос возвращает статистику по пользователям: grain — пользователь.
  • Запрос возвращает таблицу позиций в заказе: grain — позиция (order_item).

Если grain задан, можно безопасно:

  • фильтровать по тем атрибутам, которые относятся к соответствующей сущности,
  • агрегировать так, чтобы не перемешать разные уровни детализации,
  • ограничивать набор полей без лишних связей.

Если grain не задан, JOIN’ы неизбежно “размножат” строки и агрегаты начнут считать не то.

Семантические уровни: фильтрация → агрегация → форма результата

Для большинства доменных запросов полезно придерживаться порядка:

  1. Фильтрация на уровне сущности, которая задаёт grain.
  2. Агрегации на уровне той же сущности или дочерней (например, суммы по позициям заказа).
  3. Ограничение полей (SELECT-list) и приведение к форме результата.
  4. Проверка кардинальности: не получили ли мы неожиданное увеличение строк.
  5. (Опционально) Подключение “описательных” данных (справочники, статусы) — уже после того как основной набор строк сформирован.

Эта схема часто позволяет обходиться меньшим количеством JOIN’ов и избегать повторяющегося дублирования.


Меньше JOIN’ов: когда они действительно нужны

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

Виды JOIN’ов по их роли

Условно JOIN’ы можно разделить на несколько категорий:

  1. JOIN для фильтрации
    Например, фильтруем заказы по статусу. Здесь JOIN может быть необходим.
  2. JOIN для расширения результата атрибутами
    Например, добавляем имя клиента к заказу. Можно часто заменить на более аккуратный JOIN или подзапрос.
  3. JOIN для агрегаций
    Например, считаем сумму по позициям. Тут JOIN почти неизбежен, но его нужно “закрепить” к grain.
  4. JOIN для “косметики”, которая не нужна прямо сейчас
    Частая причина лишних JOIN’ов: запрос тянет половину справочников только потому, что они “могут пригодиться”.

Техника проектирования от сущностей помогает определить, какие категории действительно нужны на данном шаге. Обычно “косметика” и часть справочных JOIN’ов подключаются позже — когда кардинальность уже зафиксирована.

JOIN как источник декартовых произведений

Декартово произведение случается, когда вы соединяете таблицы без корректного условия кратности, или когда логическая модель допускает множественные связи, а вы ожидаете “одно значение”.

Пример: если у заказа может быть несколько платежей, а вы JOIN’ите orders → payments и считаете сумму, легко получить размножение позиций заказа: сумма позиций умножится на число платежей.

Это не ошибка оптимизатора — это ошибка конструкции запроса: вы не учли уровни детализации.


Проектирование от доменной модели: пошаговая техника

Рассмотрим подход на примере типичных сущностей: customers, orders, order_items, payments, order_status_history.

Шаг 1. Определите grain результата

Предположим, задача: вывести список заказов с итоговой суммой и датой последнего статуса.

Единица результата: order.

Значит, финальный SELECT должен возвращать по одной строке на заказ.

Шаг 2. Сначала — фильтрация заказов

Допустим, вы хотите заказы за период и только определённого типа.

code
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 с заказом.

code
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.

code
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, значит группа по неправильному набору полей или логика неверна.

Проверка:

code
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, а не на уровне итогового заказа.

code
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.

Небрежный подход может сделать много строк:

code
SELECT o.id
FROM orders o
JOIN payments p ON p.order_id = o.id
WHERE p.status = 'failed';

Если на заказ есть несколько failed-платежей, запрос вернёт дубликаты o.id.

Исправление через DISTINCT не всегда лучший вариант: он может быть дорогим и скрывает проблему.

Предпочтительнее:

code
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 должен быть “на заказ”. Лишние атрибуты из дочерних таблиц будут вызывать либо дубли, либо неявные агрегаты (и тогда семантика расплывается).


Практический мини-чеклист перед запуском запроса

  1. Что является единицей результата? (заказ? позиция? пользователь?)
  2. Где применяются фильтры? До JOIN’ов или внутри соответствующих CTE?
  3. Есть ли дочерние JOIN’ы, которые могут размножить строки?
    Если да — агрегируйте дочерние таблицы заранее или используйте EXISTS.
  4. Нет ли смешения уровней детализации? (атрибуты дочерней сущности в агрегате родительской)
  5. Выбран ли “текущий” элемент истории корректно? (последняя запись, а не произвольная)
  6. Ожидаемая кардинальность каждой CTE подтверждается? (быстрые проверки COUNT/GROUP BY)
  7. SELECT-list минимален и соответствует контракту?

Этот чеклист часто экономит больше времени, чем попытки “угадать” по плану выполнения.


Когда всё-таки нужны “много JOIN’ов”

Иногда проектирование “от сущностей” приводит не к минимальному числу JOIN’ов, а к более структурированным JOIN’ам: к JOIN’ам агрегатов, справочников и “схлопнутых” CTE. Число JOIN’ов может остаться высоким, но проблема дубликатов и декартовых произведений уходит.

Критерий качества здесь не в том, чтобы убрать каждое соединение, а в том, чтобы:

  • соединения были семантически обоснованы,
  • grain не разрушался,
  • агрегаты считались на правильном уровне.

Если запрос стал понятнее — значит вы нашли правильную точку.


Как это освоить на практике

Чаще всего инженеры сталкиваются с доменной моделью уже в готовом виде, но не умеют превращать её в грамотный SQL. Хороший способ — идти от малого: научиться писать CTE для агрегатов, выбирать “последнее состояние” и заменять JOIN на EXISTS там, где нужен только фильтр существования.

Если вы начинаете с нуля или возвращаетесь к базе, полезным стартом может быть курс «SQL – для начинающих!» — как способ упорядочить фундаментальные конструкции, на которых дальше легче применять описанную технику.


Выводы

Эффективные SQL-запросы под доменную модель — это не магия оптимизатора и не бесконечная настройка индексов. Это дисциплина проектирования: вы выбираете единицу результата, фильтруете на нужном уровне, агрегируете дочерние сущности в “схлопнутом” виде, а справочные данные и “последние состояния” подключаете контролируемыми способами.

Практический итог:

  • меньше JOIN’ов “в лоб” → меньше размножения строк;
  • больше агрегатов и оконных функций, сделанных осознанно → предсказуемая семантика;
  • меньше SELECT “на всякий случай” → запросы легче читать и проверять;
  • результаты становятся стабильными

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

SQL – для начинающих!

Курс по теме

SQL – для начинающих!

С нуля до уверенных запросов на SQL. Никакой теории без дела — только живые запросы, настоящие данные и готовность к работе с любой базой. Хватит гуглить «как написать JOIN» — разберись один раз и забудь.

Бесплатно
Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

SQL: диаграмма жизненного цикла индекса — от EXPLAIN до теста на регрессию производительности
диаграмма

SQL: диаграмма жизненного цикла индекса — от EXPLAIN до теста на регрессию производительности

Покажем практический процесс: как определить, какой индекс реально используется, когда он бесполезен, как проверить влияние после изменений схемы и как не словить деградацию запросов в будущем.

16 сентября 2026 г.
1050
Как читать и писать REST API контракты: статусы, ошибки и схемы ответов
читать

Как читать и писать REST API контракты: статусы, ошибки и схемы ответов

Разберём типовые ошибки в дизайне API и научимся фиксировать контракт: коды HTTP, формат ошибок, версионирование и примеры запросов/ответов.

20 июля 2026 г.
910
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

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

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

От сущности к данным: базовая схема мышления“Единица результата” (grain)Семантические уровни: фильтрация → агрегация → форма результатаМеньше JOIN’ов: когда они действительно нужныВиды JOIN’ов по их ролиJOIN как источник декартовых произведенийПроектирование от доменной модели: пошаговая техникаШаг 1. Определите grain результатаШаг 2. Сначала — фильтрация заказовШаг 3. Агрегируйте “дочерние” данные в отдельном шагеШаг 4. Получите “последнее состояние” без разведения строкШаг 5. Ограничьте поля и не тащите лишнееШаг 6. Проверьте кардинальность на каждом шагеИзбегаем дублирования строк: типовые сценарии и анти-паттерныАнти-паттерн 1: JOIN дочерних таблиц “в лоб” и агрегирование поверхАнти-паттерн 2: Смешение уровней детализации в одной SELECT-агрегацииАнти-паттерн 3: “Текущий статус” через простой JOINФильтрация и ограничение полей: предсказуемые результаты вместо сюрпризовФильтрация “внутри” правильного grainОграничение полей в SELECT-listОграничение JOIN’ов через EXISTS и полу-соединенияПредсказуемые результаты: как формализовать правила1) Каждая CTE соответствует одному смыслу и grain2) У агрегаций и оконных функций — явная цель3) SELECT-list отражает пользовательский контрактПрактический мини-чеклист перед запуском запросаКогда всё-таки нужны “много JOIN’ов”Как это освоить на практикеВыводы