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

Когда приложение кажется медленным, первая реакция часто — «пофиксим SQL». Это понятно: один запрос виден, измерим и удобно винить. Можно запустить EXPLAIN, добавить индекс, подправить JOIN и иногда получить мгновенный выигрыш.
Но в ранней стадии продукта проблемы с производительностью не реже возникают из-за формы данных, а не только из-за конкретного текста запроса. Если схема вынуждает вас бороться с базой, настройка запросов превращается в игру в Whac-A-Mole.
Проектирование схемы — это то, как вы организуете данные: таблицы, столбцы, связи и правила. Сюда входят решения вроде:
Хорошая схема делает естественный способ задавать вопросы при этом и быстрым.
Оптимизация запросов — это улучшение способов выборки или обновления данных: переписывание запросов, добавление индексов, сокращение лишней работы и избегание шаблонов, вызывающих большие сканирования.
Эта статья не про «схема хороша, запросы плохи». Речь о порядке действий: сначала приведите в порядок фундамент базы — схему — а затем настраивайте те запросы, которые действительно этого требуют.
Вы узнаете, почему решения по схеме доминируют в ранней производительности, как понять, что именно схема — узкое место, и как безопасно эволюционировать её по мере роста приложения. Это написано для продуктовых команд, основателей и разработчиков реальных приложений — не для узкоспециализированных DBA.
На ранних этапах производительность обычно связана не с хитрым SQL, а с тем, сколько данных база вынуждена просмотреть.
Запрос может быть селективным только в той мере, в какой позволяет модель данных. Если вы храните «статус», «тип» или «владельца» в слабо структурированных полях (или разнесены по непоследовательным таблицам), база часто вынуждена просмотреть намного больше строк, чтобы найти соответствия.
Хорошая схема естественным образом сужает пространство поиска: понятные столбцы, согласованные типы данных и четко ограниченные таблицы позволяют фильтровать раньше и читать меньше страниц с диска или из памяти.
Когда первичные и внешние ключи отсутствуют (или не соблюдаются), связи превращаются в догадки. Это перекладывает работу на уровень запросов:
Без ограничений плохие данные накапливаются — и запросы всё медленнее по мере роста объёмов.
Индексы полезны, когда они соответствуют предсказуемым путям доступа: соединение по внешним ключам, фильтрация по хорошо определённым столбцам, сортировка по частым полям. Если важные атрибуты хранятся не в той таблице, смешиваются значения в одном столбце или полагаются на парсинг текста, индексы не спасут — вы всё равно будете много сканировать и трансформировать.
С чистыми связями, стабильными идентификаторами и разумными границами таблиц многие повседневные запросы становятся «быстро по умолчанию», потому что они затрагивают меньше данных и используют простые, индекс-дружественные предикаты. Настройка запросов тогда превращается в завершающий шаг, а не в постоянный пожарный режим.
Ранние продукты редко имеют «стабильные требования» — у них эксперименты. Функции выпускаются, переписываются или исчезают. Небольшая команда балансирует между roadmap, поддержкой и инфраструктурой с ограниченным временем, чтобы пересматривать старые решения.
Чаще меняется не текст SQL, а смысл данных: новые состояния, новые связи, новые поля «о, нам ещё надо отслеживать…» и целые рабочие процессы, которые не были предусмотрены при запуске. Этот цикл нормален — и именно поэтому выборы по схеме так важны в начале.
Переписать запрос обычно можно локально и откатить: вы выпускаете улучшение, измеряете эффект и возвращаете назад при необходимости.
Переписывать схему иначе. Как только у вас есть реальные клиентские данные, любая структурная смена превращается в проект:
Даже при хороших инструментах изменения схемы требуют координации: обновление кода приложения, последовательность деплоев и проверка данных.
Когда база маленькая, неуклюжая схема может казаться «нормальной». По мере роста строк с тысяч до миллионов та же архитектура создаёт большие сканирования, тяжёлые индексы и дорогие JOINы — и каждая новая фича строится на этом фундаменте.
Цель ранней стадии — не идеал. Цель — выбрать схему, которая способна принимать изменения без вынужденных рискованных миграций каждый раз, когда продукт чему-то учится.
Большинство проблем с «медленными запросами» в начале не про SQL-трюки, а про неясность в модели данных. Если схема делает непонятным, что представляет запись или как записи связаны, каждый запрос становится дороже для написания, выполнения и поддержки.
Назовите несколько вещей, без которых продукт не работает: пользователи, аккаунты, заказы, подписки, события, инвойсы — что-то действительно центральное. Затем явно опишите связи: one-to-many, many-to-many (обычно с таблицей соединения) и владение (кто «содержит» что).
Практическая проверка: по каждой таблице вы должны уметь завершить фразу «Строка в этой таблице представляет ___». Если не можете — вероятно, таблица смешивает концепции, что позже породит сложные фильтры и JOINы.
Последовательность предотвращает случайные JOINы и непонятное поведение API. Выберите соглашения (snake_case vs camelCase, *_id, created_at/updated_at) и придерживайтесь их.
Также решите, кто владеет полем. Например, «billing_address» принадлежит заказу (снимок в момент покупки) или пользователю (текущий адрес по умолчанию)? Оба варианта могут быть валидными — но смешивание без явного намерения создаёт медленные и ошибочные запросы для «выяснения правды».
Используйте типы, которые избегают конверсий во время выполнения:
Когда типы неверны, СУБД не может эффективно сравнивать значения, индексы теряют пользу, и запросы часто требуют приведения типов.
Хранение одного факта в нескольких местах (например, order_total и сумма по line_items) создаёт дрейф. Если вы кешируете производное значение, документируйте это, определите источник правды и обеспечьте согласованное обновление (часто через логику приложения плюс ограничения).
Быстрая база — это обычно предсказуемая база. Ключи и ограничения делают данные предсказуемыми, предотвращая «невозможные» состояния — отсутствующие связи, дублирующиеся идентичности или значения, которые не означают того, что думает приложение. Эта чистота напрямую влияет на производительность: СУБД может строить планы оптимальнее, если уверена в данных.
Каждая таблица должна иметь первичный ключ (PK): столбец или небольшой набор столбцов, однозначно идентифицирующих строку и не меняющихся со временем. Это не просто теория — это то, что позволяет эффективно соединять таблицы, безопасно кэшировать и ссылаться на записи без догадок.
Стабильный PK также предотвращает дорогие обходные пути: при отсутствии истинного идентификатора приложения начинают «опознавать» строки по email, имени, временной метке или набору колонок — что ведёт к широким индексам, медленным JOINам и краевым случаям, когда эти значения меняются.
Внешние ключи (FK) обеспечивают связь: orders.user_id должен ссылаться на существующий users.id. Без FK в систему попадают некорректные ссылки (заказы для удалённых пользователей, комментарии для несуществующих постов), и тогда каждый запрос вынужден защитно фильтровать, делать left-join и обрабатывать NULL.
С FK планировщик запросов чаще может оптимизировать соединения, потому что связь явная и гарантирована. Вы также реже накопите осиротевшие строки, раздувающие таблицы и индексы.
Ограничения — это не бюрократия, а ограждения:
users.email.status IN ('pending','paid','canceled')).Чище данные — проще запросы, меньше плейсхолдеров «на всякий случай» и меньше лишних JOINов.
users.email и customers.email): конфликтующие идентичности и дублирующиеся индексы.Если хотите скорость в начале, усложните себе жизнь при сохранении плохих данных. База отблагодарит вас простыми планами, меньшими индексами и меньшим числом сюрпризов.
Нормализация — простая идея: хранить каждый факт в одном месте, чтобы не дублировать данные по всей базе. Когда одно и то же значение копируется в множество таблиц или столбцов, обновления становятся рискованными — одна копия меняется, другая нет, и приложение начинает показывать противоречивые ответы.
На практике нормализация означает разделение сущностей так, чтобы обновления были чистыми и предсказуемыми. Например, название и цена продукта принадлежат таблице products, а не повторяются в каждой строке заказа. Название категории — в categories, а в продуктах хранится ссылка на неё.
Это уменьшает:
Нормализацию можно довести до абсурда, когда вы разделяете данные на множество мелких таблиц, которые постоянно нужно объединять для обычных экранов. База по-прежнему вернёт корректные результаты, но обычные чтения станут медленнее и сложнее, потому что каждый запрос требует множества JOINов.
Типичный симптом ранней стадии: простая страница (например, история заказов) требует 6–10 JOINов, и производительность зависит от трафика и нагрева кэша.
Разумный баланс:
products, имена категорий в categories, связи через внешние ключи.Денормализация — это намеренное дублирование небольшого фрагмента данных, чтобы сделать частый запрос дешевле (меньше JOINов, быстрее списки). Ключевое слово — внимательно: каждое дублированное поле нуждается в плане синхронизации.
Нормализованная схема может выглядеть так:
products(id, name, price, category_id)categories(id, name)orders(id, customer_id, created_at)order_items(id, order_id, product_id, quantity, unit_price_at_purchase)Обратите внимание на тонкую выгоду: order_items хранит unit_price_at_purchase (форма денормализации), потому что нужна историческая точность, даже если цена продукта изменится позже. Это дублирование намеренное и стабильно.
Если ваш самый частый экран — «заказы с краткой информацией по позициям», вы можете денормализовать product_name в order_items, чтобы не джойнить products при каждом списке — но только если готовы поддерживать соответствие (или принимать, что это снимок на момент покупки).
Индексы часто воспринимают как волшебную «кнопку ускорения», но они работают хорошо только когда таблица структурирована верно. Если вы постоянно переименовываете столбцы, дробите таблицы или меняете связи, набор индексов будет меняться вместе с этим. Индексы лучше всего работают, когда столбцы (и способы фильтрации/сортировки) достаточно стабильны, чтобы вы не перестраивали их каждую неделю.
Вам не нужно идеально предсказывать, но нужен короткий список запросов, которые действительно важны:
Эти утверждения напрямую переводятся в столбцы, которые заслуживают индекс. Если вы не можете проговорить их вслух, обычно это проблема ясности схемы — а не индексов.
Составной индекс покрывает несколько столбцов. Порядок столбцов важен: СУБД может эффективно использовать индекс слева направо.
Например, если часто фильтруете по customer_id, а затем сортируете по created_at, индекс на (customer_id, created_at) обычно полезен. Обратный (created_at, customer_id) может не помочь тому же запросу.
Каждый дополнительный индекс имеет цену:
Чистая, последовательная схема сужает «правильные» индексы до небольшого набора, соответствующего реальным шаблонам доступа — без постоянной платы за записи и хранилище.
Медленные приложения не всегда тормозят из-за чтений. Многие ранние проблемы появляются при вставках и обновлениях — регистрации пользователей, оплате, фоновых задачах — потому что небрежная схема заставляет каждое изменение делать лишнюю работу.
Некоторые выборы по схеме незаметно увеличивают стоимость каждого изменения:
INSERT. Каскадные внешние ключи корректны и полезны, но добавляют работы при записи, которая растёт с количеством связанных данных.Если нагрузка ориентирована на чтение (ленты, страницы поиска), можно допускать больше индексов и выборочную денормализацию. Если нагрузка ориентирована на запись (сбор событий, телеметрия, высоконагруженные заказы), приоритет — схема, которая делает записи простыми и предсказуемыми, а оптимизации чтений добавлять только по необходимости.
Практический подход:
entity_id, created_at).Чистые пути записи дают запас прочности и упрощают дальнейшую оптимизацию запросов.
ORM упрощают работу с базой: вы описали модели, вызвали методы — и данные появились. Но ORM также могут скрывать дорогие SQL-паттерны до момента, когда это начнёт болеть.
Две распространённые ловушки:
.include() или вложённый сериализатор может превратиться в широкие JOINы, дублированные строки или большие сортировки — особенно если связи не явно определены.Хорошо спроектированная схема снижает вероятность появления этих паттернов и упрощает их обнаружение.
Когда таблицы имеют явные внешние ключи, уникальные ограничения и NOT NULL, ORM может генерировать более безопасные запросы, а ваш код — опираться на согласованное поведение.
Например, принуждение orders.user_id ссылаться на пользователя (FK) и уникальность users.email предотвращают целые классы краевых случаев, которые иначе превращаются в проверку на уровне приложения и дополнительные запросы.
Ваш дизайн API зависит от схемы:
created_at + id).Относитесь к решениям по схеме как к первоклассной инженерной задаче:
Если вы быстро собираете продукт с помощью chat-driven workflow (например, генерируете React-приложение плюс Go/PostgreSQL бэкенд в Koder.ai), полезно сделать «ревью схемы» частью диалога на раннем этапе. Итерации будут быстрыми, но ограничения, ключи и план миграций должны быть продуманными — особенно до появления трафика.
Некоторые проблемы с производительностью — не «плохой SQL», а то, что база борется с формой данных. Если вы видите похожие проблемы на многих эндпоинтах и отчетах, чаще причина — схема, а не настройка отдельных запросов.
Медленные фильтры — классический симптом. Если простые условия вроде “найти заказы по клиенту” или “фильтровать по дате создания” постоянно тормозят, проблема может быть в недостающих связях, несоответствии типов или столбцах, которые нельзя эффективно индексировать.
Ещё один признак — взрыв числа JOINов: запрос, который должен джойнить 2–3 таблицы, начинает цеплять 6–10 таблиц, чтобы ответить на базовый вопрос (часто из-за чрезмерной нормализации, полиморфных паттернов или «всё в одной таблице»).
Следите также за непоследовательными значениями в колонках, которые ведут себя как enum — особенно полями статуса ("active", "ACTIVE", "enabled", "on"). Непоследовательность заставляет писать защитные запросы (LOWER(), COALESCE(), OR-цепочки), которые остаются медленными независимо от оптимизации.
Начните с реалий: количество строк в таблицах и кардинальность ключевых столбцов (сколько уникальных значений). Если в столбце “status” ожидалось 4 значения, а вы находите 40 — схема уже протекает.
Затем посмотрите планы выполнения для медленных эндпоинтов. Если вы регулярно видите последовательные сканирования по колонкам соединения или большие промежуточные наборы, вероятнее всего, корень — в схеме и индексах.
Наконец, включите и просмотрите логи медленных запросов. Когда много разных запросов медленно выполняются одинаково (те же таблицы, те же предикаты), это обычно структурная проблема, которую стоит исправлять на уровне модели.
Ранние выборы редко переживают первый контакт с реальными пользователями. Цель — не «добиться совершенства», а менять схему, не ломая продакшн, не теряя данные и не останавливая команду на неделю.
Практичный рабочий процесс, масштабируемый от одного разработчика до команды:
Большинству изменений схем не нужны сложные rollout-паттерны. Отдавайте предпочтение «expand-and-contract»: код читает и старое, и новое, затем переключает записи, когда уверены.
Используйте feature flags или dual writes только при необходимости (высокий трафик, большие бэкфиллы, множество сервисов). При dual write добавьте мониторинг дрейфа и определите, какая сторона побеждает при конфликте.
Безопасный откат начинается с миграций, которые можно обратить. Практикуйте путь «undo»: удалить новый столбец легко, восстановить перезаписанные данные — нет.
Тестируйте миграции на реалистичных объёмах данных. Миграция, которая занимает 2 секунды на ноутбуке, может блокировать таблицы минуты в проде. Используйте прод-подобные количества строк и индексы, замеряйте время выполнения.
Здесь платформенные инструменты снижают риск: надёжные деплои, снапшоты/откат и возможность экспортировать код упрощают итерации схемы и логики приложения вместе. Если вы используете Koder.ai, опирайтесь на снапшоты и режим планирования при подготовке миграций, требующих аккуратной последовательности.
Ведите короткий журнал схемы: что изменено, почему и какие компромиссы приняты. Ссылкайте его из /docs или README репозитория. Указывайте заметки вроде «этот столбец намеренно денормализован» или «внешний ключ добавлен после бэкфилла 2025-01-10», чтобы будущие изменения не повторяли старые ошибки.
Оптимизация запросов важна — но она окупается больше, когда схема вам не противится. Если таблицы лишены явных ключей, связи неконсистентны или «одна строка на вещь» нарушена, вы можете потратить часы на настройку запросов, которые всё равно будут переписаны на следующей неделе.
Сначала устраните блокирующие проблемы схемы. Начните с всего, что делает корректный запрос труднодостижимым: отсутствие первичных ключей, несогласованные внешние ключи, столбцы, смешивающие смыслы, дублирующие источники правды или типы, не соответствующие реальности (например, даты в строках).
Стабилизируйте шаблоны доступа. Когда модель данных отражает поведение приложения (и вероятно будет такой в ближайшие пару спринтов), настройка запросов становится долговечной.
Оптимизируйте горячие запросы — не все запросы. Используйте логи/APM, чтобы найти медленные и частые запросы. Один эндпоинт, попадающий 10 000 раз в день, важнее редкого админ-отчёта.
Большинство ранних выигрышей дают несколько простых шагов:
SELECT *, особенно на широких таблицах).Работа над производительностью не заканчивается, но цель — сделать её предсказуемой. С чистой схемой каждая новая фича добавляет линейную нагрузку; с неаккуратной схемой — каждая фича добавляет компаундированную путаницу.
SELECT * в одном горячем пути.