Разбираем, чем UUIDv7 лучше UUIDv4 и SERIAL для PK, как он влияет на B-tree индексы и как использовать UUIDv7 в PostgreSQL 18 стандартными средствами.

Первичный ключ (PK) — это не просто «уникальный номер» строки. От формата ключа зависит поведение B-tree индексов, скорость вставок и обновлений, размер таблиц и индексов, а значит — и стоимость хранения, бэкапов и кэша. PK почти всегда участвует в связях (FK), попадает в планы запросов и нередко становится тем самым полем, по которому система «живет» годами.
UUID выбирают не из любви к длинным строкам, а из-за свойств, которые сложно получить с SERIAL и IDENTITY:
Проблема в том, что классический UUIDv4 практически случайный. Для B-tree это означает менее предсказуемые вставки и больше «движения» страниц индекса при высокой нагрузке.
UUIDv7 сохраняет формат UUID, но делает идентификаторы примерно упорядоченными по времени (внутри остаётся место для случайности). Из-за этого вставки чаще идут «в конец» индекса, улучшается локальность вставок, а чтение свежих данных по диапазону становится проще.
Дальше по шагам посмотрим, как UUIDv7 влияет на индексы и производительность в PostgreSQL 18, чем он отличается от UUIDv4 и BIGINT (SERIAL/IDENTITY), как генерировать его «из коробки», какие есть крайние случаи (время и конкуренция) и как безопасно провести миграцию ключей.
UUID часто выбирают как первичный ключ (PK), когда нужен уникальный идентификатор без централизованного «выдающего номера». Но разные версии UUID ведут себя по‑разному — и это напрямую влияет на то, как будет работать база.
UUIDv4 — это 128 бит, где основная часть значения случайна. Отсюда два практических следствия:
С точки зрения уникальности UUIDv4 обычно более чем достаточен, но его случайность — это и плюс (сложно угадать), и минус (нет естественного порядка).
UUIDv7 тоже 128‑битный, но в нём заложена временная компонента (timestamp), а остальная часть добирается случайностью, чтобы сохранять уникальность.
Что это даёт на практике:
Важно: UUIDv7 — не «магическое ускорение», но он делает идентификатор ближе к реальным сценариям работы с данными: появляется порядок, а уникальность сохраняется.
UUIDv7 стал компромиссом: стандартный UUID, при этом time-ordered, без наследия UUIDv1.
Для первичного ключа обычно важны: уникальность, масштабируемость генерации (в том числе на нескольких сервисах), предсказуемость/угадываемость (влияет на безопасность) и иногда читаемость. UUIDv7 старается закрыть эти требования так, чтобы ключ был удобен не только компьютеру, но и людям, работающим с данными.
Причина, почему UUIDv7 часто ощущается «быстрее» UUIDv4 в реальных базах, связана не с «форматом как магией», а с тем, как B-tree индекс живёт под нагрузкой.
B-tree (в том числе индекс по первичному ключу) устроен как отсортированное дерево страниц. Когда вы вставляете новую строку, PostgreSQL должен найти место в индексе по значению ключа.
С UUIDv4 ключи практически равномерно случайны. Это означает, что вставки «прыгают» по всему дереву: сегодня в одну страницу, через миллисекунду — в далёкую, потом обратно. В итоге чаще происходят:
UUIDv7 содержит временную компоненту, поэтому новые значения в среднем растут. Это не идеальная последовательность (возможны перестановки при конкуренции), но поток вставок чаще попадает в «хвост» индекса — в одну и ту же область листовых страниц.
Практический эффект: меньше хаотичных page split и лучше кэшируемость — одни и те же страницы индекса чаще остаются в shared buffers.
Когда индекс меньше «рвёт» страницами, снижается write amplification: системе нужно меньше переписывать структуры индекса, меньше WAL на обслуживание разрезов и меньше фоновой работы на удержание порядка. Косвенно это помогает и VACUUM: фрагментация индексов обычно приводит к большему объёму данных, которые нужно прогонять и хранить.
Если таблица маленькая, вставки редкие, всё помещается в память или узкое место вообще в другом (например, медленные внешние вызовы, сложные джойны, нехватка CPU), разницы между UUIDv4 и UUIDv7 можно почти не увидеть. UUIDv7 раскрывается именно на больших потоках записей и крупных индексах, где стоимость случайных вставок становится заметной.
Выбор первичного ключа обычно сводится к трём вариантам: автоинкрементный BIGINT (SERIAL/IDENTITY), случайный UUIDv4 и «почти упорядоченный» UUIDv7. У каждого — своя цена в производительности, удобстве и безопасности.
Плюсы BIGINT просты: 8 байт на значение, отличная работа B-tree индекса, предсказуемый рост — вставки почти всегда идут в конец индекса, а значит меньше дробления страниц и больше шансов на стабильные задержки.
Минусы тоже практические. Генерация номера обычно привязана к одной базе (или к одному узлу), что усложняет распределённые вставки и офлайн-сценарии. И ещё: последовательность видна наружу — по публичному ID легко угадывать объёмы данных и перебирать соседние записи, если контроль доступа сделан неидеально.
UUIDv4 хорош там, где идентификатор должен генерироваться на клиентах/сервисах без координации. Но это «чистая случайность»: в B-tree индекс записи попадают в разные места, из‑за чего растёт фрагментация, чаще происходят split страниц и увеличивается нагрузка на кэш.
Также UUID занимает 16 байт (в 2 раза больше BIGINT), поэтому и ключи, и вторичные индексы обычно получаются тяжелее.
UUIDv7 сохраняет распределённую генерацию, как у UUIDv4, но добавляет временную составляющую. На практике это означает более «локальные» вставки: новые значения чаще оказываются рядом в индексе, что уменьшает хаос по сравнению с UUIDv4 и приближает поведение к автоинкременту.
Если у вас монолит/одна БД, важна максимальная компактность и публичный ID не нужен — BIGINT чаще всего самый простой и быстрый.
Если ID генерируется в нескольких сервисах, нужен публичный идентификатор без угадываемой последовательности и вы хотите снизить проблемы UUIDv4 с индексом — UUIDv7 обычно выглядит как компромисс «скорость вставок + распределённость».
UUIDv4 стоит оставлять там, где временная упорядоченность нежелательна или уже есть жёсткие требования совместимости, а просадка по индексам приемлема.
Если вы рассчитываете на «из коробки», начните с проверки: есть ли в вашей сборке PostgreSQL 18 встроенная функция генерации UUIDv7 (имя может отличаться).
-- Посмотреть, какие функции с v7/uuid есть в системе
SELECT n.nspname, p.proname, pg_get_function_identity_arguments(p.oid) AS args
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.proname ILIKE '%uuid%v7%'
OR p.proname ILIKE '%uuidv7%'
ORDER BY 1,2;
Если в списке есть функция вроде uuidv7() или uuid_generate_v7(), её можно использовать в DEFAULT. Если нет — самый практичный «без расширений» путь остаётся генерация на стороне приложения.
Базовый шаблон один и тот же: тип uuid, DEFAULT на генератор, PRIMARY KEY.
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- или uuid_generate_v7()
created_at timestamptz NOT NULL DEFAULT now(),
payload jsonb NOT NULL
);
Если вы хотите, чтобы приложение иногда присылало свой id, а иногда полагалось на БД — оставляйте DEFAULT: при вставке с явным id значение по умолчанию не используется.
Генерация в приложении оправдана, когда:
В этом случае договоритесь об одном каноническом представлении UUID в коде (обычно строка в нижнем регистре с дефисами) и храните в БД именно uuid, а не text.
Минимум — используйте тип uuid (он сам отсекает мусор). Если важно гарантировать именно версию 7, добавьте CHECK по битам версии/варианта:
ALTER TABLE events
ADD CONSTRAINT events_id_is_uuidv7
CHECK (
(get_byte(uuid_send(id), 6) >> 4) = 7
AND (get_byte(uuid_send(id), 8) & 192) = 128
);
Так вы защититесь от случайного UUIDv4/«самодельных» значений и сохраните единый стандарт между приложением и базой.
UUIDv7 часто выбирают из‑за индексов и порядка вставок, но «цена» в байтах никуда не девается. На больших объёмах именно размер ключа начинает влиять на кэш, скорость сканов по индексу и общий I/O.
Почти всегда стоит хранить идентификатор именно в типе uuid, а не в bytea.
uuid в PostgreSQL — нативный фиксированный тип на 16 байт с готовыми операторами сравнения, сортировкой, поддержкой B-tree и понятным текстовым представлением. Он проще для логов и дампов, легче валидируется на входе и совместим с большинством драйверов/ORM.
bytea имеет смысл лишь в редких случаях: когда вы сознательно работаете с «сырыми» байтами, храните не-UUID значение или используете собственный формат. Для обычных UUID это чаще лишняя сложность.
Само значение uuid занимает 16 байт (для сравнения, bigint — 8). Но в реальности важнее то, что ключ хранится:
Из-за более крупного ключа на странице индекса помещается меньше записей. Это означает больше страниц на диске, меньше «полезных» попаданий в shared buffers/OS cache и более заметную разницу на нагрузках, где много точечных обращений по PK и по FK.
Если PK — UUIDv7, то каждая FK-колонка, ссылающаяся на него, тоже будет 16-байтной. В системах с множеством связей и таблиц‑событий это часто главная часть «переплаты»: ключ повторяется миллионы раз. Это не аргумент против UUIDv7, но повод заранее оценить объёмы и проверить, не раздуваются ли индексы на FK-таблицах.
UUID-колонки легко превращаются в «индексируем всё подряд», а это прямой множитель размера. Если вы уже фильтруете по PK/FK, отдельные индексы на ту же UUID-колонку часто не дают выигрыша.
Держите правило: каждый индекс должен оправдываться конкретным запросом. Если нужен индекс под частый фильтр — делайте составной индекс по реально селективным полям, а UUID добавляйте только при необходимости (например, как часть ключа или через INCLUDE, когда важна выдача без чтения таблицы).
UUIDv7 устроен так, что в начале значения зашит «кусочек времени». Поэтому при сортировке по UUIDv7 вы часто получаете порядок, близкий к времени создания записей. Это влияет на типовые запросы: выборку по PK, сортировку «новые сверху» и пагинацию.
Выборка по PK не меняется: WHERE id = ... остаётся таким же точным и быстрым. Изменение заметнее там, где раньше вы полагались на created_at.
Если вам нужно «показать последние записи», то ORDER BY id DESC с UUIDv7 зачастую даёт разумный результат без дополнительной сортировки по времени. Это удобно для простых лент, логов и списков.
Частично — как приближение.
Но полностью created_at он не заменяет: точные временные метки нужны для фильтров «за период», аналитики, временных зон, импорта задним числом и ситуаций, когда порядок критичен юридически или бизнес-логикой.
С UUIDv7 удобно делать keyset pagination без OFFSET: вы запоминаете последний id и продолжаете выборку WHERE id < :last_id ORDER BY id DESC LIMIT 50. Это обычно стабильнее и быстрее, чем большие OFFSET.
Ограничение подхода: UUIDv7 даёт порядок «почти по времени», но при высокой конкуренции или генерации на разных узлах возможны «перестановки» близких по времени записей. Для строго детерминированного порядка лучше использовать created_at, id.
Если данные многопользовательские, чаще всего нужен индекс под ваш реальный фильтр, например (tenant_id, id) для списков внутри арендатора. Если сортировка должна быть строго по времени — добавляйте (tenant_id, created_at, id) и сортируйте по ним. UUIDv7 хорош как первичный ключ и удобный курсор, но индексы всё равно стоит строить под конкретные запросы.
UUIDv7 хорош тем, что «в среднем» растёт по времени и поэтому вставки в B-tree индекс чаще попадают ближе к правому краю. Но в реальных системах есть ситуации, где ожидания «будет строго по порядку» ломаются — и это нормально, если понимать причины.
При одновременной генерации UUIDv7 на нескольких приложениях/сервисах порядок зависит не только от времени, но и от того, какие именно часы использует каждый узел и с какой точностью/коррекцией.
Если два сервиса сгенерировали UUID «в одном и том же миллисекундном окне», их относительный порядок в индексе может перемешаться. Для БД это не проблема: коллизии у UUIDv7 практически невероятны, а перемешивание затрагивает лишь идеальную «локальность вставок», но не корректность данных.
UUIDv7 кодирует время, но это не журнал причинно‑следственных связей.
Если вам нужен строгий порядок, используйте отдельное поле (например, created_at + бизнес‑логика) и сортируйте по нему, а UUIDv7 оставьте как удобный PK.
Главный риск — скачки системного времени (NTP step), дрейф или неверная настройка времени на части узлов. Тогда можно увидеть «прыжки назад»: новые UUIDv7 окажутся меньше уже вставленных, и индекс начнёт получать вставки не только в конец.
Практичные меры:
created_at от clock_timestamp()/now() в БД и считать его источником правды для времени.Чтобы вовремя заметить проблемы с монотонностью:
id и created_at на небольших интервалах; резкие «откаты» по времени — сигнал проблем с часами.UUIDv7 снимает многие практические боли UUIDv4, но дисциплина вокруг времени и наблюдаемости всё ещё важна — особенно в распределённых системах.
Переезд первичного ключа — это не «одна миграция», а серия небольших, обратимых изменений. Хорошая новость: на UUIDv7 обычно можно перейти без простоя, если не пытаться «перевернуть всё» за один релиз.
Есть два рабочих подхода:
Оставить старый PK (например, BIGINT) и добавить UUIDv7 как surrogate key. Это самый безопасный вариант: старые внешние ключи и интеграции продолжают жить, а новые сервисы/эндпоинты постепенно переходят на UUIDv7.
Полная замена PK на UUIDv7. Даёт более единообразную схему, но дороже: придётся обновить все FK, индексы, API-контракты и, возможно, порядок сортировок/пагинации.
На практике часто начинают с (1), а на (2) переходят только если есть сильная причина.
Ниже типовой план, который можно растянуть на 2–4 релиза:
-- 1) Добавляем колонку
ALTER TABLE orders ADD COLUMN id_v7 uuid;
-- 2) Включаем генерацию для новых строк (в PG18 — встроенная генерация UUIDv7)
ALTER TABLE orders ALTER COLUMN id_v7 SET DEFAULT (/* uuidv7 generator */);
-- 3) Заполняем старые строки батчами
-- UPDATE orders SET id_v7 = (/* uuidv7 generator */) WHERE id_v7 IS NULL ...;
-- 4) Строим уникальный индекс без блокировок записи
CREATE UNIQUE INDEX CONCURRENTLY orders_id_v7_uidx ON orders (id_v7);
Дальше — переключение внешних ключей: добавляете в дочерние таблицы колонку order_id_v7, заполняете по join-у, создаёте FK на orders(id_v7), переводите приложение на новые связи, и только потом удаляете старые ограничения.
На время миграции почти неизбежен «двойной ключ» в моделях/DTO: старый id и новый id_v7. В API полезно:
Если вы быстро собираете сервис (React на фронте, Go + PostgreSQL на бэкенде) и параллельно «примеряете» разные стратегии PK, удобно иметь среду, где можно прогнать варианты схемы и миграций без долгой ручной сборки окружения. В TakProsto.AI это обычно делается в формате чата: описываете требования (UUIDv7 как PK, сценарий миграции, нужные индексы/пагинация) — и получаете каркас приложения и DDL/миграции, которые можно доработать, развернуть и при необходимости экспортировать исходники.
Для рискованных изменений (вроде миграции ключей) особенно полезны snapshots и rollback, а «planning mode» помогает заранее разложить работу на шаги и учесть влияние на API и внешние ключи. Платформа работает на серверах в России и использует локализованные/opensource LLM-модели, что важно для проектов с требованиями к размещению данных.
Перед продом прогоните миграции на стенде с копией объёма данных: заполнение UUID может занять время и создать нагрузку. Убедитесь, что батчи не раздувают таблицу, а репликация и бэкапы укладываются в окна. Самое важное — держать миграции идемпотентными и иметь план отката (например, пока не удалены старые FK/индексы).
UUIDv7 часто выбирают не только ради индексов, но и из‑за «побочных эффектов» для безопасности и удобства интеграций. Важно понимать границы: UUID — это идентификатор, а не секрет.
Последовательные ID (SERIAL/IDENTITY) легко угадываются: если у объекта был id=120, следующий, вероятно, 121. Это упрощает перебор URL и API-параметров, если доступ контролируется неидеально.
UUIDv7 делает угадывание существенно сложнее: значение длиннее и содержит случайную составляющую. При этом он не защищает сам по себе — права доступа, проверка ownership и rate limiting всё равно обязательны. UUIDv7 также частично «подсказывает» время создания (из-за временной компоненты), что может быть нежелательно в некоторых доменах; если это критично, подумайте об уровне доступа или альтернативных схемах идентификаторов.
UUID хорош для сквозной трассировки: один и тот же идентификатор можно протащить через сервисы, очереди и логи. Практические требования:
Большинство драйверов и ORM давно умеют UUID: PostgreSQL тип uuid мапится на строки/byte array в зависимости от языка. Проверьте, что:
Если нужен короткий номер для поддержки или документов (например, «Заказ № 104392»), не пытайтесь сделать его PK. Храните отдельно:
id uuid как первичный ключ;public_number bigint как уникальный, генерируемый последовательностью (или отдельной таблицей счётчиков по типам/филиалам).Так вы получаете удобный внешний номер без компромиссов по архитектуре и безопасности.
UUIDv7 — хороший компромисс между «глобальной уникальностью UUID» и «предсказуемым порядком вставок». Но он не универсален: выбор PK зависит от архитектуры, нагрузки и того, как ID живёт вне базы.
Чаще всего BIGINT (SERIAL/IDENTITY) всё ещё лучший выбор, если:
UUIDv7 в монолите имеет смысл, когда:
Здесь UUIDv7 обычно удобнее: разные сервисы и пайплайны могут безопасно создавать записи независимо, а при объединении данных не возникает коллизий ключей и «войны последовательностей». Это упрощает репликацию, синхронизацию и импорт из внешних систем, особенно когда нет единого центра выдачи идентификаторов.
Перед тем как фиксировать PK, ответьте себе:
created_at? UUIDv7 поможет, но всё равно проверяйте запросы.Лучший финальный шаг — тест на ваших данных: снимите типичные запросы и профили (вставка, выборки по PK, джойны, сортировки, размер индекса), сделайте сравнительный бенчмарк «до/после» на копии продакшн-нагрузки и сравните метрики, а не впечатления.
Лучший способ понять возможности ТакПросто — попробовать самому.