0 / 63 (0%)

uuid и uuidv7

Brew решил выдавать клиентам ссылку на их регистрацию в программе лояльности и не хотел светить в URL последовательный числовой id (/signup/42 — видно, что регистраций всего 42, и легко перебрать соседние). Перешли на uuid — и тут же словили вторую проблему: новые регистрации, ключом которых стал случайный uuid, начали вставляться «вразнобой» по индексу, страница «последние регистрации» перестала сортироваться по ключу, а вставки стали чуть медленнее. Так Brew познакомился с разницей между случайным uuid (версия 4) и временным uuidv7 (версия 7).

Цель юнита — выбрать ключ осознанно. Числовой IDENTITY (его мы видели на базовых таблицах) хорош, но раскрывает порядок и количество. Случайный gen_random_uuid() (v4) непредсказуем, но не сортируется и фрагментирует индекс. PG18 добавил uuidv7()uuid, в начало которого зашит timestamp: он так же непредсказуем в хвосте, но монотонно растёт во времени, поэтому годится как сортируемый по времени первичный ключ. Демонстрируем его на новой таблице — схема Brew байт-совместима с kafka-cookbook (customers.id там BIGINT), и трогать её нельзя.

v4 против v7: что в них зашито

gen_random_uuid() возвращает uuid версии 4 — 122 случайных бита. Его плюс — непредсказуемость; минус — полная случайность: соседние по времени значения раскиданы по всему диапазону, поэтому по B-tree-индексу они вставляются в случайные места (фрагментация страниц), а отсортировать по такому ключу «в порядке создания» нельзя.

uuidv7() (PG18) кладёт в старшие биты Unix-время в миллисекундах, а в остаток — случайность. Версия — 7, и у него есть встроенный timestamp, который можно достать функцией uuid_extract_timestamp() (у v4 она вернёт NULL — времени там нет). Главное свойство: значения, сгенерированные позже, численно больше — ключ монотонен, вставки идут «вправо» по индексу, а сортировка по ключу совпадает с порядком создания.

Локальность B-tree и выбор ключа

Почему «вразнобой» дороже, чем «в хвост»: индекс — это B-tree, и место новой строки определяет её ключ.

plaintext
B-tree по ключу — куда ложится НОВАЯ вставка?
 
  uuid v4 (122 случайных бита)        uuidv7 (время в старших битах)
  ┌──┬──┬──┬──┬──┐                    ┌──┬──┬──┬──┬──┐
  │  │  │  │  │  │                    │  │  │  │  │▓▓│ ← все новые сюда
  └──┴──┴──┴──┴──┘                    └──┴──┴──┴──┴──┘
   ↑   ↑   ↑   ↑                                    ↑
  бьют по всем листам                 один «горячий» правый лист
  → фрагментация страниц              → плотная упаковка, рост «в хвост»
КлючПредсказуемостьЛокальность B-treeСортируется по времениКогда брать
IDENTITY (BIGINT)низкая: раскрывает порядок и объёмотличная (рост «в хвост»)даузкие внутренние таблицы; компактный и быстрый
uuid v4высокаяплохая (фрагментирует)нетпубличный id, где сортировка по ключу не нужна
uuidv7высокая в хвосте, но светит времяотличная (рост «в хвост»)дараспределённые вставки + публичный сортируемый id

Почему мы показываем свойства, а не значения

uuid по своей природе случаен в хвосте — конкретные значения в каждом прогоне разные, и вставить их в README дословно нельзя. Поэтому демо проверяет свойства, которые детерминированы: номер версии (4 vs 7), наличие встроенного времени (NULL у v4, не-NULL у v7) и монотонность — совпадает ли порядок строк по uuidv7-ключу с порядком вставки.

Что показывает наш код

Своя таблица loyalty_signups (DDL в schema.sql) с ключом на uuidv7() и независимым счётчиком seq (IDENTITY) для проверки порядка:

sql
CREATE TABLE IF NOT EXISTS loyalty_signups (
    id   UUID   NOT NULL DEFAULT uuidv7() PRIMARY KEY,
    seq  BIGINT GENERATED ALWAYS AS IDENTITY,
    ...
);

Первый запрос — детерминированные факты о версиях; третий — проверка монотонности:

sql
SELECT uuid_extract_version(gen_random_uuid())::int AS v4_version,           -- 4
       uuid_extract_version(uuidv7())::int          AS v7_version,           -- 7
       (uuid_extract_timestamp(gen_random_uuid()) IS NULL)::boolean   AS v4_has_no_timestamp,
       (uuid_extract_timestamp(uuidv7()) IS NOT NULL)::boolean        AS v7_has_timestamp;
 
SELECT bool_and(id_rank = seq_rank)::boolean AS ids_match_insertion_order    -- SignupsTimeOrdered
FROM (SELECT row_number() OVER (ORDER BY id) AS id_rank,
             row_number() OVER (ORDER BY seq) AS seq_rank FROM loyalty_signups) t;

main.go чистит таблицу, вставляет три регистрации (id присваивает БД из DEFAULT uuidv7()) и спрашивает: совпал ли порядок по id с порядком вставки? Для v7 — да. В Go uuid приезжает как pgtype.UUID; конкретные значения мы не печатаем (они случайны).

Кстати, schema.sql этого юнита — первый в курсе, что добавляет свою таблицу: make db-reset накатывает её через brew.Apply(ctx, pool, ddl) (схема Brew → DDL юнита → seed), а сам DDL main.go читает рядом с собой через runtime.Caller.

Запуск

sh
docker compose up -d
make lecture L=01-data-types/01-04-uuid-and-uuidv7 T=db-reset
make lecture L=01-data-types/01-04-uuid-and-uuidv7

Вывод:

plaintext
1) gen_random_uuid() (v4) против uuidv7() — проверяемые свойства:
   версия:           v4 = 4,  v7 = 7
   встроено время?   v4: нет (timestamp = NULL) = true;  v7: да = true
 
2) Вставили строк с ключом uuidv7: 3. Порядок по id = порядку вставки? true
   → uuidv7 монотонен во времени: годится как сортируемый по времени PK.
     (v4 случаен — такой порядок был бы лишь совпадением.)

Версии — 4 и 7; у v4 нет встроенного времени, у v7 есть. И главное: три строки, вставленные подряд, упорядочены по uuidv7-ключу ровно так же, как по счётчику вставки, — uuidv7 монотонен. Страница «последние регистрации» снова сортируется по ключу, а вставки идут «вправо» по индексу.

Заборчик

Что мы упростили:

  • Монотонность — про порядок, а не про безопасность. uuidv7 не прячет время создания (его легко достать) — если важно не раскрывать «когда», это не тот инструмент. И наоборот, v4 раскрывает меньше, но проигрывает в локальности индекса.
  • Выбор ключа — компромисс (см. таблицу выше). Для распределённых вставок и публичных идентификаторов uuidv7 — хороший дефолт; для узких внутренних таблиц IDENTITY часто всё ещё лучше — компактнее, быстрее, хотя и раскрывает порядок/объём и хуже мерджится между базами.
  • Базовые таблицы остаются на BIGINT. Намеренно, ради байт-совместимости с kafka-cookbook — новые идеи мы пробуем на новых таблицах, не на базовых.

Что забрать с собой

  • gen_random_uuid() — v4, случайный: непредсказуем, но не сортируется и фрагментирует B-tree.
  • PG18 uuidv7() — v7, со встроенным временем: монотонен, годится как сортируемый по времени первичный ключ; время достаётся uuid_extract_timestamp().
  • Значения uuid случайны — проверяй и показывай свойства (версия, монотонность), а не конкретные значения.
  • Современные идиомы (uuidv7) демонстрируй на новых таблицах; базовые таблицы Brew (*.id BIGINT) не трогай — это держит handoff с kafka-cookbook.

Дальше — юнит 01-05 «enum, массивы и intro в jsonb»: контейнерные типы — упорядоченный enum, массивы (text[] с оператором @>) и первое знакомство с jsonb; когда какой контейнер уместен, а когда пора нормализовать.

·Модуль 02

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Типы данных / uuid и uuidv7