Skip to content
Назад к блогу
Руководства

Что такое ULID? Руководство по сортируемому идентификатору

Что такое ULID? Как работает сортируемый 128-битный идентификатор: структура из метки времени и случайности, кодировка Crockford Base32 и когда выбрать его вместо UUID.

12 мин чтения

Что такое ULID? Объясняем сортируемый уникальный идентификатор

Каждый случайный UUIDv4, который вы вставляете как первичный ключ, попадает в непредсказуемое место индекса базы данных. Повторите это несколько миллионов раз — и индекс фрагментируется, кеш начинает буксовать, а запись замедляется. ULID решает эту проблему, не отнимая того, за что вы цените UUID: его по-прежнему можно создать где угодно, без центрального координатора, но он встаёт по порядку времени, а не рассыпается.

Так как же строка из 26 символов сортирует сама себя по времени? Этот механизм стоит разобрать, прежде чем браться за ULID.

ULID (Universally Unique Lexicographically Sortable Identifier) — это 128-битный идентификатор, записанный 26 символами Crockford Base32. Первые 10 символов кодируют метку времени в миллисекундах, а последние 16 — случайные биты, поэтому ULID, созданные позже, при сравнении как обычные строки всегда оказываются после более ранних. Это сортируемый уникальный идентификатор, который можно сгенерировать офлайн.

Это руководство разбирает его на части: анатомия, расшифрованная символ за символом, доказательство того, что он действительно сортируется, математика B-tree за выигрышем в базе данных и честный взгляд на то, что выдаёт встроенная метка времени. Следить за изложением можно на живом значении в генераторе ULID — пока вы читаете, сгенерируйте ULID, декодируйте его, преобразуйте в UUID.

Что такое ULID?

ULID (Universally Unique Lexicographically Sortable Identifier) — это 128-битный идентификатор, задуманный как более сортируемая и более компактная альтернатива UUID. Он записывается 26 символами Crockford Base32: первые 10 хранят 48-битную метку времени в миллисекундах от Unix epoch, а оставшиеся 16 — 80 бит случайности. Поскольку время идёт первым, строка сортируется хронологически.

Именно это последнее свойство и есть причина, по которой формат существует. UUIDv4 полностью случаен, что отлично для уникальности, но означает, что два идентификатора, созданные с интервалом в секунду, никак не связаны друг с другом. ULID сохраняет модель «без координации, генерируй где угодно» и добавляет сверху упорядочивание по времени, поэтому столбец из них естественным образом отсортирован по времени создания без чего-либо дополнительного.

Вот формат с первого взгляда:

СвойствоЗначение
Биты128
Кодировка26 символов Crockford Base32
Структура48-битная метка времени + 80 бит случайности

Дальше разберём, как работает каждый кусочек. Кодировка и сортируемость вынесены в отдельные разделы; до Base32 и доказательства упорядочивания дойдём чуть позже, а пока — структура.

Анатомия ULID: 48 бит времени + 80 бит случайности

26 символов ULID чётко делятся на две половины. Первые 10 символов — это метка времени; последние 16 — случайная часть. Разложите канонический пример, и граница станет очевидной:

01ARYZ6S41   TSV4RRFFQ69G5FAV
└────────┘   └──────────────┘
 10 chars        16 chars
48-bit ms      80-bit random
timestamp

У двух компонентов разные задачи: первый записывает когда, второй гарантирует уникальность. Декодируем каждый.

48-битная метка времени (первые 10 символов)

Ведущие 10 символов кодируют 48-битное целое число: количество миллисекунд от Unix epoch в момент создания ULID. Возьмём канонический пример прямо из спецификации:

01ARYZ6S41  ->  1469918176385 ms  ->  2016-07-30T22:36:16.385Z

Это настоящее, обратимое декодирование — вставьте 01ARYZ6S41TSV4RRFFQ69G5FAV в декодер, и вы получите обратно ровно 2016-07-30T22:36:16.385Z. Компонент времени — это обычные данные, а не хеш, поэтому его чтение не стоит ничего.

Одна небольшая деталь, которая сбивает с толку: первый символ ULID всегда находится между 0 и 7. Символ Crockford хранит 5 бит, а 48 не кратно 5 — метка времени занимает младшие 48 из 50 бит, которые могут нести 10 символов, оставляя старшие 2 бита первого символа навсегда нулевыми. Два нулевых бита ограничивают значение этого символа семёркой. Если вы когда-нибудь увидите ULID, начинающийся с 8 или выше, он испорчен.

80 бит случайности (последние 16 символов)

Оставшиеся 16 символов несут 80 бит случайности, и именно из этой половины берётся уникальность. Биты должны поступать из криптографически стойкого источника — crypto.getRandomValues в браузере, а не Math.random. Разница важна: Math.random достаточно предсказуем, чтобы атакующий мог угадать значения или вызвать коллизию, тогда как CSPRNG — нет.

Сколько места в 80 битах? Примерно 1,2 × 10²⁴ возможных значений, и это на каждую миллисекунду. Даже если вы создадите миллионы ULID внутри одной миллисекунды, шансы, что два выпадут с одними и теми же 80 битами, останутся исчезающе малыми. В отличие от метки времени, эта половина не несёт декодируемого смысла — это шум, единственное назначение которого в том, чтобы делать каждый ULID отличным от других.

Crockford Base32: почему ULID отбрасывает I, L, O и U

ULID кодируются с помощью Crockford Base32 — алфавита из 32 символов: цифры 09 и буквы AZ с четырьмя исключёнными.

0123456789ABCDEFGHJKMNPQRSTVWXYZ

Пропущенные буквы — I, L, O и U. Три отброшены, потому что похожи на цифры — I и L напоминают 1, O напоминает 0 — так что человек, читающий ULID с экрана, не спутает букву с цифрой. Обратная сторона — снисходительность к вводу: совместимый декодер сопоставляет I и L обратно с 1, а O с 0 и обрабатывает всю строку без учёта регистра. U исключена отдельно, чтобы случайно не сложились нецензурные слова.

Битовая математика — вторая причина. Каждый символ Base32 кодирует 5 бит, тогда как шестнадцатеричный символ — лишь 4. Упакуйте 128 бит по 5 бит на символ — и вам понадобится 26; упакуйте те же 128 бит по 4 бита — как это делает UUID — и понадобится 32 символа плюс четыре дефиса, итого 36. Поэтому ULID заметно короче UUID и, без дефисов, ложится прямо в URL, имя файла или заголовок без экранирования.

Crockford Base32 — это алфавит из 32 символов (09 и AZ минус I, L, O, U), кодирующий 5 бит на символ. ULID используют его, чтобы упаковать 128 бит в 26 нечувствительных к регистру, URL-безопасных символов, и — что критично — алфавит расположен по возрастанию, что и позволяет закодированной строке сортироваться так же, как сырые биты.

Почему ULID сортируются по времени

Множество статей просто сообщают, что ULID сортируются по времени; реже объясняют почему. Рассуждение опирается на два факта, которые у вас уже есть: метка времени — самая значимая часть значения, а алфавит Crockford расположен по возрастанию.

Соедините это вместе — и получите цепочку эквивалентностей:

string compare  ==  128-bit integer compare  ==  creation-time compare

Читайте слева направо. Сравнение двух ULID символ за символом (как работает строковая сортировка) даёт тот же ответ, что и сравнение их базовых 128-битных целых чисел, потому что алфавит сохраняет порядок — «более высокий» символ всегда означает большее значение. Сравнение 128-битных целых чисел даёт тот же ответ, что и сравнение времён создания, потому что метка времени располагается в самых значимых битах, поэтому она доминирует в сравнении; случайный хвост лишь разрешает ничьи в пределах одной миллисекунды. Порядок строк, порядок битов и порядок времени — это один и тот же порядок.

Быстрая демонстрация. Два ULID, созданные с разницей в одну миллисекунду:

01ARYZ6S41...   (created at T)
01ARYZ6S42...   (created at T + 1 ms)

Десятый символ переходит с 1 на 2, и простая текстовая сортировка ставит второй после первого — без столбца с меткой времени, без особого компаратора. Практическая отдача, которую раскрывает следующий раздел, умещается в одну строку: ORDER BY id возвращает строки в хронологическом порядке без дополнительного индекса.

ULID как первичные ключи базы данных: локальность B-tree

Вот где ULID отрабатывают своё. Большинство реляционных баз данных хранят индекс первичного ключа как B-tree, и то, куда в этом дереве попадает новый ключ, определяет, насколько дорогой будет вставка.

Случайный UUIDv4 попадает в непредсказуемое место при каждой вставке:

UUIDv4: каждый новый ключ метит в случайную листовую страницу. Страница часто заполнена, поэтому движок разбивает её, копирует половину строк в другое место и пачкает страницы по всему дереву. На миллионах строк это фрагментирует индекс, вытесняет полезные страницы из буферного кеша и снижает пропускную способность вставок. (Точные цифры по разбиениям страниц индекса — обычно разница в 2–10 раз на таблицах с интенсивной записью — смотрите в руководстве по сравнению.)

ULID с временным префиксом каждый раз попадает в конец:

ULID: поскольку старшие биты — это метка времени, каждый новый ключ больше предыдущего, поэтому он добавляется на правом краю индекса или рядом с ним. Вставки остаются последовательными, разбиения страниц почти исчезают, индекс остаётся компактным, а сканирование диапазона по временному окну читает непрерывную череду страниц.

Вы получаете генерацию без координации, как у UUID, с локальностью вставок, как у автоинкрементного целого, — не раскрывая угадываемый последовательный счётчик, ведь случайный хвост по-прежнему скрывает точное следующее значение.

Совет по хранению: храните 128 бит как 16 двоичных байтов — столбец uuid в PostgreSQL, BINARY(16) в MySQL — а не как 26-символьное текстовое поле, которое тратит место и раздувает индекс. Кодируйте в строку Base32 только на границах, где её видит человек или URL. Вкладка Convert генератора преобразует ULID в UUID именно для этого, ведь обе формы — одни и те же 128 бит.

Монотонные ULID: строгий порядок внутри миллисекунды

В доказательстве сортируемости есть один честный пробел: внутри одной миллисекунды обычные ULID не упорядочены строго. У них общий 10-символьный временной префикс, но их 80-битные случайные хвосты берутся независимо, поэтому то, какой из двух ULID одной миллисекунды отсортируется первым, по сути решает подбрасывание монеты. Для большинства задач это нормально. Когда нужен строгий порядок даже при субмиллисекундных темпах — нет.

Монотонная генерация закрывает этот пробел. Правило простое: первый ULID в данной миллисекунде получает свежую случайность как обычно, а каждый следующий ULID в той же миллисекунде создаётся так: берётся предыдущее 80-битное случайное значение и увеличивается на единицу (трактуется как big-endian целое число с переносом в старшие биты по мере необходимости). Поэтому каждое значение строго больше предыдущего.

Это видно на пачке, сгенерированной внутри одной миллисекунды — двигается только последний символ:

01KVT0F720ZK9N4T2QX7VR8WMC
01KVT0F720ZK9N4T2QX7VR8WMD
01KVT0F720ZK9N4T2QX7VR8WME

…WMC < …WMD < …WME, гарантированно. Это важно всякий раз, когда строки могут создаваться быстрее, чем тикают миллисекундные часы: высоконагруженные вставки, журналы событий, идентификаторы сообщений в плотном цикле. Когда часы переходят к следующей миллисекунде, генерация возвращается к свежей случайности, и цикл повторяется.

ULID против UUID: когда что использовать

Главный практический вопрос — это ULID против UUID. Вот сфокусированное сравнение — ULID против тех двух версий UUID, между которыми вы реально стали бы выбирать. (Полную матрицу выбора из пяти вариантов, включая Snowflake и NanoID, смотрите в полном сравнении ULID, UUID и Snowflake.)

СвойствоULIDUUIDv4UUIDv7
Длина26 символов36 символов36 символов
КодировкаCrockford Base32hex с дефисамиhex с дефисами
Сортируется по времени?ДаНетДа
Встраивает метку времени?Да (48-битную, мс)НетДа (48-битную, мс)
Стандартизирован?Спецификация сообществаRFC 9562RFC 9562
Лучше всего дляКоротких сортируемых IDНепрозрачных случайных IDСортируемых ID в формате UUID

Словами: берите ULID, когда вам нужна самая короткая, URL-безопасная, сортируемая строка. Берите UUIDv4, когда нужен непрозрачный, полностью случайный идентификатор без встроенного времени — например, публичный token, для которого вы предпочли бы не раскрывать, когда он был создан. Берите UUIDv7, когда нужно упорядочивание по времени, но необходимо оставаться внутри стандартного формата UUID — с битами версии и варианта на их фиксированных позициях и нативным столбцом uuid, в который его можно вставить.

Все три — это 128 бит, поэтому преобразование ULID ↔ UUID без потерь в любую сторону. Связь между ULID и ulid vs uuid v7 теснее, чем кажется: UUIDv7 — это по сути стандартизированный IETF вариант той же идеи с временным префиксом, которую первым воплотил ULID. Если вы только знакомитесь с UUID, начните сперва с основ, а затем вернитесь к этому сравнению.

Компромисс приватности: ULID выдают время своего создания

Встроенная метка времени — это и преимущество, и утечка, в зависимости от того, кто читает идентификатор. Любой, у кого есть ULID, может декодировать метку времени одним шагом и узнать точную миллисекунду, когда была создана запись, — без какого-либо доступа к вашей базе данных.

Внутри ваших собственных систем это чистый плюс: мгновенный аудит, бесплатное упорядочивание, лёгкая отладка. Но на публичном идентификаторе это настоящее раскрытие. Время создания само по себе может быть коммерчески чувствительным, а горстка ULID, собранных за период, выдаёт ваш темп создания — сколько заказов, аккаунтов или сообщений вы создаёте в секунду, — именно то, что любят оценивать конкуренты и скраперы.

Справедливости ради, это более узкая утечка, чем у UUIDv1, который исторически встраивал MAC-адрес генерирующей машины; ULID раскрывает только время, но никогда — аппаратную идентичность. И всё же взвесьте это. Простое смягчение: держите ULID внутренними, а для публичных идентификаторов, где порядок не важен, выдавайте полностью случайный UUIDv4.

Частые ошибки с ULID

Большинство проблем с ULID — это горстка предотвратимых инженерных решений, а не ошибки в самом формате. Повторяющиеся:

  • Считать, что обычные ULID одной миллисекунды упорядочены. У них общий временной префикс, но независимые случайные хвосты, поэтому их порядок не определён. Решение: используйте монотонный режим, когда нужен строгий порядок при субмиллисекундных темпах.
  • Хранить ULID как 26-символьный текст. Это тратит место и раздувает индекс. Решение: храните 128 бит как 16 байтов (uuid / BINARY(16)) и кодируйте в Base32 только на границах.
  • Ожидать, что преобразование ULID→UUID отметится как v4 или v7. Преобразование перекодирует те же биты; оно не устанавливает поля версии и варианта UUID, поэтому библиотека, проверяющая их, не увидит помеченной версии. Решение: трактуйте результат как непрозрачное 128-битное значение или сгенерируйте настоящий UUIDv7, когда нужна метка.
  • Заполнять случайность через Math.random. Он предсказуем и может вызвать коллизию. Решение: всегда используйте CSPRNG вроде crypto.getRandomValues.
  • Публично раскрывать ULID, не взвесив утечку метки времени. Смотрите раздел о приватности выше. Решение: внутренние ULID, случайный UUIDv4 для публичных идентификаторов.
  • Набирать вручную I, L, O или U в ULID. Этих букв нет в алфавите, а перенабор провоцирует ошибки. Решение: копируйте ULID, не перенабирайте их.

FAQ

Является ли ULID официальным стандартом, как UUID?

Нет. ULID — это спецификация сообщества, опубликованная на GitHub, а не IETF RFC. Она широко реализована и стабильна, но за ней нет органа стандартизации. Если вам нужен стандартизированный, упорядоченный по времени идентификатор, UUIDv7 (RFC 9562) применяет ту же идею внутри официального формата UUID.

Сколько символов в ULID и почему он короче UUID?

26 символов против 36 у UUID. ULID использует Crockford Base32, который упаковывает 5 бит на символ; шестнадцатеричный UUID упаковывает лишь 4 бита и добавляет четыре дефиса. Поэтому те же 128 бит требуют меньше символов в Base32 — и ни один из них не нуждается в URL-экранировании.

Могут ли два ULID когда-нибудь столкнуться?

Практически никогда. Внутри одной миллисекунды у ULID 80 случайных бит — около 1,2 × 10²⁴ возможностей, — так что даже генерация миллионов в миллисекунду удерживает шансы коллизии исчезающе малыми. Единственное требование — чтобы случайность заполнял криптографически стойкий ГСЧ; Math.random аннулирует гарантию.

Можно ли хранить ULID в PostgreSQL или MySQL?

Да. ULID — это 128 бит, поэтому преобразуйте его в форму UUID и храните в столбце uuid (PostgreSQL) или BINARY(16) (MySQL), а строку Base32 формируйте только на границах. Нативного типа столбца для ULID нет, но представление UUID стоит тех же 16 байтов и сохраняет индекс компактным.

Чувствительны ли ULID к регистру?

Каноническая форма — в верхнем регистре, но Crockford Base32 нечувствителен к регистру на входе: декодер читает строчные буквы так же и сопоставляет I/L с 1, а O с 0. Чтобы избежать сюрпризов при проверках на равенство и в индексах, перед хранением или сравнением приводите к единому регистру.

Закончится ли когда-нибудь 48-битная метка времени?

Не очень долго. 48 бит миллисекунд хватает до 10889 года, прежде чем счётчик переполнится, так что компонент времени фактически защищён от устаревания для любого реального приложения. Вы замените систему, язык и базу данных задолго до того, как у формата кончится место.

Можно ли генерировать ULID в браузере или на мобильном устройстве без сервера?

Да — это ключевое преимущество. ULID не нуждаются в центральном координаторе, поэтому любой узел, edge-воркер, браузер или устройство может создать ULID из своих часов плюс стойкого ГСЧ. Значения, созданные на разных машинах, всё равно потом сортируются вместе по времени, потому что метка времени живёт в самом идентификаторе.

Заключение

ULID решают конкретную, реальную проблему — случайные ключи, фрагментирующие ваш индекс, — не отнимая децентрализованной генерации. Механику стоит держать в голове:

  • ULID — это 48-битная метка времени в миллисекундах + 80 бит случайности, закодированные 26 символами Crockford Base32.
  • Он сортируется по времени, потому что метка времени — самый значимый компонент, а алфавит сохраняет порядок — порядок строк равен порядку времени.
  • Это упорядочивание даёт B-tree локальность вставок, которой не хватает случайному UUIDv4, удерживая запись быстрой, а индекс компактным.
  • Используйте монотонный режим, когда нужен строгий порядок для идентификаторов, созданных в одной миллисекунде.
  • Взвесьте утечку метки времени, прежде чем раскрывать ULID на публичных идентификаторах.
  • Выбирайте UUIDv7, когда необходимо оставаться внутри стандартного формата UUID.

Когда будете готовы пустить это в дело, откройте генератор ULID, чтобы генерировать, декодировать и преобразовывать ULID целиком в браузере — без сервера, без загрузки, ничего не сохраняется.

Теги: ulid uuid unique-identifier database primary-key

Похожие статьи

Все статьи