Шаблоны проектирования баз данных для современных приложений

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

В этой статье — шаблоны, которые важны для современных приложений. Это не академическое упражнение, а практические стратегии, позволяющие командам менять продакшен-базы быстро, поддерживаемо и безопасно. Мы пройдёмся по выбору движка хранения, принципам проектирования схемы, оптимизации запросов, архитектурным шаблонам вроде CQRS и событийного хранения и по операционным практикам, которые держат базу в форме, пока растут и команда, и данные.

Выбор базы: реляционная против NoSQL

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

Реляционные базы вроде PostgreSQL или SQLite блистают там, где у данных есть ясные связи, важна ссылочная целостность и нужно свободно запрашивать сущности вместе. Если вы строите биллинг, систему учёта складских остатков или приложение, где транзакция обязана либо пройти целиком, либо не пройти вовсе, вам нужны гарантии ACID. Реляционные базы дают их, и эти гарантии проверены двумя десятилетиями практики.

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

Вот практическая рамка для выбора основной базы:

  • По умолчанию берите PostgreSQL. Он хорошо справляется с 95% сценариев, поддерживает колонки JSON для документных данных, отлично индексирует и имеет зрелую экосистему. Начните с него и отступайте только при особой причине.
  • Тянитесь к SQLite, когда нужна встроенная база: на устройстве, в браузере через WASM или как хранилище односерверного инструмента. Нулевая настройка, очень быстрое чтение и впечатляющие возможности благодаря свежим расширениям.
  • Рассмотрите MongoDB или Firestore, если вы всегда читаете и пишете глубоко вложенный документ целиком, а требования к согласованности достаточно мягкие, чтобы допускать итоговую согласованность при чтении.
  • Избегайте ловушки нескольких баз в продакшене. Две базы удваивают операционную сложность. Добавляйте второй движок хранения только после того, как измерили, что основной не справляется.

Самое частое сожаление, которое встречается в продакшен-кодовых базах, — выбор NoSQL для реляционных данных. Если сущности ссылаются друг на друга и нужны соединения, вам нужна реляционная база. Несоответствие между объектами приложения и реляционными таблицами существует, но оно куда меньше несоответствия между по сути реляционной моделью и документным хранилищем, для неё не предназначенным.

Нормализация, денормализация и настоящая середина

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

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

-- Start normalized
CREATE TABLE orders (
  id        UUID PRIMARY KEY,
  user_id   UUID NOT NULL REFERENCES users(id),
  status    TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id         UUID PRIMARY KEY,
  order_id   UUID NOT NULL REFERENCES orders(id),
  product_id UUID NOT NULL REFERENCES products(id),
  quantity   INT NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL
);

-- Denormalize only when measured: add total to orders
ALTER TABLE orders ADD COLUMN total NUMERIC(10,2);

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

Стратегия индексов, которая действительно работает

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

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

Второй: индексируйте не колонки, а шаблоны запросов. Посмотрите на условия WHERE и ORDER BY у самых медленных запросов и создайте составные индексы, точно соответствующие этим шаблонам. Составной индекс по (status, created_at) бесполезен запросу, фильтрующему только по created_at, а индекс по (created_at, status) пригодится обоим, если created_at достаточно селективен. Порядок колонок в составном индексе решает всё.

-- Instead of separate indexes
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at);

-- Create composite indexes that match real query patterns
-- Query: SELECT * FROM orders WHERE status = 'active' ORDER BY created_at DESC;
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);

-- Query: SELECT * FROM orders WHERE user_id = $1 AND status = 'active';
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

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

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

-- Partial index: only index active orders
CREATE INDEX idx_orders_active ON orders(created_at DESC)
WHERE status = 'active';

-- This index is tiny compared to a full index and serves the query perfectly

Шаблон репозитория и абстракция доступа к данным

Шаблон репозитория посредничает между доменной логикой и кодом доступа к данным. Он даёт интерфейс, похожий на коллекцию, берёт на себя загрузку и сохранение агрегатов и прячет детали нижележащего хранилища. На практике код приложения просто вызывает методы вроде userRepository.findById(id) или orderRepository.save(order), не зная, пришли ли данные из PostgreSQL, из слоя кеша или из внешнего сервиса.

Ценность шаблона становится очевидной, когда нужно сменить базу или ввести кеш. Команда, у которой запросы разбросаны по контроллерам, сервисам и утилитам, при переходе с MongoDB на PostgreSQL столкнётся с переписыванием сотен файлов. Команда с репозиториями заменит несколько файлов реализации — интерфейс не меняется.

Но у шаблона есть известное напряжение с реляционными базами. Если интерфейс репозитория слишком общий (findAll, findById, save, delete), он не выражает богатых возможностей запроса, которые даёт реляционная база. Команды начинают добавлять специализированные методы, и абстракция постепенно протекает. Решение — принять, что репозиторий для реляционной базы будет иметь больше методов, чем репозиторий для хранилища «ключ-значение». Репозиторий пользователей с findActiveByRole, searchByName и countByStatus — это не провал абстракции, а честность по отношению к возможностям движка.

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

CQRS и событийное хранение: когда стоит идти дальше

CQRS (разделение ответственности команд и запросов) разделяет путь изменения данных и путь их чтения. В самом простом виде архитектура CQRS использует одну базу, но разные модели для записи и чтения. Модель записи следит за инвариантами и порождает события. Модель чтения потребляет их и строит денормализованные представления под конкретные запросы. Это позволяет масштабировать и оптимизировать обе стороны независимо.

Событийное хранение идёт дальше. Вместо текущего состояния сущности вы храните все события, которые его изменили. Текущее состояние получается воспроизведением этих событий. Так появляются полный аудиторский след, возможность восстановить состояние на любой момент и естественный источник событий для потребителей ниже по течению. Расплата — существенная операционная сложность: инфраструктура хранилища событий, управление проекциями, итоговая согласованность между моделями записи и чтения и умственная нагрузка мышления событиями, а не состояниями.

Честная оценка такова: большинству приложений не нужны ни CQRS, ни событийное хранение. Они добавляют сложность, оправданную только конкретными требованиями, которых более простая архитектура не закрывает. CQRS стоит рассматривать, когда чтение и запись имеют принципиально разные характеристики: высокая пропускная способность записи со сложными проекциями чтения либо разные требования к согласованности для разных операций. Событийное хранение — когда закон или продукт требуют неизменяемого аудиторского следа либо когда любое изменение состояния должно быть восстановимым и анализируемым.

Если вы решились, начните только с CQRS и добавляйте событийное хранение, лишь когда требование к аудиторскому следу сформулировано явно. CQRS на PostgreSQL с отдельной моделью чтения вполне управляем. Добавление хранилища событий сверху — большой скачок сложности, и принимать это решение нужно осознанно, со временем и бюджетом.

Миграции, пулы соединений и операционное здоровье

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

Первая: любое изменение схемы должно быть обратимой миграцией, лежащей в системе контроля версий. Flyway, Liquibase, Alembic применяют миграции по порядку и отслеживают применённые. Файлы миграций — это код: их рецензируют, тестируют и выкатывают тем же конвейером, что и код приложения. Каждая миграция должна быть маленькой и сфокусированной. Миграция, которая в одном файле добавляет колонку, заполняет данные и переименовывает таблицу, — это риск. Разбейте её на шаги, откатываемые независимо.

Вторая: пул соединений не опция. Открытие соединения стоит дорого: рукопожатие TCP, согласование SSL, аутентификация. Пул держит набор постоянных соединений, которые потоки берут и возвращают. Размер пула важен: слишком мало — запросы ждут, слишком много — база тратит время на переключение контекста. Хорошая отправная точка для PostgreSQL — pool_size = 2 × число ядер, дальше настраивайте по задержке запросов и времени ожидания соединения.

Третья: аккуратно встраивайте миграции в конвейер CI/CD. Безопасный порядок — применить миграцию до выкладки нового кода приложения. Тогда новый код застанет ожидаемую схему, а старый останется с ней совместим. Значит, любая миграция обязана быть совместимой с текущим кодом: не удаляйте колонку, на которую он ссылается, и не переименовывайте таблицы без переходного периода.

  • Расширение: добавьте новую колонку или таблицу, пока старый код ещё работает.
  • Перенос: заполните данные и переведите запись на новую структуру.
  • Сужение: удалите старую колонку или таблицу, убедившись, что старый код больше не работает.

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

SQL или ORM: как найти равновесие

Спор о том, писать ли сырой SQL или использовать объектно-реляционный маппер, — один из самых долгоживущих в разработке. У обеих сторон есть резоны, и верный ответ зависит от контекста проекта.

ORM вроде Prisma, TypeORM или SQLAlchemy дают автоматическое отображение объектов на таблицы, управление миграциями и построение запросов на языке приложения. Они убирают целый пласт шаблонного кода и ускоряют старт. Расплата в том, что ORM абстрагирует SQL: когда что-то идёт не так (медленный запрос, неожиданное соединение, эскалация блокировок), разбираться приходится и в поведении ORM, и в сгенерированном SQL. ORM умеет порождать неоптимальные запросы для сложных шаблонов доступа, а проблема N+1 преследует любую команду, которая им пользуется.

Сырой SQL даёт полный контроль над тем, что выполнится на сервере базы. Вы пишете именно тот запрос, который подходит вашей схеме и движку. Расплата — потеря автоматического отображения, необходимость самому вести миграции и SQL-строки, разбросанные по кодовой базе, которые трудно тестировать и ещё труднее рефакторить.

Прагматичная середина: ORM для 80% простых операций CRUD и сырой SQL для тех 20%, где нужна тонкая настройка производительности или сложная отчётность. Хороший ORM умеет выполнить сырой запрос и вернуть результат типизированным объектом. Простые операции — через построитель запросов, сложные — сырым SQL, и то и другое проверяйте на настоящей базе с реалистичным объёмом данных.

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

Проектирование баз данных — не разовое действие, а постоянная практика измерять, настраивать и учиться. Шаблоны из этой статьи полезны как отправная точка, но настоящее мастерство приходит из наблюдения за тем, как ваша база ведёт себя под реальной нагрузкой, и из точечных, осознанных изменений. Начинайте просто. Измеряйте всё. И не бойтесь потянуться за очередным файлом миграции.

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.