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 ALWAYS | GENERATED BY DEFAULT (≈ serial) |
|---|---|---|
| Кто владеет ключом | только БД | БД и приложение |
INSERT без id | следующий из последовательности | следующий из последовательности |
INSERT с явным id | отклонён (428C9) | принят, счётчик не двигается |
| Рассинхрон последовательности | невозможен | возможен → нужен setval после загрузки |
| Когда брать | новые таблицы (дефолт PG18) | переливка/импорт с сохранением исходных id |
Что показывает наш код
Две свои таблицы (DDL в schema.sql) — базовые таблицы Brew байт-совместимы с kafka-cookbook, их id трогать нельзя, поэтому контраст показываем на новых:
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:
-- 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 — код детерминирован, в отличие от текста сообщения, поэтому вывод воспроизводится дословно:
err = queries.InsertAlwaysExplicitID(ctx, db.InsertAlwaysExplicitIDParams{ID: 999, Label: "smuggled"})
fmt.Printf("... отклонён: SQLSTATE %s ...\n", sqlState(err)) // 428C9Запуск
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Вывод:
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 (как здесь), а когда — натуральный бизнес-код.