Идентификатор кажется технической деталью — типом колонки, который выбирают между делом и забывают. На практике это проектное решение с отложенной ценой: она не проявляется в момент CREATE TABLE, а копится незаметно — в размере индекса, в поведении при вставке, в том, что можно прочитать по одному только ID, не имея доступа к базе. Спор обычно формулируют неверно — «UUID или число». Настоящая ось другая: случайный идентификатор против монотонного. От этого выбора зависит и локальность на диске, и распределение по узлам, и то, что идентификатор случайно расскажет о вашей системе постороннему.
Эта статья — первая в серии «Идентификаторы» и вводит словарь: как устроены основные схемы генерации, по каким осям их сравнивать и какие мифы вокруг них не выдерживают проверки. Числа — throughput вставки, размер индекса, WAL-амплификация — здесь намеренно отсутствуют: они появятся в статье #2 на честном стенде PostgreSQL. Здесь — понятия, без которых эти числа потом не с чем будет связать.
В статье
- Что решает идентификатор
- Словарь идентификаторов
- Оси-трейдофы
- Мифы про идентификаторы
- Дерево решений
- Что дальше
- Источники
Что решает идентификатор
Первый развилка — суррогатный ключ против естественного. Естественный ключ — атрибут, который и так существует в предметной области и предположительно уникален: 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
Естественный ключ в этой схеме почти нигде не встречается специально — он остаётся допустимым выбором только тогда, когда внешний идентификатор (например, номер документа от партнёрской системы) уже гарантированно уникален в нужной области видимости и не должен независимо генерироваться внутри вашей системы. Даже тогда практичнее держать его отдельной уникальной колонкой поверх суррогатного PK, а не вместо него — это развязывает внутреннюю идентичность строки от внешнего формата, который вы не контролируете.
Что дальше
Эта статья дала словарь и оси сравнения — но ни одного измерения. Следующий шаг — заменить слова «случайность бьёт по локальности» на конкретные цифры: throughput вставки, размер и bloat индекса, объём WAL для bigint, UUIDv4 и UUIDv7 на одной и той же схеме и нагрузке, плюс кросс-движковый разбор вторичных индексов (PostgreSQL и MySQL/InnoDB ведут себя здесь по-разному) и поведение MongoDB/ScyllaDB при разных схемах шардирования. Всё это — статья #2, «Идентификаторы и производительность: локальность, bloat, WAL».
Дальше серия уходит в сторону практики генерации: как одна и та же задача — сгенерировать UUIDv7/ULID/Snowflake — решается в Go, Java и Rust, как типы колонок в разных СУБД обходятся с этими значениями и как идентификатор нужно согласовывать с ключом шардирования, чтобы не устроить себе горячий узел собственными руками. Это статья #3, «Идентификаторы в разных языках и БД». Тема шардирования и цены распределённости отдельно и подробно разобрана в «Шардировании на практике» — эта серия про идентификаторы лишь показывает, как выбор ключа стыкуется с этим уже разобранным счётом.
Источники
- RFC 9562 — Universally Unique IDentifiers (UUIDs) (версии v1–v8, включая стандартизацию UUIDv7 и сортируемого UUIDv6)
- ULID — спецификация
- Discord Developer Documentation — Snowflake IDs
- Segment — KSUID: спецификация и реализация
- MongoDB Manual — ObjectId
- NanoID — спецификация и реализация
- PostgreSQL 18 Release Notes — встроенные функции
uuidv4()/uuidv7()
Комментарии