PostgreSQL CookbookСхемаidentity и defaults
0 / 63 (0%)

identity и defaults

Brew переезжал со старой таблицы заказов и переливал историю скриптом. Таблица была на serial, скрипт вставлял строки с явными id из выгрузки (1001..1500), а вот последовательность за этими значениями никто не подвинул — она осталась на старом месте. Несколько недель всё работало, заказы шли, а потом приложение вдруг начало падать на ровном месте: duplicate key value violates unique constraint "orders_pkey". Последовательность доросла до 1500, попробовала выдать уже занятый id — и упёрлась в собственную историю. Классический «серийный» прострел в ногу.

Цель юнита — понять, кто владеет первичным ключом. У serial (и его прямого наследника GENERATED BY DEFAULT AS IDENTITY) владение делят БД и приложение: база подставит id, если его не дали, но и явный примет — а значит, скрипт миграции может незаметно разойтись с последовательностью. Современный дефолт PG18 — GENERATED ALWAYS AS IDENTITY: колонкой владеет только база, вписать свой id нельзя в принципе (без явного OVERRIDING SYSTEM VALUE). Заодно разберём обычный DEFAULT — выражение, которое подставляется, если значение пропущено.

Два режима IDENTITY: кто хозяин ключа

GENERATED ALWAYS AS IDENTITY — колонка целиком за базой. INSERT без id получает следующее значение из внутренней последовательности; INSERT с id отвергается с SQLSTATE 428C9 — это не баг, а защита: приложение физически не может рассинхронизировать счётчик. Именно тот прострел из истории здесь невозможен.

GENERATED BY DEFAULT AS IDENTITY ведёт себя как старый serial: без id база подставит своё, но и явный id молча примет — не двигая последовательность. Завтра она доберётся до того же значения и выдаст дубль. Удобно для переливки данных (можно сохранить исходные id), но именно эта поблажка и создаёт мину; после миграции последовательность нужно вручную подвинуть через setval (как в нашем schema/seed.sql для базовой таблицы orders).

DEFAULT: значение по умолчанию

DEFAULT проще: это выражение, которое БД подставляет в колонку, когда INSERT её пропустил. У created_at это now() — не передал время, получил текущее. IDENTITY — это, по сути, специальный DEFAULT поверх последовательности; разница в том, что обычный DEFAULT всегда можно перекрыть своим значением, а GENERATED ALWAYS — нет.

ALWAYS против BY DEFAULT

Разница — в том, кто владеет ключом и можно ли вписать свой id:

ОсьGENERATED ALWAYSGENERATED BY DEFAULT (≈ serial)
Кто владеет ключомтолько БДБД и приложение
INSERT без idследующий из последовательностиследующий из последовательности
INSERT с явным idотклонён (428C9)принят, счётчик не двигается
Рассинхрон последовательностиневозможенвозможен → нужен setval после загрузки
Когда братьновые таблицы (дефолт PG18)переливка/импорт с сохранением исходных id

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

Две свои таблицы (DDL в schema.sql) — базовые таблицы Brew байт-совместимы с kafka-cookbook, их id трогать нельзя, поэтому контраст показываем на новых:

sql
CREATE TABLE id_always (
    id          BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    label       TEXT         NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE id_by_default (
    id     BIGINT  GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    label  TEXT    NOT NULL
);

query.sql ставит три опыта: вставка без id (база раздаёт ключ), попытка вписать свой id в ALWAYS и в BY DEFAULT:

sql
-- name: InsertAlways :one
INSERT INTO id_always (label) VALUES ($1)
RETURNING id, (created_at IS NOT NULL)::boolean AS created_at_filled;
 
-- name: InsertAlwaysExplicitID :exec      -- отклонит: SQLSTATE 428C9
INSERT INTO id_always (id, label) VALUES ($1, $2);
 
-- name: InsertByDefaultExplicitID :one    -- примет явный id
INSERT INTO id_by_default (id, label) VALUES ($1, $2)
RETURNING id;

main.go тонкий: чистит таблицы, делает три вставки без id (получает 1,2,3), затем ловит ошибку явной вставки в ALWAYS и печатает её SQLSTATE — код детерминирован, в отличие от текста сообщения, поэтому вывод воспроизводится дословно:

go
err = queries.InsertAlwaysExplicitID(ctx, db.InsertAlwaysExplicitIDParams{ID: 999, Label: "smuggled"})
fmt.Printf("... отклонён: SQLSTATE %s ...\n", sqlState(err)) // 428C9

Запуск

sh
docker compose up -d
make lecture L=02-schema-and-constraints/02-01-identity-and-defaults T=db-reset
make lecture L=02-schema-and-constraints/02-01-identity-and-defaults

Вывод:

plaintext
1) GENERATED ALWAYS AS IDENTITY — id присваивает БД:
   три INSERT без id → id = [1 2 3]; created_at заполнен по DEFAULT now(): true
2) Явный id=999 в GENERATED ALWAYS → отклонён: SQLSTATE 428C9 (нельзя писать в GENERATED ALWAYS)
3) Явный id=999 в GENERATED BY DEFAULT → принят: id = 999
   (счётчик IDENTITY при этом не сдвинулся — отсюда классический рассинхрон serial; см. заборчик)

Три вставки без id легли как 1, 2, 3, а created_at заполнился сам из DEFAULT now(). Явный id в GENERATED ALWAYS отбит базой (428C9) — приложение просто не может вмешаться в ключ. А GENERATED BY DEFAULT принял 999 молча: последовательность осталась на единице — вот та самая тропинка к будущему дублю.

Заборчик

Что мы упростили: показали, что BY DEFAULT молча рассинхронит счётчик, но не стали гонять его до коллизии (duplicate key) — это десятки тысяч вставок. Дальше — границы, которые держит твой DBA, и практические правила:

  • Рассинхрон лечит setval: после любой загрузки данных с явными id последовательность подвигают через SELECT setval(pg_get_serial_sequence('t','id'), (SELECT max(id) FROM t)) — ровно это делает наш schema/seed.sql для базовой таблицы orders.
  • Новые таблицы заводи на GENERATED ALWAYS AS IDENTITY — он запрещает приложению трогать ключ и убивает целый класс багов.
  • BY DEFAULT бери осознанно и только там, где нужно временно подменять id (импорт, репликация тестовых данных), и не забывай про setval после.
  • OVERRIDING SYSTEM VALUE существует, но это аварийный люк, а не повседневный инструмент.

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

  • GENERATED ALWAYS AS IDENTITY (дефолт PG18) — ключом владеет БД: явный id отвергается (428C9), рассинхрон последовательности невозможен.
  • GENERATED BY DEFAULT AS IDENTITY (как serial) — примет явный id, не двигая счётчик; это удобно для миграций, но требует setval после, иначе будущий дубль.
  • DEFAULT <выражение> подставляется при пропуске колонки (created_at DEFAULT now()); IDENTITY — это специальный DEFAULT, который у ALWAYS нельзя перекрыть.
  • Печатай SQLSTATE, а не текст ошибки: код детерминирован, текст — нет.

Дальше — юнит 02-02 «NOT NULL, PK, натуральный против суррогатного ключа»: чем PRIMARY KEY отличается от обычного UNIQUE, почему он сразу NOT NULL, и когда ключом делать суррогатный id (как здесь), а когда — натуральный бизнес-код.

·Модуль 03

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

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

/ вы пытались открыть
Схема / identity и defaults