Внутренности PostgreSQL: XID wraparound

20 февраля 2023

Благодаря посту Внутренности PostgreSQL: ProcArray и CLOG мы узнали, как PostgreSQL определяет состояние транзакции по ее идентификатору, или XID. Однако из статьи Внутренности PostgreSQL: страницы и кортежи мы также помним, что XID является 32-х битным числом. Несложными математическими расчетами несложно понять, что даже при скромных нагрузках (~1000 TPS), уникальные XID’ы могут закончится за несколько месяцев. Давайте разберемся, как PostgreSQL решает эту проблему.

Вообще, как PostgreSQL сравнивает пару XID’ов? Для этого в файлах transam.c / transam.h есть функции TransactionIdPrecedes, TransactionIdFollows, и другие. Вот для примера реализация TransactionIdPrecedes:

/*
 * TransactionIdPrecedes --- is id1 logically < id2?
 */

bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
    /*
     * If either ID is a permanent XID then we can just do unsigned
     * comparison.  If both are normal, do a modulo-2^32 comparison.
     */

    int32    diff;

    if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
        return (id1 < id2);

    diff = (int32) (id1 - id2);
    return (diff < 0);
}

Здесь мы видим, что XID’ы сравниваются по модулю 232. Это означает, что для любого заданного XID есть примерно 2 млрд значений, которые считаются больше него (находятся в будущем), и 2 млрд значений, который считаются меньше (находятся в прошлом).

Представим, что используется самая наивная реализация. Имеется транзакция с XID равным N. После нее мы исполняем 2 млрд других транзакций. Когда мы начнем исполнять 2 млрд + 1 транзакцию, из-за целочисленного переполнения N-ая транзакция окажется для нее не в прошлом, а в будущем (вот картинка). Поэтому транзакция ошибочно решит, что не видит соответствующие данные, хотя на самом деле должна их видеть. Это означает потерю данных.

Описанный сценарий называется transaction ID wraparound, или XID wraparound, или переполнение счетчика транзакций. Реализация PostgreSQL такова, что XID wraparound произойти не может. Ранее выделенные XID освобождаются и переиспользуются будущими транзакциями. Далее по тексту мы рассмотрим, как это работает.

Но сначала необходимо подчеркнуть важность проблемы. Если СУБД настроена неправильно и/или не мониторится должным образом, то при определенных нагрузках алгоритм, предотвращающий XID wraparound, может привести к деградации производительности, а в пределе — к переходу системы в режим read-only* до ручного вмешательства DBA (история один, история два, и далее по ссылкам). Начинающие пользователи PostgreSQL не подозревают об этом.

Примечание: (*) Соответствующая документация и сообщения об ошибках долгое время не обновлялись. На момент написания этих строк в них содержалась неактуальная информация, что якобы в пределе система должна остановиться (shutdown). На практике этого не происходит. Подробности можно найти здесь.

Дополнительно ситуация усугубляется следующим. По мере приближения XID wraparound СУБД начинает агрессивно запускать VACUUM. Начинающий пользователь рассуждает так. Если СУБД тормозит из-за VACUUM, значит его нужно делать реже, или вообще отключить. Но этим пользователь делает лишь хуже. Если VACUUM приводит к деградации производительности, то запускать его следует не реже, а чаще. Как бы контринтуитивно это не казалось. Более того, как будет показано далее, VACUUM может быть лишь симптомом, а реальная проблема заключаться совершенно в ином.

К слову, использование облачных решений, таких, как Amazon RDS, никак не решает проблему. Вопреки распространенному заблуждению, что якобы «в облаках ничего не нужно менеджить, оно как-то само».

Итак, как же PostgreSQL избегает XID wraparound? В том же transam.h можно найти несколько специальных значений XID:

/* Неверное значение XID, аналог NULL */
#define InvalidTransactionId        ((TransactionId) 0)

/* Для данных созданных во время развертывания СУБД */
#define BootstrapTransactionId      ((TransactionId) 1)

/* Для замороженных кортежей */
#define FrozenTransactionId         ((TransactionId) 2)

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

Что такое замороженный кортеж? Это мы уже проходили:

Если [...] кортеж виден всем транзакциям, говорят, что он заморожен. У такого кортежа проставляется флаг HEAP_XMIN_FROZEN. На самом деле, это не отдельный флаг, а особое сочетание флагов.

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

Когда данные заморожены и видны всем, то им больше не нужен XID в поле t_xmin. Этот XID может быть переиспользован будущими транзакциями. Что же до удаленных данных, которые не видны никому, то их вообще можно не хранить. Таким данным XID’ы не нужны ни в t_xmin, ни в t_xmax.

Если факт заморозки данных определяется при помощи флага, то зачем нужно специально значение FrozenTransactionId? Так получилось по историческим причинам. В PostgreSQL до версии 9.4 флага не было и при заморозке кортежей в поле t_xmin писалось значение FrozenTransactionId. Потом сообщество пришло к выводу, что старое значение t_xmin может представлять интерес для отладки, и поэтому был придуман флаг. Однако в базах данных, которые работали под старыми версиями PostgreSQL, в t_xmin все еще может встретиться FrozenTransactionId.

На самом деле, в PostgreSQL есть два типа для представления ID транзакций:

  • TransactionId, которому в SQL соответствует тип xid. Это 32-х битные XID’ы, речь про которые шла до сих пор;
  • FullTransactionId. В SQL ему соответствует тип xid8. Это 64-х битные XID’ы. Такие существуют только в памяти и не записываются на диск;

Когда вы узнаете ID текущей транзакции при помощи pg_current_xact_id(), то получаете xid8. Младшие 32 бита FullTransactionId хранят обычный TransactionId. Старшие 32 бита принято называть «эпохой», хотя как отдельная сущность эпоха особой роли не играет.

Выделение XID в PostgreSQL происходит лениво, по мере надобности. Допустим, есть транзакция, которая только читает данные из таблиц. Ей вообще не будет присвоен XID, поскольку он не требуется для проверки видимости. Нужно знать только состояние транзакций, создавших данные. Это справедливо для всех уровней изоляции.

Заинтересованным читателям предлагается проверить это экспериментально. Используйте колонки pid, backend_xid и backend_xmin из вьюхи pg_stat_activity. Выделение нового XID происходит только при обновлении backend_xid. Значение backend_xmin помечает «горизонт xmin». Его точный физический смысл сейчас не так важен для нас. Важно то, что выделение новых XID для backend_xmin не происходит.

В общем-то, это логично. Если бы для проверки видимости транзакциям был нужен XID, мы не могли бы читать данные на hot standby репликах. Ведь у реплики нет простого способа выделить XID без потенциального конфликта с мастером. На репликах pg_current_xact_id() бросает ошибку.

Можно найти место, где реально происходит выделение нового XID, проследив цепочку вызовов из pg_current_xact_id():

pg_current_xact_id (xid8funcs.c:358)
|
+-- GetTopFullTransactionId (xact.c:467)
    |
    +-- AssignTransactionId (xact.c:616)
        |
        +-- GetNewTransactionId (varsup.c:37)

Из кода GetNewTransactionId() мы узнаем, что новые XID берутся из структуры ShmemVariableCache с типом VariableCacheData*. Структура живет в разделяемой памяти и имеет ряд полей, защищенные различными локами.

Для нас наибольший интерес представляют поля:

/*
 * These fields are protected by XidGenLock.
 */

FullTransactionId nextXid;  /* next XID to assign */

/* ... */
TransactionId xidVacLimit;  /* start forcing autovacuums here */
TransactionId xidWarnLimit; /* start complaining here */
TransactionId xidStopLimit; /* refuse to advance nextXid beyond here */
TransactionId xidWrapLimit; /* where the world ends */
/* ... */

Данные поля инициализирует наша старая знакомая, StartupXLOG(). Функция в свою очередь берет информацию из контрольной точки, а точнее, из ее копии в файле pg_control. В упрощенном виде соответствующий код выглядит так:

checkPoint = ControlFile->checkPointCopy;
ShmemVariableCache->nextXid = checkPoint.nextXid;

/* ... */

SetTransactionIdLimit(checkPoint.oldestXid, checkPoint.oldestXidDB);

SetTransactionIdLimit() по большому счету занимается тем, что обновляет xidVacLimit, xidWarnLimit, xidStopLimit и xidWrapLimit. В упрощенном виде:

/* выделение этого XID приведет к потере данных */
xidWrapLimit = oldest_datfrozenxid + (MaxTransactionId >> 1);

/* СУБД перестает выделять XIDы за 3 млн XIDов до xidWrapLimit.
   Это сделано для того, чтобы был некоторый запас XIDов
   на экстренный случай. Резервные XIDы могут быть использованы
   в single-user mode. */

xidStopLimit = xidWrapLimit - 3000000;

/* СУБД начинает писать предупреждения в лог за 40 млн XID'ов
   до xidWrapLimit */

xidWarnLimit = xidWrapLimit - 40000000;

/* СУБД начинает форсить VACUUM когда datfrozenxid становится старше
   autovacuum_freeze_max_age. */

xidVacLimit = oldest_datfrozenxid + autovacuum_freeze_max_age;

Реальный код несколько сложнее, поскольку содержит поправки на специальные значения XID’ов. Если теперь вернуться к реализации GetNewTransactionId(), мы увидим, что она использует xidVacLimit, xidWarnLimit, и т.д. именно так, как описано в комментариях выше. Само же выделение нового XID происходит путем простого инкремента nextXid с поправкой на специальные значения XID’ов — см реализацию FullTransactionIdAdvance().

В приведенном отрывке кода autovacuum_freeze_max_age является параметром конфигурации. Значение по умолчанию равно 200 млн. Узнать текущее значение через psql можно так:

SELECT current_setting('autovacuum_freeze_max_age');

Осталось понять, что такое datfrozenxid. Это всего лишь столбец из таблицы каталога pg_database. Все XID’ы меньше datfrozenxid заморожены для заданной базы данных. Также это минимум от relfrozenxid в таблице pg_class для таблиц конкретной базы данных. Смысл relfrozenxid такой же, как у datfrozenxid, только для одной таблицы, а не всей базы данных. Обновлением этих значений занимается VACUUM. Он же повторно вызывает SetTransactionIdLimit().

Заметьте, что как datfrozenxid, так и relfrozenxid имеют тип xid, для которого в SQL не определены операторы больше и меньше, ровно как и кастинг в xid8. Зато есть функция age(xid). Она говорит, как далеко заданный XID находится в прошлом относительно текущей транзакции:

SELECT datname, age(datfrozenxid) FROM pg_database;

Это можно и нужно использовать для мониторинга (вызов age(xid) не выделяет новых XID’ов, и потому не приближает XID wraparound). Если возраст datfrozenxid приближается к 2 млрд, значит у нас серьезные проблемы.

Команда VACUUM FREEZE может помочь. Но это будет временным решением. На самом деле, рост age(datfrozenxid) является признаком неправильно настроенного autovacuum, в особенности autovacuum_freeze_max_age. Другая причина проблемы может заключаться в долго живущих транзакциях. Особенно это актуально для OLAP нагрузок. Обнаружить такие транзакции можно при помощи столбца xact_start в pg_stat_activity.

В XID wraparound нет чего-то сверх сложного. Главное — это знать про данную особенность PostgreSQL и иметь соответствующие метрики в боевом окружении.

Дополнение: В продолжение темы см посты Внутренности PostgreSQL: карта видимости, Внутренности PostgreSQL: карта свободного пространства и Внутренности PostgreSQL: кэш системного каталога.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.