Кэширование значений последовательностей в PostgreSQL, bigint и uuidv7

У последовательностей есть параметр cache, который определяет сколько значений из последовательности будет кэшировать серверный процесс в своей локальной памяти для будущих вставок в течение сессии. Последовательности используются первичными и уникальными ключами. По умолчанию значения последовательностей не кэшируются. Кэширование может снизить произвдительность и сделать структуру индекса не оптимальной.

Быстрый путь вставки в индексы

В PostgreSQL есть оптимизация вставки в индекс типа btree, позволяющая не спускаться с корня дерева индекса. Серверный процесс, который выполнил вставку в правый листовой блок, запоминает ссылку на него и при последующей вставке, если новое значение больше предыдущего (или пусто) и не проходит путь от корня до листового блока. Оптимизация используется при числе уровней в индексе начиная со второго (макрос BTREE_FASTPATH_MIN_LEVEL).

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

Если процесс по какой-либо причине не может выполнить вставку в самый правый блок, то процесс забывает адрес блока и снова начинает поиск с корня. Одна из причин: столкновение на блокировке правого листового блока с другим процессом (проверяется что буфер не закреплен другими процессами). Другой процесс может продолжать, так как он не сталкивался. Получается, что процесс "отправляется на штрафной круг": читает блоки от корня до правого листового блока. За время пока процесс проходит "штрафной круг" он не сталкивается с другими процессами и не мешает другим процессам работать с правым листовым блоком. Это увеличивает общую производительность, позволяя какому-то другому процессу без конкуренции за доступ к правому блоку с большой скоростью вставлять в блок записи. Пока  другие процессы спустятся с корня дерева индекса, этот блок с большой вероятностью уже не будет самым правым листовым. Другими словами, правый блок перестаёт (снижается вероятность) быть "горячим". Это элегантное решение, снижающее конкуренцию за блок.

Кэширование значений последовательности (cache больше 1) приводит к следующему эффекту. Процесс выбирает из последовательности и кэширует несколько значений под будущие вставки строк, а также запоминает адрес самого правого листового блока. Если вставку следующей строки процесс делает через некоторое время, а не вставляет сроки без задержки, то за это время другие процессы скорее всего заполнят правый листовой блок и разделят его. В этом случае, все последующие вставки от первого процесса с закэшированными значениями будут вставляться не в самый правый листовой блок, а в блоки левее него.

Если процесс не кэширует значения последовательности (cache 1), то всегда выбирается самое свежее, а значит наибольшее, значение из последовательности и вставка строки пойдёт в самый правый блок. Исключением может быть случай, когда между выборкой значения из последовательности и получением доступа к правому листовому блоку блок успеет разделиться. Такое возможно при большой нагрузке. Нужна ли оптимизация при небольшой скорости вставки? При небольшой скорости вставки оптимизация быстрой вставки не важна, главное что строка будет вставляться в самый правый листовой блок. При большой скорости вставки оптимизация зааботает и ускорит вставки.

Почему желательно, чтобы вставки выполнялись в правый листовой блок? Потому, что в индексах типа btree PostgreSQL вставка записей в правый листовой блок оставляет структуру индекса наиболее оптимальной, структура индекса растёт равномерно и остаётся такой же эффективной, как если бы индекс был перестроен.

При использовании в качестве уникального ключа типа uuid (за исключением uuidv7, с ним проблем нет, он оптимален для вставок) оптимизации быстрой вставки не будет, так как вставляются случайные значения, а не возрастающие. Вставка неупорядоченных значений в разные листовые блоки индекса приводит к увеличению объема журнала засчет записи в журнал большего числа полных образов страниц (full page images, FPI). Возрастающие значения генерирует uuidv7().

Патч с поддержкой uuidv7 был принят 31 января 2025 года https://commitfest.postgresql.org/47/4388/ и появится в следующей версии PostgreSQL.

Тесты выполнялись на СУБД Tantor SE 17.2, так как в ней uuidv7присутствует начиная с версии 16.8.

Скрипт теста приведён полностью, чтобы его можно было легко повторить:

Скрытый текст

psql -c "alter system set checkpoint_timeout='60min';"
pgbench -i > /dev/null 2> /dev/null
sudo systemctl restart postgresql
sleep 10
psql -c 'create extension if not exists "uuid-ossp";' > /dev/null 2> /dev/null

psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity (cache 50) primary key, data bigint);" > /dev/null
echo "insert into tt1(data) values(1);" > txn.sql
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 64 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default gen_random_uuid() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 64 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 64 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "create extension if not exists pg_uuidv7;" > /dev/null 2> /dev/null
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default uuidv7() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 64 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"

psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity (cache 50) primary key, data bigint);" > /dev/null
echo "insert into tt1(data) values(1);" > txn.sql
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 32 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default gen_random_uuid() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 32 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 32 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default uuidv7() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 32 -f txn.sql 2> /dev/null | grep tps
psql -c "select count(), pg_indexes_size('tt1') from tt1;" psql -c "reindex table tt1;" > /dev/null psql -c "select count(), pg_indexes_size('tt1') from tt1;"

psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity (cache 50) primary key, data bigint);" > /dev/null
echo "insert into tt1(data) values(1);" > txn.sql
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 16 -f txn.sql 2> /dev/null | grep tps
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default gen_random_uuid() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 16 -f txn.sql 2> /dev/null | grep tps
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id bigint generated by default as identity primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 16 -f txn.sql 2> /dev/null | grep tps
psql -c "drop table if exists tt1;" > /dev/null
psql -c "create table tt1 (id uuid default uuidv7() primary key, data bigint);" > /dev/null
psql -c "vacuum analyze tt1;" > /dev/null
sleep 1
pgbench -T 30 -c 16 -f txn.sql 2> /dev/null | grep tps

В тесте устанавливаются расширения c функциями для типа данных uuid: "uuid-ossp" и pg_uuidv7. Таблица состоит из двух столбцов create table tt1 (id primary key, data bigint). Второй столбец и его тип не влияет на результаты теста и добавлен для приближения к реальности. На первый столбец создаётся уникальный индекс для первичного ключа.
В сессиях утилиты pgbench вставляются строки одиночной командой insert into tt1(data) values(1). Тестируются вставки в поля типов:
bigint, заполняемые последовательностю со свойством  cache 50.
uuid, заполняемые функцией gen_random_uuid()
bigint, заполняемые последовательностю без кэширования значений
uuid, заполняемые функцией uuidv7()

Результат выполнения команд теста (для справки):

Скрытый текст

tps = 6033.348007
count | pg_indexes_size
--------+-----------------
180018 | 5488640
(1 row)

count | pg_indexes_size
--------+-----------------
180018 | 4063232
(1 row)

tps = 5989.641340
count | pg_indexes_size
--------+-----------------
178711 | 7315456
(1 row)

count | pg_indexes_size
--------+-----------------
178711 | 5660672
(1 row)

tps = 6041.427014
count | pg_indexes_size
--------+-----------------
180261 | 4071424
(1 row)

count | pg_indexes_size
--------+-----------------
180261 | 4071424
(1 row)

tps = 5995.067495
count | pg_indexes_size
--------+-----------------
178730 | 5652480
(1 row)

count | pg_indexes_size
--------+-----------------
178730 | 5660672
(1 row)

tps = 3290.625242
count | pg_indexes_size
-------+-----------------
98463 | 2940928
(1 row)

count | pg_indexes_size
-------+-----------------
98463 | 2228224
(1 row)

tps = 3266.906578
count | pg_indexes_size
-------+-----------------
97711 | 4341760
(1 row)

count | pg_indexes_size
-------+-----------------
97711 | 3104768
(1 row)

tps = 3312.672801
count | pg_indexes_size
-------+-----------------
99050 | 2236416
(1 row)

count | pg_indexes_size
-------+-----------------
99050 | 2236416
(1 row)

tps = 3247.920498
count | pg_indexes_size
-------+-----------------
97172 | 3088384
(1 row)

count | pg_indexes_size
-------+-----------------
97172 | 3088384
(1 row)

tps = 1717.792820
tps = 1717.162280
tps = 1734.506311
tps = 1707.292961

Результаты

Для первого набора комнд из четырёх групп подобраны значения, при которых размер индексов после перестройки различен. Это ситуация, когда после выбора значения из последовательности процесс не может получить доступ к правому листовому блоку, проходит путь от корня индекса и оказывается, что за это время правый блок разделился и значение последовательности должно быть вставлено не в самый правый листовой блок. Вставка не в правый листовой блок приводит к неэффективности структуры индекса, на что указывает уменьшение размера индекса после его перестройки. Причина такой ситуации: в слишком много сессий (-с 64) при небольшом числе ядер процессора.

Во втором и третьем наборе команд уменьшено число сессий до 32 и 16. Для bigint без кэширования и uuidv7() размер индексов после перестройки не изменился и во втором примере составил: 2236416 и 3088384 байт. При использовании bigint без кэширования и uuidv7() структура индекса остаётся оптимальной. Для bigint с кэшированием, для uuid, заполняемых gen_random_uuid() и uuid_generate_v4() размеры индексов до перестройки и после перестройки индекса всегда будут различаться. Это основной результат.

Размер поля типа uuid 16 байт, что в два раза больше, чем размер поля bigint (8 байт). Размер индекса по столбцу типа uuid больше, чем размер индекса по столбцу типа bigint. Пример разницы размеров индексов:
для bigint число строк 99050, а размер индекса 2236416 байт.
для uuidv7 число строк 97172, а размер индекса 3088384 байт.
Оптимизация структуры хранения для uuidv7 возможна: по аналогии с 64-битным счетчиком транзаций верхние 32 бита хранятся в одном месте блока таблицы, что не увеличивает его размер, так же и в листовом блоке индекса хранение uuidv7 можно сделать компактным.

@OlegIct
08.03.2025 17:59 UTC
Первоисточник

Комментарии

@SergeyProkhorenko
08.03.2025 21:54 UTC
+1

Автор за деревьями не видит леса. Он, видимо, не догадывается, зачем десятки высококвалифицированных профессионалов потратили много лет на создание UUIDv7. Поэтому он делает далеко идущие глобальные неверные выводы, основываясь на незначительных деталях. При таком подходе мы бы до сих пор программировали на ассемблере, или даже в машинных кодах, так как компьютеры "оптимизированы для их использования". Такого рода "оптимизация" потом обернется огромными затратами, чтобы как-то обеспечить слияние данных с одинаковыми ключами из разных систем. Проблемы возникнут и при экспорте данных, и при параллельной генерации записей несколькими микросервисами (особенно при высокой нагрузке, когда возникает lock contention). Глобальный поиск по ключу будет невозможен, зато ошибки из-за коллизии ключей будут вполне возможны. Недоброжелатель легко догадается о количестве записей в таблице и легко подберет валидный ключ. Зато сэкономим один мегабайт индекса ))

@OlegIct
09.03.2025 06:41 UTC
+1

Вы участовали в разработке стандарта стандарта uuidv7 и мне приятно, что прочли мою статью. Основное в статье про параметр cache. uuidv7 - огромное достижение по сравнению с предыдущими, генерируемыми случайным образом. Могу сказать, что размер таблицы в примере с bigint и uuidv7 тот же самый, благодаря выравниванию размера строки в PostgreSQL. Для большинства таблиц использование uuid вместо bigint не увеличит их размер. Увеличение размера индекса в 1,38 раз несущественно, если нужны преимущества uuid. Использование uuid для идентификации строк баз данных была бы революция в архитектуре, означающая, что уникальные идентификаторы строк можно делать глобальными. Например, чтобы идентификаторы "заказов" и "товаров" не пересекались. Увеличение размера индекса покрывается тем, что uuid можно использовать там где нельзя использовать bigint, например, как номер заказа. Если использовать bigint, то можно догадаться о числе заказов, а это может быть конфиденциально. Если первичный ключ bigint, то в таблицу придется добавлять столбец для номера заказа, размер таблицы и индекса сильно увеличатся. Но есть и недостатки. К примеру, номер этой статьи на хабре 889156 и я ее написал потому, что у моей первой статьи номер круглый 888854 и я хотел чтобы и у этой статьи номер тоже был круглый, но не успел. Если бы хабр использовал uuid, я бы не торопился писать статью. Шуточно, но доля правды в этом есть ))

Сравнение uuid vs bigint это холивар, где сторонники uuid более активны. Инерция велика - oid в системных каталогах PostgreSQL это int, а даже не bigint. Важно развитие отрасли и все минусы и плюсы использования типов данных должны честно перечисляться и быть известны, искусственно рекламировать или склонять в сторону uuid я считаю неправильным. Затыкать рот тем, кто упоминает недостатки uuid тоже. Использоваться должно оптимальное решение, тогда программные системы будут лучше работать. Например, недостаток uuid - по телефону сложно продиктовать и набрать руками. Ещё до uuid я написал по заказу генератор в формате booking reference (используется системами бронирования), которые 6 символов (5 символов быстро исчерпываются, а 7 не достигаются), скомбинировав последовательность со счетчиком времени для защиты дублирования при рестарта сервера приложений. Плюсы - краткость для печати и защита от подбора. Недавно поискал есть ли стандарт на формат bookref, но не нашел. Часто используется, а стандарта нет.

09.03.2025 09:19 UTC
+2

"Важно развитие отрасли и все минусы и плюсы использования типов данных должны честно перечисляться и быть известны, искусственно рекламировать или склонять в сторону uuid я считаю неправильным. Затыкать рот тем, кто упоминает недостатки uuid тоже. Использоваться должно оптимальное решение, тогда программные системы будут лучше работать"

Авторов и контрибьюторов UUIDv7 невозможно упрекнуть в том, что они не видят или скрывают недостатки, а тем более затыкают рот критикам. Наоборот, вся история UUIDv7 - это история выявления и преодоления недостатков UUID. Идея была в том, чтобы не выбирать локальное и поэтому несовместимое "оптимальное" решение из нескольких убогих вариантов, а сделать универсальный тип идентификатора, устраняя все возможные недостатки UUID, вплоть до влияния сверхвысокой частоты генерации или сбоев системных часов, раскрытия даты и времени создания записи и возникновения lock contention при записи.

И это преодоление недостатков UUID продолжается. Например, UUID занимает много места и плохо идентифицируется в человекочитаемом документе, а также его трудно копировать с помощью мыши. До недавнего времени эти недостатки пытались преодолеть использованием различных кодировок (наиболее популярная Crockford's Base32), но, видимо, лучше вообще скрыть UUID с глаз человека за интерактивный виджет с идентиконом. А диктовка по телефону приятным голосом вполне по силам виртуальному помощнику.

В UUID еще много нераскрытых достоинств. Например, это прекрасное средство преодоления границ между информационными системами и глобального поиска. Но конкретные разработки ждут своего часа.