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