SQLLab
Все статьи

Первичные ключи в PostgreSQL: SERIAL, BIGSERIAL, UUID или IDENTITY?

Первичные ключи в PostgreSQL: SERIAL vs BIGSERIAL vs GENERATED ALWAYS AS IDENTITY vs UUID. Производительность вставки, проблемы переполнения, когда UUID.

30 марта 2026 г.·8 мин чтения·

Выбор типа первичного ключа — одно из первых решений при создании таблицы, и оно влияет на производительность, масштабирование и архитектуру системы. В 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:

  1. Последовательность жёстко привязана к столбцу — не существует отдельно.
  2. Чище интегрируется с pg_dump и репликацией.
  3. 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.

Вопросы для самопроверки

  1. Чем BIGSERIAL отличается от BIGINT GENERATED ALWAYS AS IDENTITY, если диапазон значений у них одинаковый?
  2. Что произойдёт с высоконагруженной таблицей, если исчерпать максимум SERIAL (2 147 483 647)?
  3. Почему UUID v4 фрагментирует B-tree индекс первичного ключа, а UUID v7 — нет?
  4. В каком случае составной первичный ключ (order_id, product_id) полностью закрывает потребность в индексах, а в каком нужен ещё один индекс?
  5. Почему для внутренней таблицы с редким ростом не стоит сразу брать UUID «про запас»?

Закрепите понимание проектирования баз данных в нашем тренажёре SQL.

Похожие статьи

Попробуй на практике

500+ SQL задач с реальных собеседований — бесплатно

Открыть тренажёр →

Проверяй решения — бесплатно

Регистрация за 30 секунд. Решай задачи, получай фидбек, отслеживай прогресс.

Зарегистрироваться →