Переход на 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), степень его применения и достаточность остаются на усмотрение разработчика.