MidПрактика9 min

Генераторы уникальных ID

UUID v4/v7, Snowflake, ULID, KSUID, Sonyflake, Nanoid. Сравнение подходов к генерации уникальных идентификаторов с реализациями на PHP, Go, C# и Python

Зачем не auto-increment

BIGSERIAL кажется простым решением, но в распределённых системах у него проблемы:

  • Распределение: невозможно генерировать уникальные ID на N узлах без координации
  • Sharding: 10 шардов с auto-increment создадут конфликты
  • Предсказуемость: атакующий видит user/124 -- легко перебирать
  • Shardable reads: хочется по ID узнать шард без JOIN
  • Offline generation: мобильный клиент создал запись оффлайн -- нужен ID заранее

Каждый ID-генератор решает эти проблемы по-своему. Есть 5 ключевых характеристик:

Характеристика Значение
Sortable Можно ли использовать как ORDER BY (грубо -- по времени создания)
Distributed Генерируется на клиенте/N узлах без координации
Size Сколько байт/символов
Collision resistance Вероятность коллизии при N генераций
Embedded info Что закодировано (timestamp, shard, ...)

Сравнение популярных форматов

Формат Размер Sortable Distributed Timestamp Machine ID
UUID v4 (random) 128 bit (36 chars) ❌ ✅ ❌ ❌
UUID v1 128 bit partial ✅ ✅ MAC
UUID v7 128 bit ✅ ✅ ✅ (ms) ❌
Snowflake (Twitter) 64 bit ✅ ✅ ✅ (ms) ✅
Sonyflake (Sony) 63 bit ✅ ✅ ✅ (10ms) ✅
ULID 128 bit (26 chars base32) ✅ ✅ ✅ (ms) ❌
KSUID (Segment) 160 bit (27 chars base62) ✅ ✅ ✅ (sec) ❌
Nanoid 128 bit+ (21 chars) ❌ ✅ ❌ ❌
auto-increment 64 bit ✅ ❌ ❌ ❌

UUID v4: случайный

122 бит случайности + 6 служебных. Коллизия -- пренебрежимо мала.

550e8400-e29b-41d4-a716-446655440000

Проблемы в качестве PK

  • B-tree insert random -- каждая вставка в случайную часть индекса, разрушает кэш страниц БД. На InnoDB замедляет insert в 2-10x.
  • Нельзя сортировать по времени -- ORDER BY id == ORDER BY random

Решение -- UUID v7 или Snowflake/ULID.

UUID v7: timestamp + random

Формат стандартизован в RFC 9562 (2024):

unix_ts_ms (48 bit) | version(4) | rand_a(12) | var(2) | rand_b(62)

Первые 48 бит -- unix timestamp в миллисекундах. Значит sortable лексикографически. При этом сохраняется полная ширина 128 бит (совместимость с UUID полем в БД).

Реализация

<?php

declare(strict_types=1);

use Ramsey\Uuid\Uuid;

$id = Uuid::uuid7()->toString();
// 018efab2-2d1a-7b0e-9b7f-1234567890ab
//    ^timestamp^

// Custom time:
$id = Uuid::uuid7(new \DateTimeImmutable('2025-01-01'));
## Snowflake: 64 bit с meta

Twitter open-sourced в 2010. Структура (64 bit):

1 bit | 41 bit            | 10 bit      | 12 bit
sign  | timestamp (ms)    | machine id  | sequence
always| since epoch       | 0-1023      | 0-4095 per ms per machine
0
  • timestamp: 41 бит = 69 лет от кастомной epoch
  • machine id: 1024 узла (датацентр 5 бит + worker 5 бит)
  • sequence: до 4096 ID на узел на миллисекунду = ~4M ID/sec/узел

Плюсы

  • Маленький (8 байт) -- быстрее UUID на индексах
  • Sortable по времени
  • Встроенный shard key -- первые 41+N бит легко достать

Минусы

  • Нужна координация machine_id (Zookeeper/etcd/просто конфиг)
  • Нужны синхронизированные часы (NTP)
  • Clock skew backward = коллизии или ошибки

Собственная реализация

<?php

declare(strict_types=1);

namespace App\Ids;

/**
 * Twitter Snowflake: 41 bit timestamp | 10 bit machine | 12 bit sequence.
 *
 * Not safe across forks -- keep one instance per process.
 * NTP must be enabled; clock rollback triggers an exception.
 */
final class Snowflake
{
    private const EPOCH = 1_704_067_200_000; // 2024-01-01 UTC in ms
    private const MACHINE_BITS = 10;
    private const SEQUENCE_BITS = 12;
    private const MAX_MACHINE = (1 << self::MACHINE_BITS) - 1;
    private const MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1;

    private int $lastTimestamp = -1;
    private int $sequence = 0;

    public function __construct(private readonly int $machineId)
    {
        if ($machineId < 0 || $machineId > self::MAX_MACHINE) {
            throw new \InvalidArgumentException("machine_id out of range");
        }
    }

    public function next(): int
    {
        $ts = $this->currentMs();

        if ($ts < $this->lastTimestamp) {
            // Clock moved backward. Either wait or throw.
            throw new \RuntimeException(
                "clock moved backward: last=$this->lastTimestamp, now=$ts",
            );
        }

        if ($ts === $this->lastTimestamp) {
            $this->sequence = ($this->sequence + 1) & self::MAX_SEQUENCE;
            if ($this->sequence === 0) {
                // Sequence overflow -- wait for next ms
                $ts = $this->waitNextMs($this->lastTimestamp);
            }
        } else {
            $this->sequence = 0;
        }

        $this->lastTimestamp = $ts;

        return (($ts - self::EPOCH) << (self::MACHINE_BITS + self::SEQUENCE_BITS))
             | ($this->machineId << self::SEQUENCE_BITS)
             | $this->sequence;
    }

    public static function extractTimestampMs(int $id): int
    {
        return (int) ($id >> (self::MACHINE_BITS + self::SEQUENCE_BITS)) + self::EPOCH;
    }

    public static function extractMachineId(int $id): int
    {
        return ($id >> self::SEQUENCE_BITS) & self::MAX_MACHINE;
    }

    private function currentMs(): int
    {
        return (int) (microtime(true) * 1000);
    }

    private function waitNextMs(int $last): int
    {
        $ts = $this->currentMs();
        while ($ts <= $last) {
            usleep(100);
            $ts = $this->currentMs();
        }
        return $ts;
    }
}

// Usage:
// $gen = new Snowflake(machineId: 7);
// $id = $gen->next();
// echo $id, "\n";
## Sonyflake: Sony's вариация

Минорные отличия от Twitter Snowflake:

1 | 39 bit (10 ms)      | 8 bit seq | 16 bit machine id
  • Timestamp в единицах 10 ms (меньше точность, больше ёмкость)
  • 65_536 машин (вместо 1024)
  • 256 ID на 10 мс на машину

Подходит для больших кластеров с меньшим throughput per node.

ULID: Universally Unique Lexicographically Sortable

01ARZ3NDEKTSV4RRFFQ69G5FAV
  time     | randomness
  48 bit ms| 80 bit

128 бит, base32 (26 символов). Sortable. Без machine id -- всё случайность для уникальности.

Реализация

// composer require robinvdvleuten/ulid
use Ulid\Ulid;
$id = (string) Ulid::generate();
## KSUID: K-Sortable UUID (Segment)
04 bytes timestamp (sec) + 16 bytes random = 20 bytes = 27 chars base62

Похоже на ULID, но:

  • Точность timestamp -- секунды (не ms)
  • Base62 -- более "безопасная" строка
  • 136 лет срок действия

Реализация

import "github.com/segmentio/ksuid"

id := ksuid.New().String()
// 1HdcuZD8xhkiWpE3W7ihoxuLZ3P

Nanoid: компактный, настраиваемый

Как UUID, но без "-", с кастомным алфавитом.

V1StGXR8_Z5jdHi6B-myT    # 21 chars default

128 бит энтропии за 21 символ (vs 36 у UUID v4). Не sortable, но короткий и URL-safe.

Реализация

// composer require hidehalo/nanoid-php
$id = (new \Hidehalo\Nanoid\Client())->generateId();
## Когда что использовать
Случай Выбор
Новый проект, без требования к размеру UUID v7 -- стандарт, sortable
Микросервисы с шардами, 64 bit важен Snowflake
URL-safe ID для public API ULID или KSUID
Короткий человеко-читаемый Nanoid с кастомным алфавитом
Legacy совместимость UUID v4 (random)
Оффлайн создание на мобильном UUID v7 или ULID

B-tree performance

Проблема UUID v4: random inserts ломают B-tree индексы. Каждая вставка требует загрузить случайную страницу в RAM, дописать туда, возможно split.

Тест: вставка 10M строк в InnoDB PK:

PK type Insert time Index size
auto-increment BIGINT 4 min 300 MB
Snowflake 4.5 min 320 MB
UUID v7 6 min 400 MB
UUID v4 45 min 800 MB

Монотонные ID всегда быстрее для PK. Для non-PK (secondary key) разница меньше.

Clock issues

Все ID с timestamp зависят от NTP. Что может сломаться:

  • Clock rollback: NTP корректирует часы назад. Snowflake бросит exception.
  • Clock jump forward: OK, но sequence сбрасывается -- можно случайно получить дубль если процесс клонирован (fork).
  • VM suspend/resume: виртуалка поспала час -- часы отстали на час. Миллион ID в одну ms.

Защиты

  • NTP обязателен, мониторинг clock_skew метрики
  • На Snowflake clock rollback -> fail fast
  • Machine ID должен быть уникален per process (включая replicas в k8s)

Выбор machine_id

  • Конфиг: простейший, но требует деплой-тайм решение
  • Hostname hash: mod 1024 -- возможны коллизии
  • ZooKeeper/etcd lease: правильный способ для production
  • Kubernetes pod index (StatefulSet ordinal): ordinal + datacenter_id в ENV

Pitfalls

  • ID генерируется в коде, не в БД -- невозможен LAST_INSERT_ID(). Клиент знает ID сразу после Commit.
  • BIGINT vs UUID столбец: Snowflake в PG идёт в BIGINT (8 байт), UUID -- в UUID (16 байт). Различие 2x.
  • Sharding by ID: у Snowflake шард = id % N_shards или machine_id. У UUID -- берём первые 16-32 бит.
  • Sequence overflow: 4096 ID/ms/node -- при burst нужно ждать следующую ms. На практике редко достижимо.
  • Sortable != monotonic: два узла в одну ms выдадут близкие, но не упорядоченные ID. Для точной монотонности -- централизованный генератор (etcd counter).
  • Security через obscurity: sortable ID утекает информация о порядке создания. Пользователь видит 100 заказов за день? Неприемлемо в некоторых случаях -- используйте public_id (nanoid) отдельно от internal_id.

Комбинация внутреннего и публичного

Часто две колонки:

CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,       -- Snowflake (internal)
    public_id   TEXT UNIQUE NOT NULL,     -- ULID/Nanoid (customer-facing)
    ...
);

id -- для JOINов (8 байт, быстро). public_id -- для URL (безопасно, красиво).

Выводы

  • UUID v7 -- разумный default для новых проектов
  • Snowflake -- если нужно 64 бит + встроенный shard key
  • ULID / KSUID -- для внешних API (URL-safe)
  • Никогда не используйте UUID v4 как PK в БД с высоким insert-rate
  • Clock discipline (NTP) критичен для timestamp-based ID
  • machine_id берётся из etcd/k8s ordinal, не из hostname hash
  • Разделение internal_id / public_id -- частый и полезный паттерн