← назад к разделу

UUID — стандартный способ делать идентификаторы в распределённых системах. Но даже если вы работаете с одним PostgreSQL, выбор типа, версии UUID и того, кто его генерирует, сильно влияет на производительность. Разберём с нуля.

Тип uuid, а не varchar(36)

Когда хранят UUID в базе, первый инстинкт — положить его в строку. UUID выглядит как 550e8400-e29b-41d4-a716-446655440000, это 36 символов, значит, varchar(36) — логично. Но это неправильно.

PostgreSQL знает про UUID и хранит его как 16 байт — бинарное представление. varchar(36) будет занимать минимум 37 байт плюс накладные расходы, а сравниваться посимвольно, как текст.

-- правильно
CREATE TABLE customer (
    id   uuid PRIMARY KEY,
    name text NOT NULL
);

-- частая ошибка
CREATE TABLE customer_bad (
    id   varchar(36) PRIMARY KEY,
    name text NOT NULL
);

Разница на практике:

Свойствоuuidvarchar(36)
Размер16 байт36 байт + накладные
Сравнениедва 64-битных числапосимвольное
Валидация форматапри вставкеникакой ('not-a-uuid' пройдёт)
Нормализация регистраданет (один UUID в двух регистрах = два разных значения)

На схеме с десятками таблиц и внешними ключами разница в размере складывается в десятки гигабайт. Индексы тоже становятся больше и медленнее.

Итог: всегда используйте тип uuid. Никакого varchar(36), char(36) или text.

UUID v4 и почему он плохо работает как первичный ключ

UUID v4 — полностью случайный. Каждый новый идентификатор получается в произвольном месте числовой оси. Для базы данных это проблема.

Первичный ключ в PostgreSQL — это btree-индекс. Btree держит данные в отсортированном порядке. Когда вы вставляете строки с UUID v4, каждая новая вставка попадает в случайное место этого дерева:

INSERT 550e8400...  → страница 47 индекса
INSERT a1b2c3d4...  → страница 9123
INSERT 3f2504e0...  → страница 218

Это значит:

  • Каждая вставка может потребовать загрузить другую страницу с диска.
  • Postgres вынужден делить страницы btree при вставках в середину — они заполняются на 30–60%, остальное пространство тратится впустую.
  • Буферный кеш постоянно вытесняется, потому что горячие страницы не концентрированы.
  • Запрос «последние 100 заказов» не может воспользоваться индексом эффективно — идентификаторы перемешаны по всему дереву.

На таблице в 100 млн строк это выражается в 2–5-кратном замедлении вставок и просадках при прогреве кеша.

UUID v7 — тот же UUID, но с временем внутри

UUID v7 устроен иначе: первые 48 бит — это временная метка (миллисекунды), остальное — случайные биты.

2026-05-07T12:00:00.001 → 0190abcd-...
2026-05-07T12:00:00.002 → 0190abce-...
2026-05-07T12:00:00.003 → 0190abcf-...

Соседние по времени вставки получают соседние идентификаторы — и попадают на одну и ту же страницу btree. Всё меняется:

  • Страницы индекса заполняются на 90%+.
  • Буферный кеш держит горячие страницы — они рядом.
  • Запрос «последние N записей» превращается в простой диапазонный поиск по индексу.
  • Глобальная уникальность сохраняется — 74 случайных бита достаточно, чтобы не было коллизий.
  • UUID v7 так же непредсказуем снаружи — угадать чужой идентификатор невозможно.

Переходить с v4 на v7 легко: тип колонки в PostgreSQL тот же — uuid, формат тот же — 16 байт. Меняется только генератор.

Правило: для первичных ключей и внешних ключей используйте UUID v7, не v4. Функция gen_random_uuid() в PostgreSQL генерирует v4 — не используйте её для PK.

Как генерировать UUID v7 в приложении

Генерировать UUID нужно на стороне приложения, не в базе данных. Причина проста: тогда идентификатор становится известен до того, как строка записана в базу. Это позволяет:

  • записать событие в очередь с тем же id, что пойдёт в базу;
  • вернуть клиенту идентификатор сразу, не дожидаясь коммита;
  • использовать id в логах с самого начала транзакции.
// build.gradle.kts
// implementation("com.github.f4b6a3:uuid-creator:5.3.7")

import com.github.f4b6a3.uuid.UuidCreator;
import java.util.UUID;

UUID id = UuidCreator.getTimeOrderedEpoch();   // UUID v7
// go get github.com/google/uuid@v1.6.0

import "github.com/google/uuid"

id, err := uuid.NewV7()   // UUID v7 (доступен с v1.6.0)
if err != nil {
    return err
}
// npm install uuid
// npm install --save-dev @types/uuid

import { v7 as uuidv7 } from "uuid";

const id: string = uuidv7();   // UUID v7
# Python 3.12+ — uuid.uuid7() в stdlib
import uuid

record_id = uuid.uuid7()   # UUID v7

В PostgreSQL 18 и новее есть встроенная функция uuidv7() — для случаев, когда генерация на стороне базы всё же нужна. До PostgreSQL 18 только приложение или расширение.

bigint или UUID — как выбрать

UUID — не единственный вариант. bigint IDENTITY (автоинкрементный целочисленный идентификатор) занимает 8 байт против 16, быстрее и проще в отладке. Выбор зависит от требований.

Критерийbigint IDENTITYuuid (v7)
Размер8 байт16 байт
Производительностьбыстреечуть медленнее
Уникальность между сервисаминужна координацияавтоматическая
Экспозиция в публичном APIраскрывает объёмы (/order/12345)непрозрачный
Известен до записи в базунетда
Удобство отладкипроще (1234 в логе)сложнее

UUID оправдан, когда:

  • Идентификатор должен быть уникальным глобально — несколько сервисов или баз данных генерируют id независимо.
  • Id выставлен наружу в API или URL, и вы не хотите, чтобы по нему можно было угадать объём данных. /order/12345 явно говорит «у нас примерно 12 тысяч заказов».
  • Id нужен до записи в базу — для событий, для возврата клиенту до коммита.

bigint IDENTITY оправдан, когда:

  • Один сервис, одна база данных, нет нужды в глобальной уникальности.
  • Id внутренний — наружу не выходит.
  • Простота важна: 1234 в логах искать легче, чем UUID.

Можно использовать оба

На практике крупные продукты часто совмещают: bigint как внутренний первичный ключ для быстрых join, и отдельный uuid для всего, что выходит наружу.

CREATE TABLE order_doc (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id   uuid   NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    -- ...
);

bigint — для внутренних соединений таблиц. uuid — для API и URL. Если нужен uuid в public_id именно v7 — генерируйте на стороне приложения и передавайте явно, не полагайтесь на DEFAULT gen_random_uuid() (это v4).

UUID и внешние ключи: индекс обязателен

PostgreSQL не создаёт индекс по внешнему ключу автоматически. На маленьких таблицах это незаметно. На больших — серьёзная проблема.

Когда вы удаляете запись, PostgreSQL проверяет: нет ли в дочерней таблице строк, которые ссылаются на неё. Без индекса это полный перебор дочерней таблицы (sequential scan). На 100 млн строк — секунды на каждое удаление.

CREATE TABLE order_doc (
    id uuid PRIMARY KEY
);

CREATE TABLE order_item (
    id       uuid PRIMARY KEY,
    order_id uuid NOT NULL REFERENCES order_doc(id)
);

-- этот индекс нужно создать вручную
CREATE INDEX ix_order_item_order_id ON order_item(order_id);

Правило: если в таблице есть внешний ключ типа uuid — создавайте индекс по нему явно.

Как перевести существующий varchar(36) на тип uuid

Если в базе уже есть колонка varchar(36) с UUID и нужно перейти на тип uuid без остановки сервиса, делают это поэтапно:

  1. Добавляют новую колонку id_uuid uuid.
  2. Заполняют её: UPDATE t SET id_uuid = id::uuid; — небольшими порциями, чтобы не блокировать таблицу.
  3. Строят индексы и переносят внешние ключи на новую колонку (CREATE INDEX CONCURRENTLY).
  4. Переключают приложение читать из новой колонки.
  5. В отдельном релизе удаляют старую varchar(36).

Такой подход называют expand-contract: сначала расширяем схему, потом сжимаем.

Коротко

  • Тип uuid в PostgreSQL хранит 16 байт и сравнивает как число. varchar(36) — 37+ байт, посимвольное сравнение, без валидации формата.
  • UUID v4 полностью случайный — вставки попадают в случайные места btree, страницы заполняются на 30–60%, кеш вытесняется.
  • UUID v7 содержит временную метку в первых 48 битах — соседние вставки попадают рядом, страницы заполняются на 90%+, диапазонный поиск работает.
  • Генерируйте UUID на стороне приложения — id известен до записи в базу.
  • bigint IDENTITY занимает 8 байт и проще. UUID оправдан для публичных API, глобальной уникальности и когда id нужен до коммита.
  • По внешнему ключу типа uuid нужен явный индекс — PostgreSQL его не создаёт автоматически.

Что почитать дальше

  • Строковые типы в PostgreSQL — почему не varchar для UUID.
  • Числа и точность в PostgreSQL — bigint IDENTITY подробнее.
  • Индексы в PostgreSQL — когда и какой индекс нужен.
  • Миграции без остановки сервиса — expand-contract pattern.