Один из наших клиентов, эксплуатирующий PostgreSQL под большой нагрузкой, столкнулся с проблемой, связанной с переполнением счетчика транзакций (xid wraparound), причем выхода из нее штатными средствами не существовало. Мы решили проблему с помощью хирургического вмешательства и выпустили патч, предотвращающий возникновение таких ситуаций в будущем.
В этой заметке мы расскажем, как и почему может произойти проблема и как ее не допустить.
Устройство счетчика транзакций PostgreSQL
Одной из самых привлекательных особенностей PostgreSQL является возможность работы в условиях высокой конкуренции за данные: читающие транзакции не блокируют пишущие и наоборот. Все это происходит благодаря механизму многоверсионности (MVCC). Реализация основана на том, что каждая транзакция в PostgreSQL имеет свой номер (идентификатор), называемый xid. Номера все время увеличиваются, так что транзакция с меньшим номером считается начавшейся раньше, а транзакция с большим номером — позже. Каждая строчка в таблице имеет, помимо прочего, два дополнительных системных поля, которые не показываются в запросах пользователей: они называются xmin и xmax. Поле xmin хранит номер транзакции, которая создала данную строку, а xmax — номер транзакции, которая ее удалила (если, конечно, такое произошло). Таким образом, каждая строчка может иметь несколько версий с разной областью видимости. Такой подход к организации хранения данных называется версионным.
Пользовательское содержимое строки в PostgreSQL никогда не изменяется, меняются только системные поля. Обновление данных (UPDATE) помечает строку как удаленную, то есть устанавливает xmax = xid_current, и создает новую копию строки с обновленным содержимым, у которой xmin = xid_current.
Когда PostgreSQL считывает данные из таблиц, это всегда происходит в контексте некоторого снимка данных (snapshot). При создании снимка данных в нем запоминается текущий номер транзакции, по которому из нескольких версий строки можно выбрать ту, которая будет видна в контексте текущего снимка. Кроме того, в снимок включается список всех не завершенных в данный момент транзакций, поскольку изменения, сделанные такими транзакциями, не должны попасть в снимок.
Версии строк, вышедшие из областей видимости всех действующих транзакций, становятся не нужными. Чтобы удержать базу данных от избыточного роста, специальный фоновой процесс, называемый autovacuum, удаляет старые версии строк — те, в которых xmax младше всех работающих на текущий момент транзакций.
Счетчик транзакций имеет размер 32 бита, то есть может хранить примерно четыре миллиарда значений. Это, конечно, не так уж много. Звучали предложения сделать его 64-битным, однако не стоит забывать, что в этом случае за счет накладных расходов заметно возрос бы объем базы — ведь в каждой строке хранятся xmin и xmax. Представьте себе, что предел счетчика в 2^32-1 достигнут. Прибавляем единичку — и счетчик переполняется и сбрасывается в ноль. Это привело бы к катастрофе — ведь PostgreSQL рассчитывает на то, что номера транзакций всегда увеличиваются.
Конечно же, есть механизм, предотвращающий такую ситуацию. Во-первых, пространство номеров транзакций закольцовано: младше на самом деле считается не та транзакция, у которой номер меньше, а та, которая отстоит от другой меньше, чем на пол-круга. Во-вторых, в ходе очистки (VACUUM) таблиц выполняется так называемая заморозка. Процесс vacuum/autovacuum, помимо удаления “мертвых” строк со старым xmax, обрабатывает также и “живые” строки со старым значением xmin. Строки, чей xmin гораздо меньше самой старой из запущенных транзакций и “возраст” превышает vacuum_freeze_min_age, “замораживаются” (помечается специальными служебными битами). Они перестает подчиняться обычным правилам видимости и всегда считается старше любой обычной транзакции. Таким образом, очистка постоянно замораживает старые строки, следуя по кругу за счетчиком транзакций.
Возраст самой старой транзакции в базе хранится в системном каталоге:
SELECT datname, age(datfrozenxid) FROM pg_database;
Также ведется статистика по каждой таблице:
SELECT relname, age(relfrozenxid) FROM pg_class;
Фоновые процессы следят за транзакциями в автоматическом режиме, но при эксплуатации PostgreSQL администратор должен следить за тем, чтобы возраст самой старой транзакции в базе не приближался к 2^31 (пол-круга, половина от всех допустимых значений). Тогда PostgreSQL может гарантировать правильность определения возраста транзакции (с учетом цикличности счетчика). Если же возраст datfrozenxid приблизился к указанной точке, то PostgreSQL не сможет больше выдавать номера транзакций и прекратит работу из соображений сохранности данных, требуя ручного вмешательства и проведения очистки (VACUUM).
Именно поэтому следует избегать сверхдлинных транзакций, за время которых счетчик успевает увеличиться на 2 миллиарда.
Проблема и лечение
Именно переполнение datfrozenxid произошло у одного из наших клиентов. Администратор вручную запустил команду VACUUM FREEZE, которая проработала 8 дней. За это время под нагрузкой было выдано порядка 2^31 новых номеров транзакций. Стоит отметить, что, хотя VACUUM работает вне транзакции, но при запуске создает снимок данных, с помощью которого отличает устаревшие версии строк от актуальных. Система остановилась и требовала ручного вмешательства, но, несмотря на то, что обслуживание было проведено, уже не смогла запуститься в рабочем режиме.
Проблема заключалась в том, что перед обновлением переменных, расположенных в разделяемой памяти, команда VACUUM пыталась получить новый номер транзакции, чтобы убедиться в том, что заморозка прошла корректно и в системе нет транзакций “из будущего”. Поскольку доступные номера закончились, команда завершалась с ошибкой, из-за чего переменные, отвечающие за диапазон доступных номеров, не обновлялись. Для исправления проблемы было выработано два решения: оперативное и постоянное.
Оперативное решение требовалось для того, чтобы как можно быстрее возобновить работу БД заказчика. Для этого нам пришлось вручную отредактировать переменные в разделяемой памяти.
- Выбираем номер (xid) самой старой транзакции, записанной в БД:
SELECT datfrozenxid FROM pg_database ORDER BY age(datfrozenxid) DESC LIMIT 1;
- Подключаемся с помощью gdb к любому процессу PostgreSQL и выполняем команду:
set ShmemVariableCache->oldestXid = <выбранный_xid>
- Перезапускаем PostgreSQL
В результате этих действий значения, отвечающие за диапазон доступных транзакций, были обновлены как в разделяемой памяти, так и на диске, и СУБД заказчика снова смогла продолжить нормальную работу.
Постоянное решение проблемы заключается в том, чтобы команда VACUUM вообще не получала отдельный номер транзакции. Патч и инструкция по воспроизведению ситуации были высланы в список рассылки hackers. Данное исправление будет включено во все версии PostgreSQL.
Изменения в 9.6
Неприятное свойство заморозки состоит в том, что для этого необходимо просканировать всю таблицу. Обычная очистка (vacuum) работает хитрее: если в процессе работы оказывается, что все версии строк на странице являются актуальными (то есть xmax = 0), такая страница отмечается в специальном файле, который называется картой видимости (visibility map). К таким страницам vacuum больше не возвращается до тех пор, пока в них не произойдут какие-то изменения (при которых отметка в карте видимости автоматически снимается).
К сожалению, заморозка не использует карту видимости: ведь даже на страницах с исключительно актуальными версиями строк могут отказаться еще не замороженные номера транзакций в поле xmin. А периодическое полное сканирование может вызывать проблемы при очень большом размере таблицы.
В PostgreSQL 9.6, бета-версия которого уже выпущена, эта сложность была преодолена. Карта видимости теперь расширена таким образом, чтобы содержать в себе и “карту заморозки”: в ней будут отмечаться страницы, на которых все транзакции уже заморожены.
Мониторинг переполнения счетчика транзакций
Для контроля переполнения нужно просматривать возраст транзакций datfrozenxid из системного каталога pg_database. Если вы пользуетесь системой Zabbix, попробуйте клиент мониторинга mamonsu, в котором уже присутствует нужная метрика. Клиент доступен по адресу: mamonsu.
Выводы
Такие сложные продукты, как реляционные СУБД, никогда не бывают абсолютно свободны от ошибок. Несмотря на надежность PostgreSQL, при эксплуатации вы можете столкнуться с неприятными проблемами. Сообщество предоставляет поддержку, но, во-первых, вам придется проделать серьезную работу по оформлению сообщения об ошибке (чтобы разработчики смогли воспроизвести вашу ситуацию) и во-вторых, никто не гарантирует срок исправления.
Вот почему, работая с системами, критичными для бизнеса, полезно иметь техническую поддержку от компании-вендора, разработчики которой разбираются в исходном коде, могут выполнить необходимые исправления и сделают это за минимально возможное время.
Комментарии (11)
yul
20.05.2016 13:25+1Счетчик транзакций имеет размер 32 бита, то есть может хранить примерно четыре миллиарда значений. Это, конечно, не так уж много. Звучали предложения сделать его 64-битным, однако не стоит забывать, что в этом случае за счет накладных расходов заметно возрос бы объем базы — ведь в каждой строке хранятся xmin и xmax.
По-моему лишние 8 байт на строку в наше время не такая уж и большая плата за отсутствие таких проблем в обозримом будущем. По крайней мере, как опцию можно было бы сделать.slysha
20.05.2016 13:58+1Большинству такое обозримое будущее — это бесконечность. Такие ситуации могут встретиться лишь на высоконагруженных системах. А для этого случая уже сделан патч, поэтому в данном случае краха уже не случится.
yul
20.05.2016 15:54-1Для такого случая — да. В будущем могут вскрыться и другие. Проще предотвратить, чем лечить.
zuborg
20.05.2016 16:18Размер заголовка строки уже 24 байта (это минимум, при наличии больше чем 8-ми NULL полей будет ещё больше). Если строки большие, то 8 лишних байт роли не сыграют, но если у вас сотни миллионов компактных строк, то тут и 24 байта выглядят не всегда подъемными.
slysha
23.05.2016 16:55+1Если у нас сотни миллионов строк это всего навсего пара лишних гигабайт. Гораздо гораздее версионность системы и WAL будут кушать место чем лишние 8 байт на запись. Хотя с другой стороны, вспоминая экономию на временных метках и «проблему 2000», всегда есть возможность попрограммировать в будущем. Зачем напрягаться сейчас, когда есть возможность переложить на будущие поколения погромистов %)
Iktash
Заранее извиняюсь, если мой вопрос глупый, т.к. я очень далек от разработки БД.
Я правильно понимаю, что в момент, когда с начала выполнения транзакции прошло 2^31 других транзакций бОльшая часть из этих 2^31 уже выполнена и их номера хранить не надо? Вопрос к тому, что разве нельзя по достижению середины круга проходить по всему счетчику и убрать оттуда все лишнее, оставив только те транзакции, которые еще не выполнены? Таким образом, мы сохраним порядок транзакций, что нам и требовалось?
vadv
именно это и делает процесс вакуум, он проставляет специальный номер транзакции — FrozenXid (так было до 9.5), а в 9.5 проставляется специальный Hint Bit.
счетчик прописал свой номер в данных, и идти надо по данным, а не по счетчику. На больших данных это накладно. Поэтому существуют процессы, которые выполняют это в фоне.
в идеале все работает "само", факап описанный в статье произошел из-за того, что 8 дней выполнялся процесс работающий в своем снимке данных, можно сказать в своей транзакции.
slysha
Если вдруг появится залипшая транзакция такое может случится. Просто в этом случае процесс был более менее запущен вручную.
vadv
Ждем конфигурируемого "snapshot too old" :)
slysha
И redo logs + undotbs