PostgreSQL CookbookСхемаNOT NULL, PK, натуральный vs суррогатный ключ
0 / 63 (0%)

NOT NULL, PK, натуральный vs суррогатный ключ

Brew завёл таблицу кофеен и сделал первичным ключом человекочитаемый код: BREW-CENTRAL, BREW-NORTH. Логично — код короткий, говорящий, по нему удобно искать. На этот код по внешним ключам сослались остатки на складе и позиции заказов. А потом маркетинг переименовал BREW-NORTH в BREW-NEVA. И выяснилось, что «переименовать» — это сменить значение первичного ключа: пришлось каскадом править код во всех ссылающихся таблицах, а отчёты, которые держали старый код в кэше, начали ссылаться в пустоту. Боль возникла не из-за переименования, а из-за того, что изменчивый бизнес-код сделали идентичностью строки.

Цель юнита — две вещи. Первая: что такое PRIMARY KEY на самом деле — это NOT NULL плюс UNIQUE в одном объявлении (поэтому ключ не бывает пустым и не повторяется). Вторая: когда ключом делать натуральный код (бизнес-значение), а когда — суррогатный id (синтетический, ничего не значащий). Короткий ответ: суррогат для идентичности, натуральный код — отдельной UNIQUE-колонкой для поиска.

PRIMARY KEY = NOT NULL + UNIQUE

PRIMARY KEY — не отдельный «тип колонки», а связка двух ограничений. Объявив code TEXT PRIMARY KEY, ты автоматически получил NOT NULL (даже не написав его) и UNIQUE. Поэтому в PK-колонку нельзя положить NULL — это not_null_violation (SQLSTATE 23502) — и нельзя повторить значение — unique_violation (23505). Эти два инварианта и делают строку адресуемой: по значению ключа всегда есть ровно одна строка либо ни одной.

NOT NULL работает и сам по себе, на обычной колонке: name TEXT NOT NULL ловит пропущенное обязательное поле тем же 23502. Это первая линия валидации — она в схеме, а не в коде приложения, поэтому её не обойти ни забытой проверкой, ни кривым клиентом.

Натуральный против суррогатного ключа

Натуральный ключ — это бизнес-значение, которое и так уникально: код кофейни, ISBN книги, телефон. Плюс — не нужна лишняя колонка, ключ «осмысленный». Минус, который и подстрелил Brew: бизнес-значения меняются, а первичный ключ менять больно — за него держатся внешние ключи и кэши.

Суррогатный ключ — синтетический id (GENERATED ALWAYS AS IDENTITY из 02-01), который не значит ничего, кроме «это вот эта строка». Бизнес-код при этом не исчезает — он живёт отдельной колонкой с UNIQUE (искать по нему по-прежнему удобно). Переименование кода теперь не трогает id: внешние ключи ссылаются на стабильный id и не ломаются. Цена — лишняя колонка и лишний индекс. Для большинства таблиц приложения это правильный дефолт; натуральный ключ хорош там, где значение действительно неизменно (код страны ISO 3166).

Какой ключ брать

ОсьНатуральный ключСуррогатный ключ
Что этобизнес-значение как идентичность (код, ISBN, телефон)синтетический id, не значит ничего
При смене бизнес-значенияменяется сам ключ → тащит за собой FK и кэшиid неизменен, меняется лишь атрибут UNIQUE-кода
Лишняя колонка и индекснетда (id + UNIQUE-код)
Где «настоящая» уникальностьна самом ключена UNIQUE-коде, а не на id
Когда братьдействительно неизменные значения (коды стран/валют), составной PK junction-таблицдефолт для большинства таблиц приложения

Что показывает наш код

Две таблицы (DDL в schema.sql): одна на натуральном ключе, другая — на суррогатном с кодом-UNIQUE. Заметь: NOT NULL на code в shop_natural не написан — его навязал PRIMARY KEY:

sql
CREATE TABLE shop_natural (
    code  TEXT  PRIMARY KEY,          -- PK ⇒ NOT NULL + UNIQUE автоматически
    name  TEXT  NOT NULL
);
CREATE TABLE shop_surrogate (
    id    BIGINT  GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code  TEXT    NOT NULL UNIQUE,    -- бизнес-код — атрибут, не идентичность
    name  TEXT    NOT NULL
);

query.sql бьёт по инвариантам PK и показывает контраст при переименовании:

sql
-- name: InsertNaturalNullCode :exec     -- NULL в PK → 23502
INSERT INTO shop_natural (code, name) VALUES (NULL, $1);
 
-- name: RenameNaturalCode :exec         -- меняет САМО значение ключа
UPDATE shop_natural SET code = $2 WHERE code = $1;
 
-- name: RenameSurrogateCode :exec       -- меняет атрибут, id неизменен
UPDATE shop_surrogate SET code = $2 WHERE code = $1;

main.go переименовывает BREW-OLDBREW-NEW в обеих таблицах. В натуральной проверяет: старого ключа больше нет, появился новый — сам ключ уехал. В суррогатной — что id до и после одинаков: сменился только атрибут code. Ошибки печатаются как SQLSTATE (код детерминирован, текст — нет).

Запуск

sh
docker compose up -d
make lecture L=02-schema-and-constraints/02-02-not-null-pk-natural-vs-surrogate T=db-reset
make lecture L=02-schema-and-constraints/02-02-not-null-pk-natural-vs-surrogate

Вывод:

plaintext
1) PRIMARY KEY = NOT NULL + UNIQUE (таблица на натуральном ключе code):
   NULL в PK-колонку code      → отклонён: SQLSTATE 23502 (not_null_violation)
   дубль code 'BREW-CENTRAL'   → отклонён: SQLSTATE 23505 (unique_violation)
2) NOT NULL на обычной колонке:
   NULL в name                 → отклонён: SQLSTATE 23502 (not_null_violation)
3) Переименование ключа 'BREW-OLD' → 'BREW-NEW':
   натуральный PK (code):  старого ключа нет (false), новый есть (true) — сменилось само значение ключа
   суррогат (id):          id = 1 → 1 неизменен, сменился только атрибут code — identity строки стабильна

PK отбил и NULL (23502), и дубль (23505); NOT NULL на name — тот же 23502. А в блоке 3 видно главное: натуральный ключ при переименовании сменился целиком (BREW-OLD исчез, BREW-NEW появился — за ним должны были «уехать» все внешние ключи), тогда как суррогатный id остался 1, и поменялся лишь атрибут code. Именно это и спасает от боли Brew.

Заборчик

Что мы упростили: показали суррогат как «почти всегда правильный дефолт», но не довели до конца тему внешних ключей — здесь ни одна таблица на наши code/id не ссылается, поэтому каскад переименования остался за кадром (его сделаем в 02-03). В проде выбор ключа — это компромисс, который держат в голове и твой DBA, и ты:

  • Суррогат развязывает идентичность и бизнес-значение (рекомендуемый дефолт для таблиц приложения), но добавляет колонку и индекс и требует помнить, что «настоящая» уникальность — на UNIQUE-коде, а не на id.
  • Натуральный ключ хорош для действительно неизменных значений (коды валют, стран) и в стыковочных таблицах «многие-ко-многим», где составной натуральный PK естественен (наша базовая таблица inventory (shop_id, drink_id) — ровно такая).
  • Всегда вешай NOT NULL явно на бизнес-обязательные поля: пусть схема, а не код, отвечает за то, что данные не приедут пустыми.

Что забрать с собой

  • PRIMARY KEY — это NOT NULL + UNIQUE: ключ не бывает NULL (23502) и не повторяется (23505); NOT NULL ставит схему первой линией валидации.
  • Натуральный ключ = бизнес-значение как идентичность: меняется вместе с бизнесом, и его смена «тащит» внешние ключи.
  • Суррогатный ключ = синтетический id владеет идентичностью, бизнес-код живёт UNIQUE-колонкой; переименование кода не трогает id. Это правильный дефолт для большинства таблиц приложения.
  • Составной натуральный ключ уместен в junction-таблицах (inventory (shop_id, drink_id) в схеме Brew).

Дальше — юнит 02-03 «Внешние ключи (ON DELETE CASCADE/SET NULL)»: как FK держит ссылочную целостность (висящая ссылка → 23503) и что делать с детьми при удалении родителя — каскадно удалить, обнулить ссылку или вовсе запретить удаление.

·Модуль 03

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Схема / NOT NULL, PK, натуральный vs суррогатный ключ