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

О чём спор: схема против оптимизации запросов
Спор обычно начинается одинаково: база данных «тормозит», и первая реакция — ускорять SELECT. Добавляют LIMIT, переписывают JOIN, включают кэш, подбирают «хитрые» индексы и радуются, что запрос стал выполняться в 10 раз быстрее.
Параллельно почти никто не задаёт более неприятный вопрос: а правильная ли у нас модель данных? То есть как именно сущности разложены по таблицам, где хранятся связи, какие типы и ограничения выбраны, что считается источником правды.
Почему многие начинают с запросов
На раннем этапе данных мало, реальной нагрузки почти нет, а тестовые замеры легко «выиграть» микрооптимизациями. Кажется, что проблема решена — пока не появляется рост пользователей, больше записей и новые сценарии (фильтры, отчёты, интеграции).
Именно поэтому ранняя скорость часто обманчива: запросы можно подогнать под текущий объём, но они не проверяют, выдержит ли модель данные через полгода.
Главная мысль статьи
Проектирование схемы БД задаёт пределы для любой оптимизации запросов.
Если модель вынуждает постоянно делать тяжёлые JOIN по неудачным ключам, хранит неоднозначные статусы, допускает дубли или плохо отражает реальные связи, то «ускорение SELECT» превращается в бесконечную череду заплаток. Напротив, хорошо продуманная схема снижает сложность запросов, делает индексацию предсказуемой и помогает масштабироваться.
Кому это будет полезно
- продактам — чтобы понимать, почему «быстро починить запрос» не всегда экономит время;
- аналитикам — чтобы модель не ломала отчётность и метрики;
- разработчикам — особенно без глубокой специализации в администрировании БД, чтобы принимать более устойчивые решения на старте.
Почему схема задаёт потолок производительности
Производительность базы данных ограничивается не тем, насколько хитро вы напишете SQL, а тем, какие данные и связи заложены в модель. Схема — это «физика мира» вашего приложения: она определяет, какие запросы естественны, сколько действий потребуется для ответа и сколько «лишней работы» будет делать СУБД.
Какие запросы получаются «родными»
Хорошо спроектированная схема делает частые сценарии прямолинейными: один‑два JOIN, понятные фильтры, предсказуемые индексы. Плохая схема заставляет собирать информацию из множества таблиц, где связь выражена косвенно или вовсе не выражена. В итоге появляются лишние JOIN, подзапросы, CTE и обходные пути, которые трудно ускорять без изменения структуры данных.
Потолок появляется там, где начинаются обходные пути
Когда логическая сущность разорвана на несколько таблиц без ясной причины или, наоборот, свалена в одну «универсальную» таблицу, запросы начинают:
- читать больше строк, чем нужно;
- делать больше соединений, чем оправдано бизнес-логикой;
- постоянно «вычислять» связи на лету (например, через строковые ключи или JSON-поля).
Такие решения увеличивают объём работы для планировщика и диска, а также осложняют индексацию. И главное — это трудно исправить точечно: вы упираетесь в потолок, который задаёт сама модель.
Оптимизация запросов лечит симптомы
На старте большинство «медленных запросов» — не про отсутствие хитростей в SQL, а про то, что модель данных вынуждает базу делать лишнюю работу. Тюнинг запроса может дать временный выигрыш, но если причина в схеме, улучшение быстро заканчивается: следующий похожий запрос снова будет медленным, потому что фундамент тот же.
Типичные ошибки схемы на раннем этапе
На старте хочется «быстрее запустить», поэтому схема часто растёт хаотично: добавили пару таблиц, потом ещё столбцы «на всякий случай», и вот уже любая новая фича требует боли. Эти ошибки обычно не видны в первой версии продукта, но начинают дорого стоить, когда появляются реальные данные и нагрузка.
Неправильные связи и дублирование сущностей
Частая история — перепутать 1:N и M:N. Например, у заказа может быть много товаров, и один товар может встречаться в тысячах заказов. Если попытаться запихнуть это в одну таблицу «заказы» со списком товаров или несколькими колонками product1, product2, вы получите сложные обновления, невозможность корректно считать итоги и постоянные дубликаты.
Похожая проблема — создать «две почти одинаковые сущности»: client и customer, user и account, company и organization. Потом приходится синхронизировать поля и разбираться, где «истина».
Смешивание разных смыслов в одной таблице
Когда в одной таблице смешивают разные процессы, например «заказ» и «платёж», всё усложняется: у заказа может быть несколько попыток оплаты, возвраты, частичные оплаты. В итоге появляются поля, которые заполнены «иногда», статусы конфликтуют, а отчёты начинают врать. Лучше разделить сущности и связать их явными ключами.
Списки и JSON «про запас» вместо явных таблиц
JSON-колонка — не зло, но подход «положим туда массив, потом разберёмся» часто превращается в тупик: сложно проверять целостность, неудобно искать и агрегировать, тяжело делать миграции. Если данные участвуют в фильтрации, связях или отчётах — им обычно нужна отдельная таблица.
Нет уникальности и ключей — появляются дубликаты
Если не задать первичные ключи, внешние ключи и уникальные ограничения, система быстро накопит дубли: два одинаковых email, два «одинаковых» платежа, повторяющиеся позиции заказа. Потом придётся писать скрипты очистки и придумывать правила сопоставления задним числом — это почти всегда дороже, чем поставить ограничения сразу.
Нормализация и денормализация: как выбрать баланс
Нормализация и денормализация — не «религия», а управляемый компромисс. На старте важно не выжать миллисекунды из SELECT, а построить модель, которая сохраняет смысл данных и не ломается при росте продукта.
Когда стоит нормализовать
Нормализация полезна там, где критична целостность и нужен единый источник правды.
Если у вас есть сущность «клиент» (имя, телефон, статус), лучше хранить её в одной таблице и ссылаться внешними ключами из заказов, подписок и обращений. Так вы:
- уменьшаете дубликаты и риск «разъехавшихся» значений (в одном месте клиент активен, в другом — уже заблокирован);
- упрощаете изменения (поменяли телефон — обновилось везде);
- снижаете вероятность логических ошибок, которые потом дороже любой оптимизации запросов.
Когда допустима денормализация
Денормализация оправдана, когда чтение сильно преобладает над записью, а данные нужны в «готовом для выдачи» виде: витрины для отчётов, списки в интерфейсе, агрегаты.
Например, для страницы заказов удобно иметь поле orders.total_amount, даже если оно считается из позиций. Это ускорит чтение и снимет нагрузку со сложных JOIN/агрегаций — но добавит обязанность поддерживать корректность суммы при каждом изменении позиций.
Как принимать решение
Спросите себя:
- Какова частота операций: что делается чаще — чтение или запись?
- Насколько критична консистентность: можно ли терпеть небольшую задержку обновления витрины?
- Где цена ошибки выше: в отчёте «примерно верно» или в биллинге «строго верно»?
Компромиссный подход
Практичный старт — базовая нормализация основной модели + отдельные витрины для чтения (материализованные представления, агрегатные таблицы, кэш на уровне приложения). Так вы сохраняете чистую «истину» в ядре и при этом получаете быстрые сценарии чтения без разрушения схемы.
Индексы: думать о них рано, но строить — осознанно
Индексы часто воспринимают как «волшебную кнопку ускорения». На старте о них действительно стоит думать — но не спешить добавлять десятки индексов «на всякий случай». Индекс ускоряет доступ к данным, но не исправляет неудачную модель: если сущности смешаны, связи неочевидны, а поля имеют разный смысл в разных строках, никакой индекс не спасёт.
Какие вопросы задать заранее
Ещё до первых индексов зафиксируйте ключевые сценарии:
- Что система делает чаще: читает или пишет?
- По каким полям вы фильтруете (
WHERE), как сортируете (ORDER BY), что выбираете для пагинации? - Какие запросы должны быть быстрыми всегда (например, лента, карточка объекта, поиск по номеру), а какие могут быть «тяжёлыми» (разовые отчёты)?
Важно смотреть не на «красоту запроса», а на форму доступа: один и тот же SELECT может работать отлично или плохо в зависимости от того, по каким колонкам идёт фильтр и в каком порядке.
Какие поля почти наверняка станут ключевыми
Практически в любой схеме быстро проявляются «опорные» колонки:
- идентификаторы (primary key) и внешние ключи (foreign key);
- временные метки (
created_at,updated_at, дата события) — для сортировок и выборок по диапазону; - поля статуса (
state,is_active) — если по ним регулярно фильтруют.
Именно вокруг них обычно строятся первые осознанные индексы, включая составные (например, (user_id, created_at)).
Почему «индексировать всё» — плохая стратегия
Каждый индекс нужно поддерживать: при INSERT/UPDATE/DELETE база обновляет не только таблицу, но и все связанные индексы. Итог — медленнее записи, больше места на диске, дольше миграции, сложнее понимание, какой индекс реально используется.
Хорошее правило для старта: добавляйте индекс только под конкретный критичный запрос, после проверки плана выполнения (EXPLAIN), и удаляйте/перестраивайте то, что не приносит измеримой пользы.
Целостность данных важнее временного ускорения
Оптимизировать запросы приятно: цифры в профилировщике падают, страницы открываются быстрее. Но если база допускает «неправильные» данные, скорость превращается в иллюзию — вы начинаете тратить недели на поиск причин расхождений, ручные правки и аварийные скрипты. Хорошая схема в первую очередь защищает данные, а уже потом помогает ускорять чтение.
Каскадные удаления и обновления: где помогают, а где опасны
Каскады (ON DELETE/ON UPDATE CASCADE) полезны там, где связь действительно «родитель–дети» и удаление родителя должно гарантированно убрать хвосты. Например, удалили черновик заказа — удалились его позиции.
Опасность — случайно удалить «полмира». Для важных сущностей чаще подходит RESTRICT/NO ACTION, а вместо удаления — мягкое удаление (флаг archived/deleted_at) с явными правилами, что считать активным.
Уникальные ограничения как защита от ошибок приложения
Уникальные индексы и ограничения ловят классы ошибок, которые приложение неизбежно пропускает при гонках и повторных запросах: дубль email, повторный номер документа, две «активные» подписки на пользователя.
Лучше зафиксировать правило в БД, чем надеяться на проверки в коде и «правильный порядок» запросов.
CHECK-ограничения и справочники для качества данных
CHECK помогает удерживать диапазоны и форматы: сумма ≥ 0, статус только из допустимых значений, дата окончания не раньше даты начала. Справочники (таблицы-словарики) и внешние ключи предотвращают «самодельные» статусы и опечатки, которые ломают отчёты и фильтры.
Цена отсутствия ограничений: чистка данных и ручные исправления
Если ограничения не заданы, долг проявляется поздно и больно: несовпадение остатков, «висячие» записи без родителя, разные написания одного значения. Потом появляются дорогие процедуры: регулярные сверки, ручные корректировки, миграции «поправить всё сразу». Это почти всегда дороже, чем чуть более строгая схема с самого начала.
Как схема влияет на масштабирование и рост нагрузки
Пока в базе 10 000 строк, почти любая «нормальная» схема выглядит быстрой. Но на 10 000 000 строк меняется всё: чтение начинает упираться в объём данных, запись — в блокировки, а фоновые операции (индексация, бэкапы, миграции) — во время и ресурсы. Поэтому масштабирование часто ломается не на уровне запросов, а на уровне того, как данные разложены по таблицам.
Рост данных: когда маленькие решения становятся дорогими
Если сущности «склеены» в одну таблицу, то рост одного типа данных тянет за собой весь объект. В результате выборка пары полей вынуждена таскать за собой лишние колонки, а обновления чаще затрагивают большие строки. На маленьких объёмах это незаметно, на больших — превращается в постоянный перерасход диска, кеша и I/O.
Широкие таблицы и «таблицы-гиганты»
Широкая таблица (много колонок, часть из них редко нужна) ухудшает локальность данных: в памяти и в дисковом кеше помещается меньше полезных строк. Даже если запрос выбирает 3–5 полей, движок часто читает страницы целиком, а значит платит за «балласт». Отдельная проблема — поля с большими текстами/JSON: они раздувают строки и делают случайный доступ дороже.
Хорошая схема обычно разделяет «часто читаемое» и «редко читаемое» (например, профиль пользователя и его расширенные настройки), чтобы горячие запросы работали по компактным данным.
Горячие точки: конкуренция за одни и те же строки
Нагрузка растёт — и внезапно выясняется, что все запросы спорят за одну запись: глобальный счётчик, агрегат в одной строке, «последний номер заказа», общий баланс, флаг состояния. Это не проблема оптимизации SQL: это структурная проблема.
Схема должна избегать единственных точек записи — распределять счётчики, хранить события вместо постоянных обновлений одной строки, использовать ключи, которые равномерно распределяют запись.
Партиционирование и архивирование: закладываются заранее
Когда таблица становится огромной, хочется отделить «актуальное» от «исторического»: по дате, по клиенту, по региону. Но партиционирование и архивирование проще, если в схеме есть естественный ключ разреза (например, created_at), стабильные первичные ключи и понятные связи.
Если же данные смешаны, нет явных границ, а связи завязаны на «всё со всем», то переход на партиции или выгрузку архивов превращается в болезненный проект.
Вывод: схема — это не только про удобство хранения, но и про то, насколько предсказуемо система переживёт рост.
Миграции и эволюция: схема либо помогает, либо тормозит
Любой продукт меняется: появляются новые статусы, дополнительные атрибуты, новые связи между сущностями. Если схема данных изначально «узкая» (перегруженные поля, смешанные смыслы, неявные связи), то каждое изменение превращается не в обычную миграцию, а в мини‑проект с рисками для доступности.
Почему плохая схема делает миграции болезненными
Главная проблема — цена изменения. Непродуманная модель часто приводит к тяжёлым операциям: пересборка таблиц, массовые UPDATE по миллионам строк, перерасчёт агрегатов, смена ключей. На реальной нагрузке это выливается в долгие блокировки, деградацию времени ответа и простой.
Типичный пример: статус заказа хранится строкой в одном поле, а со временем добавляются подпроцессы, причины отмены, источники статуса. В итоге одно поле начинает содержать «смысловой винегрет», и переезд на нормальную модель требует долгого backfill и сложной совместимости.
Подходы к безопасным изменениям
Безопасная эволюция обычно строится как последовательность маленьких шагов, а не как «большой взрыв»:
- Добавить новый столбец/таблицу, не ломая текущие записи и запросы.
- Backfill: заполнить новые поля историческими данными порциями (батчами), контролируя нагрузку.
- Двойная запись на период перехода (при необходимости): писать и в старую, и в новую структуру.
- Переключение чтения: сначала перевести чтение на новую схему, затем удалить старые поля.
Такой подход проще реализовать, когда схема позволяет «жить в двух версиях» и не заставляет вас переписывать половину базы ради одного атрибута.
Как уменьшать риск изменений
Продумайте заранее механики совместимости:
- Версионирование данных там, где формат может меняться (например, события, состояния).
- Мягкие удаления (
deleted_at/флаг), чтобы не ломать ссылки и аудит. - Обратная совместимость: новые поля добавляются так, чтобы старый кодинг и логика могли игнорировать их без ошибок.
Хорошая схема ускоряет развитие: вы чаще делаете небольшие миграции без простоя, вместо редких, но опасных «перестроек».
Когда всё-таки стоит оптимизировать запросы в начале
Оптимизация запросов на старте действительно бывает уместной — но не «на всякий случай», а когда есть конкретный bottleneck, который уже заметен пользователям или мешает разработке. В противном случае вы рискуете потратить время на микротюнинг, который ничего не изменит в ощущаемой скорости.
Сигналы, что пора заняться запросами
Оптимизировать стоит рано, если выполняются два условия: есть метрики и есть узкое место.
Например:
- один и тот же запрос вызывается очень часто (на главном экране, в авторизации, в расчёте корзины) и даёт ощутимую задержку;
- время ответа скачет при небольшом росте данных — даже до «настоящих» объёмов;
- база начинает грузить CPU/диск именно из‑за конкретного запроса, а не «в целом».
Сначала измерить, потом править
Начните с измерений: посмотрите время выполнения, частоту вызовов и план запроса (EXPLAIN/ANALYZE). Часто выясняется, что «медленно» не из‑за SQL, а из‑за того, что запрос вызывают 30 раз на одну страницу, или из‑за выборки лишних полей.
Быстрые победы без переделки схемы
Есть улучшения, которые почти не затрагивают модель данных:
- добавить или поправить индексы под реальные фильтры и сортировки;
- ограничить выборку: выбирать только нужные колонки, ставить
LIMIT; - ввести нормальную пагинацию (и не делать «показать всё» по умолчанию);
- убрать N+1 (когда приложение делает много мелких запросов вместо одного понятного).
Не закапываться в микротюнинг
Если оптимизация не даёт заметного выигрыша в пользовательском сценарии или не снижает нагрузку на систему — остановитесь. На раннем этапе важнее, чтобы решения были проверяемыми, измеряемыми и легко откатывались.
Отчёты и аналитика: как не испортить основную модель
Желание «сразу построить отчёты» часто толкает команду подгонять проектирование схемы БД под красивые сводные таблицы. Проблема в том, что аналитические запросы и транзакционные операции (создать заказ, оплатить, изменить статус) предъявляют разные требования. Если смешать цели, вы получите модель, которая и пишет медленно, и считает неудобно.
«Схема под отчёты» vs «схема под транзакции»: разделяйте цели
Транзакционная схема (OLTP) должна быть понятной, проверяемой и устойчивой к конкуренции: чёткие сущности, связи, ограничения целостности, минимальный риск «двойного» смысла полей.
Аналитика (OLAP) любит предсказуемые срезы и агрегаты: «выручка по дням», «воронка по этапам», «LTV по когортам». Для этого часто нужны денормализованные представления, широкие таблицы и заранее посчитанные суммы — но держать всё это в основной модели опасно: она начинает раздуваться и терять однозначность.
Витрины, materialized views или отдельная аналитическая БД
Практичный подход: оставить основную модель «истиной», а для отчётов сделать слой витрин.
- Витрины (reporting tables) — отдельные таблицы, куда вы складываете подготовленные данные.
- Materialized views — компромисс, если ваша СУБД поддерживает их и вы контролируете обновление.
- Отдельная аналитическая БД — вариант, когда отчётов много, нагрузка растёт, нужны тяжёлые группировки.
Важно: витрины можно перестроить, а основную модель — лучше не «ломать» ради каждого нового графика.
ETL/ELT: не смешивайте сырьё и агрегаты
Не записывайте агрегаты (например, «выручка за день») в те же таблицы, где живут первичные события (платежи, позиции заказа). События — это «сырьё», агрегаты — производные. Держите их раздельно и обновляйте по расписанию или по событиям.
Минимальный набор для аналитики без поломки схемы
На старте обычно достаточно:
- таблиц событий/фактов (заказы, платежи, возвраты);
- справочников (пользователи, товары, каналы);
- одной‑двух витрин: дневные агрегаты и витрина «заказ в разрезе основных атрибутов».
Так вы сохраните чистоту транзакционной модели и при этом быстро получите отчёты, не превращая схему в компромисс ради первой же презентации.
Практический чек-лист проектирования схемы для старта
Эта часть — про минимальный набор действий, который помогает сделать схему «живучей» ещё до первой нагрузки. Идея простая: лучше потратить день на ясную модель, чем недели на латание последствий.
1) Ключевые вопросы перед стартом
- Какие сущности существуют на самом деле? (пользователь, заказ, платёж, подписка, складская позиция — не «таблица1/таблица2»).
- Какие связи между ними и какова их кратность? 1:1, 1:N, N:M (и где нужен связующий объект).
- Какой жизненный цикл у каждой сущности? создание → изменение → закрытие/удаление → архив.
- Кто и что имеет право менять? роли, владельцы, «можно редактировать только до статуса X».
- Что является источником истины? где хранится финальный статус/сумма/адрес, а где — копия для удобства.
2) Мини-набор правил «по умолчанию»
- Первичные ключи есть везде; внешние ключи — там, где важны связи.
- Ограничения:
NOT NULLдля обязательных полей,UNIQUEдля естественных идентификаторов (email, номер заказа),CHECKдля простых правил. - Именование: единый стиль (например,
snake_case), одинаковые названия для одинаковых смыслов. - Временные поля:
created_at,updated_at, при необходимостиdeleted_at(мягкое удаление) и поле версии/причины изменения.
3) Как документировать, чтобы не забыть
Сделайте три артефакта: ER-диаграмму, словарь данных (что значит каждое поле, допустимые значения) и пакет примеров запросов (топ‑5 сценариев продукта: «лента», «корзина», «поиск», «отчёт для менеджера»).
Если вы собираете продукт в режиме «быстро проверить гипотезу», полезно иметь инструмент, который дисциплинирует этот процесс. В TakProsto.AI (vibe‑coding платформа) многие команды начинают именно с проговаривания сущностей и связей в чате, а затем фиксируют результат в виде структуры проекта: фронтенд на React, бэкенд на Go и PostgreSQL. Это удобно как минимум тем, что вы раньше сталкиваетесь с вопросами ключей, ограничений и миграций — ещё до того, как «оптимизация запросов» станет единственным способом тушить пожары.
Дополнительный плюс: в TakProsto.AI можно делать снапшоты и откаты (rollback) — это помогает безопаснее переживать изменения схемы на ранних итерациях, когда вы неизбежно уточняете модель. А экспорт исходников оставляет вам полный контроль над тем, как дальше вести миграции и тюнинг уже «вручную».
4) Когда пересматривать модель
Сигналы, что схема «трещит»: появляются «универсальные» таблицы‑склады, множатся поля *_text и *_json без правил, часто нужны сложные JOIN для базовых экранов, миграции становятся рискованными, а бизнес‑правила живут только в приложении и регулярно обходятся.
Итоги: что сделать уже сейчас
Если вы на старте продукта или пересобираете существующую базу, выигрыш чаще приносит не «вылизывание» отдельных запросов, а понятная и устойчивая схема. Она задаёт правила игры: какие данные допустимы, как они связаны и что будет происходить при росте нагрузки.
1) Инвестируйте в схему и ограничения
Сделайте модель данных читаемой: названия, связи, единый стиль типов и дат. Добавьте ограничения, которые защищают качество: NOT NULL там, где «пусто» не имеет смысла, UNIQUE для естественных ключей, FOREIGN KEY для связей, CHECK для простых правил.
Это снижает количество «исправлений задним числом» и делает приложение предсказуемее, даже если запросы пока не идеальны.
2) Оптимизируйте запросы только по фактам
Не угадывайте узкие места. Включите сбор метрик: время ответа, количество чтений/записей, частоту запросов, рост таблиц. Дальше — точечные улучшения: переписать конкретный запрос, добавить индекс под реальный фильтр/сортировку, убрать N+1 на уровне приложения.
Если нет измерений — есть риск ускорить то, что и так быстро, и усложнить схему без пользы.
3) Держите баланс: простота сейчас, путь для роста потом
Нормализуйте по умолчанию, а денормализуйте осознанно: когда есть подтверждённый отчётный сценарий или горячая точка чтения. Заранее продумайте, как будет жить модель при изменениях: миграции, обратная совместимость, возможности расширения без массовых переписываний.
4) Следующий шаг: ревизия и план улучшений
Выберите 3–5 самых важных сущностей, пройдитесь по связям и ограничениям, выпишите проблемы (дубли, «магические» статусы, отсутствующие ключи) и составьте план миграций на 2–3 итерации. Начните с того, что повышает целостность и снижает риск ошибок — это окупается быстрее всего.
Если вы делаете продукт быстро и параллельно уточняете модель, полезно заранее договориться о «контуре истины»: какие таблицы считаются ядром, где можно строить витрины и кэш, а где любые денормализации должны быть строго контролируемыми. Такой подход хорошо сочетается с TakProsto.AI: платформа помогает ускорить программирование и выпуск итераций, но при этом не отменяет (и не заменяет) здоровую дисциплину проектирования схемы и ограничений — наоборот, делает её проще внедрить как стандарт команды.
FAQ
Почему проектирование схемы БД часто важнее, чем оптимизация SQL-запросов?
Схема определяет, какие запросы вообще возможны и сколько работы требуется, чтобы их выполнить.
Если данные разложены так, что для базового экрана нужны тяжёлые JOIN, вычисление связей «на лету» или чтение лишних строк, то оптимизация одного SELECT даст краткосрочный эффект, а следующий похожий запрос снова упрётся в те же структурные проблемы.
Почему в начале кажется, что достаточно ускорить SELECT?
На маленьких объёмах легко «выиграть» миллисекунды:
- данных мало, планировщик выбирает быстрые пути даже без идеальной схемы;
- тестовые запросы не отражают будущие отчёты, фильтры и интеграции;
- нагрузка по записи/конкуренции почти отсутствует.
Когда объём и сценарии растут, выясняется, что запросы были быстрыми случайно, а модель не масштабируется.
Как понять, что проблема в схеме, а не в конкретном запросе?
Частые признаки:
- базовые сценарии требуют много
JOIN/CTE «без бизнес-смысла»; - связи выражены косвенно (строковые ключи, JSON вместо таблиц);
- появляются «универсальные» таблицы-склады и поля с разными значениями по смыслу;
- данные дублируются, нет однозначного источника правды.
Если вы регулярно «обходите» схему в запросах — это сигнал, что потолок задаёт модель, а не SQL.
Когда на старте стоит нормализовать данные?
Нормализуйте по умолчанию, когда важны:
- единый источник правды (клиент, тариф, статус, справочники);
- целостность и отсутствие дублей;
- предсказуемые обновления (изменили одно значение — оно корректно отражается везде).
Практика: храните сущности (например, client) в одной таблице и связывайте внешними ключами, вместо копирования полей в десятки мест.
Когда денормализация оправдана и как не сломать консистентность?
Денормализация уместна, если:
- чтение сильно доминирует над записью;
- вы ускоряете конкретный горячий сценарий (список, витрина, агрегат);
- вы готовы поддерживать корректность производных полей.
Пример: поле orders.total_amount может ускорить интерфейс и отчёты, но требует дисциплины обновления при изменении позиций заказа.
Когда JSON-поля и “списки в колонке” превращаются в проблему?
Если значение участвует в:
- фильтрации (
WHERE), - связях,
- агрегациях,
- отчётности,
то почти всегда лучше сделать явную таблицу, а не «массив в JSON на будущее».
JSON имеет смысл для редко используемых, слабоструктурированных атрибутов, которые не нужны для ключевых запросов и ограничений целостности.
Какие ограничения в БД нужно добавить сразу, даже на MVP?
Минимум, который окупается почти всегда:
PRIMARY KEYв каждой таблице;FOREIGN KEYтам, где связь действительно важна;UNIQUEдля естественных идентификаторов (email, номер документа/заказа);NOT NULLдля обязательных полей;CHECKдля простых правил (диапазоны, допустимые статусы).
Это защищает от дублей, «висячих» строк и тихих ошибок приложения.
Когда и как правильно думать про индексы на старте?
Думать — рано, добавлять массово — опасно.
Подход:
- зафиксируйте 3–5 критичных сценариев (фильтры, сортировки, пагинация);
- добавляйте индекс только под конкретный запрос;
- проверяйте план через
EXPLAIN/EXPLAIN ANALYZE; - удаляйте/перестраивайте индексы без измеримой пользы.
Индексы ускоряют чтение, но замедляют INSERT/UPDATE/DELETE и усложняют миграции.
В каких случаях оптимизация запросов всё-таки нужна в начале?
Оптимизируйте рано, если есть измерения и видно узкое место:
- запрос вызывается часто и тормозит ключевой экран;
- время ответа нестабильно уже при умеренном росте данных;
- нагрузка на CPU/диск явно связана с конкретным запросом.
Быстрые улучшения без переделки схемы: убрать N+1, выбрать только нужные колонки, нормализовать пагинацию, добавить точечный индекс под реальный WHERE/ORDER BY.
Как поддержать отчёты и аналитику, не испортив транзакционную схему?
Не смешивайте OLTP и аналитику в одной структуре.
Практичный вариант:
- основная схема — нормализованная «истина» для транзакций;
- для отчётов — отдельные витрины/агрегатные таблицы или materialized views;
- события (факты) храните отдельно от агрегатов.
Так отчёты можно перестраивать, не ломая продакшен-модель и не усложняя каждую запись/обновление.