Совсем скоро (в конце сентября) выйдет PostgreSQL 18. Релиз готовит важные обновления — от асинхронного I/O до EXPLAIN с показателями CPU и WAL. 

Довольно громкая новинка — нативная поддержка UUIDv7, нового стандарта уникальных идентификаторов, идеально подходящих для B-tree индексов. 

В новом переводе от команды Spring АйО рассказывается, почему это важно, как работает UUIDv7 и чем он лучше UUIDv4 для современных распределённых систем.


UUIDv7 в PostgreSQL 18

PostgreSQL 18 уже на подходе — началось бета-тестирование. Среди множества улучшений в этом релизе — поддержка UUIDv7. Это вариант UUID на основе временной метки, который хорошо работает с B-tree индексами. В этой статье мы обсудим, что такое UUID, чем полезен UUIDv7 и как вы можете использовать его в PostgreSQL.

PostgreSQL 18

Beta-версия PostgreSQL 18 (beta 1) вышла относительно недавно. Релиз включает в себя множество новых фич, улучшений и исправлений. Как обычно, сообществу предлагается протестировать версию и сообщить о найденных багах — цель состоит в том, чтобы выпустить стабильный и качественный релиз в сентябре.

Среди ключевых нововведений:

  • Асинхронный I/O (с использованием io_uring) — ускорение последовательных сканирований (seqscan) и операций VACUUM в 2–3 раза

  • “Пропускное сканирование” в мультиколоночных B-tree индексах и более умная оптимизация OR/IN.

    Комментарий от эксперта Spring АйО, Михаила Поливахи: Речь про так называемый «skip scan». Это оптимизация в рамках Postgres 18, которая применяется в определенных ситуациях, когда задействован мультиколоночный btree индекс. Пока устоявшегося в сети термина для перевода на русский язык я по крайней мере не знаю, поэтому, перевели как «Пропускное сканирование».

    Оптимизация на самом деле крайне важная, но она не является целью статьи. Позже, если будут просьбы, мы про неё расскажем в деталях, и как Вы можете ей пользоваться

  • Сохранение статистики планировщика при major обновлениях

  • Функции для работы с UUIDv7

  • Виртуальные вычисляемые столбцы

  • Вход через OAuth и предупреждение об окончании поддержки md5

  • EXPLAIN ANALYZE теперь показывает I/O, CPU, WAL

  • Временные ограничения, LIKE для недетерминированных правил локализации (речь про collation), casefolding

  • Новая версия протокола обмена данными: 3.2 (первая с 2003 года!)

Хотя функция uuidv7() — не самая «громкая» фича релиза (это звание скорее достаётся асинхронному I/O), она, пожалуй, самая ожидаемая. Её чуть было не добавили ещё в версии 17, и многие пользователи были разочарованы, что этого не произошло.

Что такое UUID и зачем он нужен?

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

Традиционно в реляционных базах данных для генерации уникальных идентификаторов использовались автоинкрементные типы (например, SERIAL или IDENTITY). Это работает эффективно на одной машине (хотя даже в этом случае есть недостатки), но при масштабировании возникает необходимость в генерации уникальных идентификаторов на всех узлах.

UUID удобно использовать в качестве первичных ключей в базах данных в следующих типичных сценариях:

Генерация уникальных идентификаторов в распределённых БД

Хотя многие распределённые СУБД поддерживают автоинкрементные (identity) столбцы, они имеют ограничения и проблемы с производительностью.

Непредсказуемые публичные идентификаторы

При правильной генерации UUID нельзя угадать, предсказать или использовать для получения информации о системе. Если, например, в качестве идентификатора клиента используется автоинкремент, злоумышленник может сканировать все существующие ID, и сможет предсказать следующий идентификатор, или, например, оценить число ваших клиентов и т.п.

Позволить клиентам генерировать идентификаторы самостоятельно

Использование UUID даёт возможность клиентам генерировать идентификаторы без координации с сервером. Это полезно в мобильных приложениях и serverless архитектурах, где желательно минимизировать сетевое взаимодействие.

Благодаря этим преимуществам UUID широко применяется в качестве первичных ключей. Однако есть и три распространённые проблемы при использовании UUID в базах данных:

  1. Сортировка: UUID не упорядочены по значению в каком-либо полезном смысле.

  2. Локальность в индексах: Новые UUID генерируются случайным образом, а значит, вставки происходят в произвольные места индекса. Это приводит к раздутию индекса и проблемам с производительностью.

    Комментарий от эксперта Spring АйО, Михаила Поливахи: На эту тему, да и вообще в целом по использованию Btree индексов я рекомендую посмотреть доклад Владимира Ситникова: https://www.youtube.com/watch?v=y-Wtyvme4gE

  3. Размер: UUID — это 128-битное значение. Многие разработчики по умолчанию используют INT (32-бит) или BIGINT (64-бит) для первичных ключей. Для таблиц с большим числом очень маленьких записей это может быть ощутимым оверхедом.

Далее мы посмотрим, как UUIDv7 решает две из трёх этих проблем.

Размер UUID может стать проблемой при ограниченном дисковом пространстве или при наличии ограничений по сетевой пропускной способности, но стоит отметить, что современные процессоры умеют сравнивать 128-битные значения за одну инструкцию (например, CMEQ — часть SIMD-инструкций), поэтому операции с UUID в базах данных хорошо оптимизированы. Главное — использовать бинарное представление UUID (тип UUID) как в базе, так и в приложении, а не строковое.

Зачем нужен UUIDv7?

UUID впервые был стандартизирован в RFC 4122 в 2005 году. Этот документ определил пять вариантов/версий UUID, из которых наиболее широко используются варианты 1 и 4. Позже спецификация была обновлена — в мае 2024 года был опубликован RFC 9562, который добавил версии 6–8 (хотя первая публичная черновая версия появилась ещё в 2020 году). С днём рождения, RFC 9562 и UUIDv7!

Чтобы обосновать необходимость обновления спецификации, RFC 9562 рассматривает популярный сценарий использования UUID в качестве первичных ключей в базах данных:

«Одна из областей, где UUID получил широкое распространение, — это ключи баз данных ... но версии UUID 1–5, изначально определённые в [RFC4122], лишены некоторых важных свойств, например:

Версии UUID, не упорядоченные по времени (такие как UUIDv4, описанный в разделе 5.4), обладают плохой локальностью в индексах баз данных. Это означает, что последовательно созданные значения не находятся рядом друг с другом в индексе, что приводит к вставкам в случайные места. В результате возникают серьёзные проблемы с производительностью в структурах вроде B-tree и его вариациях».

Комментарий от эксперта Spring АйО, Михаила Поливахи

Вот тут авторы не поясняют, но я думаю, это важно. Почему вообще вставка в разные места индекса это проблема. Проблема в том, что из-за того, что UUIDv4 полностью рандомный, мы сталкиваемся с тем, что потенциально вставка, допустим, трёх строк со случайно сгенерированным UUIDv4 ведет к тому, что: 

1. Скорее всего, прочитается 3 разные страницы памяти из heap-а для вставки + 3 потенциально дополнительных fsync системных вызова. Конечно, страницы индекса могут быть закешированы, скажет опытный разработчик, но тем не менее.

С UUIDv7 вставка будет почти точно в рамках одной странички, т.к. все 3 новые записи попадают в конец btree.

2.  Как известно, b-tree самобалансирующийся. Это достигается путем сплита страниц и т.п. Из-за такой рандомизации UUIDv4 split страничек непредсказуемо происходит в произвольных местах индекса, чем потенциально может вызвать  крупные ротации в рамках страниц индекса (сплит вызывает собой другой сплит и т.д.).

Само собой, UUIDv7 и вставка в конец индекса приводит к сплитам и мерджу страничек тоже, но их во-первых, предположительно, должно быть просто меньше.

В течение последних 10+ лет многие разработчики распределённых приложений и крупные вендоры пытались создать более подходящий, сортируемый по времени уникальный идентификатор для использования в базах данных. Это привело к появлению множества реализаций, решающих одну и ту же задачу по-разному.

RFC перечисляет 16 (!) различных нестандартных реализаций UUID с разными компромиссами.

Хотя RFC вводит три новых варианта UUID, действительно интересен только UUIDv7. UUIDv6 добавлен лишь для обратной совместимости — в документе прямо говорится: «Системы, не зависящие от UUIDv1, ДОЛЖНЫ использовать UUIDv7». UUIDv8 предназначен для экспериментальных и специфичных для вендора расширений.

UUIDv7 решает сразу две ключевые проблемы:

  • сортировка значений

  • локальность вставок в индекс

Он использует временную метку в формате Unix Epoch в качестве старших 48 бит, а оставшиеся 74 бита отводятся под случайные значения (ещё несколько битов занимают версия UUID и вариация/variant). Это делает UUID одновременно упорядоченным по времени и уникальным. Стандарт также допускает добавление миллисекундной точности и/или аккуратно инкрементируемого счётчика для обеспечения порядка внутри одной секунды. В итоге UUIDv7 — отличный выбор для использования в качестве первичного ключа в базах данных: он уникален, сортируем и имеет хорошую локальность в индексах.

UUIDv7 в PostgreSQL 18

До выхода PostgreSQL 18 UUIDv7 не поддерживался на нативном уровне. Встроенная функция gen_random_uuid() генерировала UUIDv4, а популярное расширение uuid-ossp, хоть и добавляло поддержку других вариантов UUID, ограничивалось только теми, что были описаны в RFC 4122.

PostgreSQL 18 добавляет новую функцию: uuidv7(), которая генерирует значения UUIDv7. В реализации Postgres включена 12-битная субмиллисекундная составляющая сразу после основной временной метки (разрешено, но не обязательно по стандарту). Это гарантирует монотонность всех UUIDv7, сгенерированных в рамках одного backend-процесса Postgres (одной сессии).

Комментарий от эксперта Spring АйО, Михаила Поливахи

Под backend-процессом имеется в виду процесс, который представляет собой fork процесса postmaster. Этот backend-процесс операционной системы обрабатывает ваш запрос.

Важно то, что подобных backend процессов, как их часто называют в разного рода литературе, может быть довольно много, и монотонный increase в UUIDv7, как заявляет автор, гарантируется в рамках одного backend процесса. Имейте это в виду. Это важно.

Для единообразия также была добавлена функция uuidv4() как алиас для gen_random_uuid().

Вызов uuidv7() создаёт новый UUIDv7, используя текущее время. Если необходимо сгенерировать UUIDv7 для другого времени, в функцию можно передать необязательный интервал.

Существующие функции Postgres для извлечения временной метки и версии из UUID также были обновлены с учётом UUIDv7. Вот пример использования новых функций:

postgres=# select uuidv7();
                uuidv7
--------------------------------------
 0196ea4a-6f32-7fd0-a9d9-9c815a0750cd
(1 row)

postgres=# select uuidv7(INTERVAL '1 day');
                uuidv7
--------------------------------------
 0196ef74-8d09-77b0-a84b-5301262f05ad
(1 row)

postgres=# SELECT uuid_extract_version(uuidv4());
 uuid_extract_version
----------------------
                    4
(1 row)

postgres=# SELECT uuid_extract_version(uuidv7());
 uuid_extract_version
----------------------
                    7
(1 row)

postgres=# SELECT uuid_extract_timestamp(uuidv7());
   uuid_extract_timestamp
----------------------------
 2025-05-19 20:50:40.381+00
(1 row)

postgres=# SELECT uuid_extract_timestamp(uuidv7(INTERVAL '1 hour'));
   uuid_extract_timestamp
----------------------------
 2025-05-19 21:50:59.388+00
(1 row)

postgres=# SELECT uuid_extract_timestamp(uuidv7(INTERVAL '-1 day'));
   uuid_extract_timestamp
----------------------------
 2025-05-18 20:51:15.774+00
(1 row)

Использование uuidv7() в качестве первичного ключа таблицы максимально простое, а возможность извлекать временную метку делает UUID удобным для сортировки и анализа времени создания записи:

CREATE TABLE test (
    id uuid DEFAULT uuidv7() PRIMARY KEY,
    name text
);

INSERT INTO test (name) VALUES ('foo');
INSERT INTO test (name) VALUES ('bar');
-- this will be sorted to the beginning of the list since we are making it 1h older than the other two
INSERT INTO test (id, name) VALUES (uuidv7(INTERVAL '-1 hour'), 'oldest');

SELECT uuid_extract_timestamp(id), name FROM test ORDER BY id;

   uuid_extract_timestamp   |  name
----------------------------+--------
 2025-05-19 19:55:43.87+00  | oldest
 2025-05-19 20:55:01.304+00 | foo
 2025-05-19 20:55:01.305+00 | bar
(3 rows)

Все эти функции подробно описаны в официальной документации PostgreSQL. А если вас интересуют детали реализации, вы можете изучить соответствующий патч.

Заключение

PostgreSQL 18 приносит практичные улучшения, которые по достоинству оценят опытные разработчики. Нативная поддержка UUIDv7 — незаметное, но весьма значимое нововведение, решающее давние проблемы проектирования баз данных.

UUID всегда были компромиссом: они надёжны, гарантированно уникальны и эффективно генерируются в распределённых системах — но при этом страдали от проблем с производительностью при использовании в B-tree индексах. UUIDv7 объединяет лучшее из двух миров: глобальную уникальность и упорядоченность, совместимую с B-tree и нагрузками на запись. PostgreSQL 18 делает работу с ними ещё удобнее.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (10)


  1. Akina
    11.09.2025 18:00

    UUIDv7 в PostgreSQL 18

    Описание несколько неточное, и даже вследствие этой неточности имхо противоречащее ранее написанному:

    Он использует временную метку в формате Unix Epoch в качестве старших 48 бит, а оставшиеся 74 бита отводятся под случайные значения (ещё несколько битов занимают версия UUID и вариация/variant).

    В реализации постгресса случайная часть сокращена до 62 бит, а 12 бит хоть и описываются как "sub-millisecond", на самом деле если и не являются, то вполне могут считаться самым что ни на есть вульгарным автоинкрементом (правда, не смотрел, действительно ли там плюсуют по единичку, или используется рандомное приращение в малом диапазоне, но сути это не меняет). Что уж никак не есть рандом. К слову, это описывается стандартом.

    К тому же 4-битное поле версии располагается между штампом времени и этим 12-битным дополнением, но в рамках одного инстанса и одного пакета изменение версии нереально, так что его можно считать константой. И, рассматривая сортируемость, игнорировать.


    1. SergeyProkhorenko
      11.09.2025 18:00

      12 бит хоть и описываются как "sub-millisecond", на самом деле если и не являются, то вполне могут считаться самым что ни на есть вульгарным автоинкрементом

      Не надо домыслов. Субмиллисекунды совершенно честные. Для этого разработчик (Андрей Бородин) залез в самые глубинные потроха операционной системы и извлек то, о чем 99.9999% программистов не знают. Кстати, для маков не удалось достать 12 битов времени - там только 10, а 2 младших бита - рандомные. Честные субмиллисекунды, в отличие от счетчика, дают почти безупречную (но не гарантированную) монотонность идентификаторов, даже если идентификаторы генерятся на разных бэкендах.

      Кстати, в переведенной статье ошибочно утверждается (вообще статья крайне неудачная), что "12-битная субмиллисекундная составляющая ... гарантирует монотонность всех UUIDv7, сгенерированных в рамках одного backend-процесса Postgres (одной сессии)". На самом деле все UUIDv7, сгенерерованные в рамках одного бэкенда будут гарантированно монотонными независимо от наличия или отсутствия субмиллисекундной составляющей. Монотонность гарантируется таймстемпом, а если при лавинообразной генерации точность таймстемпа недостаточна, то весь таймстемп начинает работать как счетчик - это особенность алгоритма в PostgreSQL 18.


  1. ermadmi78
    11.09.2025 18:00

    Было бы здорово ещё как-то вероятность коллизий оценить.


    1. nin-jin
      11.09.2025 18:00

      Тут можно поиграться. Для 3К айдишника в один момент времени, вероятность коллизии не превышает 1e-12


    1. SergeyProkhorenko
      11.09.2025 18:00

      Вероятность коллизий UUIDv7, сгенерерованных в рамках одного бэкенда, строго равна нулю. Даже если бы в UUIDv7 не было случайной составляющей, а был только таймстемп, работающий в критических режимах как счетчик, коллизии были бы невозможны, как невозможны коллизии при автоинкременте в рамках одного бэкенда.

      Вероятность коллизий UUIDv7, сгенерерованных несколькими бэкендами (при одновременной генерации на клиентах, при генерации несколькими микросервисами, при слиянии данных из разных таблиц) тоже можно сделать строго равной нулю, если сдвинуть таймстемпы на разных бэкендах на разные и достаточно большие интервалы. Для этого в функции uuidv7() есть параметр типа "интервал". Но никакой реальной необходимости в этом нет, так как UUIDv7, сгенерерованные несколькими бэкендами, не столкнутся даже без манипуляций с таймстемпами.

      В остальных случаях вероятность коллизий ненулевая, но пренебрежимо мала. Достоверно оценить ее невозможно. Расчеты игнорируют то существенное обстоятельство, что в таблице с небольшим количеством строк возможность коллизий гораздо меньше, чем в таблице с огромным количеством строк. Формулы обычно исходят из того, что имеется потенциально бесконечно большая таблица, куда непрерывно добавляются строки вплоть до возникновения коллизии идентификаторов, и никакие строки не удаляются. Реальность, конечно, не имеет ничего общего с этой моделью.

      Ни одного известного случая истинной коллизии UUID до сих пор не было. Хотя были случаи, когда из-за ошибок в алгоритме сталкивались копии одного и того же UUID.


      1. nin-jin
        11.09.2025 18:00

        Тогда формулы такие. Заполненность таблицы тут не имеет значения, ибо коллизия возможна только с тем же моментом времени.


        1. SergeyProkhorenko
          11.09.2025 18:00

          А какова вероятность того, что в таблице окажутся UUIDv7 с одинаковым таймстемпом? Вы это явно упустили в своей формуле. Если вероятность такого события практически нулевая, то уже безразлично, какой длины случайная часть идентификатора - вероятность коллизии будет практически нулевой. Беда всех таких формул, что модель, на которой они основаны, не описывается. При попытке описать модель авторы формул столкнулись бы с тем, что их модель не соответствует реальности


      1. hogstaberg
        11.09.2025 18:00

        Мысль на грани идиотии для исключения коллизии в случае многих бэкэндов: небольшую часть из 62 случайных бит разрешить отдавать под фиксированный id бэкэнда (уникальный для каждого из них). Тогда даже если каким-то чудом совпадут временная и случайная часть битов, гарантированно уникальная фиксированная часть обеспечит отсутствие коллизии. Да, теряем на размере случайной части, но, если не увлекаться и условные 6-8 бит позволить откусывать, то выглядит всё ещё не так страшно. Впрочем это, очевидно, будет уже не совсем UUIDv7)


  1. nin-jin
    11.09.2025 18:00

    Стоит иметь ввиду, что при монотонных айдишниках ребалансировки b-tree происходят существенно чаще, чем при рандомных. А это не дешёвая операция.


    1. SergeyProkhorenko
      11.09.2025 18:00

      Всё же при выборе типа идентификатора или ключа имеет смысл опираться не на противоречивые теоретические аргументы за и против, не имеющие численного выражения, а на бенчмарки. А бенчмарки говорят, что UUIDv7 и автоинкремент обеспечивают примерно одинаковый темп вставки и поиска записей, а UUIDv4 существенно им уступает.

      См. статью UUID Benchmark War

      Правда и бенчмарки тоже дают не полную картину. UUIDv7 по сравнению с автоинкрементом позволяют избавиться от лишних расчетов и таблиц при слиянии данных. Ведь при использовании автоинкремента необходима замена ключей. Но разницу так просто не посчитать