- PVSM.RU - https://www.pvsm.ru -
В системах, где генерируются миллионы событий в день (например логи), первичные ключи часто строятся на основе UUID. Это логично: они глобально уникальны, не требуют централизованного генератора и хорошо масштабируются.
Но не все UUID одинаково полезны. Особенно когда речь заходит о производительности БД.
В нашем сервисе мониторинга и анализа PostgreSQL [1] мы использовали [2] UUID v1 - и столкнулись с ростом дисковой нагрузки на PostgreSQL. После перехода на UUID v7 удалось добиться сокращения числа операций с диском в 2 раза, на четверть снизить размеры индексов и полностью устранить их фрагментацию.
Обе версии хранят временную метку, но делают это по-разному.
UUID v1 [3] использует количество 100-наносекундных интервалов с полуночи 15 октября 1582 года (начало григорианского календаря [4]). При этом временная метка разбита на три части и распределена по структуре UUID не последовательно. В результате лексикографический порядок UUID не совпадает с хронологическим:
а это приводит к тому, что при вставке UUID-значений в B-tree индекс:
новые записи разбрасываются по всей структуре
листовые страницы заполняются неравномерно
СУБД постоянно выделяет новые страницы
растет число "грязных" буферов, что приводит к повышенной активности bgwriter [5]
UUID v7 [6] основан на стандартном Unix-времени (миллисекунды с 1 января 1970 года) и хранит эту метку в начале UUID в порядке big-endian — от старших разрядов к младшим, при этом хронологический порядок совпадает с лексикографическим:
Благодаря этому UUID v7 значения при вставке в дерево индекса B-tree:
всегда дописываются в конец индекса
листовые страницы заполняются последовательно
фрагментация стремится к нулю
плотность страниц близка к максимуму
В Postgres чтение или запись страницы происходит через буферный менеджер, который выделяет их в буферном кэше.
При выполнении запроса клиентским процессом может потребоваться освобождение места в буферном кэше для новых страниц, при этом происходит вытеснение "грязных" страниц на диск. Кроме клиентских процессов синхронизацию с диском "грязных" страниц выполняет процесс фоновой записи bgwriter [5].
При использовании UUID v7 количество создаваемых и изменяемых страниц индекса значительно сокращается, а это приводит к снижению:
количества выделяемых страниц в буферном кэше
активности фоновой записи
вытеснения "грязных" страниц
всплесков записи WAL при full_page_writes
и как следствие к снижению IO-нагрузки:
Создадим две таблицы 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
Вышедшей в мае 2024 года спецификацией RFC 9562 [7] предусматривается несколько вариантов реализации UUID v7 . Все они различаются методом генерации значения поля rand_a - для обеспечения уникальности UUID , созданных в один момент времени, в нем может содержаться случайное значение или монотонный счетчик.
Для одноузловых реализаций, т.е. для случаев когда генерация UUID для всего потока данных выполняется на одном узле, рекомендуется использовать один из методов:
Счетчик с фиксированной разрядностью.
Этот метод использует популярная библиотека uuid [8] для Node.js - значение счетчика можно передать в параметре options.seq .
Если этот параметр не задан, то в поле rand_a будет случайное значение, которое выдается методом crypto.randomFillSync, таким образом нарушая монотонность значений UUID, поэтому параметр options.seq лучше задавать в приложении.
Монотонная случайность.
В качестве счетчика используются случайные данные, т.е. значение приращения счетчика является случайным числом больше 0. Этот вариант помогает сохранить необходимый уровень непредсказуемости последовательности UUID .
Использование поля счетчика для повышения точности временной метки.
Этот метод используется в PostgreSQL [9] для размещения микросекундной и наносекундной составляющей временной метки:
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 [8] можно размещать идентификатор узла в старших разрядах параметра options.seq , оставляя младшие для монотонного счетчика.
Переход на UUID v7 наиболее оправдан и дает заметный эффект, если эти идентификаторы являются частью многих индексов.
Ещё одним важным моментом является то, что последовательно сгенерированные UUID v7, имеющие близкие временные метки, теоретически более уязвимы к атакам перебором. Хотя спецификация предусматривает механизм для повышения непредсказуемости (метод 2 [10]), степень его применения и достаточность остаются на усмотрение разработчика.
Автор: MGorkov
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/postgresql/443189
Ссылки в тексте:
[1] сервисе мониторинга и анализа PostgreSQL: https://habr.com/ru/companies/tensor/articles/487380/
[2] использовали: https://habr.com/ru/companies/tensor/articles/516384/
[3] UUID v1: https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-1
[4] григорианского календаря: https://ru.wikipedia.org/wiki/%D0%93%D1%80%D0%B8%D0%B3%D0%BE%D1%80%D0%B8%D0%B0%D0%BD%D1%81%D0%BA%D0%B8%D0%B9_%D0%BA%D0%B0%D0%BB%D0%B5%D0%BD%D0%B4%D0%B0%D1%80%D1%8C
[5] bgwriter: https://postgrespro.ru/docs/postgresql/current/runtime-config-resource#RUNTIME-CONFIG-RESOURCE-BACKGROUND-WRITER
[6] UUID v7: https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7
[7] RFC 9562: https://datatracker.ietf.org/doc/html/rfc9562
[8] uuid: https://www.npmjs.com/package/uuid#uuidv7options-buffer-offset
[9] PostgreSQL: https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/uuid.c#L585
[10] метод 2: https://datatracker.ietf.org/doc/html/rfc9562#section-6.2-5.3
[11] Источник: https://habr.com/ru/companies/tensor/articles/989032/?utm_source=habrahabr&utm_medium=rss&utm_campaign=989032
Нажмите здесь для печати.