Выбор идентификатора: bigint, UUID, ULID, Snowflake

Идентификатор — проектное решение с отложенной ценой. Разбор bigint, UUIDv4, UUIDv7/ULID, Snowflake, ObjectId по осям, которые реально влияют на систему: кто генерирует, монотонность и сортируемость, размер ключа, что утекает наружу, вероятность коллизий. Главная ось — не «UUID или число», а случайный против монотонного; дерево выбора и разбор мифов.

Идентификатор кажется технической деталью — типом колонки, который выбирают между делом и забывают. На практике это проектное решение с отложенной ценой: она не проявляется в момент CREATE TABLE, а копится незаметно — в размере индекса, в поведении при вставке, в том, что можно прочитать по одному только ID, не имея доступа к базе. Спор обычно формулируют неверно — «UUID или число». Настоящая ось другая: случайный идентификатор против монотонного. От этого выбора зависит и локальность на диске, и распределение по узлам, и то, что идентификатор случайно расскажет о вашей системе постороннему.

Эта статья — первая в серии «Идентификаторы» и вводит словарь: как устроены основные схемы генерации, по каким осям их сравнивать и какие мифы вокруг них не выдерживают проверки. Числа — throughput вставки, размер индекса, WAL-амплификация — здесь намеренно отсутствуют: они появятся в статье #2 на честном стенде PostgreSQL. Здесь — понятия, без которых эти числа потом не с чем будет связать.

Ретрофутуристская схема-каталог типов идентификаторов в стиле «Полдень. XXI век»: вертикальная ось «монотонный ↔ случайный», дерево выбора от узла «кто и когда генерирует», карточки-приборы bigint (8 байт), UUIDv7/ULID (часы и кости, 128 бит), Snowflake (дисплеи время/узел/счётчик, 64 бита), ObjectId (12 байт), UUIDv4 (россыпь случайных бит, 122 бита)

В статье

Что решает идентификатор

Первый развилка — суррогатный ключ против естественного. Естественный ключ — атрибут, который и так существует в предметной области и предположительно уникален: email, номер заказа от партнёра, ИНН контрагента. Соблазн понятен — не плодить лишнюю колонку. Проблема в слове «предположительно»: естественные ключи меняются (email переезжает между аккаунтами), не гарантируют глобальную уникальность (два независимых партнёра нумеруют заказы с единицы) и часто содержат персональные данные, которые не хочется тащить в внешние ключи по всей схеме. Суррогатный ключ — искусственное значение, единственная задача которого — быть идентичностью строки, без всякой смысловой нагрузки. Для схем, рассчитанных на рост и распределение, суррогатный ключ почти всегда предпочтительнее — но выбор конкретной схемы генерации для него как раз и есть тема этой статьи.

Второй вопрос — кто генерирует идентификатор и где:

  • База данных. Sequence (bigserial, GENERATED BY DEFAULT AS IDENTITY) — сервер сам решает следующее значение при вставке. Просто, монотонно в рамках одного инстанса, но требует обращения к БД за значением (или RETURNING) и плохо масштабируется на несколько независимых мастеров без дополнительных трюков вроде шага инкремента или разделения диапазонов.
  • Приложение. UUIDv4, UUIDv7, ULID, NanoID — клиент генерирует значение до вставки, без похода в БД и без координации с другими узлами. Это критично для event sourcing и офлайн-сценариев, где идентичность объекта нужна раньше, чем он попал в хранилище. Плата — вероятность коллизии должна быть пренебрежимо мала (см. ниже), потому что никто не проверяет уникальность заранее.
  • Координируемая распределённая генерация. Snowflake и его производные — каждому узлу заранее выдан идентификатор (node id) через какой-то внешний механизм — конфигурацию, ZooKeeper/etcd, лидер-элекшн. Координация происходит один раз при старте узла, а не на каждый сгенерированный ID, — но она не бесплатна: рассинхронизация часов между узлами и сама раздача node id — реальная эксплуатационная поверхность, которую разберёт статья #3 этой серии.

Словарь идентификаторов

bigserial / IDENTITY. Классическая sequence в реляционной БД: 8 байт (bigint), строго монотонна в рамках инстанса, генерируется на сервере. Что утекает: сам факт монотонности — по разнице соседних ID можно оценить объём (сколько заказов создано между двумя моментами), поэтому публично видимые инкрементальные ID — частая утечка бизнес-метрики конкурентам.

UUIDv4. 128 бит, из которых 6 зафиксированы под версию и вариант — то есть 122 случайных бита. Генерируется где угодно без всякой координации, полностью немонотонен: соседние вызовы дают значения, разбросанные по всему 128-битному пространству. Ничего не утекает — ни времени, ни узла, ни порядка вставки. Цена — именно эта случайность бьёт по локальности на диске, разбор в статье #2.

UUIDv7 и ULID. Идея одна и та же в обеих схемах: старшие биты — время создания (миллисекунды), младшие — случайность. Результат — k-sortable идентификатор: значения, созданные позже, лексикографически больше созданных раньше (с точностью до миллисекунды), что даёт локальность вставки почти как у sequence, сохраняя децентрализованную генерацию UUID. UUIDv7 — часть RFC 9562 (май 2024), где формально стандартизирован наравне с UUIDv6 (сортируемый вариант v1) и v8 (кастомный). ULID — более ранняя неофициальная спецификация с тем же принципом раскладки битов, но собственной текстовой кодировкой (Crockford’s Base32, 26 символов) вместо канонической UUID-строки с дефисами.

Важная оговорка про «монотонность», которую легко перечитать сильнее, чем она есть: формат гарантирует порядок между миллисекундами — значение, созданное в более позднюю миллисекунду, всегда больше. А порядок внутри одной миллисекунды форматом не задан: там лежат случайные биты, и будет ли последовательность строго возрастающей в тесном цикле, зависит от реализации — использует ли она монотонный счётчик в младших битах (RFC 9562 описывает такой метод как опциональный, спецификация ULID — как рекомендованный) или просто берёт свежую случайность на каждый вызов. Для локальности вставки это почти не важно (страница B-tree всё равно одна и та же), но полагаться на строгую монотонность UUIDv7/ULID как на гарантию формата — нельзя: это свойство конкретной библиотеки, которое стоит проверять. PostgreSQL 18 добавил встроенную генерацию через uuidv7():

CREATE TABLE orders (
  id      uuid PRIMARY KEY DEFAULT uuidv7(),
  payload jsonb NOT NULL
);

Что утекает: время создания записи — так же, как у UUIDv1. Отличие от bigserial в утечке объёма тоньше: по временной метке видно, когда объект создан, но не видно порядковый номер среди всех объектов — прямого счётчика тут нет.

Snowflake. 64 бита, собранные из трёх частей: таймстамп (обычно 41 бит, миллисекунды от кастомной эпохи), идентификатор узла (обычно 10 бит: датацентр + воркер) и локальный счётчик (обычно 12 бит, сбрасывается каждую миллисекунду):

| 1 бит (не используется) | 41 бит timestamp (мс) | 10 бит node id | 12 бит sequence |

Компактнее UUID (8 байт против 16), сортируем по времени, генерируется без обращения к БД — но требует заранее выданного node id, то есть той самой координации. Что утекает: время создания, идентификатор узла/датацентра (по которому иногда можно оценить географию или инфраструктуру), и — как побочный эффект — примерная скорость выпуска ID с конкретного узла.

KSUID. Схема Segment: 160 бит — 32-битный таймстамп с секундной точностью (от собственной эпохи 2014 года) плюс 128 бит случайности, закодированные в Base62 в строку из 27 символов. Крупнее UUID и Snowflake, но не требует никакой координации и при этом сортируем по времени с точностью до секунды.

MongoDB ObjectId. 12 байт: 4-байтный таймстамп (секунды), 5 байт случайного значения, зафиксированного на весь процесс-генератор, и 3-байтный счётчик, инкрементируемый в рамках процесса. Результат приблизительно монотонен — растёт вместе со временем и внутри одного процесса строго не повторяется, — но не строго упорядочен между разными процессами, генерирующими параллельно. Родной формат _id в MongoDB, дёшев в генерации на стороне драйвера. При шардировании по _id монотонность превращается в обратную сторону трейдофа — горячий правый чанк; это подробно в статье про шардирование MongoDB и в статье #3 этой серии.

NanoID. Чисто случайная строка настраиваемой длины и алфавита (по умолчанию 21 символ URL-безопасного алфавита, порядка 126 бит энтропии) — идейно ближе к UUIDv4, чем к ULID: не сортируем, ничего не утекает, зато компактнее классической UUID-строки и не тащит зафиксированные версия/вариант-биты. Хорош, когда просто нужен компактный непредсказуемый идентификатор для URL, без претензии на сортируемость.

Идентификатор Размер Монотонность Кто генерирует Что утекает
bigserial/IDENTITY 8 байт строгая БД (sequence) объём (по разнице ID)
UUIDv4 16 байт нет где угодно, без координации ничего
UUIDv7 / ULID 16 байт k-sortable (мс) где угодно, без координации время создания
Snowflake 8 байт k-sortable (мс) приложение, нужен node id время, узел, темп выпуска
KSUID 20 байт k-sortable (с) где угодно, без координации время (с точностью до секунды)
MongoDB ObjectId 12 байт приблизительная драйвер MongoDB время создания
NanoID обычно 21 байт (строка) нет где угодно, без координации ничего

Оси-трейдофы

Координация при генерации. Диапазон от «никакой» (UUIDv4, NanoID, UUIDv7/ULID, KSUID — всем нужен только локальный источник случайности и, для time-ordered схем, часы) до «нужен внешний источник истины» (sequence в БД — единственный писатель; Snowflake — заранее выданный node id). Между ними — паттерны вроде hi/lo, которыми пользуются некоторые ORM (например, Hibernate): приложение резервирует у БД целый диапазон значений одним запросом, а дальше выдаёт ID из этого диапазона локально, не обращаясь к серверу на каждую вставку. Это компромисс: координация есть, но амортизированная.

Сортируемость (k-sortable). bigserial, Snowflake, UUIDv7/ULID, KSUID и (приблизительно) ObjectId растут вместе со временем создания — значит, сортировка по ID эквивалентна сортировке по времени, а вставка ложится в конец физической структуры. UUIDv4 и NanoID — нет: соседние по времени вставки значения оказываются в случайных местах пространства ключей. Именно эта разница определяет поведение B-tree на вставке — устройство самого дерева эта статья не пересказывает, оно разобрано в «Индексы в БД: зачем нужны, какие бывают и как устроены»; здесь важно только то, что монотонность и физическая локальность — одна и та же ось.

Размер ключа. 8 байт (bigint, Snowflake) против 16 байт (UUID, ULID как бинарное значение) против 20 байт (KSUID) против 12 байт (ObjectId) — разница напрямую умножается на каждую запись в первичном индексе и в каждом внешнем ключе, который на него ссылается. Отдельная и частая ошибка — хранить UUID/ULID/KSUID текстовой строкой вместо нативного бинарного типа: 36-символьная UUID-строка с дефисами занимает 36+ байт против 16 байт в колонке типа uuid, а сравнение строк дороже побайтового сравнения фиксированной длины. К этому мы вернёмся в мифах ниже и подробнее — в статье #3, в разделе про типы колонок.

Утечка информации. У time-ordered схем (UUIDv1/v6/v7, ULID, Snowflake, KSUID, ObjectId) идентификатор буквально содержит момент создания записи — иногда с точностью до миллисекунды. У Snowflake добавляется node id, то есть можно судить о датацентре или физическом узле. У строго монотонных числовых ID (bigserial) утекает не время, а объём — по разнице соседних значений видно, сколько записей создано между двумя моментами; именно поэтому публично видимые последовательные ID заказов или счетов — частый источник утечки бизнес-метрики конкурентам или наблюдателям.

Вероятность коллизий. Для UUIDv4 это чистая математика парадокса дней рождения на 122 случайных битах: чтобы вероятность хотя бы одной коллизии достигла 50%, нужно сгенерировать порядка 2,7×10¹⁸ значений — величина того же порядка, что и генерация миллиарда UUID в секунду на протяжении десятков лет. На практике ни одна система такого темпа не достигает, поэтому коллизия UUIDv4 — не риск, с которым инженер реально сталкивается. У Snowflake коллизия — не вероятностное, а структурное событие: она невозможна по построению, пока node id уникален и часы каждого узла не идут назад; если оба условия нарушены, дубликат или откат ID — реальный сценарий, разбираемый в статье #3. У KSUID случайность (128 бит) считается в рамках секундного бакета — порядок величины коллизии остаётся астрономически малым, но рамка отличается. У ObjectId случайная часть (5 байт) фиксируется один раз на процесс, а не на документ, — значит, в пределах одного процесса-генератора коллизии не будет вообще (её предотвращает счётчик), а вероятность имеет смысл считать только между независимыми процессами.

Мифы про идентификаторы

«UUID безопаснее, потому что непредсказуем». Верно только для полностью случайных схем — UUIDv4 и NanoID. UUIDv1, UUIDv6, UUIDv7 и ULID прямо в старших битах несут время создания — то есть предсказуемы ровно в той части, где зашито время, и это осознанный компромисс ради сортируемости, а не случайная дыра. Но даже полная непредсказуемость UUIDv4 не решает задачу авторизации: если сервис проверяет владение объектом только по факту знания его ID, а не по явной проверке прав — это Broken Object Level Authorization (BOLA/IDOR) независимо от того, насколько ID трудно угадать. «ID трудно подобрать» — не замена проверке доступа; разбор этого класса ошибок — в статье про OWASP API Security Top-10Скоро.

«UUIDv4 нельзя ставить в первичный ключ». Можно — с точки зрения корректности это работает без всяких оговорок: уникальность, ссылочная целостность, индексирование — всё функционирует. Реальный вопрос не «можно ли», а «во сколько это обходится» — page splits, раздувание индекса, дополнительный WAL при случайной вставке. Это вопрос производительности с конкретным числовым ответом, а не категорический запрет, и ответ — в статье #2 этой серии.

«Строкой хранить проще». Хранение UUID/ULID/KSUID как текстовой строки вместо нативного бинарного типа колонки действительно проще в том смысле, что не нужно думать о кодировании — но обходится дороже по месту (лишние байты на каждую строку и на каждый индекс, где эта колонка участвует) и часто ломает использование эффективного бинарного сравнения в пользу более медленного сравнения строк с учётом collation. Тот же эффект — источник несовпадения типов на границе приложение/БД (::text-каст, ломающий индекс), уже разобранный в статье об индексах и языках/ORM. Сравнение конкретных типов колонок (uuid против bytea против text) с числами по месту и покрытию индекса — в статье #3, «Идентификаторы в разных языках и БД».

Дерево решений

flowchart TD Q1{"ID нужен приложению\nдо записи в БД\n(event sourcing, офлайн, без round-trip)?"} Q1 -->|"Нет — БД может\nсгенерировать сама"| Q2{"Одна БД,\nодин writer,\nбез будущего шардирования?"} Q1 -->|"Да — генерация\nна стороне клиента"| Q3{"Важна временная\nсортируемость\n(k-sortable)?"} Q2 -->|"Да"| BIGINT["bigint / IDENTITY\nмонотонно, 8 байт, просто"] Q2 -->|"Нет — независимые\nwriter'ы или шардирование"| Q4{"Есть возможность\nвыдать и поддерживать\nnode id по узлам?"} Q3 -->|"Да"| UUID7["UUIDv7 / ULID\nk-sortable, без координации"] Q3 -->|"Нет — просто нужна\nуникальность"| UUID4["UUIDv4 / NanoID\nслучайно, без координации"] Q4 -->|"Да — координация\nоправдана"| SNOW["Snowflake и производные\nкомпактно, k-sortable, нужен node id"] Q4 -->|"Нет — не хотим\nоперировать node id"| UUID7B["UUIDv7 / ULID\nтот же выбор, что и слева"] style BIGINT fill:#f9f3e3,stroke:#8b7355 style UUID4 fill:#ded9cb,stroke:#3a3631 style UUID7 fill:#c9e4c5,stroke:#5b8a5e style UUID7B fill:#c9e4c5,stroke:#5b8a5e style SNOW fill:#f4d9c6,stroke:#c67a4a

flowchart TD
  Q1{"ID нужен приложению\nдо записи в БД\n(event sourcing, офлайн, без round-trip)?"}
  Q1 -->|"Нет — БД может\nсгенерировать сама"| Q2{"Одна БД,\nодин writer,\nбез будущего шардирования?"}
  Q1 -->|"Да — генерация\nна стороне клиента"| Q3{"Важна временная\nсортируемость\n(k-sortable)?"}

  Q2 -->|"Да"| BIGINT["bigint / IDENTITY\nмонотонно, 8 байт, просто"]
  Q2 -->|"Нет — независимые\nwriter'ы или шардирование"| Q4{"Есть возможность\nвыдать и поддерживать\nnode id по узлам?"}

  Q3 -->|"Да"| UUID7["UUIDv7 / ULID\nk-sortable, без координации"]
  Q3 -->|"Нет — просто нужна\nуникальность"| UUID4["UUIDv4 / NanoID\nслучайно, без координации"]

  Q4 -->|"Да — координация\nоправдана"| SNOW["Snowflake и производные\nкомпактно, k-sortable, нужен node id"]
  Q4 -->|"Нет — не хотим\nоперировать node id"| UUID7B["UUIDv7 / ULID\nтот же выбор, что и слева"]

  style BIGINT fill:#f9f3e3,stroke:#8b7355
  style UUID4 fill:#ded9cb,stroke:#3a3631
  style UUID7 fill:#c9e4c5,stroke:#5b8a5e
  style UUID7B fill:#c9e4c5,stroke:#5b8a5e
  style SNOW fill:#f4d9c6,stroke:#c67a4a
Дерево выбора идентификатора: от условий генерации к конкретной схеме

Естественный ключ в этой схеме почти нигде не встречается специально — он остаётся допустимым выбором только тогда, когда внешний идентификатор (например, номер документа от партнёрской системы) уже гарантированно уникален в нужной области видимости и не должен независимо генерироваться внутри вашей системы. Даже тогда практичнее держать его отдельной уникальной колонкой поверх суррогатного PK, а не вместо него — это развязывает внутреннюю идентичность строки от внешнего формата, который вы не контролируете.

Что дальше

Эта статья дала словарь и оси сравнения — но ни одного измерения. Следующий шаг — заменить слова «случайность бьёт по локальности» на конкретные цифры: throughput вставки, размер и bloat индекса, объём WAL для bigint, UUIDv4 и UUIDv7 на одной и той же схеме и нагрузке, плюс кросс-движковый разбор вторичных индексов (PostgreSQL и MySQL/InnoDB ведут себя здесь по-разному) и поведение MongoDB/ScyllaDB при разных схемах шардирования. Всё это — статья #2, «Идентификаторы и производительность: локальность, bloat, WAL».

Дальше серия уходит в сторону практики генерации: как одна и та же задача — сгенерировать UUIDv7/ULID/Snowflake — решается в Go, Java и Rust, как типы колонок в разных СУБД обходятся с этими значениями и как идентификатор нужно согласовывать с ключом шардирования, чтобы не устроить себе горячий узел собственными руками. Это статья #3, «Идентификаторы в разных языках и БД». Тема шардирования и цены распределённости отдельно и подробно разобрана в «Шардировании на практике» — эта серия про идентификаторы лишь показывает, как выбор ключа стыкуется с этим уже разобранным счётом.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии