UUID — стандартный выбор для распределённых сервисов: идентификатор можно сгенерить на клиенте до записи, нет конфликтов между сервисами, удобно ссылаться извне. Но как и любой инструмент, он бьёт, если применять без понимания.
1. Тип uuid, не varchar(36)
PG-T-040 UUID хранится в типе uuid. Никогда не varchar(36) / char(36) / text.
-- правильно
CREATE TABLE customer (
id uuid PRIMARY KEY,
name text NOT NULL
);
-- неправильно
CREATE TABLE customer_bad (
id varchar(36) PRIMARY KEY,
name text NOT NULL
);
Что теряем при varchar(36):
- Размер:
uuid— 16 байт,varchar(36)— 36 байт + 1–4 байта overhead. На больших таблицах с FK это умножается на каждый индекс и каждую foreign key. Реальная экономия — десятки гигабайт на больших схемах. - Скорость: сравнение
uuid— две операцииint64. Сравнениеvarchar(36)— посимвольное. - Валидация:
uuidпроверяет формат на вставке.varchar(36)пропустит'not-a-uuid'. - Регистр и канонический формат:
varchar(36)хранит «как есть» — два UUID, отличающихся только регистром, считаются разными ключами.uuidнормализует.
2. v4 vs v7: тот же бит-формат, разная производительность
UUID v4 — это 122 случайных бита + версия. UUID v7 — это 48-битный timestamp + 74 случайных бита + версия.
PG-T-041 Для PK / FK берём UUID v7, не v4.
Причина — устройство B-tree-индекса в PostgreSQL.
Что происходит с UUID v4
UUID v4 случаен — соседние генерируемые id попадают в случайные позиции индекса:
INSERT a1b2c3... → страница 47
INSERT 4d5e6f... → страница 9123
INSERT 9a8b7c... → страница 218
Каждая вставка тащит из диска / прогревает в кеше новую страницу. Размер индекса растёт быстрее, чем должен (страницы заполняются на 30–60%, потому что btree разбивает их при вставках в середину). Кеш Postgres постоянно вытесняется. Чтения через PK медленнее, чем при последовательном id.
На небольших таблицах разницы не видно. На таблице 100M строк это 2–5x разница в скорости вставки и заметные просадки на хорошо прогретом кеше.
Что меняет UUID v7
Первые 48 бит UUID v7 — это unix-time в миллисекундах. Соседние по времени id физически близки в индексе:
2026-05-07T12:00:00.001 → 0190abcd-...
2026-05-07T12:00:00.002 → 0190abce-...
2026-05-07T12:00:00.003 → 0190abcf-...
Все попадают в одну страницу btree. Индекс растёт компактно (страницы заполняются на 90%+), кеш не вытесняется при последовательных вставках, чтение по диапазону «последние N заказов» становится range-scan, а не random-scan.
При этом сохраняются плюсы UUID:
- глобальная уникальность без обращения к одной БД;
- не угадывается извне (74 случайных бита достаточно);
- генерится на клиенте до записи.
Цена перехода
Никакая. UUID v7 — это тот же тип uuid PostgreSQL, тот же 16-байтный формат. Меняется только генератор.
3. Где взять генератор UUID v7
В PostgreSQL 18 — встроенная функция uuidv7(). До 18 — расширение или генерация на клиенте.
В Java (рекомендуется — генерация на стороне приложения):
import com.github.f4b6a3.uuid.UuidCreator;
UUID id = UuidCreator.getTimeOrderedEpoch(); // UUID v7
Библиотека uuid-creator (или аналоги — java-uuid-generator, nanoid с другим форматом). Генерация на клиенте удобна тем, что id известен до записи — можно сразу публиковать события / возвращать клиенту, не дожидаясь ответа БД.
В PostgreSQL до 18:
Свой триггер с реализацией v7 (рецепты есть в публичных gist'ах). Менее удобно, чем клиентская генерация — теряется свойство «id известен до записи».
PG-T-042 Генерируем UUID v7 на стороне приложения (если PG < 18 или если нужен id до commit). На стороне БД — только когда приложению он не нужен заранее.
4. UUID vs bigint IDENTITY: что выбирать
PG-T-043 bigint IDENTITY дешевле и быстрее. UUID — когда нужен по делу.
UUID оправдан, когда:
- Сервисов несколько, и id должен быть глобально уникальным без координации.
- Id отдаётся наружу (публичный API, ссылка в письме) и не должен подсказывать порядок и объёмы (
/order/12345показывает «у нас примерно 12K заказов» —IDENTITYне подходит для публичных URL). - Запись и публикация события / возврат клиенту должны произойти до commit-а (id нужен сразу).
bigint IDENTITY оправдан, когда:
- Один сервис, одна БД.
- Внешний контракт не требует UUID.
- Нужна простота в дебаге (увидеть id 1234 в логе и пойти по нему — проще, чем
0190abcd-1234-7890-...).
Обычная практика для маркетплейса:
CREATE TABLE order_doc (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- внутренний id
public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), -- внешний id (можно UUID v7)
...
);
Внутренний bigint — для FK и быстрых джойнов. Публичный uuid — для API и URL.
5. UUID и FK
PG-T-044 При UUID-PK — обязательно индекс по FK с такой же uuid-колонкой.
PostgreSQL не строит индекс по FK автоматически. На малых таблицах это не страшно, на больших — DELETE родителя уйдёт в seq-scan дочерней таблицы. См. composite индексы и FK (вторая волна раздела).
6. Что делать с уже существующими varchar(36)
Реально-боевая задача миграции:
- Добавить новую колонку
id_uuid uuid. - Backfill:
UPDATE t SET id_uuid = id::uuid; - Создать индексы / FK на новую колонку (concurrent).
- Переключить приложение читать новую колонку.
- Дропнуть старую
varchar(36)(отдельный релиз).
Это паттерн expand-contract — подробно в статье про миграции (третья волна).
Чек-лист на ревью
- [ ] Все UUID-колонки — тип
uuid, неvarchar(36)/char(36). - [ ] Для PK/FK берётся UUID v7 (или
bigint IDENTITY), не v4. - [ ] UUID генерируется на стороне приложения, если id нужен до commit.
- [ ] При UUID-PK на дочерней таблице — индекс по FK.
- [ ] Если внутренний id —
bigint IDENTITY, для публичных URL — отдельнаяuuid-колонка.
Связанные
- Числа и точность —
IDENTITYкак альтернатива. - Антипаттерны.