UUID — стандартный способ выдавать идентификаторы, когда их раздаёт не одна база, а несколько сервисов сразу. Но даже на одном PostgreSQL три решения — какой взять тип колонки, какую версию UUID и кто его генерирует — заметно меняют скорость вставок и размер индексов.
Первичный ключ — это btree-индекс, а он разложен по страницам. Случайный v4 отправляет каждую вставку в свою страницу; у v7 первые 48 бит — время, поэтому подряд созданные ключи ложатся рядом.
Тип uuid, а не varchar(36)
Когда UUID кладут в базу первый раз, инстинкт — положить его строкой: 550e8400-e29b-41d4-a716-446655440000 — это 36 символов, значит, varchar(36). Так делать не нужно: у PostgreSQL есть собственный тип uuid, и он хранит значение как 16 байт двоичного кода, а не как текст.
-- так не надо
CREATE TABLE customer_bad (
id varchar(36) PRIMARY KEY,
name text NOT NULL
);
-- так надо
CREATE TABLE customer (
id uuid PRIMARY KEY,
name text NOT NULL
);
varchar(36) займёт 37 байт — 36 символов плюс байт заголовка строки. Проверять это надо на колонке таблицы: pg_column_size по голому литералу покажет 40, потому что у значения «на весу» заголовок длиннее — четыре байта вместо одного. Но дело не только в размере: тип uuid приводит запись к одному виду, а строка — нет.
живой пример
SELECT '550e8400-e29b-41d4-a716-446655440000'::uuid
= '550E8400-E29B-41D4-A716-446655440000'::uuid AS uuid_same,
'550e8400-e29b-41d4-a716-446655440000'
= '550E8400-E29B-41D4-A716-446655440000' AS text_same;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Для типа uuid это одно значение, для строки — два разных. Стоит одному сервису записать идентификатор в верхнем регистре, а другому поискать его в нижнем — и строка не найдётся. Заодно uuid проверяет формат при вставке: 'not-a-uuid' в такую колонку не пройдёт, а в varchar(36) ляжет молча.
| Свойство | uuid | varchar(36) |
|---|---|---|
| Размер | 16 байт | 37 байт |
| Сравнение | 16 байт разом | посимвольное |
| Валидация формата | при вставке | никакой ('not-a-uuid' пройдёт) |
| Нормализация регистра | да | нет (один UUID в двух регистрах = два разных значения) |
Посчитать нетрудно: 21 лишний байт на строку — это около двух гигабайт на таблице в 100 млн строк, и это только сама колонка. Но идентификатор редко живёт в одиночестве: он повторяется в каждой ссылающейся таблице и в каждом индексе по этой ссылке, так что на схеме с десятками таблиц и внешними ключами лишний объём умножается. Индексы растут вместе с данными и медленнее читаются. Поэтому — только тип uuid, никакого varchar(36), char(36) или text.
UUID v4 плохо ложится в первичный ключ
UUID v4 — полностью случайный. Каждый новый идентификатор получается в произвольном месте числовой оси. Для базы данных это проблема.
Первичный ключ в PostgreSQL — это btree-индекс, а btree держит ключи в отсортированном порядке и разложенными по страницам. Когда вы вставляете строки с UUID v4, каждая новая вставка попадает в случайное место дерева:
Три подряд идущие вставки попадают на три страницы в разных концах дерева: случайный ключ раскидывает их по всему индексу, и ни одна из этих страниц не задержится в кеше.
Отсюда три следствия.
- Каждая вставка может потребовать поднять с диска отдельную страницу, и буферный кеш всё время вытесняется: горячих страниц нет, горячее всё дерево.
- Вставка в середину заполненной страницы заставляет Postgres делить её пополам, поэтому при случайных вставках и умолчательном
fillfactorстраницы по замерам стоят заполненными на 30–60 %, а индекс занимает примерно вдвое больше места, чем мог бы. Это свойство не самого типа, а случайного порядка вставки. - По первичному ключу нельзя взять «последние N записей»: идентификаторы перемешаны, и для такого запроса нужен отдельный индекс по времени создания.
На таблице в 100 млн строк это выражается в двух-пятикратном замедлении вставок и просадках, пока кеш не прогреется.
Дорожает не только индекс. Каждая вставка в случайное место индекса меняет очередную страницу, а страница, изменённая впервые после контрольной точки, попадает в журнал предзаписи целиком — такой полный образ занимает восемь килобайт независимо от того, что вы добавили двадцать байт ключа. Последовательные ключи трогают одну и ту же правую страницу и платят за неё один раз; случайные раскидывают вставки по тысячам страниц, и журнал растёт в разы. Отсюда и практическая половина замедления: больше журнала — больше работы дискам, дольше репликация, больше объём резервных копий. Как устроен этот журнал — в статье про WAL.
UUID v7 — тот же UUID, но с временем внутри
UUID v7 устроен иначе: первые 48 бит — это метка времени в миллисекундах, остальное — случайные биты. Значит, созданные подряд значения различаются только хвостом:
живой пример
SELECT id,
to_timestamp(('x' || substr(replace(id::text, '-', ''), 1, 12))::bit(48)::bigint / 1000.0) AS created_at
FROM (VALUES
('0199c1a2-3b4c-7a1e-8f00-2b7d9c4e1a55'::uuid),
('0199c1a2-3b12-7f42-93c1-8ae05d6b2210'::uuid),
('0199c1a2-3ad0-7c88-a4f2-11e3b9740c6d'::uuid)
) AS v7(id)
ORDER BY id;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первые 48 бит UUID v7 — это миллисекунды с начала 1970 года, и запрос их вырезает: replace убирает дефисы, substr(…, 1, 12) берёт первые двенадцать шестнадцатеричных цифр, 'x' || делает из них строку, которую PostgreSQL читает как битовую, ::bit(48)::bigint превращает биты в число, а to_timestamp(… / 1000.0) переводит миллисекунды в момент времени.
Запрос достаёт первые 12 шестнадцатеричных знаков — те самые 48 бит — и превращает их во время. Сортировка по идентификатору даёт порядок создания, хотя про время в запросе не сказано ни слова.
Из-за этого исчезают все три следствия v4:
- Буферный кеш держит горячий хвост дерева — новые ключи всегда рядом, а не размазаны по всему индексу.
- Соседние по времени вставки попадают на одну страницу btree, и она набивается на 90 % и больше.
- «Последние N записей» — обычный диапазонный поиск по первичному ключу, отдельный индекс не нужен.
Глобальная уникальность при этом не страдает: под случайную часть остаётся 74 бита, подобрать чужой идентификатор перебором по-прежнему нельзя. Но одно v7 выдаёт: время создания записи лежит в первых битах открытым текстом, и его достаёт запрос выше. Если по идентификатору в адресе страницы нельзя узнать, когда объект появился, берите v4.
Переходить с v4 на v7 легко: тип колонки в PostgreSQL тот же — uuid, формат тот же — 16 байт. Меняется только генератор. Функция gen_random_uuid() выдаёт v4, поэтому для первичного ключа её брать не стоит.
Где v7 всё же неудобен, кроме очевидной утечки времени создания.
Монотонность внутри одной миллисекунды стандарт оставляет на усмотрение реализации: одна библиотека дополняет случайными битами, другая ведёт счётчик, третья — и то и другое. Поэтому два ключа, сгенерированных в одну миллисекунду разными библиотеками (или разными экземплярами приложения), упорядочены между собой только приблизительно. Полагаться на порядок v7 как на порядок событий нельзя — для этого есть колонка со временем.
Часы, переведённые назад (сдвиг зоны, поправка синхронизации времени), отправляют новые ключи левее уже вставленных — на это время возвращаются те самые случайные вставки, от которых v7 и спасал. Ничего не ломается, но всплеск нагрузки на диск в момент поправки объясняется именно этим.
И отдельный случай — таблица, разбитая на части по времени. Для таблицы целиком v7-ключ всегда «правый край», а вот при записи сразу в несколько частей (текущий месяц и соседний, или партиции по клиентам) правых краёв становится столько же, сколько активных частей, и выигрыш размывается: каждая часть держит свою горячую страницу индекса.
Как генерировать UUID v7 в приложении
Генерировать UUID лучше в приложении, а не в базе: тогда идентификатор известен до того, как строка записана. Это позволяет:
- записать событие в очередь с тем же id, что пойдёт в базу;
- вернуть клиенту идентификатор сразу, не дожидаясь коммита;
- использовать id в логах с самого начала транзакции.
// build.gradle.kts
// implementation("com.github.f4b6a3:uuid-creator:6.1.1")
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 — нужна версия 10 или новее, экспорт v7 появился в ней
import { v7 as uuidv7 } from "uuid";
const id: string = uuidv7(); // UUID v7
# Python 3.14+ — uuid.uuid7() в стандартной библиотеке
# для 3.12-3.13 понадобится пакет uuid6 или uuid_utils
import uuid
record_id = uuid.uuid7() # UUID v7
В PostgreSQL 18 и новее есть встроенная функция uuidv7() — для случаев, когда генерация на стороне базы всё же нужна. До PostgreSQL 18 только приложение или расширение.
Первое, что стоит сказать прямо: UUID.randomUUID() в Java — это версия 4, то есть 122 случайных бита и ровно та картина вставок, из-за которой затевался весь разговор. Генератора v7 в стандартной библиотеке нет и на момент Java 25 не появилось: версия задаётся битами внутри значения, а UUID в JDK умеет только случайную и построенную из имени. Поэтому v7 берут из сторонней библиотеки — она собирает значение сама и отдаёт обычный java.util.UUID, с которым дальше работают и драйвер, и jOOQ, и Hibernate. Альтернатива без библиотеки — попросить базу: колонка с DEFAULT и функцией генерации, но тогда идентификатор становится известен только после вставки, а это ломает привычный порядок «собрали объект в коде, потом сохранили».
bigint или UUID — как выбрать
UUID — не единственный вариант. bigint GENERATED ALWAYS AS IDENTITY — это счётчик, который ведёт сама база: 8 байт вместо 16, короче в логах, быстрее в соединениях таблиц. Цена — счётчик один на базу, и значение становится известно только после вставки.
| Критерий | bigint IDENTITY | uuid (v7) |
|---|---|---|
| Размер | 8 байт | 16 байт |
| Уникальность между сервисами | нужна координация | автоматическая |
| Виден снаружи, в API | раскрывает объёмы (/order/12345) | ничего не сообщает |
| Известен до записи в базу | нет | да |
| Удобство отладки | проще (1234 в логе) | сложнее |
Строчка про API — про то, что /order/12345 прямо говорит: заказов у нас примерно двенадцать тысяч. Считать чужие объёмы по возрастающим номерам умеют давно.
Правило простое: UUID берут, когда идентификаторы раздают несколько сервисов независимо, когда id выходит наружу или когда он нужен до записи в базу. bigint IDENTITY — когда база одна, id наружу не выходит и важнее простота.
Можно использовать оба
На практике крупные продукты часто совмещают: bigint как внутренний первичный ключ для быстрых соединений и отдельный uuid для всего, что выходит наружу.
CREATE TABLE order_doc (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
public_id здесь без DEFAULT: v7 умеет генерировать приложение, а gen_random_uuid() дал бы v4. На PostgreSQL 18 и новее выбор шире — там можно написать DEFAULT uuidv7() и получить нужную версию прямо из базы.
Про «сложнее в отладке» есть простое лекарство, которое снимает половину возражения: наружу отдают не тридцать шесть символов с дефисами, а короткую запись того же значения. Шестнадцать байт в base64url — это 22 символа (AXm7k9Qe...), в base58 — около 22 без похожих друг на друга символов. В базе при этом лежит тот же uuid, а преобразование живёт на границе: в API, в адресах страниц и в журналах. Выгода не только в длине — такую строку человек может продиктовать и перенести глазами, а полный UUID из лога обычно копируют, потому что прочитать его нельзя.
UUID и внешние ключи: индекс обязателен
PostgreSQL не создаёт индекс по внешнему ключу автоматически — только по первичному. На маленьких таблицах это незаметно, на больших выходит боком.
Когда вы удаляете запись, PostgreSQL проверяет: нет ли в дочерней таблице строк, которые на неё ссылаются. Без индекса это полный перебор дочерней таблицы. На 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 вдвое толще индекса по bigint: шестнадцать байт против восьми, плюс служебные данные на запись. Значит, и цена пропущенного индекса выше: поиск дочерних строк при удалении родителя и соединение по внешнему ключу идут перебором таблицы, которая сама стала больше. На схеме с bigint забытый индекс по внешнему ключу обычно замечают на сотнях тысяч строк, на схеме с uuid — заметно раньше.
Как перевести существующий varchar(36) на тип uuid
Одним ALTER TABLE ... TYPE uuid такую колонку не переделать: он переписывает таблицу целиком под тяжёлой блокировкой, и на большой таблице это остановка сервиса. Поэтому рядом заводят новую колонку id_uuid uuid, заполняют её порциями (UPDATE t SET id_uuid = id::uuid по диапазонам ключа), строят индексы через CREATE INDEX CONCURRENTLY, переключают приложение на новую колонку и только в следующем релизе удаляют старую.
Такой приём называют expand-contract: сначала схему расширяют, потом сжимают.
Главное препятствие такой миграции — не синтаксис, а данные. В varchar(36) за годы могло попасть что угодно: строка с пробелом по краям, в верхнем регистре, с фигурными скобками ({...}), обрезанная до 35 символов, пустая строка или просто -. Приведение упадёт на первой такой строке с invalid input syntax for type uuid, и упадёт посередине, заблокировав таблицу.
Поэтому сначала считают, сколько таких строк, и только потом переписывают тип:
SELECT count(*) FROM orders
WHERE external_id IS NOT NULL
AND external_id !~* '^\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$';
Ноль — можно переводить. Не ноль — решают по каждой группе отдельно: пробелы и регистр чинятся (trim, lower), скобки убираются, а строки, которые идентификатором не являются вовсе, либо обнуляются, либо уезжают в отдельную таблицу «разобрать руками». Делать это надо до ALTER TABLE, отдельной миграцией, потому что чинить данные под уже начавшейся перезаписью типа не получится.
Коротко
- Тип
uuidхранит 16 байт и сравнивает их разом;varchar(36)— это 37 байт, посимвольное сравнение, без проверки формата и без приведения регистра. - UUID v4 полностью случайный: вставки попадают в случайные страницы btree, страницы рвутся и стоят заполненными на 30–60 %.
- У UUID v7 первые 48 бит — метка времени: соседние по времени ключи ложатся рядом, страницы набиваются на 90 %+, «последние N» берутся диапазоном по первичному ключу.
- Время создания из v7 достаётся запросом. Если этого показывать нельзя — берите v4.
- Генерируйте UUID в приложении: id известен до записи в базу.
gen_random_uuid()— это v4; встроенныйuuidv7()появился только в PostgreSQL 18. bigint IDENTITYзанимает 8 байт и проще в отладке; UUID оправдан для публичных адресов, глобальной уникальности и когда id нужен до коммита.- По внешнему ключу типа
uuidиндекс создают вручную — PostgreSQL его не делает. UUID.randomUUID()— это v4, генератора v7 в JDK нет: его берут из библиотеки или из базы, но тогда ключ узнаётся только после вставки.- Случайный ключ раздувает не только индекс, но и журнал предзаписи: первая правка страницы пишется в него целиком, а случайные вставки трогают тысячи страниц.
- Перевод
varchar(36)вuuidупирается не в синтаксис, а в мусор: регистр, пробелы, скобки и обрезки считают запросом и чинят отдельной миграцией доALTER TABLE.
Что почитать дальше
- Строковые типы в PostgreSQL — почему varchar не место для идентификатора.
- Числа и точность в PostgreSQL — про bigint и счётчики подробнее.
- Индексы в PostgreSQL — как устроен btree и когда нужен другой тип индекса.
- Миграции без остановки сервиса — expand-contract целиком, с блокировками и порядком шагов.