Будущее PostgreSQL: как 64-битный счетчик транзакций решает проблему масштабирования
Много лет в комьюнити 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
Таким образом, для стабильной работы базы необходимо и достаточно выполнения двух условий:
разница между идентификатором текущего номера транзакции и номера самой старой записи не превосходит число 232
обеспечено стабильное выполнение 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 сможет адаптироваться ко все возрастающим нагрузкам, чтобы занять еще большую долю рынка.