
Введение
Однажды утром, просматривая очередную порцию свежих коммитов в Postgres, я увидел следующее1:
commit bd8d9c9bdfa0c2168bb37edca6fa88168cacbbaa Author: Heikki Linnakangas <heikki.linnakangas@iki.fi> Date: Tue Dec 9 13:53:03 2025 +0200 Widen MultiXactOffset to 64 bits This eliminates MultiXactOffset wraparound and the 2^32 limit on the total number of multixid members. Multixids are still limited to 2^31, but this is a nice improvement because 'members' can grow much faster than the number of multixids. On such systems, you can now run longer before hitting hard limits or triggering anti-wraparound vacuums.
Вот так вот, тихо и даже буднично, Postgres преодолел одно из своих досадных ограничений: ушло в историю ограничение на количество транзакций в мультитранзакции. Формально, оно, конечно, теперь ограничено 8-байтным беззнаковым целым, но это число настолько велико, что я не могу даже себе представить его исчерпание в каком-то отдалённом будущем.
Когда много лет занимаешься какой-то работой, и в конце она завершается, то сложно поверить, что это всё закончилось. Ведь сохранялась ещё маленькая вероятность того, что сообщество могло «откатить» коммит. Теперь, когда прошло уже почти три месяца (и коммит, кажется, «прижился») мне хотелось бы поделиться своими соображениями на эту тему, как непосредственного участника событий и автора патча.
Три брата-акробата
Вообще, в Postrges есть три узких места, связанных с 32-битными счётчиками:
идентификаторы транзакций. Они же xid или «ксиды»;
идентификаторы мультитранзакций, также известные как mxid;
офсеты мультитранзакций. Малоизвестная для пользователей штука, однако, при стечении обстоятельств, весьма неприятная, но об этом расскажем позже.
Надо заметить, что каждый из этих счётчиков умеет «оборачиваться», то есть нормально переживает переполнение, и это не приводит к остановке СУБД или потере данных. В зависимости от вашей нагрузки и размеров БД, вы можете даже не заметить что, скажем, счётчик транзакций после 4 миллиардов стал 1073.
Каждый из счётчиков может стать проблемой, а может и не стать. И по каждому из них можно написать по целой статье. Но чем меньше база данных, и чем меньше на неё транзакционная нагрузка, тем меньше вероятность возникновения проблем. Это, наверное, и так очевидно.
Механизм мультитранзакций
Егор Рогов в своей книге «PostgreSQL 17 изнутри»2 определяет это так: мультитранзакция — это группа транзакций, которой присвоен отдельный номер2. Между прочим, книга доступна бесплатно, что называется без регистрации и СМС. Рекомендую её всем интересующимся. В ней очень подробно рассмотрена архитектура мультитранзакций Postgres: что это такое и зачем она нужна. Моя зона интересов лежит в плоскости того, как устроен механизм мультитранзакций, то есть я занимаюсь вопросом их реализации. Поэтому на моём уровне абстракции мультитранзакция — это некоторый контейнер, в который можно положить произвольное количество настоящих идентификаторов транзакций, блокирующих запись.
Во-первых, он явно используется при вызове конструкций типа SELECT... FOR SHARE, FOR UPDATE. Во-вторых, он неявно используется для поддержания целостности внешних ключей, то есть пользователи используют их гораздо чаще, чем они думают.
Давайте посмотрим как это устроено.
Когда несколько транзакций блокируют одну запись, Postgres не может сохранить все эти идентификаторы транзакций в одном поле заголовка записи: поле t_xmax имеет размер всего 4 байта, а значит, может разместить только один идентификатор. В случае, если нужно разместить несколько блокирующих транзакций, то поле t_xmax уже называется идентификатором мультитранзакции (внутри Postgres используется тип MultiXactId), а уже этот идентификатор ссылается на внутреннюю структуру данных, хранящую ма��сив реальных транзакций.
Вот небольшая выдержка из исходников (https://github.com/postgres/postgres/blob/master/src/include/access/htup_details.h):
/*------------------------------------------------------------------------- * * htup_details.h * POSTGRES heap tuple header definitions. ... typedef struct HeapTupleFields { TransactionId t_xmin; /* inserting xact ID */ TransactionId t_xmax; /* deleting or locking xact ID */ ... } HeapTupleFields; ... struct HeapTupleHeaderData { union { HeapTupleFields t_heap; DatumTupleFields t_datum; } t_choice; ... uint16 t_infomask; /* various flag bits, see below */ ... }; ... #define HEAP_XMAX_IS_MULTI 0x1000 /* t_xmax is a MultiXactId */ ...
Структура HeapTupleFields и есть заголовок записи. Нас интересуют поля t_xmax и t_infomask. Если в t_infomask поднят флаг HEAP_XMAX_IS_MULTI, то значение, хранящееся в t_xmax, нужно воспринимать не как номер транзакции, а как идентификатор мультитранзакции, то есть контейнер, в котором будут лежать номера настоящих транзакций, блокирующих эту запись. Данные же мультитранзакций хранятся в директории $PGDATA/pg_multixact.
Для записи мультитранзакций на диск (как я уже сказал, наша задача — сохранить массив реальных транзакций) используется двухуровневая схема хранения:
$PGDATA/pg_multixact/offsets хранит то смещение мультитранзакции в members, которое нужно взять, чтобы получить начало массива реальных транзакций.
$PGDATA/pg_multixact/members хранит уже сами транзакции. В реальности там хранится структура MultiXactMember, но сейчас это для нас не важно.
Чтобы записать новый идентификатор мультитранзакции, мы вызываем функцию GetNewMultiXactId (https://github.com/postgres/postgres/blob/master/src/backend/access/transam/multixact.c)
static MultiXactId GetNewMultiXactId(int nmembers, MultiXactOffset *offset) { ... /* Assign the MXID */ result = MultiXactState->nextMXact; ... /* Выделяем сегменты $PGDATA/pg_multixact/offsets */ ExtendMultiXactOffset(NextMultiXactId(result)); ... nextOffset = MultiXactState->nextOffset; ... /* Выделяем сегменты $PGDATA/pg_multixact/members */ ExtendMultiXactMember(nextOffset, nmembers); ... return result; }
Итого:
Мультитранзакция — это контейнер произвольной длины, который хранит номера реальных транзакций, заблокировавших запись.
Номер мультитранзакции хранится непосредственно в самом заголовке записи и имеет размер 4 байта.
Сами мультитранзакции хранятся в виде SLRU (https://github.com/postgres/postgres/blob/master/src/backend/access/transam/slru.c) сегментов и состоят из двух частей.
Первая часть называется offsets и показывает смещение во второй части members, в которой уже хранятся реальные номера транзакций.
Офсеты не имеют семантического смысла за пределами механизма хранения, они только указывают позицию в файле.
Вот такая, надо признать, нетривиальная схема хранения массива переменной длины. Можно ли было выбрать другую схему? Я думаю да, но сейчас это обсуждать уже бесполезно, такова реальность, с которой приходится мириться.
Проблема
Почему может возникать проблема? Да, пусть не самая простая схема хранения мультитранзакций, но ведь их должно хватать. Ведь одномоментно Postgres не может выполнять транзакции, отстоящие друг от друга больше чем на 231.
Да, но... Не всё так просто3. Очень интересный разбор3 для тех, у кого работа связана с администрированием Postrges, поэтому с ним непременно стоит ознакомится.
Есть две неприятные особенности у мультитранзакций.
Первая — это иммутабельность: создав мультитранзакцию однажды, мы уже не можем добавить в неё новые транзакции. Каждый раз создаётся новая мультитранзакция со своим собственным набором значений.
Вторая особенность — квадратичный4 рост офсетов при повторном взятии блокировок одной и той же записи.
Давайте посмотрим как последовательно запись будет блокироваться транзакциями T1, T2, T3 и T4.
Выполняем T1: mxact = [T1]; Итого 1.
Выполняем T2: mxact = [T1, T2]; Итого 3.
Выполняем T3: mxact = [T1, T2, T3]; Итого 6.
Выполняем T4: mxact = [T1, T2, T3, T4]; Итого 10.
Забавно, что это последовательность треугольных5 чисел.
Эти проблемы усугубляются, как справедливо замечено в описании инцидента4, отсутствием стандартного способа мониторинга переполнения офсетов, малым документированием и непонятным текстом об ошибке.
Решение: переход на 64 бита
После принятия коммита bd8d9c9bdfa0c2168bb37edca6 Postgres стал использовать 8-байтные офсеты, что сделало проблему их переполнения гипотетической. Фактически риск переполнения исчез.
Сохранена обратная совместимость. Как я уже писал выше, офсеты не имеют семантического смысла за пределами механизма хранения: они только указывают позицию в файле. То есть это просто детали реализации, которые просочились «наружу». При очередном обновлении утилита pg_upgarde выполнит все нужные преобразования, и новый кластер БД будет работать на 64-битных офсетах.
При любом техническом решении должен быть компромисс. Чем пришлось пожертвовать, чтобы совершить переход на 64-битные офсеты?
В первую очередь, это размер сегментов смещений offsets. Он увеличился в два раза. Но необходимо учитывать, что это не пользовательские данные, это часть внутреннего механизма пользователя, а размер директории $PGDATA/pg_multixact напрямую зависит от структуры ваших данных и типа нагрузки. Однако, я не представляю, чтобы в реальных условиях это стало проблемой. Если же я не прав, то выход всё же есть. Дело в том, что офсеты расположены на странице последовательно (хотя и попадают туда не по порядку), поэтому можно применить сжатие — записывать офсеты группой, для каждой из которых выделить некоторую «базу», а остальные офсеты хранить как смещение относительно этой «базы». Вопрос только в размере этой группы.
Мы не стали сейчас этого делать сейчас, так как это снижает надёжность системы в целом. При потере одного байта на странице (а я напомню, что страницы SLRU не хранят контрольных сумм) мы рискуем потерять не одну мультитранзакцию, а всю группу.
Второй компромисс состоит в том, что, как я писал выше, формат $PGDATA/pg_multixact бинарно несовместим с предыдущими версиями, и это значит, что необходима конвертация при обновлении. Эту работу берёт на себя утилита pg_upgarde, и в ней добавился новый этап.
В целом я считаю, что надёжность кластера БД и удобство его администрирования важнее незначительного в рамках всего кластера увеличения размеров хранения служебной информации.
Ну и нельзя не порадоваться, что пользоваться Postrges теперь стало ещё проще. Ушёл оборот одного из важнейших счётчиков. Теперь администратору нужно следить только за возрастом транзакций и мультитранзакций.
Что дальше?
Работа не прекращается. Мы продолжаем работать над переводом Postrges на полноценные 64-битные транзакции. На самом деле, упомянутый патч — это только часть той большой работы, которую ещё предстоит проделать.
Я глубоко убеждён, что движение в направлении поддержки полноценных 64-битных транзакций в Postrges — это то, что должно быть сделано. 32-битные транзакции должны уйти в прошлое как исторический анекдот. Ведь ещё в основополагающем документе6 "THE DESIGN OF POSTGRES" Michael Stonebraker указывал что транзакции должны быть 64-битные.
Every tuple has an immutable unique identifier (IID) that is assigned at tuple creation time
and never changes. This is a 64 bit quantity assigned internally by POSTGRES. Moreover, each transaction has a unique 64 bit transaction identifier (XACTID) assigned by POSTGRES.
Выбор в пользу 32-х бит был продиктован, на мой взгляд, только ограничениями тех платформ, которыми располагали разработчики в середине 90-х. Нет никакой магии в том, чтобы ограничиваться 2 миллиардами возможных транзакций. Архитектура, быстродействие современных ЭВМ, возросшие объёмы хранимых данных — всё это толкает нас на постоянное развитие, и Postgres должен соответствовать вызовам времени.
