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

Спор обычно начинается одинаково: база данных «тормозит», и первая реакция — ускорять SELECT. Добавляют LIMIT, переписывают JOIN, включают кэш, подбирают «хитрые» индексы и радуются, что запрос стал выполняться в 10 раз быстрее.
Параллельно почти никто не задаёт более неприятный вопрос: а правильная ли у нас модель данных? То есть как именно сущности разложены по таблицам, где хранятся связи, какие типы и ограничения выбраны, что считается источником правды.
На раннем этапе данных мало, реальной нагрузки почти нет, а тестовые замеры легко «выиграть» микрооптимизациями. Кажется, что проблема решена — пока не появляется рост пользователей, больше записей и новые сценарии (фильтры, отчёты, интеграции).
Именно поэтому ранняя скорость часто обманчива: запросы можно подогнать под текущий объём, но они не проверяют, выдержит ли модель данные через полгода.
Проектирование схемы БД задаёт пределы для любой оптимизации запросов.
Если модель вынуждает постоянно делать тяжёлые JOIN по неудачным ключам, хранит неоднозначные статусы, допускает дубли или плохо отражает реальные связи, то «ускорение SELECT» превращается в бесконечную череду заплаток. Напротив, хорошо продуманная схема снижает сложность запросов, делает индексацию предсказуемой и помогает масштабироваться.
Производительность базы данных ограничивается не тем, насколько хитро вы напишете SQL, а тем, какие данные и связи заложены в модель. Схема — это «физика мира» вашего приложения: она определяет, какие запросы естественны, сколько действий потребуется для ответа и сколько «лишней работы» будет делать СУБД.
Хорошо спроектированная схема делает частые сценарии прямолинейными: один‑два JOIN, понятные фильтры, предсказуемые индексы. Плохая схема заставляет собирать информацию из множества таблиц, где связь выражена косвенно или вовсе не выражена. В итоге появляются лишние JOIN, подзапросы, CTE и обходные пути, которые трудно ускорять без изменения структуры данных.
Когда логическая сущность разорвана на несколько таблиц без ясной причины или, наоборот, свалена в одну «универсальную» таблицу, запросы начинают:
Такие решения увеличивают объём работы для планировщика и диска, а также осложняют индексацию. И главное — это трудно исправить точечно: вы упираетесь в потолок, который задаёт сама модель.
На старте большинство «медленных запросов» — не про отсутствие хитростей в SQL, а про то, что модель данных вынуждает базу делать лишнюю работу. Тюнинг запроса может дать временный выигрыш, но если причина в схеме, улучшение быстро заканчивается: следующий похожий запрос снова будет медленным, потому что фундамент тот же.
На старте хочется «быстрее запустить», поэтому схема часто растёт хаотично: добавили пару таблиц, потом ещё столбцы «на всякий случай», и вот уже любая новая фича требует боли. Эти ошибки обычно не видны в первой версии продукта, но начинают дорого стоить, когда появляются реальные данные и нагрузка.
Частая история — перепутать 1:N и M:N. Например, у заказа может быть много товаров, и один товар может встречаться в тысячах заказов. Если попытаться запихнуть это в одну таблицу «заказы» со списком товаров или несколькими колонками product1, product2, вы получите сложные обновления, невозможность корректно считать итоги и постоянные дубликаты.
Похожая проблема — создать «две почти одинаковые сущности»: client и customer, user и account, company и organization. Потом приходится синхронизировать поля и разбираться, где «истина».
Когда в одной таблице смешивают разные процессы, например «заказ» и «платёж», всё усложняется: у заказа может быть несколько попыток оплаты, возвраты, частичные оплаты. В итоге появляются поля, которые заполнены «иногда», статусы конфликтуют, а отчёты начинают врать. Лучше разделить сущности и связать их явными ключами.
JSON-колонка — не зло, но подход «положим туда массив, потом разберёмся» часто превращается в тупик: сложно проверять целостность, неудобно искать и агрегировать, тяжело делать миграции. Если данные участвуют в фильтрации, связях или отчётах — им обычно нужна отдельная таблица.
Если не задать первичные ключи, внешние ключи и уникальные ограничения, система быстро накопит дубли: два одинаковых email, два «одинаковых» платежа, повторяющиеся позиции заказа. Потом придётся писать скрипты очистки и придумывать правила сопоставления задним числом — это почти всегда дороже, чем поставить ограничения сразу.
Нормализация и денормализация — не «религия», а управляемый компромисс. На старте важно не выжать миллисекунды из SELECT, а построить модель, которая сохраняет смысл данных и не ломается при росте продукта.
Нормализация полезна там, где критична целостность и нужен единый источник правды.
Если у вас есть сущность «клиент» (имя, телефон, статус), лучше хранить её в одной таблице и ссылаться внешними ключами из заказов, подписок и обращений. Так вы:
Денормализация оправдана, когда чтение сильно преобладает над записью, а данные нужны в «готовом для выдачи» виде: витрины для отчётов, списки в интерфейсе, агрегаты.
Например, для страницы заказов удобно иметь поле orders.total_amount, даже если оно считается из позиций. Это ускорит чтение и снимет нагрузку со сложных JOIN/агрегаций — но добавит обязанность поддерживать корректность суммы при каждом изменении позиций.
Спросите себя:
Практичный старт — базовая нормализация основной модели + отдельные витрины для чтения (материализованные представления, агрегатные таблицы, кэш на уровне приложения). Так вы сохраняете чистую «истину» в ядре и при этом получаете быстрые сценарии чтения без разрушения схемы.
Индексы часто воспринимают как «волшебную кнопку ускорения». На старте о них действительно стоит думать — но не спешить добавлять десятки индексов «на всякий случай». Индекс ускоряет доступ к данным, но не исправляет неудачную модель: если сущности смешаны, связи неочевидны, а поля имеют разный смысл в разных строках, никакой индекс не спасёт.
Ещё до первых индексов зафиксируйте ключевые сценарии:
WHERE), как сортируете (ORDER BY), что выбираете для пагинации?Важно смотреть не на «красоту запроса», а на форму доступа: один и тот же SELECT может работать отлично или плохо в зависимости от того, по каким колонкам идёт фильтр и в каком порядке.
Практически в любой схеме быстро проявляются «опорные» колонки:
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 помогает удерживать диапазоны и форматы: сумма ≥ 0, статус только из допустимых значений, дата окончания не раньше даты начала. Справочники (таблицы-словарики) и внешние ключи предотвращают «самодельные» статусы и опечатки, которые ломают отчёты и фильтры.
Если ограничения не заданы, долг проявляется поздно и больно: несовпадение остатков, «висячие» записи без родителя, разные написания одного значения. Потом появляются дорогие процедуры: регулярные сверки, ручные корректировки, миграции «поправить всё сразу». Это почти всегда дороже, чем чуть более строгая схема с самого начала.
Пока в базе 10 000 строк, почти любая «нормальная» схема выглядит быстрой. Но на 10 000 000 строк меняется всё: чтение начинает упираться в объём данных, запись — в блокировки, а фоновые операции (индексация, бэкапы, миграции) — во время и ресурсы. Поэтому масштабирование часто ломается не на уровне запросов, а на уровне того, как данные разложены по таблицам.
Если сущности «склеены» в одну таблицу, то рост одного типа данных тянет за собой весь объект. В результате выборка пары полей вынуждена таскать за собой лишние колонки, а обновления чаще затрагивают большие строки. На маленьких объёмах это незаметно, на больших — превращается в постоянный перерасход диска, кеша и I/O.
Широкая таблица (много колонок, часть из них редко нужна) ухудшает локальность данных: в памяти и в дисковом кеше помещается меньше полезных строк. Даже если запрос выбирает 3–5 полей, движок часто читает страницы целиком, а значит платит за «балласт». Отдельная проблема — поля с большими текстами/JSON: они раздувают строки и делают случайный доступ дороже.
Хорошая схема обычно разделяет «часто читаемое» и «редко читаемое» (например, профиль пользователя и его расширенные настройки), чтобы горячие запросы работали по компактным данным.
Нагрузка растёт — и внезапно выясняется, что все запросы спорят за одну запись: глобальный счётчик, агрегат в одной строке, «последний номер заказа», общий баланс, флаг состояния. Это не проблема оптимизации SQL: это структурная проблема.
Схема должна избегать единственных точек записи — распределять счётчики, хранить события вместо постоянных обновлений одной строки, использовать ключи, которые равномерно распределяют запись.
Когда таблица становится огромной, хочется отделить «актуальное» от «исторического»: по дате, по клиенту, по региону. Но партиционирование и архивирование проще, если в схеме есть естественный ключ разреза (например, created_at), стабильные первичные ключи и понятные связи.
Если же данные смешаны, нет явных границ, а связи завязаны на «всё со всем», то переход на партиции или выгрузку архивов превращается в болезненный проект.
Вывод: схема — это не только про удобство хранения, но и про то, насколько предсказуемо система переживёт рост.
Любой продукт меняется: появляются новые статусы, дополнительные атрибуты, новые связи между сущностями. Если схема данных изначально «узкая» (перегруженные поля, смешанные смыслы, неявные связи), то каждое изменение превращается не в обычную миграцию, а в мини‑проект с рисками для доступности.
Главная проблема — цена изменения. Непродуманная модель часто приводит к тяжёлым операциям: пересборка таблиц, массовые UPDATE по миллионам строк, перерасчёт агрегатов, смена ключей. На реальной нагрузке это выливается в долгие блокировки, деградацию времени ответа и простой.
Типичный пример: статус заказа хранится строкой в одном поле, а со временем добавляются подпроцессы, причины отмены, источники статуса. В итоге одно поле начинает содержать «смысловой винегрет», и переезд на нормальную модель требует долгого backfill и сложной совместимости.
Безопасная эволюция обычно строится как последовательность маленьких шагов, а не как «большой взрыв»:
Такой подход проще реализовать, когда схема позволяет «жить в двух версиях» и не заставляет вас переписывать половину базы ради одного атрибута.
Продумайте заранее механики совместимости:
deleted_at/флаг), чтобы не ломать ссылки и аудит.Хорошая схема ускоряет развитие: вы чаще делаете небольшие миграции без простоя, вместо редких, но опасных «перестроек».
Оптимизация запросов на старте действительно бывает уместной — но не «на всякий случай», а когда есть конкретный bottleneck, который уже заметен пользователям или мешает разработке. В противном случае вы рискуете потратить время на микротюнинг, который ничего не изменит в ощущаемой скорости.
Оптимизировать стоит рано, если выполняются два условия: есть метрики и есть узкое место.
Например:
Начните с измерений: посмотрите время выполнения, частоту вызовов и план запроса (EXPLAIN/ANALYZE). Часто выясняется, что «медленно» не из‑за SQL, а из‑за того, что запрос вызывают 30 раз на одну страницу, или из‑за выборки лишних полей.
Есть улучшения, которые почти не затрагивают модель данных:
LIMIT;Если оптимизация не даёт заметного выигрыша в пользовательском сценарии или не снижает нагрузку на систему — остановитесь. На раннем этапе важнее, чтобы решения были проверяемыми, измеряемыми и легко откатывались.
Желание «сразу построить отчёты» часто толкает команду подгонять проектирование схемы БД под красивые сводные таблицы. Проблема в том, что аналитические запросы и транзакционные операции (создать заказ, оплатить, изменить статус) предъявляют разные требования. Если смешать цели, вы получите модель, которая и пишет медленно, и считает неудобно.
Транзакционная схема (OLTP) должна быть понятной, проверяемой и устойчивой к конкуренции: чёткие сущности, связи, ограничения целостности, минимальный риск «двойного» смысла полей.
Аналитика (OLAP) любит предсказуемые срезы и агрегаты: «выручка по дням», «воронка по этапам», «LTV по когортам». Для этого часто нужны денормализованные представления, широкие таблицы и заранее посчитанные суммы — но держать всё это в основной модели опасно: она начинает раздуваться и терять однозначность.
Практичный подход: оставить основную модель «истиной», а для отчётов сделать слой витрин.
Важно: витрины можно перестроить, а основную модель — лучше не «ломать» ради каждого нового графика.
Не записывайте агрегаты (например, «выручка за день») в те же таблицы, где живут первичные события (платежи, позиции заказа). События — это «сырьё», агрегаты — производные. Держите их раздельно и обновляйте по расписанию или по событиям.
На старте обычно достаточно:
Так вы сохраните чистоту транзакционной модели и при этом быстро получите отчёты, не превращая схему в компромисс ради первой же презентации.
Эта часть — про минимальный набор действий, который помогает сделать схему «живучей» ещё до первой нагрузки. Идея простая: лучше потратить день на ясную модель, чем недели на латание последствий.
NOT NULL для обязательных полей, UNIQUE для естественных идентификаторов (email, номер заказа), CHECK для простых правил.snake_case), одинаковые названия для одинаковых смыслов.created_at, updated_at, при необходимости deleted_at (мягкое удаление) и поле версии/причины изменения.Сделайте три артефакта: ER-диаграмму, словарь данных (что значит каждое поле, допустимые значения) и пакет примеров запросов (топ‑5 сценариев продукта: «лента», «корзина», «поиск», «отчёт для менеджера»).
Если вы собираете продукт в режиме «быстро проверить гипотезу», полезно иметь инструмент, который дисциплинирует этот процесс. В TakProsto.AI (vibe‑coding платформа) многие команды начинают именно с проговаривания сущностей и связей в чате, а затем фиксируют результат в виде структуры проекта: фронтенд на React, бэкенд на Go и PostgreSQL. Это удобно как минимум тем, что вы раньше сталкиваетесь с вопросами ключей, ограничений и миграций — ещё до того, как «оптимизация запросов» станет единственным способом тушить пожары.
Дополнительный плюс: в TakProsto.AI можно делать снапшоты и откаты (rollback) — это помогает безопаснее переживать изменения схемы на ранних итерациях, когда вы неизбежно уточняете модель. А экспорт исходников оставляет вам полный контроль над тем, как дальше вести миграции и тюнинг уже «вручную».
Сигналы, что схема «трещит»: появляются «универсальные» таблицы‑склады, множатся поля *_text и *_json без правил, часто нужны сложные JOIN для базовых экранов, миграции становятся рискованными, а бизнес‑правила живут только в приложении и регулярно обходятся.
Если вы на старте продукта или пересобираете существующую базу, выигрыш чаще приносит не «вылизывание» отдельных запросов, а понятная и устойчивая схема. Она задаёт правила игры: какие данные допустимы, как они связаны и что будет происходить при росте нагрузки.
Сделайте модель данных читаемой: названия, связи, единый стиль типов и дат. Добавьте ограничения, которые защищают качество: NOT NULL там, где «пусто» не имеет смысла, UNIQUE для естественных ключей, FOREIGN KEY для связей, CHECK для простых правил.
Это снижает количество «исправлений задним числом» и делает приложение предсказуемее, даже если запросы пока не идеальны.
Не угадывайте узкие места. Включите сбор метрик: время ответа, количество чтений/записей, частоту запросов, рост таблиц. Дальше — точечные улучшения: переписать конкретный запрос, добавить индекс под реальный фильтр/сортировку, убрать N+1 на уровне приложения.
Если нет измерений — есть риск ускорить то, что и так быстро, и усложнить схему без пользы.
Нормализуйте по умолчанию, а денормализуйте осознанно: когда есть подтверждённый отчётный сценарий или горячая точка чтения. Заранее продумайте, как будет жить модель при изменениях: миграции, обратная совместимость, возможности расширения без массовых переписываний.
Выберите 3–5 самых важных сущностей, пройдитесь по связям и ограничениям, выпишите проблемы (дубли, «магические» статусы, отсутствующие ключи) и составьте план миграций на 2–3 итерации. Начните с того, что повышает целостность и снижает риск ошибок — это окупается быстрее всего.
Если вы делаете продукт быстро и параллельно уточняете модель, полезно заранее договориться о «контуре истины»: какие таблицы считаются ядром, где можно строить витрины и кэш, а где любые денормализации должны быть строго контролируемыми. Такой подход хорошо сочетается с TakProsto.AI: платформа помогает ускорить программирование и выпуск итераций, но при этом не отменяет (и не заменяет) здоровую дисциплину проектирования схемы и ограничений — наоборот, делает её проще внедрить как стандарт команды.
Схема определяет, какие запросы вообще возможны и сколько работы требуется, чтобы их выполнить.
Если данные разложены так, что для базового экрана нужны тяжёлые JOIN, вычисление связей «на лету» или чтение лишних строк, то оптимизация одного SELECT даст краткосрочный эффект, а следующий похожий запрос снова упрётся в те же структурные проблемы.
На маленьких объёмах легко «выиграть» миллисекунды:
Когда объём и сценарии растут, выясняется, что запросы были быстрыми случайно, а модель не масштабируется.
Частые признаки:
JOIN/CTE «без бизнес-смысла»;Если вы регулярно «обходите» схему в запросах — это сигнал, что потолок задаёт модель, а не SQL.
Нормализуйте по умолчанию, когда важны:
Практика: храните сущности (например, client) в одной таблице и связывайте внешними ключами, вместо копирования полей в десятки мест.
Денормализация уместна, если:
Пример: поле orders.total_amount может ускорить интерфейс и отчёты, но требует дисциплины обновления при изменении позиций заказа.
Если значение участвует в:
WHERE),то почти всегда лучше сделать явную таблицу, а не «массив в JSON на будущее».
JSON имеет смысл для редко используемых, слабоструктурированных атрибутов, которые не нужны для ключевых запросов и ограничений целостности.
Минимум, который окупается почти всегда:
PRIMARY KEY в каждой таблице;FOREIGN KEY там, где связь действительно важна;UNIQUE для естественных идентификаторов (email, номер документа/заказа);NOT NULL для обязательных полей;Думать — рано, добавлять массово — опасно.
Подход:
EXPLAIN / EXPLAIN ANALYZE;Индексы ускоряют чтение, но замедляют и усложняют миграции.
Оптимизируйте рано, если есть измерения и видно узкое место:
Быстрые улучшения без переделки схемы: убрать N+1, выбрать только нужные колонки, нормализовать пагинацию, добавить точечный индекс под реальный WHERE/ORDER BY.
Не смешивайте OLTP и аналитику в одной структуре.
Практичный вариант:
Так отчёты можно перестраивать, не ломая продакшен-модель и не усложняя каждую запись/обновление.
CHECKЭто защищает от дублей, «висячих» строк и тихих ошибок приложения.
INSERT/UPDATE/DELETE