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
);
Разница на практике:
| Свойство | uuid | varchar(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 IDENTITY | uuid (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 без остановки сервиса, делают это поэтапно:
- Добавляют новую колонку
id_uuid uuid. - Заполняют её:
UPDATE t SET id_uuid = id::uuid;— небольшими порциями, чтобы не блокировать таблицу. - Строят индексы и переносят внешние ключи на новую колонку (
CREATE INDEX CONCURRENTLY). - Переключают приложение читать из новой колонки.
- В отдельном релизе удаляют старую
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.