Много лет в комьюнити PostgreSQL никто не верил, что эта СУБД, в принципе, может использоваться в системах с большой транзакционной нагрузкой. То есть какие-то тестовые лаборатории, бэкенд веб-приложений средней руки и так далее — вот его типичные задачи. А когда нужна серьезная нагрузка, это уже надо брать СУБД за много денег и не сомневаться. Ну и раз никто не верил, то и не развивал особенно его в эту сторону, оставляя все больше повисших в воздухе вопросов.

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

Что за счётчик такой? И немного о  многоверсионности в PostgreSQL

Одна из интересных особенностей PostgreSQL состоит в том, каким образом он обрабатывает конкурентный доступ к данным для множества пользователей. Например, читающие транзакции не блокируют пишущие, что довольно очевидно. Но и пишущие транзакции не блокируют читающие. А возможно все это благодаря механизму многоверсионности MVCC. Если кратко, то смысл его в том, что каждая транзакция работает со своей версией данных.  В один момент времени в СУБД может одновременно существовать несколько версий данных, доступ к которым происходит по правилам видимости.

Для обеспечения работы механизма MVCC PostgreSQL необходимо отслеживать, какие транзакции уже завершены, а какие еще активны. Для этого каждой транзакции присваивается уникальный номер — идентификатор (xid). Его можно узнать, используя функцию txid_current(). Учетом этих номеров и занимается счетчик транзакций.

Однако важно понимать, что не стоит использовать понятия «больше» и «меньше», когда речь идет о номерах транзакций. Лучше говорить «старше» и «младше». Объясню почему: номера транзакций (xid) сравниваются друг с другом по модулю 232, то есть образуют кольцо. Все транзакции, отстающие от текущей в сторону минуса на 231, считаются «старше» и находятся «в прошлом», а отстоящие на 231 в положительную сторону считаются «младше» и находятся «в будущем».

Графически это можно представить следующим образом:

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

Так исторически сложилось: история счётчика транзакций

Примерно до версии 8.2 постгрес просто падал при достижении максимального значения счетчика транзакций, то есть при его исчерпании. На дворе шел 2006 год, и выгрести ограничение в 4 миллиарда было крайне сложной задачей, пусть даже и теоретически возможной. А единственный способ продолжить работать после переполнения в те времена — сдампить базу и создать ее заново.

Тут даже комментировать ничего не надо на тему удобства, поэтому в версии 8.2 был представлен механизм обнуления счетчика транзакций, вернее его циклического перезапуска. Отныне, чтобы не допустить остановки СУБД, необходимо постоянное выполнение очистки (VACUUM) в фоновом режиме. При успешном запуске этот механизм обеспечивает зацикливание счётчика транзакций вместо перезапуска СУБД, с присвоением старым значениям особого FrozenTransactionId идентификатора. Таким образом достигается условие, что число активных транзакций никогда не превышает лимита в 4 с небольшим млрд значений. Также при каждом обороте делается +1 в так называемом счетчике эпох, чтобы знать, сколько раз уже мы провернули этот трюк.

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

Тогда в чём суть проблемы?

С принципом работы механизма самого счетчика транзакций проблем нет. Он действительно хорошо сделан и отлично работает, причем столько же, сколько существует сам Postgres. Но в этом же лежит причина его фатального недостатка: механизм создавался в те времена, когда четырехбайтное беззнаковое число (немногим более 4 млрд — 4 294 967 296) казалось огромным и почти неисчерпаемым. И если вам кажется, что проблема надуманная, а 49 710 транзакций в секунду — это что-то из мира фантастики, то большой привет вам передают многочисленные ретейлеры, заводы с фабриками и, в конце концов, федеральные ведомства. В их инсталляциях количество активных сессий исчисляется тысячами, и это нормальный режим работы. Поэтому у многих наших клиентов счетчик должен оборачиваться буквально раз в сутки. То есть живут они на пороховой бочке, надеясь, что ничто не помешает очередному проходу вакуума.

Но на этом проблемы не заканчиваются. Как было сказано выше, среди прочей служебной информации помимо системных полей существует также поле флагов. Там в дело вступает уже знакомый нам VACUUM, но с параметром FREEZE. Это агрессивная «заморозка» кортежей. Он должен запускаться время от времени для всех записей, находящихся далеко в прошлом, и проставлять флаг о том, что эта запись является «замороженной», то есть  видна для всех транзакций и имеет особый FrozenTransactionId

Таким образом, для стабильной работы базы необходимо и достаточно выполнения двух условий:

  1. разница между идентификатором текущего номера транзакции и номера самой старой записи не превосходит число 232

  2. обеспечено стабильное выполнение VACUUM FREEZE до момента нарушения правила 1.

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

  • «Долгие» («висячие») транзакции не дают выполнить заморозку. При этом разница между самой старой записью и текущей продолжает увеличиваться прямо на наших глазах. И будто этого мало, мы не можем априори знать, какая транзакция считается «долгой», а какая — нет (ниже об этом рассказано подробнее).

  • На современном «железе» исчерпание идентификаторов транзакций может достигаться за день. Когда начинать бить тревогу, что VACUUM не успеет отработать? За пять минут? За час? В середине рабочего дня? Корректно ли признать «долгой» транзакцию, выполняющуюся несколько минут? А если было запущено формирование тяжелого отчета, который формируется несколько часов? Какие критерии применимы для определения «долгой» транзакции? Когда «долгая» транзакция может считаться «зависшей»? Кто должен принять решение о ее прерывании?

  • Администратор СУБД постоянно должен быть готов принять самые решительные меры, в случае если разница между самой старой записью и текущим номером транзакции начинает опасно увеличиваться. И даже если у нас есть корректно настроенный мониторинг с алертами, круглосуточные дежурства, четкий план действий и проводятся регулярные учения, в конечном итоге администратор берет на себя ответственность за все те потери, которые бизнес может понести в результате его действий, пусть даже технически его действия будут абсолютно верными.

Иными словами, если вы можете позволить себе остановить ваш кластер БД в любое время, запустить его в однопользовательском режиме для выполнения команды VACUUM FREEZE и подождать несколько часов, то проблема, описанная в этой статье, вас не касается. Но если вам не повезло, вы живете в реальном мире хайлоада и ваш Postgres — это часть системы с высокими требованиями к доступности, все описанные выше проблемы рано или поздно вас догонят, а скорее всего, так и вовсе уже видны на горизонте.

Также в конце постановки проблемы хочется отдельной строкой упомянуть подвид баз данных, в которые данные только добавляются, а уже хранимые никак не изменяются (insert-only). Как ни удивительно, но Postgres на таких системах тоже постоянно запускает VACUUM для «заморозки» старых записей таблицы, пусть даже она только читается.

Что мы сделали? Появление 64-bit xid

Сразу же хочется воскликнуть: «Ну так давайте просто изменим тип переменной, хранящей счетчик, git push — и проблема решена!» Но не все так просто. Во-первых, весь остальной код постгреса ожидает, что в счетчике будет 32-битное число, а значит, и все проверки тоже, что автоматически приводит к рефакторингу большого объема кода. Но даже это не основная проблема. Как упоминалось выше, в каждом кортеже (он же tuple в английской документации) хранятся xmin и xmax. Получается, что в каждый кортеж теперь надо было бы писать не 8 байт, а 16. А при условии что минимальный кортеж — это 24 байта, размер базы начинает стремительно увеличиваться.

Все это побудило нас, Postgres Professional, первыми в мире заняться разработкой 64-bit xid. На это ушло несколько лет, однако нам удалось создать реализацию расширения пространства идентификаторов транзакций до астрономического числа 18 446 744 073 709 551 615 (2 в степени 64, или восемнадцать квинтиллионов четыреста сорок шесть квадриллионов семьсот сорок четыре триллиона семьдесят три миллиарда семьсот девять миллионов пятьсот пятьдесят одна тысяча шестьсот пятнадцать), которую мы представили сообществу, и именно она теперь используется примерно во всех форках Postgres, коммерческих, бесплатных — не важно. Другой стабильно работающей реализации пока никто не видел.

Поэтому, со своей стороны, мы уже пять лет, как перешли на использование восьмибайтных идентификаторов. Так что можем смело утверждать, что решение получилось надежным и стабильным, а проблема переполнения счетчика транзакций стала больше гипотетической, чем реальной. Несложные расчеты показывают, что исчерпание значений 64-битного счетчика при транзакционной нагрузке, вызывающей переполнение 32-битного счетчика раз в день, произойдет примерно через 400 лет.

Наша реализация была детально описана с примерами кортежей в статье Максима Орлова, а патч предложен для включения в ванильный Postgres. Однако в комьюнити его принимать отказались из-за размера и сложности, поэтому мы уже несколько лет ведем работу по постепенному внедрению этого патча в Vanila-версию. О ходе работ и загадочном статусе Returned with feedback Максим рассказывал на конференции этим летом.

Задержка с принятием патча, конечно же, никак не остановила разработчиков других форков постгреса, так что сейчас о поддержке в своих дистрибутивах 64-bit xid заявил действительно примерно каждый. Но мы это прекрасно понимали и пошли на такой шаг абсолютно осмысленно, чтобы максимально поднять интерес как к самой проблеме со счетчиком, так и предложить другим разработчикам оценить нашу реализацию, а еще лучше — предложить свою. Поэтому наша позиция состоит не в продвижении нашего варианта как единственно возможного или самого правильного. Скорее, мы рекомендуем сообществу уделить больше времени реализации идеи 64-битных идентификаторов транзакций и приветствуем улучшения и предложения.

Итого

Первая часть (с привлечением внимания) уже однозначно удалась. Идея получила должное внимание, но внедрение таких революционных изменений — дело небыстрое по многим причинам, не только техническим. Однако усиливающиеся темпы цифровизации и рост нагрузок на СУБД по всему миру постоянно демонстрируют комьюнити важность и актуальность этой проблемы. С другой стороны, мы уже накопили большой багаж историй успеха наших заказчиков, развернувших различные версии Postgres Pro на своих продакшн-системах. А поскольку новый счетчик транзакций там используется по дефолту, наша реализация доказала свою работоспособность. Так что продолжаем работать и надеемся, что PostgreSQL сможет адаптироваться ко все возрастающим нагрузкам, чтобы занять еще большую долю рынка.

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


  1. dude_sam
    18.12.2024 13:20

    Как упоминалось выше, в каждом кортеже (он же tuple в английской документации) хранятся xmin и xmax. Получается, что в каждый кортеж теперь надо писать не 8 байт, а 16.

    А как оно "включается" на уже существующей БД? Поднимается бэкап на версии Postgres c имплементированным 64-bit xid и что потом? Оба столбца каждой строки каждой таблицы апдейтится под новый тип данных или что? Или всё остаётся как есть и только в следующее изменение кортежа меняется тип этих столбцов? Или вообще всё иначе?


    1. Loxmatiymamont Автор
      18.12.2024 13:20

      Это неточность формулировки. Правильно должно звучать так: надо было бы писать не 8, а 16 (в статье исправил тоже). Именно по этой причине мы не пошли таким путём. Мы храним туплы в старом 32–х битном формате, сохраняя 8 байт ксидов для каждого тупла, но дополнительно размещаем на странице “базу”, сложение с которой и выдаёт нам 8-ми байтный ксид.


      1. dude_sam
        18.12.2024 13:20

        Ага! Спасибо! Теперь понятно.


      1. Portnov
        18.12.2024 13:20

        Эта база ведь прибавляется и к xmin, и к xmax всех туплов на этой странице?

        А если разница между xmin и xmax туплов на одной странице вдруг станут отличаться больше чем на 4млрд? :) понятно что маловероятная ситуация...


    1. Okunev_PY
      18.12.2024 13:20

      При поднятии бэкапа все счётчики сбрасываються и заполняються уже средствами текущего движка СУБД.

      Поэтому размерность кортежей в исходной БД роли не играет


      1. dude_sam
        18.12.2024 13:20

        Ну, я больше про тип спрашивал, но не про значения. Но там выше объяснили, что не изменяется как-бы ничего.


  1. Ivan22
    18.12.2024 13:20

    postgres плохо подходит для высоконагруженных транзакционных субд в первую очередь вовсе не из-за размерности xid. Главная причина - механизм версионности, который плодит версии строки внутри того же сегмента данных, что приводит к распуханию сегментов данных, потом к необходимости вакуума по тем же страницам данных, а потом и к дефрагментации из-за этого (и необходимости в vacuum full). Это все просто антипаттерн для системы где много изменений в данных. И сравните это с эталоном - Oracle, где старые версии строки хранятся в отдельном сегменте данных undo log, и поэтому: 1. Не распухает основной сегмент данных. 2. Очистка старых версий (вакуум) НЕ мешает работе с основными данными, 3. дефрагментация из-за пустых версий отсутствует. (единственное где из-за этого оракл проиграет - при Rollback-ах. Но много ли их выообще?? )

    Это фатальный недостаток который в PG принципиально не излечим


    1. vitaly_il1
      18.12.2024 13:20

      Интересно, не думал об этом.
      Можете посоветовать пост/статью на эту тему со сравнением популярных DB?


    1. Roman2dot0
      18.12.2024 13:20

      orioledb придёт, порядок наведёт.


      1. Sleuthhound
        18.12.2024 13:20

        Там очень глобальные доработки в ядре и я сильно сомневаюсь что удастся их пропихнуть в основной состав Pg.

        А переходить в чистом виде на OrioleDB не каждый решиться без поддержки.

        И кстате пока в OrioleDB нельзя создавать индексы отличные от B-Tree, но очень ждем что команда Александра Короткова это решит.


        1. Roman2dot0
          18.12.2024 13:20

          Там патчи направлены на расширение функционала "table access method".

          Часть патчей уже приняли, остальные ожидается в 18 версии.

          Основное будет реализовано как расширение. Так что не всё так плохо.


    1. ViacheslavNk
      18.12.2024 13:20

      К сожалению да, слабое место postgree на высоких нагрузках это MVCC.


  1. MatveevDmitry
    18.12.2024 13:20

    Стоит добавить что это сделало невозможным использование служебного столбца xmin для разрешения конфликтов доступа в оптимистичной модели Entity Framework Core в .NET. Именно его предлагают использовать в ванильном PostgreSQL для этих целей. Официальный драйвер PostgreSQL логично не может больше смапить этот столбец в Postgres Pro Enterprise на тип unsigned int, потому что ожидается другой тип. И это явилось для нас очень неприятным открытием и послужило отказом от использования Postgres Pro Ent.


    1. Loxmatiymamont Автор
      18.12.2024 13:20

      Да, есть такое. Но на Майкрософт мы повлиять не можем. С другой стороны, все понимают что рано или поздно в ванилле тип данных тоже изменится, после чего им придется изменить свой драйвер.

      P.s. в standart версии ксиды хранятся по старорежимному, так что если нет потребности именно в ent, это не будет проблемой.


      1. MatveevDmitry
        18.12.2024 13:20

        Это с скорее к разработчикам драйвера npgsql, но они вряд ли что то будут делать, чего нет в ванили. Кстати в последних версиях он поддерживает тип xid8. Но при попытке создания xmin с этим типом в enterprise через model (code) first, все равно получаю в БД xid, и оно конечно не работает. Надо будет попробовать покопаться во внутрянке драйвера когда будет время, может удастся научить его работать с вашими xid. Некоторые наши заказчики настаивают на использовании именно Postgres Pro Enterprise.


  1. Wesha
    18.12.2024 13:20

    Это называется не «решили проблему», а «отложили проблему на неопределённое время». («Проблема 2000» передаёт горячий привет эстафету «проблеме 2038»).


  1. manyakRus
    18.12.2024 13:20

    Хочу использовать поле xmin как номер версии записи в коде go, чтобы случайно не записать в БД устаревшую версию записи из памяти.
    Сейчас это бесполезно т.к. 32битный ИД может стать меньше чем был.
    Хочу 64битный xmin :-)


    1. AdAbsurdum
      18.12.2024 13:20

      Можно сделать колонку и класть туда 64 битный "xmin здорового человека" используя txid_current()


  1. dbax
    18.12.2024 13:20

    Сдается мне что проблема 32-битного счетчика транзакций в большой мере надуманная.

    Почему за все годы никто в глобальной pgdg не серьезно задумался об этом?

    Скорее всего 99,99% инсталляций не сталкиваются с подобной проблемой даже близко. А в тех случаях когда это все-таки "выстреливает" имело место изначально "кривая" архитектура приложения(АПП и БД в комплексе)