Сравнение UUID v1, v4 и v7 и дизайн первичного ключа в БД

Опубликовано 2026-04-13 8 мин чтения

Резюме (TL;DR)

Скажу вещь, из-за которой можно получить пару возражений: использовать UUID v4 как первичный ключ в таблице с высоким темпом вставок — это стрелять себе по ногам. В одном профиле PostgreSQL 16, который я смотрел, у таблицы events стояла uuid v4 (gen_random_uuid()) в качестве первичного ключа, и каждый INSERT попадал в случайный лист B-tree, остужая shared_buffers; фрагментация индекса копилась в районе высоких однозначных процентов. Смена типа колонки на v7 — та же uuid-типа шириной 16 байт, тот же индекс — опустила среднюю задержку INSERT примерно до трети прежней, и показатели фрагментации в pg_stat_user_indexes стабилизировались. Выбор версии UUID — это не «криптографическое решение», а решение о дизайне базы данных. UUID — это 128 бит, в записи 8-4-4-4-12 четыре бита отводятся под версию, ещё 2–3 бита — под variant. v1 зашивает время и идентификатор узла (исторически MAC-адрес) и примерно сортируется по времени, но выдаёт хост. v4 даёт 122 бита случайности и отличную непредсказуемость ценой отсутствия порядка. v7, стандартизованный в RFC 9562 (2024), кладёт в старшие 48 бит метку времени Unix в миллисекундах, а остальное заполняет случайностью, совмещая безопасность v4 и индекс-локальность v1. Для идентификаторов публичного API, где важно скрыть время создания, v4 всё ещё базовый выбор. Почти во всех остальных местах v7 подходит лучше, а v1 — это легаси: даже если БД его примет, не ставьте его в дефолт для нового дизайна.

Предыстория и концепции

UUID — это 128 бит. По традиции записывается как 32 шестнадцатеричных цифры через дефис: xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx. В позиции M стоит версия (1, 4, 7 и т. д.), а старшие биты N — variant. Всё остальное версия интерпретирует по-своему.

Версия 1 спроектирована для уникальности, охватывающей и время, и машины. 60-битная метка времени в 100-наносекундных интервалах от 1582-10-15, clock sequence на случай отката часов и 48-битный node ID, исторически равный MAC-адресу сетевой карты. С приватностью здесь прямолинейно: в v1 UUID с ноутбука обычно вшит MAC этого ноутбука, и это раскрывается одним запуском uuid CLI. Современные библиотеки часто рандомизируют node ID, но встречается и старое поведение.

Версия 4 — это 122 бита случайности и 6 бит на версию и variant. Предполагается стойкий генератор (в браузере это crypto.randomUUID(), в PostgreSQL — gen_random_uuid()). Коллизии при реалистичных масштабах практически невозможны: по оценке из парадокса дня рождения даже на миллиарде значений вероятность столкновения порядка 1 к триллиону. Недостаток — два соседних v4 UUID никак не связаны, и поэтому вставки в индекс бьют по случайным страницам; страдает и локальность кэша, и усиление записи (WAL, full-page writes).

Версия 7 вошла в RFC 9562, опубликованный в 2024 году; этот RFC отменил RFC 4122 и добавил v6/v7/v8. У v7 сверху лежит 48-битная метка времени Unix в миллисекундах, затем тег версии, маленькое rand_a, тег variant и 62-битный хвост rand_b. На практике это означает, что v7, сгенерированные в одну и ту же миллисекунду, сортируются друг с другом естественно, а между миллисекундами — хронологически. Одного случайного хвоста хватает, чтобы разделять UUID внутри одной миллисекунды. В PostgreSQL 18 появилась нативная uuidv7(), а в более старых версиях тот же результат достижим через расширение pg_uuidv7 или библиотеку на уровне приложения (Node uuid 9.x, Python uuid6).

Биты variant важны, потому что они отличают семейство RFC 9562/4122 от легаси-форматов Microsoft/Apollo. В этой статье предполагается RFC-variant (первая цифра группы N — одна из 8, 9, a, b).

Формат хранения — отдельный вопрос. Нативный тип uuid в PostgreSQL 16 хранит значение в 16 байтах. В MySQL обычно пишут BINARY(16) или CHAR(36); второй удваивает объём и превращает сравнение в посимвольное. Выбор версии переплетается с форматом хранения. Сортировать v7 по бинарю дёшево и корректно; сортировка по шестнадцатеричной строке тоже корректна, но медленнее, а v4 не имеет смыслового порядка ни в каком формате.

Сравнение и данные

Свойствоv1v4v7
Вход генерацииМетка времени + clock sequence + node ID122 бита случайности48 бит Unix ms + случайный хвост
ПриватностьЧасто раскрывает MACНикаких данных о хосте или времениВремя создания (ms) видно, хост — нет
Сортировка по времениВозможна (но байтовый порядок ≠ хронологии, нужна переупаковка)НетДа, лексикографический порядок совпадает с хронологическим
Индекс-локальностьСредняяПлохая (случайные вставки по всему B-tree)Хорошая (почти монотонно растущие ключи)
Типичные примененияЛегаси, часть COM/Windows IDПубличные ID API, сессионные токены, солиЛоги событий, высокая частота вставок, пагинация по времени
ЭнтропияНизкая (в основном время и узел)Высокая (~122 бита)Высокая в хвосте (~74 бита), коллизии в пределах ms маловероятны

Грубая модель для головы: v4 максимизирует непредсказуемость в ущерб производительности индекса; v7 сохраняет достаточную непредсказуемость для большинства применений и восстанавливает любимый базой паттерн «вставляем в конец»; а v1 нужно помнить как исторический артефакт, но не выбирать.

Практические сценарии

Сценарий 1 — append-heavy лог событий. Нагрузка из вступления выглядела ровно так. Таблица, в которую пишутся миллионы строк в день и запросы к ней обычно вида «последние 24 часа, по времени», — прямой кандидат для v7. Новые строки садятся в конец индекса первичного ключа, «горячие» страницы остаются горячими, а запросы по временному диапазону попадают в соседние сегменты индекса. Часто достаточно поменять дефолт колонки с v4 на v7, и при неизменном коде запросов падают задержки записи и фрагментация индекса.

Сценарий 2 — публичный ID, видимый пользователю. Ссылка вида /orders/{id} должна быть непредсказуемой, чтобы заказы других пользователей нельзя было перебрать. Здесь v4 — безопасный дефолт. Если хочется и преимуществ v7, помните: v7 раскрывает время создания с точностью до миллисекунды. Для заказов это, возможно, нормально, а в более чувствительном контексте (число платежей в минуту как бизнес-сигнал) — уже утечка. Паттерн, который я рекомендовал одной команде: v7 как внутренний первичный ключ, а наружу выдаётся отдельный v4 или короткий случайный slug. Получается двойной ID, индекс не страдает, а время наружу не утекает.

Сценарий 3 — мультирегиональная или шардированная система. Временной префикс в v7 помогает разным регионам органично смешиваться в хронологическом порядке, но внутри одной миллисекунды порядок между регионами не гарантирован. Если между регионами нужна более строгая упорядоченность, хорошо работает ULID (48 бит времени + 80 бит случайности в кодировке Crockford Base32) — примерно те же свойства, но компактнее: 26 символов текста. Если нужен ещё более жёсткий контракт, остаются Snowflake-подобные ID (оригинальный из Twitter и Discord-вариация — оба 64-битные) с явным machine ID, но за это придётся платить дополнительной координацией.

Распространённые заблуждения

«UUID всегда медленный в БД». Он медленнее 4-байтного int, но значительная часть реальной стоимости — это фрагментация B-tree при случайных вставках, а её v7 почти устраняет. Хранить UUID стоит 16 байтами, а не 36-символьной строкой: индекс вдвое меньше и сравнения быстрее. Многие бенчмарки в духе «UUID медленные» на деле про «v4, сохранённый в MySQL как CHAR(36)».

«Только v4 безопасен». Хвост случайности v7 — это достаточно крупный пул энтропии, чтобы для большинства применений — сессионные ссылки, ID в API — перебор был нереалистичен. Вопрос не в предсказуемости, а в утечке времени: v7 показывает момент создания. Если вас это устраивает (часто устраивает), v7 — разумный выбор и для внешних ID.

«UUID надо хранить строкой». В строковой форме это 36 символов (32 шестнадцатеричных плюс 4 дефиса), в бинарной — 16 байт, и байтовая сортировка сразу даёт правильный порядок. С учётом небусного байтового порядка v1 и того, что у v7 порядок байтов должен совпадать с временем, бинарное хранение удобнее. Тип uuid в PostgreSQL уже хранит значение в 16 байтах, так что отдельное преобразование не нужно.

«На утечку MAC в v1 никто не смотрит». Восстановление MAC из v1 — это открытое преобразование, и форензические инструменты (uuid -d и т. п.) по умолчанию его достают. Если UUID попадает в URL, тикеты поддержки или логи, которыми делятся, — это вполне реальное раскрытие информации.

Чек-лист

  1. Это ключ часто вставляемого индекса? По умолчанию v7. v4 — только когда непредсказуемость времени создания действительно важна.
  2. UUID видят пользователи или партнёры? Работают оба варианта. Вопрос один — готовы ли вы мириться с утечкой времени у v7.
  3. Postgres? Нативный uuid (16 байт). В MySQL — BINARY(16), если только вам не нужна именно строковая совместимость.
  4. Нужен ли строгий порядок между несколькими генераторами? Только v7 не хватит. Смотрите ULID (с тем же временным префиксом) или Snowflake-подобные ID с явным machine ID.
  5. Есть ли v1 в кодовой базе? Задокументируйте утечку MAC и спланируйте миграцию, как только схема позволит.
  6. Генерация на клиенте? Используйте библиотеку, вызывающую криптостойкий RNG: в современных браузерах v4 делает crypto.randomUUID(), а v7 обычно обёрнут поверх того же RNG.

Связанный инструмент

Генератор UUID Patrache Studio делает v4 и v7 локально, так что сгенерированные значения не оседают в логах сторонних сервисов. UUID почти всегда путешествуют внутри JSON-payload, и в статье JSON: форматирование, валидация и JSON Schema в реальной работе описаны схемные паттерны, которые сохраняют типы ID при передаче между сервисами. Если нужно получить короткое текстовое представление вроде 22-символьного ID из 16-байтного UUID, статья Base64 и URL-кодирование объясняет, почему берут именно Base64URL, а не стандартный Base64.

Источники