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:
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 и показывает контраст при переименовании:
-- 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-OLD → BREW-NEW в обеих таблицах. В натуральной проверяет: старого ключа больше нет, появился новый — сам ключ уехал. В суррогатной — что id до и после одинаков: сменился только атрибут code. Ошибки печатаются как SQLSTATE (код детерминирован, текст — нет).
Запуск
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Вывод:
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) и что делать с детьми при удалении родителя — каскадно удалить, обнулить ссылку или вовсе запретить удаление.