Зачем не 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'));
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";
Минорные отличия от 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();
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 -- частый и полезный паттерн