open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 06 · 06

Дизайн схемы: нормализуй, потом денормализуй осознанно

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

SQL Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

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

Сначала нормализуй: один факт, одно место

Нормализация — это дефолт, и правило большого пальца, схватывающее бóльшую часть: каждый факт живёт ровно в одном месте. Email клиента хранится один раз, на строке users; заказ ссылается на пользователя по id и никогда не копирует email. Когда email меняется, ты обновляешь одну строку, и каждый заказ автоматически отражает истину, потому что её никогда не дублировали. (Глава relational-model трека databases разбирает нормальные формы формально; здесь мы фокусируемся на инженерном суждении.)

Выигрыш — целостность обновления: нет способа, чтобы две копии факта разошлись, потому что копия одна. Цена — что чтение полной картины требует джойнов — чтобы показать заказ с текущим именем клиента, ты джойнишь orders к users. Для подавляющего большинства OLTP-нагрузок (Online Transaction Processing — оперативная обработка транзакций) этот джойн дёшев (индексированный поиск), а целостность бесценна. Начинай нормализованно. Всегда.

Моделируй типами Postgres, не строками

Сквозная нить всего раздела: схема, опирающаяся на систему типов Postgres, обеспечивается движком; stringly-typed схема толкает эту работу в хрупкий код приложения. Конкретно, предпочитай:

  • enum status order_status (или CHECK ... IN) вместо свободного status text, который может держать 'Paid', 'paid' и 'PAID';
  • tstzrange с ограничением EXCLUDE вместо двух text-колонок с ISO-строками, которые приложение должно парсить и сравнивать;
  • numeric/целые центы вместо text «amount»; timestamptz вместо text-даты; tags text[] (или джойн-таблицу) вместо строки через запятую;
  • jsonb-документ вместо сериализованного блоба, который база не может индексировать или валидировать;
  • domain (CREATE DOMAIN email AS text CHECK (value ~ '^[^@]+@[^@]+$')), чтобы прикрепить переиспользуемое ограничение к семантическому типу.

Каждое из этого переносит правило из «приложение помнит проверить» в «движок гарантирует». Stringly-typed колонка — отложенный баг: сегодня она принимает мусор, а позже всплывает он продовым инцидентом.

Суррогатные против натуральных ключей

Натуральный ключ — колонка, которая уже реальный идентификатор (ISO-код страны, SKU, email). Суррогатный ключ — бессмысленный id, сгенерированный движком (bigint identity или uuid). Суждение:

  • Суррогат — безопасный дефолт для первичного ключа. Он стабилен (клиент может сменить email; его id никогда не меняется), компактен и единообразен. Внешние ключи указывают на суррогат, так что смена натурального значения не каскадит через всю базу.
  • Натуральные ключи заслуживают место как UNIQUE-ограничения (тебе всё равно нужен users.email UNIQUE) и изредка как первичный ключ, когда значение действительно неизменно и внешне осмысленно — таблица currencies(code) с ключом 'USD', 'EUR'. Даже тогда осторожно: «неизменные» идентификаторы имеют привычку меняться (компании переименовываются, страны разделяются).

Прагматичное правило: суррогатный первичный ключ + натуральные уникальные ограничения. Получаешь стабильную цель джойна и всё равно обеспечиваешь реальное правило уникальности.

Денормализуй осознанно — и держи согласованным

Денормализация — это оптимизация производительности, к которой тянешься после замеров, а не стартовая поза. Она верна, когда чтение горячее, джойн действительно дорог на твоём масштабе, а дублируемый факт редко меняется — предвычисленный order_total, кешированный comment_count, дружественная поиску копия. Не подлежащее переговорам: денормализованная копия должна держаться согласованной движком, а не надеждой. Два чистых способа:

-- 1) STORED generated колонка: вывод внутри той же строки, всегда верна, без триггера
ALTER TABLE order_items
  ADD COLUMN line_total bigint GENERATED ALWAYS AS (qty * unit_price_cents) STORED;

-- 2) триггер: поддержка межстрочного агрегата (например orders.item_count) при смене позиции
CREATE FUNCTION bump_item_count() RETURNS trigger AS $$
BEGIN
  UPDATE orders SET item_count = item_count + 1 WHERE id = NEW.order_id;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

STORED generated колонка обрабатывает выводы той же строки бесплатно (без триггера, не может дрейфовать). Межстрочные агрегаты требуют триггера или периодически обновляемого materialized view — и это реальная цена сопровождения, которую берёшь осознанно. Кардинальный грех — ошибка из Hook: скопировать факт и затем довериться приложению обновить каждую копию. Денормализация, поддерживаемая приложением, дрейфует; поддерживаемая движком — нет.

Почему это работает

Почему не денормализовать с самого начала «на всякий случай ради производительности»? Потому что ты меняешь определённую, постоянную цену (каждая запись должна поддерживать копии; каждая смена рискует дрейфом) на гипотетическую, будущую цену чтения, которую ты не замерял. Нормализованные OLTP-схемы с верными индексами обслуживают подавляющее большинство нагрузок миллисекундными джойнами. Преждевременная денормализация — это схемный эквивалент преждевременной оптимизации: она впекает сложность и класс багов согласованности, чтобы решить проблему, которой у тебя может никогда не быть. Нормализуй, индексируй, замеряй — и денормализуй лишь конкретный горячий путь, который докажет, что ему это нужно, с движком, поддерживающим копию.

Расставь шаги по порядку

Упорядочь workflow дизайна схемы от дефолтной позы до крайней меры:

  1. 1 Нормализуй: помести каждый факт ровно в одно место
  2. 2 Моделируй настоящими типами/ограничениями Postgres, не stringly-typed колонками
  3. 3 Используй суррогатные первичные ключи плюс натуральные UNIQUE-ограничения
  4. 4 Добавь индексы для горячих путей запросов и замерь через EXPLAIN ANALYZE
  5. 5 Только если замеренное чтение всё ещё слишком медленное, денормализуй этот конкретный путь
  6. 6 Держи денормализованную копию согласованной через generated колонку или триггер
Викторина

Строка заказа копирует email клиента. Клиент меняет email. В чём ключевая проблема этого денормализованного дизайна?

Викторина

Ты решаешь, что денормализованный line_total (qty × unit_price) стоит кешировать на order_items. Как держать его согласованным?

Вспомните перед уходом
  1. 01
    Что даёт нормализация, чего она стоит и почему это дефолт?
  2. 02
    Суррогатные против натуральных ключей — какой для первичного ключа и где место натуральным?
  3. 03
    Когда стоит денормализовать и как удержать копию от дрейфа?
Итог

Дизайн схемы — это дисциплина того, где живёт факт. Дефолт — нормализовать, чтобы каждый факт жил ровно в одном месте, что делает обновления согласованными по построению и держит OLTP-джойны дёшево — начинаешь здесь всегда. На всём пути моделируй настоящими типами и ограничениями Postgres — enum, range с EXCLUDE, numeric/центы, timestamptz, массивы, jsonb и domain — чтобы движок обеспечивал то, что stringly-typed схема оставила бы хрупкому коду приложения. Выбирай суррогатные первичные ключи (стабильный, компактный bigint identity или uuid) целью джойна и держи натуральные ключи как UNIQUE-ограничения, повышая натуральный ключ до первичного лишь когда он действительно неизменен и внешне осмыслен. Денормализация — замеренная оптимизация крайней меры для доказанного горячего пути чтения, никогда не стартовая поза — и денормализованная копия должна поддерживаться движком: STORED generated колонка для выводов той же строки (не может дрейфовать) или триггер / materialized view для межстрочных агрегатов, никогда код приложения, который тихо даёт копиям разойтись. Теперь, когда увидишь схему, где имя клиента скопировано в каждую строку заказа, или status text, весело хранящий 'Paid', 'paid' и 'PAID' — ты знаешь структурную проблему и фикс: нормализуй факт, типизируй колонку, отдай правило движку.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.