Почти в каждой таблице есть столбец, который должен сам выдавать новые номера: id заказа, номер билета, ключ записи. Делать это руками опасно - два параллельных клиента легко возьмут одно и то же число. В postgresql за уникальные растущие номера отвечает отдельный объект - последовательность (sequence), а поверх неё стоят два слоя удобства: устаревший serial и стандартный GENERATED AS IDENTITY. Отдельная история - генерируемые столбцы, которые вычисляют своё значение из других полей. В этом уроке разберём, как устроен автоинкремент внутри, чем IDENTITY лучше serial и где у всего этого острые углы.

Как это работает
Последовательность - это маленький служебный объект в базе, который хранит одно число (текущее состояние) и умеет атомарно его увеличивать. Когда вы зовёте nextval, сервер под блокировкой берёт текущее значение, прибавляет шаг и отдаёт результат. Ключевая мысль: выдача номера происходит вне транзакционных правил отката. Если вы взяли nextval, а потом откатили транзакцию, номер уже потрачен - он не вернётся в пул. Поэтому в id почти всегда есть дырки, и это нормально, а не баг.
Раздача номеров не блокирует параллельных клиентов надолго именно потому, что не подчиняется откату. Два сеанса, делающих INSERT одновременно, получат два разных значения и не будут ждать друг друга. За скорость отвечает кеш: параметр CACHE говорит, сколько номеров сеанс забирает себе наперёд одним движением, чтобы не дёргать общий объект на каждую вставку.
Теперь слои поверх. Старый способ - тип serial: это не настоящий тип, а сокращение. Postgres создаёт обычный столбец integer, отдельную последовательность и вешает значение по умолчанию nextval. Проблема в том, что связь между столбцом и последовательностью получается рыхлой: последовательность - самостоятельный объект, её можно случайно удалить, в столбец можно вписать любое число вручную мимо счётчика.
Современный и стандартный способ - GENERATED AS IDENTITY (появился в SQL-стандарте, в postgresql с версии 10). Здесь последовательность жёстко принадлежит столбцу, управляется через ALTER TABLE и не живёт своей жизнью. Есть два режима: ALWAYS запрещает вписывать значение руками (только через OVERRIDING), BY DEFAULT разрешает, ведя себя похоже на serial, но аккуратнее. На 2026 год для новых таблиц правильно использовать IDENTITY, а не serial.
Третья вещь - генерируемые столбцы GENERATED ALWAYS AS (выражение) STORED. Это не про автоинкремент, а про вычисляемое поле: его значение всегда выводится из других столбцов той же строки и физически хранится на диске (STORED). Вписать в него значение напрямую нельзя - база сама пересчитает при INSERT и UPDATE. Удобно для нормализованных копий: длина текста, цена с налогом, нижний регистр для поиска.
SQL и примеры
Создадим последовательность и посмотрим на её поведение вручную.
Код: Выделить всё
CREATE SEQUENCE demo_seq START 100 INCREMENT 1 CACHE 1;
SELECT nextval('demo_seq'); -- 100
SELECT nextval('demo_seq'); -- 101
SELECT currval('demo_seq'); -- 101, последнее значение В ЭТОМ сеансе
Сравним два способа автоинкремента. Так выглядит правильная современная таблица:
Код: Выделить всё
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created timestamptz DEFAULT now()
);
INSERT INTO orders (created) VALUES (now()); -- id присвоится сам
-- попытка задать id руками упадёт:
-- INSERT INTO orders (order_id, created) VALUES (5, now()); -- ОШИБКА
-- продавить можно явно:
INSERT INTO orders (order_id, created)
OVERRIDING SYSTEM VALUE VALUES (5, now());
Код: Выделить всё
ALTER TABLE bookings.flights
ADD COLUMN duration_min integer
GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (scheduled_arrival - scheduled_departure)) / 60
) STORED;
SELECT flight_no, scheduled_departure, duration_min
FROM bookings.flights
ORDER BY duration_min DESC
LIMIT 5;
Частая практическая задача - починить счётчик после ручной загрузки данных. Если вы залили строки в orders напрямую (через COPY или миграцию из 1С) с готовыми id, внутренняя последовательность об этом не знает и начнёт выдавать номера с единицы, ловя дубликаты. Сдвигаем её на максимум:
Код: Выделить всё
SELECT setval(
pg_get_serial_sequence('orders', 'order_id'),
(SELECT max(order_id) FROM orders)
);
Частые грабли
- Ожидание, что id будут без пропусков. Откаты и кеш гарантированно делают дырки. Если нужна сплошная нумерация документов - это отдельная бизнес-логика со своей таблицей-счётчиком, а не sequence.
- Ручная вставка в serial-столбец мимо последовательности. Потом первый же автоинкремент даёт дубликат и нарушение первичного ключа. С IDENTITY ALWAYS этой ловушки нет - база не пустит.
- Перенос данных из 1С или дампа без setval. Строки легли, а счётчик остался на старте - и новые INSERT падают. Всегда сдвигайте последовательность после массовой заливки.
- currval без nextval в сеансе - ошибка, а не ноль. И currval/lastval не видят значений из других сеансов, не используйте их для поиска чужих id.
- bigint против integer. integer кончается на 2.1 млрд. Для растущих таблиц сразу берите bigint - переделка типа первичного ключа под нагрузкой это боль.
- Попытка записать в GENERATED ALWAYS ... STORED столбец или построить его на изменчивой функции вроде now(). Разрешены только IMMUTABLE-выражения от столбцов той же строки.
- DEFAULT nextval не копируется при CREATE TABLE ... LIKE без INCLUDING DEFAULTS, а владение последовательностью так вообще не переносится - легко получить таблицу без автоинкремента.
- Создайте таблицу products с ключом prod_id bigint GENERATED ALWAYS AS IDENTITY и столбцами name text, price numeric.
- Вставьте 3 строки без указания prod_id, убедитесь, что номера выдались сами.
- Добавьте генерируемый столбец price_vat numeric GENERATED ALWAYS AS (price * 1.20) STORED и проверьте значения.
- Попробуйте сделать INSERT с явным prod_id - получите ошибку, затем повторите с OVERRIDING SYSTEM VALUE.
- Залейте строку с заведомо большим prod_id через OVERRIDING, после чего вставьте обычную строку и поймайте конфликт ключа.
- Почините счётчик через setval и pg_get_serial_sequence, повторите вставку - теперь без ошибки.
- Через psql выполните \d products и найдите в описании привязанную последовательность и определение генерируемого столбца.
- Почему в столбце с автоинкрементом появляются пропуски и почему это не считается ошибкой?
- Чем GENERATED AS IDENTITY надёжнее serial и в чём разница между режимами ALWAYS и BY DEFAULT?
- Что вернёт currval, если в текущем сеансе ещё ни разу не вызывали nextval по этой последовательности?
- Зачем после загрузки данных из 1С или дампа вызывать setval и как узнать имя нужной последовательности?
- Можно ли в генерируемом столбце STORED использовать now() или обращаться к другой таблице, и почему?
- В каких случаях для первичного ключа стоит сразу брать bigint, а не integer?