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)

Реально-боевая задача миграции:

  1. Добавить новую колонку id_uuid uuid.
  2. Backfill: UPDATE t SET id_uuid = id::uuid;
  3. Создать индексы / FK на новую колонку (concurrent).
  4. Переключить приложение читать новую колонку.
  5. Дропнуть старую 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 как альтернатива.
  • Антипаттерны.