Переход на UUID v7
Переход на UUID v7

В системах, где генерируются миллионы событий в день (например логи), первичные ключи часто строятся на основе UUID. Это логично: они глобально уникальны, не требуют централизованного генератора и хорошо масштабируются.

Но не все UUID одинаково полезны. Особенно когда речь заходит о производительности БД.

В нашем сервисе мониторинга и анализа PostgreSQL мы использовали UUID v1 - и столкнулись с ростом дисковой нагрузки на PostgreSQL. После перехода на UUID v7 удалось добиться сокращения числа операций с диском в 2 раза, на четверть снизить размеры индексов и полностью устранить их фрагментацию.

UUID v1 и v7 - в чем отличие?

Обе версии хранят временную метку, но делают это по-разному.

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

    генерация и сортировка UUID v1
    генерация и сортировка UUID v1

    а это приводит к тому, что при вставке UUID-значений в B-tree индекс:

    1. новые записи разбрасываются по всей структуре

    2. листовые страницы заполняются неравномерно

    3. СУБД постоянно выделяет новые страницы

    4. растет число "грязных" буферов, что приводит к повышенной активности bgwriter

  • UUID v7 основан на стандартном Unix-времени (миллисекунды с 1 января 1970 года) и хранит эту метку в начале UUID в порядке big-endian — от старших разрядов к младшим, при этом хронологический порядок совпадает с лексикографическим:

генерация и сортировка UUID v7
генерация и сортировка UUID v7

Благодаря этому UUID v7 значения при вставке в дерево индекса B-tree:

  1. всегда дописываются в конец индекса

  2. листовые страницы заполняются последовательно

  3. фрагментация стремится к нулю

  4. плотность страниц близка к максимуму

Почему это отличие особенно важно для write-heavy систем?

работа с диском в PostgreSQL
работа с диском в PostgreSQL

В Postgres чтение или запись страницы происходит через буферный менеджер, который выделяет их в буферном кэше.

При выполнении запроса клиентским процессом может потребоваться освобождение места в буферном кэше для новых страниц, при этом происходит вытеснение "грязных" страниц на диск. Кроме клиентских процессов синхронизацию с диском "грязных" страниц выполняет процесс фоновой записи bgwriter.

При использовании UUID v7 количество создаваемых и изменяемых страниц индекса значительно сокращается, а это приводит к снижению:

  • количества выделяемых страниц в буферном кэше

  • активности фоновой записи

  • вытеснения "грязных" страниц

  • всплесков записи WAL при full_page_writes

и как следствие к снижению IO-нагрузки:

Количество операций с диском/сек
Количество операций с диском/сек
Использование диска и очереди
Использование диска и очереди
Количество выделяемых буферов
Количество выделяемых буферов
Фоновая запись "грязных" блоков bgwriter'ом
Фоновая запись "грязных" блоков bgwriter'ом

Сравнительный тест 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 для всего потока данных выполняется на одном узле, рекомендуется использовать один из методов:

  1. Счетчик с фиксированной разрядностью.

    Этот метод использует популярная библиотека uuid для Node.js - значение счетчика можно передать в параметре options.seq .

    Если этот параметр не задан, то в поле rand_a будет случайное значение, которое выдается методом crypto.randomFillSync, таким образом нарушая монотонность значений UUID, поэтому параметр options.seq лучше задавать в приложении.

  2. Монотонная случайность.

    В качестве счетчика используются случайные данные, т.е. значение приращения счетчика является случайным числом больше 0. Этот вариант помогает сохранить необходимый уровень непредсказуемости последовательности UUID .

  3. Использование поля счетчика для повышения точности временной метки.

    Этот метод используется в 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)


  1. SergeyProkhorenko
    27.01.2026 16:18

    В случае многоузловой реализации в структуру UUID рекомендуется добавить значение идентификатора узла (или воркера). Это необходимо для гарантии уникальности UUID, созданных на разных узлах в один момент времени.


    То, что Вы рекомендуете, является грубым и ничем не оправданным нарушением положений RFC 9562, описывающих UUIDv7. Уникальность UUIDv7, созданных на разных узлах в одну и ту же миллисекунду, а в PostgreSQL - в интервале 250 наносекунд, (ещё попробуйте попасть в этот интервал и добиться такого совпадения!!!) в полной мере обеспечивается рандомной частью достаточной длины.

    Ещё одним важным моментом является то, что последовательно сгенерированные UUID v7, имеющие близкие временные метки, теоретически более уязвимы к атакам перебором.


    Ничего подобного.

    Даже в самом худшем случае в некоторых реализациях (при использовании длинного счетчика) длина случайной части минимум 32 бита. Угадать счетчик не получится, так как он в нормальных реализациях инициализируется случайным числом каждую миллисекунду.

    В PostgreSQL длина случайной части 62 бита. Последовательно перебирать 62 бита - занятие совершенно бесперспективное.

    Пользуйтесь встроенной функцией генерации UUIDv7 в PostgreSQL 18, и у вас никогда не будет проблем.


    1. dpbm
      27.01.2026 16:18

      Есть реализации uuiv7, где длина случайной части 0 бит, т.е. просто счетчик там. Про них, похоже, говорит автор. А так да, лучше всю свободную часть случайной величиной заполнить. И использование части ее в своих целях (например, для хранения идентификатора узла или типа процесса) не противоречит стандарту, т.к. снаружи все равно получающийся идентификатор остается v7 как есть.


      1. SergeyProkhorenko
        27.01.2026 16:18

        Есть реализации uuiv7, где длина случайной части 0 бит, т.е. просто счетчик там.

        Такие якобы "реализации" UUIDv7 вообще не являются UUID, потому что не соответствуют стандарту RFC 9562.

        А так да, лучше всю свободную часть случайной величиной заполнить. И использование части ее в своих целях (например, для хранения идентификатора узла или типа процесса) не противоречит стандарту, т.к. снаружи все равно получающийся идентификатор остается v7 как есть.

        На самом деле это противоречит стандарту RFC 9562. Поэтому это не UUIDv7 и вообще не UUID, а просто какой-то самодельный идентификатор.


    1. MGorkov Автор
      27.01.2026 16:18

      То, что Вы рекомендуете, является грубым и ничем не оправданным нарушением положений RFC 9562, описывающих UUIDv7

      Приведенная цитата основана на рекомендациях из раздела 6.4 RFC:

      Some implementations MAY desire the utilization of multi-node, clustered, applications that involve two or more nodes independently generating UUIDs that will be stored in a common location. While UUIDs already feature sufficient entropy to ensure that the chances of collision are low, as the total number of UUID generating nodes increases, so does the likelihood of a collision

      Node IDs:

      With this method, a pseudorandom Node ID value is placed within the UUID layout. This identifier helps ensure the bit space for a given node is unique, resulting in UUIDs that do not conflict with any other UUID created by another node with a different node id.

      The location and bit length are left to implementations and are outside the scope of this specification.

      Т.е. при генерации UUID на разных узлах, а в нашем случае этих узлов много, для гарантии уникальности рекомендуется использовать node-id в структуре uuid.

      Более того, в RFC рекомендуется использовать случайный node-id для дополнительной гарантии уникальности:

      implementations should utilize the pseudorandom Node ID option if additional collision resistance for distributed UUID generation is a requirement

      В нашей системе все uuid создаются в воркерах приложения, почитайте как все устроено.

      Поэтому это предложение нам не подходит:

      Пользуйтесь встроенной функцией генерации UUIDv7 в PostgreSQL 18, и у вас никогда не будет проблем.

      И думаю что не только нам - эта версия PG только недавно вышла, прежние версии без встроенной функции uuidv7 будут работать еще долго.


      1. SergeyProkhorenko
        27.01.2026 16:18

        "Рекомендации", на которые Вы ссылаетесь, содержат также следующее положение:

        Implementations that choose to leverage an embedded node id SHOULD utilize UUIDv8.

        То есть, это не UUIDv7, а UUIDv8, и Вы не должны описывать свои идентификаторы как UUIDv7 и записывать номер версии 7 в сами идентификаторы (если не докажете, что это абсолютно необходимо). Если же Вы так делаете, то это вообще не UUID, потому что нарушается стандарт RFC 9562. И это не случайная ошибка стандарта, а осознанный выбор разработчиков стандарта, которые специально обсуждали этот момент.

        А что касается UUIDv8, то это маргинаньная версия, о которой RFC 9562 говорит следующее:

        UUIDv8 provides a format for experimental or vendor-specific use cases. The only requirement is that the variant and version bits MUST be set as defined in Sections 4.1 and 4.2. UUIDv8's uniqueness will be implementation specific and MUST NOT be assumed.

        То есть, разработчики стандарта снимают с себя всякую ответственность за такие эксперименты. Таким образом, Ваша цитата - это не "рекомендации", а наоборот, предостережение - не надо так делать!
        _____________________________

        Я прочитал статью по Вашей ссылке. Так вот, судя по этой статье Вы нарушаете еще одно требование RFC 9562 о генерации UUIDv7 предпочтительно в БД:

        Applications using a monolithic database may find using database-generated UUIDs (as opposed to client-generated UUIDs) provides the best UUID monotonicity. In addition to UUIDs, additional identifiers MAY be used to ensure integrity and feedback.

        И у Вас в статье в качестве первичного ключа используется чудовищный франкенштейн - составной ключ из псевдо-UUIDv7 и recno («естественный» порядковый номер записи в рамках пакета). Хотя вся мотивация разработки UUID заключалась именно в том, что они в чистом виде должны использоваться в качестве первичного ключа. Цитирую RFC 9562:

        Modern applications have a need to create and utilize UUIDs as the primary identifier for a variety of different items in complex computational systems, including but not limited to database keys...

        _____________________________

        Что касается старых версий PostgreSQL, то там для генерации UUIDv7 может использоваться какое-нибудь из имеющихся расширений.


        1. MGorkov Автор
          27.01.2026 16:18

          Implementations that choose to leverage an embedded node id SHOULD utilize UUIDv8

          думаю, что здесь речь идет именно о таких node-id , которые используются например в UUIDv1 . Эти node-id затем возможно как-то используются для идентификации генерирующего узла. В нашем случае это не так - nodeid используется в качестве разделения диапазонов монотонного счетчика на разных генерирующих узлах, именно об этом написано в последнем абзаце раздела об особенностях реализации. И связано это с тем, что библиотека uuid по умолчанию ставит в поле rand_a случайное значение, нарушая этим монотонность.

          судя по этой статье Вы нарушаете еще одно требование RFC 9562 о генерации UUIDv7 предпочтительно в БД

          не увидел здесь какого-то четкого требования RFC

          И у Вас в статье в качестве первичного ключа используется чудовищный франкенштейн - составной ключ из псевдо-UUIDv7 и recno 

          Посмотрите на дату статьи, архитектура приложения разрабатывалась задолго до появления RFC 9562

          Что касается старых версий PostgreSQL, то там для генерации UUIDv7 может использоваться какое-нибудь из имеющихся расширений.

          Мое замечание касалось рекомендации использовать именно встроенную в PG 18 функцию


  1. yrub
    27.01.2026 16:18

    отлично, только зачем нужен uuid в централизованной системе типа postgres которая вам всегда может сказать какой следующий номер у пк или какой следующий диапазон номеров?

    ЗЫ: секурити тоже не аргумент, все равно косвенно v7 дает информацию а проблема легко решается алгоритмами, раз уж вы хотите спрятать значение счетчика


    1. uranik
      27.01.2026 16:18

      А вдруг надо будет слить две таблицы пользователей/ордеров/сделок в одну.