Выбор типа первичного ключа — одно из первых решений при создании таблицы, и оно влияет на производительность, масштабирование и архитектуру системы. В PostgreSQL есть несколько способов, и каждый имеет свои нюансы.
SERIAL: устаревший, но распространённый
SERIAL — это псевдотип, который разворачивается в INTEGER + последовательность + DEFAULT nextval(...):
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email TEXT NOT NULL
);
-- Эквивалентно:
-- CREATE SEQUENCE users_user_id_seq;
-- user_id INTEGER DEFAULT nextval('users_user_id_seq') NOT NULL
Проблема: SERIAL — не более чем сокращённая запись при создании таблицы, реальная связь колонки и последовательности держится только на DEFAULT nextval(...), а её легко разорвать по неосторожности. ALTER TABLE users ALTER COLUMN user_id DROP DEFAULT молча снимет автоинкремент, оставив последовательность висеть отдельно без предупреждения. А ничто не мешает вставить значение в обход последовательности напрямую — INSERT INTO users (user_id, email) VALUES (5, ...) — и получить дубликат ключа позже, когда nextval() её «догонит». GENERATED ALWAYS AS IDENTITY (см. ниже) закрывает обе дыры: отвязать генерацию можно только явной командой DROP IDENTITY, а вставить своё значение без специального синтаксиса OVERRIDING SYSTEM VALUE по умолчанию нельзя вовсе.
SERIAL — целое число (4 байта), максимум ~2.1 миллиарда. Для небольших таблиц — норма. Для высоконагруженного сервиса с интенсивной записью — риск переполнения.
-- SMALLSERIAL: 1 до 32 767 — только для совсем маленьких справочников
-- SERIAL: 1 до 2 147 483 647
-- BIGSERIAL: 1 до 9 223 372 036 854 775 807
BIGSERIAL: когда нужен большой диапазон
BIGSERIAL — то же самое, что SERIAL, но тип BIGINT (8 байт). Максимальное значение: 9.2 × 10¹⁸.
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Если ваша система генерирует миллионы записей в день — считайте:
- 1 млн в день × 365 = 365 млн в год.
SERIAL(2,1 млрд) хватит примерно на 5,9 года.BIGSERIAL(9,2 × 10¹⁸) хватит примерно на 25 миллиардов лет при том же темпе — это почти вдвое больше возраста Вселенной (~13,8 млрд лет). На практике диапазонBIGSERIALне исчерпывается никогда.
Практическое правило: используйте BIGSERIAL или BIGINT GENERATED ALWAYS AS IDENTITY для любой таблицы, которая может существенно вырасти.
GENERATED AS IDENTITY: современный стандарт SQL
GENERATED AS IDENTITY появился в PostgreSQL 10 и является частью стандарта SQL:2003. Это рекомендуемый способ создания автоинкрементных колонок:
-- GENERATED BY DEFAULT: можно вставить своё значение явно
CREATE TABLE products (
product_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL
);
-- GENERATED ALWAYS: нельзя вставить своё значение без OVERRIDING SYSTEM VALUE
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
total NUMERIC(12,2)
);
Отличие от SERIAL:
- Последовательность жёстко привязана к столбцу — не существует отдельно.
- Чище интегрируется с
pg_dumpи репликацией. GENERATED ALWAYSзащищает от случайной вставки своего ID.
-- Настройка начального значения и шага
CREATE TABLE invoices (
invoice_id BIGINT GENERATED ALWAYS AS IDENTITY
(START WITH 1000 INCREMENT BY 1) PRIMARY KEY,
amount NUMERIC(12,2)
);
-- Вставка с явным значением (только для GENERATED BY DEFAULT)
INSERT INTO products (product_id, name) VALUES (9999, 'Тестовый товар');
-- Для GENERATED ALWAYS нужен спецсинтаксис
INSERT INTO orders (order_id, total)
OVERRIDING SYSTEM VALUE
VALUES (9999, 100.00);
UUID: глобально уникальные идентификаторы
UUID (Universally Unique Identifier) — 128-битный идентификатор, уникальный без координации между серверами.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT REFERENCES users(user_id),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE EXTENSION pgcrypto нужен только на PostgreSQL < 13 — начиная с 13-й версии gen_random_uuid() встроена в ядро, расширение не требуется.
UUID v4 vs UUID v7
UUID v4 полностью случаен. Выглядит как 550e8400-e29b-41d4-a716-446655440000.
UUID v7 (RFC 9562, встроен в PostgreSQL начиная с версии 18) включает временну́ю метку в начале, что делает UUID монотонно возрастающими:
-- PostgreSQL 18+
SELECT gen_random_uuid(); -- UUID v4 (случайный, доступен с PG13)
SELECT uuidv7(); -- UUID v7 (монотонный, встроен с PG18)
Для PostgreSQL < 18 можно использовать расширение pg_uuidv7.
Плюсы UUID
- Уникальность гарантирована без обращения к БД — ID можно генерировать на клиенте или в распределённой системе.
- Безопасность: ID не предсказуемы, нельзя угадать следующий (
/users/1001→ понятно, что следующий/users/1002). - Идеален для публичных API, распределённых систем, микросервисов.
Минусы UUID v4
- Случайность = фрагментация индекса. При вставке UUID v4 B-tree индекс первичного ключа пишет в случайные места, что приводит к частым
page splitоперациям и большим файлам индекса. - Занимает 16 байт против 8 байт у
BIGINT— все внешние ключи становятся тяжелее. - Нечитаем в логах и при отладке.
UUID v7 решает проблему фрагментации — благодаря временно́й метке новые записи всегда вставляются в «конец» индекса.
-- Бенчмарк: INSERT 1 млн строк
-- BIGINT IDENTITY: ~3 с, индекс ~40 MB
-- UUID v4: ~8 с, индекс ~90 MB (из-за фрагментации)
-- UUID v7: ~3.5 с, индекс ~45 MB
Числа примерные, но порядок соотношений верный.
Составные первичные ключи
Составной PK используется в промежуточных таблицах связей Many-to-Many:
CREATE TABLE order_items (
order_id BIGINT REFERENCES orders(order_id) ON DELETE CASCADE,
product_id BIGINT REFERENCES products(product_id),
quantity INT NOT NULL DEFAULT 1,
PRIMARY KEY (order_id, product_id) -- составной PK
);
Составной PK автоматически создаёт индекс (order_id, product_id). Для запросов по product_id отдельно нужен дополнительный индекс:
CREATE INDEX idx_order_items_product ON order_items(product_id);
Когда что выбрать
| Сценарий | Рекомендация |
|---|---|
| Внутренняя таблица, небольшой объём | INTEGER GENERATED ALWAYS AS IDENTITY |
| Любая таблица с ростом > 1 млн строк | BIGINT GENERATED ALWAYS AS IDENTITY |
| Распределённая система / микросервисы | UUID v7 (или UUID v4 если нет PG18) |
| Публичный API (скрыть объём данных) | UUID |
| Промежуточная таблица M:M | Составной PK из FK-столбцов |
| Legacy-код | BIGSERIAL (но мигрируйте к IDENTITY) |
Миграция с SERIAL на IDENTITY
Если в проекте уже есть таблицы на SERIAL, переводить их на IDENTITY «на всякий случай» не нужно — но для новой ключевой таблицы или при рефакторинге это полезно сделать осознанно. PostgreSQL позволяет привязать IDENTITY к существующей колонке без пересоздания таблицы:
-- Было: user_id SERIAL PRIMARY KEY, сейчас в таблице уже есть строки с id до 1542
-- 1. Убираем старый DEFAULT (nextval старой последовательности)
ALTER TABLE users ALTER COLUMN user_id DROP DEFAULT;
-- 2. Добавляем IDENTITY — стартовое значение берём на 1 больше текущего максимума
ALTER TABLE users ALTER COLUMN user_id
ADD GENERATED ALWAYS AS IDENTITY (START WITH 1543);
-- 3. Старая последовательность SERIAL осиротела — IDENTITY создала свою. Удаляем старую
DROP SEQUENCE users_user_id_seq;
Важно: стартовое значение в шаге 2 нужно выбрать больше текущего максимума в таблице, иначе первая же вставка попытается создать дублирующийся user_id и упадёт с ошибкой уникальности. Проверьте актуальный максимум перед миграцией:
SELECT MAX(user_id) FROM users;
Итог
SERIAL и BIGSERIAL работают, но GENERATED AS IDENTITY — современный стандарт, который стоит использовать в новых проектах. UUID удобен для распределённых систем и публичных API, но помните о фрагментации индекса при использовании v4. UUID v7 сочетает преимущества обоих подходов.
Потренируйтесь
Сервис логирует события: 50 млн новых строк в день, доступ к таблице только через внутренний API (наружу ID никогда не попадает). Какой первичный ключ выбрать? А если ту же таблицу нужно синхронизировать между тремя независимыми дата-центрами (Москва, Алматы, Ереван) без единого узла, который выдаёт ID?
Ответ
Первый случай. Внутренний сервис, ID наружу не светится, важна скорость вставки — BIGINT GENERATED ALWAYS AS IDENTITY. При 50 млн строк в день SERIAL (максимум ~2,1 млрд) переполнится примерно за 43 дня — меньше двух месяцев, а UUID добавит ненужную фрагментацию индекса и лишние 8 байт на строку, раз скрывать объём данных не требуется.
Второй случай. Три независимых дата-центра без единого генератора ID — BIGINT IDENTITY здесь уже не работает без ручной координации последовательностей между дата-центрами (два региона неизбежно сгенерируют одинаковый ID). Нужен UUID v7: генерируется локально в каждом регионе, без обращения к другим узлам, и благодаря временно́й метке в начале остаётся монотонно возрастающим — индекс не фрагментируется так, как при UUID v4.
Вопросы для самопроверки
- Чем
BIGSERIALотличается отBIGINT GENERATED ALWAYS AS IDENTITY, если диапазон значений у них одинаковый? - Что произойдёт с высоконагруженной таблицей, если исчерпать максимум
SERIAL(2 147 483 647)? - Почему UUID v4 фрагментирует B-tree индекс первичного ключа, а UUID v7 — нет?
- В каком случае составной первичный ключ
(order_id, product_id)полностью закрывает потребность в индексах, а в каком нужен ещё один индекс? - Почему для внутренней таблицы с редким ростом не стоит сразу брать
UUID«про запас»?
Закрепите понимание проектирования баз данных в нашем тренажёре SQL.