
В системах, где генерируются миллионы событий в день (например логи), первичные ключи часто строятся на основе UUID. Это логично: они глобально уникальны, не требуют централизованного генератора и хорошо масштабируются.
Но не все UUID одинаково полезны. Особенно когда речь заходит о производительности БД.
В нашем сервисе мониторинга и анализа PostgreSQL мы использовали UUID v1 - и столкнулись с ростом дисковой нагрузки на PostgreSQL. После перехода на UUID v7 удалось добиться сокращения числа операций с диском в 2 раза, на четверть снизить размеры индексов и полностью устранить их фрагментацию.
UUID v1 и v7 - в чем отличие?
Обе версии хранят временную метку, но делают это по-разному.

-
UUID v1 использует количество 100-наносекундных интервалов с полуночи 15 октября 1582 года (начало григорианского календаря). При этом временная метка разбита на три части и распределена по структуре UUID не последовательно. В результате лексикографический порядок UUID не совпадает с хронологическим:

генерация и сортировка UUID v1 а это приводит к тому, что при вставке UUID-значений в B-tree индекс:
новые записи разбрасываются по всей структуре
листовые страницы заполняются неравномерно
СУБД постоянно выделяет новые страницы
растет число "грязных" буферов, что приводит к повышенной активности bgwriter
UUID v7 основан на стандартном Unix-времени (миллисекунды с 1 января 1970 года) и хранит эту метку в начале UUID в порядке big-endian — от старших разрядов к младшим, при этом хронологический порядок совпадает с лексикографическим:

Благодаря этому UUID v7 значения при вставке в дерево индекса B-tree:
всегда дописываются в конец индекса
листовые страницы заполняются последовательно
фрагментация стремится к нулю
плотность страниц близка к максимуму
Почему это отличие особенно важно для write-heavy систем?

В Postgres чтение или запись страницы происходит через буферный менеджер, который выделяет их в буферном кэше.
При выполнении запроса клиентским процессом может потребоваться освобождение места в буферном кэше для новых страниц, при этом происходит вытеснение "грязных" страниц на диск. Кроме клиентских процессов синхронизацию с диском "грязных" страниц выполняет процесс фоновой записи bgwriter.
При использовании UUID v7 количество создаваемых и изменяемых страниц индекса значительно сокращается, а это приводит к снижению:
количества выделяемых страниц в буферном кэше
активности фоновой записи
вытеснения "грязных" страниц
всплесков записи WAL при full_page_writes
и как следствие к снижению IO-нагрузки:




Сравнительный тест UUID v1 vs v7
Создадим две таблицы uuidv1 и uuidv7 с миллионом записей в каждой:
Код создания и заполнения таблиц
-- создаем функции для генерации UUID v1 и v7
CREATE OR REPLACE FUNCTION public.uuid_v1_from_timestamp(ts timestamptz)
RETURNS uuid AS $$
DECLARE
uuid_epoch CONSTANT BIGINT := 12219292800;
hundred_ns_intervals bigint;
time_low bigint;
time_mid bigint;
time_hi_and_version bigint;
BEGIN
hundred_ns_intervals := (extract('epoch' FROM ts) + uuid_epoch) * 10000000;
hundred_ns_intervals := hundred_ns_intervals & (1::bigint << 60) - 1;
time_low := (hundred_ns_intervals & (1::bigint << 32) - 1)::bigint;
time_mid := (hundred_ns_intervals >> 32 & (1::bigint << 16) - 1)::bigint;
time_hi_and_version := (hundred_ns_intervals >> 48 & (1::bigint << 12) - 1)::bigint;
time_hi_and_version := (time_hi_and_version | (1 << 12))::bigint;
RETURN (
lpad(to_hex(time_low), 8, '0') || '-' ||
lpad(to_hex(time_mid), 4, '0') || '-' ||
lpad(to_hex(time_hi_and_version), 4, '0') || '-' ||
to_hex((random() * 10000)::integer & 0x3FF | 0x8000) || '-' ||
substring(md5(random()::text)::bytea, 1, 12)
)::uuid;
END;
$$
LANGUAGE plpgsql
IMMUTABLE;
-- 12-битный счетчик для заполнения поля rand_a (метод 1 по RFC 9562)
CREATE SEQUENCE IF NOT EXISTS uuid_v7_counter_seq
MINVALUE 0
MAXVALUE 4095
CYCLE;
CREATE OR REPLACE FUNCTION public.uuid_v7_from_timestamp(ts timestamptz)
RETURNS uuid AS $$
WITH ms AS (
SELECT (extract('epoch' FROM ts) * 1000)::bigint unix_ms
)
, time_hex AS (
SELECT lpad(to_hex(unix_ms), 12, '0') hex_time
FROM ms
)
, counter_val AS (
SELECT nextval('uuid_v7_counter_seq')::integer cnt
)
, counter_hex AS (
SELECT lpad(to_hex(cnt), 3, '0') hex_cnt
FROM counter_val
)
, rand_hex AS (
SELECT substr(md5(random()::text || clock_timestamp()::text), 1, 16) hex_rand
)
SELECT (
substring(hex_time, 1, 8) || '-' ||
substring(hex_time, 9, 4) || '-' ||
'7' || substring(hex_cnt, 1, 3) || '-' ||
to_hex(('x' || substring(hex_rand, 1, 2))::bit(8)::integer | 0x80) ||
substring(hex_rand, 3, 2) || '-' ||
substring(hex_rand, 5, 12)
)::uuid
FROM
time_hex
, counter_hex
, rand_hex;
$$
LANGUAGE sql
VOLATILE;
-- создаем таблицы
CREATE TABLE uuidv1(
id uuid PRIMARY KEY
, n integer
);
CREATE TABLE uuidv7(
id uuid PRIMARY KEY
, n integer
);
-- генерим данные и заполняем таблицы
CREATE TEMPORARY TABLE temp_ts_series AS
WITH ms_offsets AS (
SELECT
generate_series(1, 1000000) n
, floor(random() * 10 + 1)::integer ms_step
)
, ts_series AS (
SELECT
n
, '2026-01-16 00:00:00.000'::timestamptz +
make_interval(secs => sum(ms_step) OVER(ORDER BY n) / 1000.0) ts
FROM ms_offsets
)
SELECT
n
, ts
FROM ts_series;
INSERT INTO uuidv1
SELECT
uuid_v1_from_timestamp(ts) id
, n
FROM temp_ts_series
ORDER BY n;
INSERT INTO uuidv7
SELECT
uuid_v7_from_timestamp(ts) id
, n
FROM temp_ts_series
ORDER BY n;
Посмотрим как хранятся данные в таблицах и индексах:
SELECT
n
, id
, ctid
FROM uuidv1
ORDER BY id
LIMIT 10;
n id ctid
612988 000010d0-f25d-11f0-805d-df3bd6c09e1f (3904,60)
144547 00001f90-f257-11f0-80e3-1b998253fd4f (920,107)
534847 00002d50-f25c-11f0-81ae-78adc84ecb48 (3406,105)
691090 00004270-f25e-11f0-8065-39c8db08a45f (4401,133)
769137 00004d00-f25f-11f0-800b-8e399a4190ba (4898,151)
534848 00005460-f25c-11f0-82c4-6dd5b8884835 (3406,106)
300607 00005bc0-f259-11f0-82eb-8529c176b021 (1914,109)
456801 000070e0-f25b-11f0-81ff-fd3202215765 (2909,88)
847253 00007ea0-f260-11f0-80d2-9fcc70dbc2e5 (5396,81)
66182 00008a30-f256-11f0-8059-1e483507a7bc (421,85)
При чтении по ключу система обращалась к 9 разным страницам таблицы (3904, 920, 3406, 4401, 4898, 1914, 2909, 5396, 421).
SELECT
n
, id
, ctid
FROM uuidv1
ORDER BY id
LIMIT 10;
n id ctid
1 019bc375-4084-7240-e9d5-14b030feca23 (0,1)
2 019bc375-4085-7241-b8f7-dcd9df23b7db (0,2)
3 019bc375-408c-7242-f8d9-674e52c2e754 (0,3)
4 019bc375-4090-7243-cc93-f59aba90865e (0,4)
5 019bc375-4096-7244-a49c-65a25576a034 (0,5)
6 019bc375-409a-7245-e079-760b71847af8 (0,6)
7 019bc375-40a3-7246-dcd8-b6c370c34483 (0,7)
8 019bc375-40a5-7247-e6f1-cf8ea60ec9de (0,8)
9 019bc375-40a9-7248-bda4-655691f847ac (0,9)
10 019bc375-40b2-7249-a951-db03d74db04e (0,10)
При чтении UUID v7 - только к одной странице (0).
Проверим, сколько потребуется страниц индекса при записи одной страницы таблицы:
Запрос
WITH tbls AS (
SELECT unnest(ARRAY['uuidv1', 'uuidv7']) tablename
)
, idxs AS (
SELECT
indexname
FROM
tbls
NATURAL JOIN
pg_indexes
)
, idx_info AS (
SELECT
indexname
, idx_page
, (bt_page_items(indexname, idx_page)).*
FROM
idxs
, generate_series(
1
, (
SELECT
relpages - 1
FROM
pg_class
WHERE
relname = indexname
)
) idx_page
)
SELECT
indexname
, array_agg(DISTINCT idx_page) idx_pages
FROM
idx_info
WHERE
(htid::text::point)[0] = 0 -- только для первой страницы таблицы
GROUP BY
1;indexname |
idx_pages |
uuidv1_pkey |
{256,343,1192,1193,2114,2252,2448,3181,4146,4275,4527} |
uuidv7_pkey |
{1} |
При записи одной страницы данных потребовалось создать 11 страниц индекса в варианте с UUID v1 и всего 1 для UUID v7.
Это происходит потому, что в отличие от UUID v1 для UUID v7 логический порядок строк совпадает с физическим порядком в таблице, что отражает статистика корреляции:
SELECT
tablename,
correlation
FROM pg_stats
WHERE tablename IN ('uuidv1', 'uuidv7')
AND attname = 'id'
ORDER BY tablename;
tablename correlation
uuidv1 0.03630882
uuidv7 1
Характеристика индексов:
SELECT
idx
, index_size
, leaf_pages
, avg_leaf_density
, leaf_fragmentation
FROM
unnest(ARRAY['uuidv1_pkey', 'uuidv7_pkey']) idx
, pgstatindex(idx);
Метрика |
UUID v1 |
UUID v7 |
Размер индекса |
37 МБ |
30 МБ |
Листовых страниц |
4721 |
3832 |
Средняя плотность |
73.1% |
89.98% |
Фрагментация |
49.27% |
0% |
Индекс на UUID v7 получился компактнее, плотнее и полностью упорядоченным, что напрямую снижает:
количество операций чтения/записи с диска
объём выделяемых буферов
нагрузку на фоновый процесс bgwriter
Особенности реализации UUID v7
Вышедшей в мае 2024 года спецификацией RFC 9562 предусматривается несколько вариантов реализации UUID v7 . Все они различаются методом генерации значения поля rand_a - для обеспечения уникальности UUID , созданных в один момент времени, в нем может содержаться случайное значение или монотонный счетчик.
Для одноузловых реализаций, т.е. для случаев когда генерация UUID для всего потока данных выполняется на одном узле, рекомендуется использовать один из методов:
-
Счетчик с фиксированной разрядностью.
Этот метод использует популярная библиотека uuid для Node.js - значение счетчика можно передать в параметре
options.seq.Если этот параметр не задан, то в поле rand_a будет случайное значение, которое выдается методом
crypto.randomFillSync, таким образом нарушая монотонность значений UUID, поэтому параметрoptions.seqлучше задавать в приложении. -
Монотонная случайность.
В качестве счетчика используются случайные данные, т.е. значение приращения счетчика является случайным числом больше 0. Этот вариант помогает сохранить необходимый уровень непредсказуемости последовательности UUID .
-
Использование поля счетчика для повышения точности временной метки.
Этот метод используется в PostgreSQL для размещения микросекундной и наносекундной составляющей временной метки:
UUID version 7 consists of a Unix timestamp in milliseconds (48 bits) and
74 random bits, excluding the required version and variant bits. To ensure
monotonicity in scenarios of high-frequency UUID generation, we employ the
method "Replace Leftmost Random Bits with Increased Clock Precision (Method 3)",
described in the RFC. This method utilizes 12 bits from the "rand_a" bits
to store a 1/4096 (or 2^12) fraction of sub-millisecond precision.
unix_ts_ms is a number of milliseconds since start of the UNIX epoch,
and sub_ms is a number of nanoseconds within millisecond. These values are
used for time-dependent bits of UUID.
В случае многоузловой реализации в структуру UUID рекомендуется добавить значение идентификатора узла (или воркера). Это необходимо для гарантии уникальности UUID, созданных на разных узлах в один момент времени.
При этом местоположение в структуре UUID, размер поля, метод создания и согласования идентификаторов узлов не входит в спецификацию. Например, при использовании модуля uuid можно размещать идентификатор узла в старших разрядах параметра options.seq , оставляя младшие для монотонного счетчика.
Заключение
Переход на UUID v7 наиболее оправдан и дает заметный эффект, если эти идентификаторы являются частью многих индексов.
Ещё одним важным моментом является то, что последовательно сгенерированные UUID v7, имеющие близкие временные метки, теоретически более уязвимы к атакам перебором. Хотя спецификация предусматривает механизм для повышения непредсказуемости (метод 2), степень его применения и достаточность остаются на усмотрение разработчика.
Комментарии (15)

yrub
27.01.2026 16:18отлично, только зачем нужен uuid в централизованной системе типа postgres которая вам всегда может сказать какой следующий номер у пк или какой следующий диапазон номеров?
ЗЫ: секурити тоже не аргумент, все равно косвенно v7 дает информацию а проблема легко решается алгоритмами, раз уж вы хотите спрятать значение счетчика
SergeyProkhorenko
То, что Вы рекомендуете, является грубым и ничем не оправданным нарушением положений RFC 9562, описывающих UUIDv7. Уникальность UUIDv7, созданных на разных узлах в одну и ту же миллисекунду, а в PostgreSQL - в интервале 250 наносекунд, (ещё попробуйте попасть в этот интервал и добиться такого совпадения!!!) в полной мере обеспечивается рандомной частью достаточной длины.
Ничего подобного.
Даже в самом худшем случае в некоторых реализациях (при использовании длинного счетчика) длина случайной части минимум 32 бита. Угадать счетчик не получится, так как он в нормальных реализациях инициализируется случайным числом каждую миллисекунду.
В PostgreSQL длина случайной части 62 бита. Последовательно перебирать 62 бита - занятие совершенно бесперспективное.
Пользуйтесь встроенной функцией генерации UUIDv7 в PostgreSQL 18, и у вас никогда не будет проблем.
dpbm
Есть реализации uuiv7, где длина случайной части 0 бит, т.е. просто счетчик там. Про них, похоже, говорит автор. А так да, лучше всю свободную часть случайной величиной заполнить. И использование части ее в своих целях (например, для хранения идентификатора узла или типа процесса) не противоречит стандарту, т.к. снаружи все равно получающийся идентификатор остается v7 как есть.
SergeyProkhorenko
Такие якобы "реализации" UUIDv7 вообще не являются UUID, потому что не соответствуют стандарту RFC 9562.
На самом деле это противоречит стандарту RFC 9562. Поэтому это не UUIDv7 и вообще не UUID, а просто какой-то самодельный идентификатор.
MGorkov Автор
Приведенная цитата основана на рекомендациях из раздела 6.4 RFC:
Т.е. при генерации UUID на разных узлах, а в нашем случае этих узлов много, для гарантии уникальности рекомендуется использовать node-id в структуре uuid.
Более того, в RFC рекомендуется использовать случайный node-id для дополнительной гарантии уникальности:
В нашей системе все uuid создаются в воркерах приложения, почитайте как все устроено.
Поэтому это предложение нам не подходит:
И думаю что не только нам - эта версия PG только недавно вышла, прежние версии без встроенной функции uuidv7 будут работать еще долго.
SergeyProkhorenko
"Рекомендации", на которые Вы ссылаетесь, содержат также следующее положение:
То есть, это не UUIDv7, а UUIDv8, и Вы не должны описывать свои идентификаторы как UUIDv7 и записывать номер версии 7 в сами идентификаторы (если не докажете, что это абсолютно необходимо). Если же Вы так делаете, то это вообще не UUID, потому что нарушается стандарт RFC 9562. И это не случайная ошибка стандарта, а осознанный выбор разработчиков стандарта, которые специально обсуждали этот момент.
А что касается UUIDv8, то это маргинаньная версия, о которой RFC 9562 говорит следующее:
То есть, разработчики стандарта снимают с себя всякую ответственность за такие эксперименты. Таким образом, Ваша цитата - это не "рекомендации", а наоборот, предостережение - не надо так делать!
_____________________________
Я прочитал статью по Вашей ссылке. Так вот, судя по этой статье Вы нарушаете еще одно требование RFC 9562 о генерации UUIDv7 предпочтительно в БД:
И у Вас в статье в качестве первичного ключа используется чудовищный франкенштейн - составной ключ из псевдо-UUIDv7 и recno («естественный» порядковый номер записи в рамках пакета). Хотя вся мотивация разработки UUID заключалась именно в том, что они в чистом виде должны использоваться в качестве первичного ключа. Цитирую RFC 9562:
_____________________________
Что касается старых версий PostgreSQL, то там для генерации UUIDv7 может использоваться какое-нибудь из имеющихся расширений.
MGorkov Автор
думаю, что здесь речь идет именно о таких node-id , которые используются например в UUIDv1 . Эти node-id затем возможно как-то используются для идентификации генерирующего узла. В нашем случае это не так - nodeid используется в качестве разделения диапазонов монотонного счетчика на разных генерирующих узлах, именно об этом написано в последнем абзаце раздела об особенностях реализации. И связано это с тем, что библиотека uuid по умолчанию ставит в поле rand_a случайное значение, нарушая этим монотонность.
не увидел здесь какого-то четкого требования RFC
Посмотрите на дату статьи, архитектура приложения разрабатывалась задолго до появления RFC 9562
Мое замечание касалось рекомендации использовать именно встроенную в PG 18 функцию