Идентификаторы в разных языках и БД

Как одна задача — сгенерировать UUIDv7/ULID/Snowflake — решается в Go, Java и Rust (и почему ops/sec между языками сравнивать нельзя), как типы колонок PostgreSQL (uuid/bytea/text) обходятся с этими значениями и как ::text-каст ломает индекс, и как согласовать идентификатор с ключом шардирования, чтобы монотонный ключ не устроил горячий узел.

Статья #1 серии дала словарь — случайный идентификатор против монотонного, кто генерирует, что утекает наружу. Статья #2 показала цену этого выбора числами на честном стенде PostgreSQL/MySQL/MongoDB/ScyllaDB: случайный ключ хуже ложится в B-tree, шире раздувает вторичные индексы в MySQL/InnoDB, а горячий шард в MongoDB получается не от монотонности как таковой, а от того, хешируется ли shard key. Здесь — третий и последний слой: не «какую схему выбрать», а «как её реализовать на конкретном стеке» и «где стек вообще меняет ответ».

Три темы. Первая — генерация: один и тот же UUIDv7/ULID/Snowflake написан на Go, Java и Rust, и цифры throughput между ними выглядят соблазнительно сравнимыми, но сравнивать их напрямую — методологическая ошибка, и в статье будет ровно на этом сделан акцент. Вторая — хранение: PostgreSQL даёт три способа держать UUID в колонке (uuid, bytea, text), они не эквивалентны по размеру, а безобидный на вид ::text-каст в WHERE ломает индекс. Третья — распределённая генерация: node bits, сдвиг часов, координация — механизм, а не бенчмарк.

Ретрофутуристская схема «линия генерации и распределения ID» в стиле «Полдень. XXI век»: слева верстаки Go, Java, Rust штампуют токены UUIDv7/ULID с табличкой «скорость языков не сравнивать»; в центре колонки хранения uuid, bytea, text разной ширины и разорванный индекс на «::text»; справа кольцевой распределитель — маршрут ranged для монотонного ключа ведёт в горячий шард, маршрут hashed распределяет равномерно по шардам

В статье

Генерация ID: Go, Java, Rust

Стенд запускает один и тот же контракт на трёх языках: сгенерировать N = 1 000 000 идентификаторов каждого вида, замерить throughput и проверить два структурных свойства байтового представления — monotonic_within_ms (срез значений в порядке генерации не нарушает сортировку) и byte_sortable (лексикографический порядок байт совпадает с порядком генерации). Для всех генераторов здесь оба свойства проверяются одним и тем же способом — сортировкой среза, — поэтому по сути это один и тот же тест дважды.

Отдельно про имя monotonic_within_ms: оно историческое и обещает больше, чем делает. Тест не разбивает значения на миллисекундные бакеты и не проверяет порядок внутри каждого из них — он берёт весь срез в порядке генерации целиком и смотрит, не нарушена ли сортировка. По смыслу это «порядок генерации сохранён» (generation_order_preserved), а не «монотонность внутри миллисекунды». Дальше в тексте имя используется как есть — так его печатает скрипт стенда, — но читать его следует именно в этом, более слабом значении.

func bench(name string, n int, gen func() []byte) {
    vals := make([][]byte, n)
    start := time.Now()
    for i := 0; i < n; i++ {
        vals[i] = gen()
    }
    elapsed := time.Since(start).Seconds()
    ops := float64(n) / elapsed
    mono := sort.SliceIsSorted(vals, func(i, j int) bool {
        return string(vals[i]) < string(vals[j])
    })
    fmt.Printf("go %s ops_per_sec=%.0f monotonic_within_ms=%v byte_sortable=%v\n",
        name, ops, mono, mono)
}

bench("uuidv4", n, func() []byte { u := uuid.New(); return u[:] })
bench("uuidv7", n, func() []byte { u, _ := uuid.NewV7(); return u[:] })

Java-версия устроена так же (сравнение Arrays.compareUnsigned вместо строкового), Rust — через Vec<Vec<u8>> и windows(2).all(...). Один нюанс Rust стоит упомянуть отдельно: ulid::Ulid::generate() из голого вызова не монотонен внутри одной миллисекунды (свежие случайные биты на каждый вызов) — чтобы получить сопоставимое с Go (ulid.DefaultEntropy()) и Java (UlidCreator.getMonotonicUlid()) поведение, стенд держит один ulid::Generator на весь прогон, который внутри миллисекунды инкрементирует случайную часть вместо повторной рандомизации.

Полная таблица — все 10 строк, как напечатал скрипт:

lang generator ops/sec monotonic_within_ms byte_sortable
go uuidv4 6831278 false false
go uuidv7 6018882 true true
go ulid 16289431 true true
go snowflake 25603 true true
java uuidv4 1536007 false false
java uuidv7 2421736 true true
java ulid 32604089 true true
rust uuidv4 1934513 false false
rust uuidv7 1803308 true true
rust ulid 16064646 true true

Версии библиотек за этими числами:

Язык Библиотека Версия
Go github.com/google/uuid v1.6.0
Go github.com/oklog/ulid/v2 v2.1.1
Go github.com/sony/sonyflake v1.3.0
Java com.github.f4b6a3:uuid-creator 6.1.1
Java com.github.f4b6a3:ulid-creator 5.2.4
Rust uuid 1.24.0
Rust ulid 3.0.0

Обновление 20.08.2026. С выходом Go 1.27 UUID появился в стандартной библиотеке (пакет uuid). Замеры в таблице выше сняты на github.com/google/uuid v1.6.0 и остаются в силе — но для нового кода на Go выбор зависимости изменился: там, где раньше требовался сторонний пакет, теперь есть вариант обойтись стандартной библиотекой.

Теперь — четыре оговорки, ради которых эта таблица вообще имеет смысл в статье. Без них цифры выше читаются как «Java быстрее всех в ulid, а Go медленнее всех в snowflake», и оба прочтения ложные.

ops/sec между языками не сравнивать. У Java нет отдельной фазы warmup: JIT (C1/C2) разогревается прямо внутри замеряемого цикла, а не до него. У трёх языков разные источники случайности под капотом — crypto/rand в Go, SecureRandom/ThreadLocalRandom в Java, getrandom(2) в Rust — и разная нагрузка на аллокатор/GC (Go и Java собирают мусор во время бенчмарка, Rust — нет). Валидно сравнивать порядок величины только внутри одного языка: в Go ulid (16 289 431 ops/sec) на порядок быстрее uuidv4 (6 831 278) — это сравнение корректно, потому что оба числа сняты в одном рантайме с одним и тем же шумом вокруг.

snowflake на Go (25 603 ops/sec) — не «Go медленный». Это self-throttle самой библиотеки sony/sonyflake: протокол Snowflake у неё зашит в 8-битный sequence-счётчик на 10-миллисекундное окно — максимум 256 уникальных ID за 10 мс на инстанс, то есть расчётный потолок 256 / 0.01с = 25 600 ops/sec, что почти в точности и наблюдается (25 603). Это ограничение самого генератора, не языка — и сравнивать не с чем: Java- и Rust-версий snowflake в стенде нет.

Результат определяется типом идентификатора и режимом генерации библиотеки, а не языком. uuidv4false/false во всех трёх языках, и это действительно свойство самого формата: 122 случайных бита не выстроятся в порядок ни в Go, ни в Java, ни в Rust. А вот true/true у uuidv7/ulid — утверждение более слабое, чем «формат гарантирует монотонность». Формат гарантирует лишь time-order между миллисекундами: значение, созданное в более позднюю миллисекунду, всегда больше, потому что время лежит в старших битах. Порядок внутри одной миллисекунды формат не задаёт — там младшие биты, и будет ли последовательность возрастающей, зависит от того, ведёт ли реализация монотонный счётчик (в RFC 9562 такой метод описан как опциональный, в спецификации ULID — как рекомендованный) или берёт свежую случайность на каждый вызов. В этом стенде true получилось для конкретных выбранных библиотек и их режимов по умолчанию (google/uuid, oklog/ulid, f4b6a3/uuid-creator, f4b6a3/ulid-creator, крейты uuid и ulid) — совпадение по всем трём языкам говорит о том, что вопрос решается на уровне формата и библиотеки, а не рантайма, но не о том, что любая реализация UUIDv7 обязана быть строго монотонной. Разбор этой границы — в статье #1.

monotonic_within_ms не точнее самого теста. Флаг получен той же самой проверкой, что и byte_sortable — сортировкой среза и сравнением как беззнаковых байт, — а не отдельным строгим доказательством монотонности на всех клоках и при любой конкуренции потоков. Честная формулировка: «в этом прогоне порядок генерации не нарушил байтовую сортировку», не больше.

И общая оговорка ко всем цифрам этого раздела: они сняты на одном хосте и не являются бенчмарком production-производительности генераторов ID. Полезен порядок величины внутри одного языка и структурные флаги (monotonic_within_ms, byte_sortable), а не абсолютные ops/sec и тем более не их сравнение между языками.

Хранение: типы колонок PostgreSQL

Генерация — только половина задачи; вторая половина — как значение легло в колонку. PostgreSQL 18 даёт три варианта для UUID-подобного идентификатора: нативный тип uuid (16 байт с оператора́ми сравнения), bytea (те же 16 байт, но без специализированного типа) и text (36-символьное строковое представление). Стенд создаёт три таблицы, заполняет каждую независимыми значениями uuidv7() (встроенная функция PostgreSQL 18) и меряет pg_total_relation_size — таблица + первичный индекс + toast:

create table s_uuid (id uuid primary key);
create table s_bytea(id bytea primary key);
create table s_text (id text primary key);

insert into s_uuid  select uuidv7() from generate_series(1, 200000);
insert into s_bytea select uuid_send(uuidv7()) from generate_series(1, 200000);
insert into s_text  select uuidv7()::text from generate_series(1, 200000);
Тип колонки total_bytes (таблица+PK+toast) отношение к uuid
uuid 15220736 1.000
bytea 18612224 1.223
text 25518080 1.677

Нативный uuid компактнее bytea на 22.3% и заметно компактнее text — на 67.7%. Разница объясняется просто: bytea хранит те же 16 байт, но без специализированного типа-оптимизации хранения; text хранит 36-символьное строковое представление (с дефисами) плюс varlena-заголовок — почти вдвое больше сырых байт, чем нужно значению.

Отдельно от размера — план запроса, и здесь разница не количественная, а качественная. Прямое сравнение с приведением типов совпадает — индекс работает:

explain (costs off)
select 1 from s_uuid where id = '018f...'::uuid;

-- Index Only Scan using s_uuid_pkey

А вот приведение самой колонки к тексту перед сравнением индекс ломает — даже когда сравниваемое значение то же самое:

explain (costs off)
select 1 from s_uuid where id::text = '018f...';

-- Gather
--   -> Parallel Seq Scan on s_uuid
--        Filter: ((id)::text = '018f...'::text)

Планировщик видит не голую колонку id, а вычисляемое выражение id::text — btree-индекс s_uuid_pkey построен по значениям типа uuid, а не по их текстовому представлению, и применить его к выражению напрямую нельзя. Индекс существует, значения идентичны, но Index Only Scan превращается в Parallel Seq Scan. Это ровно та ошибка ORM и ручных запросов, которая разобрана в статье об индексах и языках/ORM: параметр, пришедший из приложения не тем типом (там — строка на месте bigint, здесь — явный ::text-каст на месте uuid), рвёт применимость индекса одинаковым механизмом.

Оговорка важна: значения в трёх таблицах — независимые вызовы uuidv7(), то есть разные значения, не один и тот же UUID, сохранённый тремя способами. Различие между таблицами — тип колонки, а не содержимое. Абсолютные байты (15 220 736 / 18 612 224 / 25 518 080) не переносить как норму для прод-размеров — они сняты на одном хосте при N = 200 000. Важны порядок (uuid < bytea < text) и факт поломки индекса кастом — оба воспроизводимы независимо от конкретных чисел.

Распределённые ID: механизм, не измерение

Всё, что стенд измерил про распределённую генерацию, — это одно число из предыдущего раздела: self-throttle Snowflake (25 603 ops/sec на Go/sonyflake). Всё остальное здесь — механизм, а не бенчмарк: multi-node стенда с реальным сетевым расхождением часов и несколькими узлами Snowflake в этой серии нет.

Node bits. Snowflake-подобный идентификатор устроен как несколько полей, упакованных в одно 64-битное число: таймстамп (в старших битах — отсюда монотонность и byte-sortability), идентификатор узла/машины и локальный sequence-счётчик в пределах временно́го окна. Конкретная разбивка битов — деталь реализации конкретной библиотеки (у sonyflake, как показано выше, sequence — 8 бит на 10-миллисекундное окно); общий принцип один: чем больше бит уходит под ID узла, тем больше узлов может генерировать ID параллельно без координации, но тем меньше остаётся под sequence-счётчик на узел — то есть ниже предельный throughput с одного узла в единицу времени. Id узла обычно назначается статически (номер пода/шарда из конфигурации) или через внешнюю координацию (аренда слота в ZooKeeper/etcd) — если два инстанса случайно получат один и тот же node ID, они начнут штамповать пересекающиеся идентификаторы, и никакая локальная проверка этого не поймает.

Сдвиг часов (clock skew). Snowflake-схема предполагает, что системные часы узла монотонно идут вперёд. Если часы сдвигаются назад — NTP-коррекция, миграция VM, ручное вмешательство, — возможны два эффекта: узел либо генерирует ID с более ранним таймстампом, чем уже выданные им же ранее (монотонность внутри узла нарушается), либо, если реализация ждёт возврата часов вперёд, блокируется на генерации до этого момента. Устойчивые реализации либо детектируют сдвиг и отказывают в генерации (fail-fast — не тихо портить данные), либо держат резервный счётчик-заглушку сверх системного времени. Ни то, ни другое стенд не проверял: это описание механизма, о котором нужно знать, выбирая библиотеку, а не измеренный на этом стенде эффект.

Координация. Между «полностью coordination-free» и «полностью coordinated» — спектр. sonyflake (использован в стенде) — coordination-free в рантайме: node ID берётся один раз при старте (обычно из последнего октета IP или явной настройки), дальше узел генерирует ID независимо, без сетевого похода за координацией; цена — тот самый self-throttle в 256 ID/10мс на инстанс. На другом полюсе — схемы вроде Hibernate hi/lo allocator или PostgreSQL sequence с шагом (INCREMENT BY N, разные START WITH на разных узлах-писателях): здесь координация происходит один раз за пачку (allocator резервирует диапазон значений у БД), а не на каждый ID, что снимает throughput-ограничение ценой централизованного источника диапазонов. Это тот же выбор, что был сформулирован в статье #1 на уровне схемы генерации — здесь он же на уровне библиотеки: сколько координации нужно, чтобы держать гарантию уникальности, и что теряется взамен (throughput с узла, доступность при недоступности координатора, сложность эксплуатации).

Шардирование: выравнивание ID с ключом

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

ScyllaDB хеширует partition key всегда, безусловно (Murmur3Partitioner) — монотонный, k-sortable идентификатор вроде UUIDv7 безопасен как часть partition key именно потому, что хеш стирает порядок значения ещё до того, как он повлияет на распределение по узлам. MongoDB даёт выбор: ranged shard key раскладывает данные по диапазонам значений — и монотонно растущий ключ на ranged-схеме предсказуемо льётся в один и тот же «крайний» чанк, потому что каждое новое значение больше всех предыдущих; hashed shard key устраняет эту проблему тем же механизмом, что у Scylla работает всегда. Числа для обоих случаев — в статье #2 и в «MongoDB: шардирование в проде»; пересказывать их здесь незачем.

Практический вывод для генерации: выбор идентификатора и выбор ключа шардирования нельзя проектировать независимо друг от друга. Монотонный, byte-sortable ID (UUIDv7, ULID, Snowflake) даёт хорошую локальность вставки в B-tree — это показано в статье #2 — но именно та же монотонность становится риском, если этот же идентификатор без дополнительного хеширования используется как ключ шардирования на движке, где хеширование не обязательно (MongoDB ranged, ручное range-based шардирование поверх PostgreSQL/Citus). Общая механика цены распределённости после того, как данные уже разложены по узлам, — в «Шардирование на практике»; здесь важно другое: сам факт монотонности идентификатора не говорит ничего про его пригодность как ключа шардирования — это отдельная ось, определяемая тем, хеширует ли конкретный движок этот ключ перед распределением.

Cross-language вывод

Собирая три раздела вместе — где выбор языка/драйвера реально меняет ответ, а где нет:

Ось Язык/драйвер меняет ответ Почему
Throughput генерации ID Формально да, но сравнивать напрямую нельзя JIT-warmup, аллокатор/GC, источник энтропии — разные шумовые условия, не чистая скорость языка
Порядок величины внутри языка (ulid быстрее uuidv4) Нет — воспроизводится в любом языке стенда Разница в объёме работы (генерация случайных байт против дополнительной сортируемой структуры), а не в рантайме
Монотонность/byte-sortability по формату ID Нет — одинаково во всех трёх языках Решается форматом и режимом библиотеки, не рантаймом: порядок между миллисекундами даёт формат (время в старших битах), внутри миллисекунды — монотонный счётчик конкретной реализации
self-throttle Snowflake Нет данных для Java/Rust в этом стенде Свойство конкретной библиотеки (sonyflake), не языка вообще
Размер колонки под ID Не язык, а тип колонки PostgreSQL uuid vs bytea vs text — решение схемы БД, драйвер лишь передаёт значение
Поломка индекса ::text-кастом Не язык — ORM/драйвер может как воспроизвести, так и не воспроизвести Зависит от того, каким типом параметра ORM отправляет значение в запрос, а не от языка приложения как такового
Уязвимость к горячему шарду Не язык — движок БД и тип shard key Хеширует ли движок ключ шардирования (Scylla — всегда, MongoDB — по выбору)

Главный итог серии: выбор идентификатора расщепляется на независимые решения, которые легко перепутать. Схема генерации (bigint/UUID/ULID/Snowflake) — тема статьи #1. Цена этой схемы для конкретного движка хранения — статья #2. Реализация на конкретном языке и тип колонки — эта статья: язык влияет на как быстро сгенерировать ID (и то с оговорками), но не на какими свойствами этот ID обладает — монотонность и сортируемость держит формат и режим генератора, а не рантайм. А пригодность как ключа шардирования — вообще отдельная, третья ось, определяемая не идентификатором и не языком, а тем, как конкретный движок распределяет данные по узлам.

Соседние статьи той же линии «Поперёк языков» разбирают, где ещё язык навязывает архитектурное решение: «Ошибки, паники и исключения» — про два канала отказа, «Дженерики, шаблоны, erasure»готовится, с 15 сентября — про то, как компилятор превращает параметрический полиморфизм в машинный код. Тот же приём, что и здесь: синтаксис на поверхности один, а решения, которые он на самом деле навязывает, — разные.

Источники

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

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

Комментарии