EvolCode
треки/Базы данных·02 / 07
13 мин чтения

Как спроектировать схему БД, чтобы потом не плакать

Самая дорогая ошибка в проекте — кривая схема БД. Код можно отрефакторить за вечер, фронт переделать за неделю. А переехать с «всё в одной таблице» на «нормализовано» — это миграция данных на живой системе с риском всё положить. Поэтому 30 минут размышлений над схемой ДО кода экономят неделю боли потом. В этом уроке — практический фреймворк проектирования.

Алгоритм: от пользовательских историй к таблицам

Не начинай со схемы — начни со списка действий пользователя. «Юзер регистрируется», «создаёт проект», «добавляет участников», «комментирует». Каждое существительное в этих фразах — кандидат в таблицу: User, Project, Member, Comment. Каждое отношение между существительными — связь. «Юзер создаёт проекты» = User имеет много Project (1:N). «Юзер участвует в проектах» = User ↔ Project через Member (N:M). Этот процесс называется domain modeling — и он важнее, чем знание SQL.

Нормализация: правило «одно место — одна правда»

Нормализация — принцип «каждый факт хранится в одном месте». Плохо: в таблице orders колонки customer_name, customer_email — потому что когда юзер обновит email, в старых заказах останется старый. Правильно: orders ссылается на users по userId, имя и email хранятся ТОЛЬКО в users. Когда юзер обновится — все заказы автоматически «увидят» новые данные через JOIN. Это первая нормальная форма (1НФ): не дублируй данные, ссылайся на источник.

Когда осознанно денормализовать

Иногда нормализация мешает. Пример: в посте блога ты хочешь показать имя автора. С нормализацией — каждый раз JOIN с users. Если постов 100 на странице, это 100 JOIN. Решение: денормализация — копируешь author_name прямо в posts. Минус: при смене имени надо обновить во всех постах. Плюс: запрос быстрее в 10 раз. Когда денормализовать осознанно: 1) поле почти не меняется (имя автора, заморозка цены в заказе), 2) запрос — горячий (главная страница), 3) альтернатива — сложный запрос или N+1. Без этих условий — нормализуй.

Ключи: первичный, внешний, составной

Primary key (PK) — уникальный идентификатор строки. Используй cuid()/uuid() вместо автоинкремента (1,2,3...) для публичных id — иначе по url /order/123 видно сколько у тебя заказов всего. Foreign key (FK) — поле, ссылающееся на PK другой таблицы (orders.user_id → users.id). FK гарантирует ссылочную целостность: нельзя создать заказ для несуществующего юзера, нельзя удалить юзера если есть его заказы (или удалятся каскадом). Composite key — уникальность по комбинации полей: enrollment(student_id, course_id) — студент не может дважды записаться на тот же курс.

Антипаттерны схемы — список того, чего НЕ делать

JSON-колонка вместо отдельной таблицы. Хранишь user.permissions как JSON-массив — поиск «у кого есть permission X» теперь требует full-scan и regexp. Размытые поля типа status: string без enum или constraint — через год там 47 разных значений с опечатками. Хранение паролей открытым текстом — passwordHash или удаляешь юзеров. Поля типа address — на самом деле это страна, город, улица, дом — 4 колонки или связанная таблица addresses. Float для денег — копейки теряются на округлениях, используй integer (хранить копейки) или decimal. Эти 5 антипаттернов покрывают 80% проблем со схемой.

примерprisma
// ❌ ПЛОХО — всё свалено в одну таблицу, дубли, нет ссылок
model Order {
  id            String  @id
  customerEmail String   // дубль из User
  customerName  String   // дубль из User
  items         String   // JSON-строка с товарами — нельзя искать
  status        String   // "pending", "Pending", "PENDING" — будет хаос
  totalDollars  Float    // float для денег — потеря копеек
}


// ✅ ХОРОШО — нормализовано, типизировано, со связями
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  orders    Order[]
}

model Order {
  id           String      @id @default(cuid())
  userId       String
  user         User        @relation(fields: [userId], references: [id])
  totalCents   Int         // в копейках, без потерь
  status       OrderStatus // enum, фиксированные значения
  items        OrderItem[]
  createdAt    DateTime    @default(now())

  @@index([userId, createdAt])
}

model OrderItem {
  id        String  @id @default(cuid())
  orderId   String
  order     Order   @relation(fields: [orderId], references: [id], onDelete: Cascade)
  productId String
  quantity  Int
  priceCents Int    // снимок цены НА МОМЕНТ заказа — осознанная денормализация
}

enum OrderStatus {
  pending
  paid
  shipped
  delivered
  cancelled
}

Plохая схема vs хорошая — сторона спроса (e-commerce)

главное
  • 01Начинай не со схемы, а с пользовательских историй — существительные становятся таблицами
  • 02Нормализация = «один факт — одно место». Дубли данных = дубли проблем
  • 03Денормализуй ОСОЗНАННО: только если поле почти не меняется и запрос горячий
  • 04Используй cuid()/uuid() для публичных ID — автоинкремент палит твою статистику
  • 05Деньги — в копейках через Int, не во Float (теряются центы при округлениях)
  • 06Статусы — через enum, не строки. Через год в строковой колонке окажется 47 вариантов с опечатками
  • 07JSON-колонка = «не подумал какие тут поля». Если возможно — выноси в отдельную таблицу
проверь себя
01У тебя в `orders` есть колонки `customer_email` и `customer_name`. Юзер обновил профиль. Что произойдёт со старыми заказами?
02Почему Float — плохой тип для хранения денежных сумм?
03Почему лучше использовать cuid() вместо автоинкремента (1, 2, 3...) для публичных ID?
следующий урок: Индексы — почему один запрос быстрый, а другой кладёт сервер