almdev Технический блог

UUID v7 как первичный ключ: что меняется в Doctrine и в шаблонах
Зачем уходить от автоинкремента, чем v7 лучше v4 и где это больно.

Переход на UUID v7 в Symfony-проекте: генерация в конструкторе, выбор типа колонки в PostgreSQL, ловушка сравнения в Twig и честные цифры по размеру индексов.

Подкатегория: Данные

Чтение
5 мин
Технологии / версии
Symfony · Doctrine · PostgreSQL · UUID
10 фев 2026 · 5 мин · Backend
Последовательность уникальных кристаллических ключей выходит из часового механизма
Данные 02/2026

Автоинкремент рассказывает о вас больше, чем хочется. По адресу /issues/17 клиент видит, что задач в системе семнадцать. По /org/3/ — что вы третья организация в базе. Для внутреннего инструмента это неважно, для продукта, который показывают на демо, — неприятно.

Я перешёл на UUID v7 в середине проекта, когда таблиц было около сорока. Расскажу, что реально изменилось, включая то, о чём в статьях обычно не пишут.

Почему v7, а не v4

UUID v4 — это 122 случайных бита. Как идентификатор он безупречен, но случайные вставки снижают локальность записей в B-tree и чаще затрагивают разные страницы индекса. На маленькой таблице разницы можно не увидеть; на миллионах строк размер индекса и характер записи уже стоит измерять.

UUID v7 кладёт в старшие 48 бит метку времени в миллисекундах, а оставшиеся 74 доступных бита заполняет случайностью или комбинацией счётчика и случайности. Новые записи обычно попадают в недавнюю часть индекса. Сортировка по ключу примерно соответствует времени создания, но не заменяет ORDER BY created_at: строгий порядок внутри одной миллисекунды зависит от монотонности конкретного генератора.

Побочный эффект: время создания записи вытаскивается из ключа. Для внутренних сущностей это удобно, для публичных токенов — утечка, о которой стоит помнить. Идентификаторы приглашений и ссылок сброса пароля я оставил на v4 именно поэтому.

Генерация в приложении, а не в базе

Ключ создаётся при конструировании сущности. Не в базе, не при flush(). Ниже сокращённый фрагмент trait; конструктор самой сущности обязан вызвать метод инициализации:

trait HasUuidIdentity
{
    #[ORM\Id]
    #[ORM\Column(type: UuidType::NAME, unique: true)]
    private Uuid $id;

    protected function initializeUuidIdentity(): void
    {
        $this->id = Uuid::v7();
    }

    public function getId(): Uuid
    {
        return $this->id;
    }
}
// Фрагмент конструктора сущности
public function __construct(/* доменные аргументы */)
{
    $this->initializeUuidIdentity();
    // остальная инициализация
}

Это главный практический выигрыш от отказа от автоинкремента, и он не про безопасность. Идентификатор известен сразу после new Issue(), до всякого обращения к базе. Можно собрать граф связанных объектов, положить идентификатор в сообщение очереди, вернуть его в ответе — и только потом сделать flush(). С автоинкрементом каждая из этих операций требовала промежуточного сохранения.

Компромисс: $id больше не сигнализирует, сохранена ли сущность. Раньше null означал «новая». Теперь вопрос нужно формулировать точнее: EntityManager::contains() показывает, управляется ли объект текущим EntityManager, но не доказывает наличие строки в базе. Для доменного состояния «уже создана» нужен отдельный признак или явный сценарий, а состояние Unit of Work годится только для инфраструктурного кода.

При гидрации Doctrine создаёт объект через рефлексию, минуя конструктор, так что при чтении из базы генерация не срабатывает. Это ровно то поведение, которое нужно, но выглядит как магия, пока не заглянешь в ReflectionClass::newInstanceWithoutConstructor.

Тип колонки в PostgreSQL

На уровне Doctrine способ хранения зависит от платформы базы. В PostgreSQL естественный выбор — нативный тип uuid; варианты вроде binary(16) относятся прежде всего к СУБД без сопоставимого нативного типа.

Я выбрал PostgreSQL uuid. Он занимает 16 байт, читается в консоли, ищется по WHERE id = '018f...' и не превращает pg_dump в набор бинарного мусора. Хранить те же 16 байт в bytea здесь нет смысла: формат перестаёт проверяться как UUID, а отладка становится хуже.

#[ORM\Column(type: UuidType::NAME, unique: true)]

Текстовое хранение проигрывает нативному: канонический UUID занимает 36 символов плюс служебные байты переменной строки вместо 16 байт типа uuid. База сравнивает текстовое представление, а не компактные 16 байт, и не проверяет семантику UUID самим типом.

Ловушка сравнения в шаблоне

На таком выражении легко потерять вечер: оно полагается на неявное сравнение двух объектов.

{% if issue.assignee.id == currentMember.id %}

Twig использует нестрогое сравнение PHP: два объекта одного класса с одинаковыми свойствами могут оказаться равны, поэтому утверждать, что результат всегда false, нельзя. Проблема в другом — шаблон зависит от внутреннего устройства value object вместо его публичного контракта.

Однозначный вариант использует API Symfony UID:

{% if issue.assignee.id.equals(currentMember.id) %}

Если в шаблон приходят не два объекта Uuid, а объект и строка, я сначала привожу оба значения к канонической строке. Скалярные поля вроде Doctrine foreign key нужно сравнивать как скаляры; переносить правило для объектов на них не надо.

Внешние ключи и вес индексов

Ключ стал в четыре раза шире, чем int, и в два раза шире, чем bigint. Это не бесплатно.

Замер на таблице задач, 480 тысяч строк, PostgreSQL 16:

Чтоbigintuuid
Первичный ключ10 МБ18 МБ
Индекс по проекту11 МБ19 МБ
Таблица целиком214 МБ258 МБ

Плюс двадцать процентов к объёму. При этом планы запросов не поменялись, а времена выборок на этих объёмах отличались в пределах шума.

Что действительно бы кусалось — составные индексы из трёх идентификаторов. Такой индекс на UUID-колонках весит столько, что имеет смысл посмотреть на частичные индексы или на покрытие другим порядком колонок. У меня до этого не дошло, но держу в голове.

Каскады работают ровно так же, как раньше. Единственное место, где пришлось быть аккуратным, — ручные DELETE в миграциях данных: без кавычек PostgreSQL не примет UUID-литерал.

Где UUID не нужен

Не все таблицы получили новый ключ, и это не непоследовательность, а решение.

Справочники — статусы, приоритеты, типы задач, роли — остались на smallint или на строковых кодах. Их не показывают в URL, их не создают через API, их десятки. Раздувать индексы связей ради единообразия смысла нет: wm_issue.priority_id в виде UUID добавил бы 14 байт на каждую задачу без единого выигрыша.

Связующие таблицы «многие ко многим» — тоже без собственного ключа: составной первичный ключ из двух внешних решает задачу и экономит индекс.

Журнальные таблицы, которые пишутся пачками и читаются по времени, я оставил на bigserial. Там важна скорость вставки и компактность, а идентификатор наружу не уходит вовсе.

Итог через год

Единственное, о чём жалею, — что не сделал этого сразу. Миграция сорока таблиц с автоинкремента заняла три дня, из которых два ушли на переписывание внешних ключей и починку тестов, где идентификаторы были захардкожены как 1 и 2.

Двадцать процентов объёма — честная цена за то, что идентификатор известен до сохранения, а в URL не видно размера базы. Оговорка одна: если бы проект писал десятки миллионов строк в сутки, я бы считал внимательнее и, скорее всего, оставил числовой ключ внутри, а UUID вынес отдельной публичной колонкой.

#symfony #doctrine #postgresql #uuid