Перейти к основному содержимому

Изоляция транзакций и история записей в MVCC Pangolin

Атомарная единица, по которой учитываются модификации (версии одной записи) в MVCC - одна транзакция.

Чтобы организовать очередь согласованных транзакций, сервер имеет внутренний счетчик транзакций — общий для экземпляра (кластера баз) системный объект, который работает как последовательность. Счетчик с момента инициализации кластера баз вначале монотонно возрастает, а затем допускает повторное использование значений. Почти как последовательность с атрибутом CYCLE.

Каждая транзакция на старте выбирает значение этого счетчика как следующий идентификатор из последовательности. Его и называют идентификатором транзакции или XID. Оно сохраняется за транзакцией и всеми данными, которые она изменила, от старта до «заморозки» — переноса транзакции в исторический архив.

Когда транзакция выполняется над записью DML, она завершает историю старой версии и, возможно, добавляет обновленную копию в виде новой версии записи. Каждая из версий автоматически помечается «сроком действия»: от XID транзакции, которая добавила версию (xmin) и до XID транзакции, которая ее удалила (xmax). Если версия активна (действительна, не удалена), то во втором поле xmax у нее может быть 0 или идентификатор такой удаляющей транзакции, которая уже отменена или еще активна, не зафиксирована.

Сроки действия разных версий одной записи не пересекаются никогда.

Номера транзакций невозможно напрямую сравнить между собой. Например, транзакция № 100 моложе транзакции с номером 3 миллиарда. Поэтому для хранения номеров транзакций определен отдельный служебный тип данных: xid. Значения этого типа нельзя напрямую сравнивать, складывать или вычитать. Стандартная функция age(<идентификатор: xid>) возвращает «возраст» транзакции в единицах счетчика - количество номеров, выданных от ее старта до последнего значения счетчика транзакций на текущий момент. Возраст транзакций имеет тип integer, эти значения можно сравнивать между собой, складывать и вычитать.

Горизонты событий и снимки

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

Дополнительно строится свой согласованный образ базы на каждый из запросов (при уровне изоляции READ COMMITTED) или на первый запрос в транзакции (уровень REPEATABLE READ и выше). Для этого сессия на старте запроса/транзакции запоминает:

  • xid старшей из транзакций, активных прямо сейчас - xmin (можно понимать как локальный «горизонт» этой операции);
  • следующий, еще никому не выделенный сейчас xid - xmax (этот xid когда-то будет присвоен следующей транзакции в очереди);
  • список xid всех активных сейчас транзакций - xip_list.

В продолжение всей операции (запроса в REPEATABLE READ или транзакции в READ COMMITTED) правила видимости результатов между транзакциями таковы:

  • результаты транзакций до xmin - принять;
  • активности транзакций от xmin до xmax - просматривая xip_list, принять активности зафиксированных транзакций и отбросить активности транзакций, которые не завершены или отменены;
  • активности самой операции - принять;
  • активности более молодых транзакций (xid >= xmax) - отбрасывать, чтобы не разрушить представление о данных, которое было согласовано на момент старта операции.

Для того чтобы отбрасывать эффекты соседних транзакций, не требуется, как в Oracle, по каждому блоку данных читать векторы отмены (undo records) и формировать в памяти исторический образ блока. Поскольку здесь исторические версии записей хранятся вместе с текущими, достаточно из версий записи выбрать ту, которая действовала на момент старта операции.

Для операции (запроса/транзакции) совокупность (xmin, xmax, xip_list) называется «снимком» (snapshot). Каждый снимок исчерпывающе определяет согласованное состояние базы, видимое в заданной операции.

Пример просмотра горизонта событий транзакции:

First_db=# BEGIN;
BEGIN
First_db=*# SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin
--------------
19838
(1 row)

Пример просмотра снимков операций:

$ pgbench -c 10 -j 5 -T 120 First_db &
<..>
$ psql -d First_db
<..>
First_db=# BEGIN ISOLATION LEVEL READ COMMITTED;
BEGIN
First_db=*# SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin
--------------
194230
(1 row)
First_db=*# SELECT txid_current_snapshot();
txid_current_snapshot
--------------------------------------------------
200128:200174:200128,200161,200168,200170,200172
(1 row)
First_db=*# SELECT txid_current_snapshot();
txid_current_snapshot
-----------------------------
204776:204782:204776,204780
(1 row)
First_db=*# SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin
--------------
194230
(1 row)
First_db=*# SELECT txid_current_snapshot();
txid_current_snapshot
-------------------------------------------
247416:247432:247416,247428,247429,247430
(1 row)
First_db=*# SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin
--------------
194230
(1 row)
First_db=*# COMMIT;
COMMIT
First_db=# SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
backend_xmin
--------------
260196
(1 row)

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

  • мешает очистке исторических данных, заставляет удерживать значительное количество устаревших версий записей в таблицах;
  • вносит накладные расходы при разрастании массива статусов транзакций и сложности снимков операций, усложняет правила видимости версий записей в операциях.

Сервер по умолчанию автоматически поддерживает такое состояние по каждой из таблиц, в котором сохранены все версии строк, потенциально интересные любой из сессий, имеющих возможность увидеть таблицу. Изменить это поведение можно при помощи параметра old_snapshot_threshold.

Отметку, которая объединяет горизонты событий всех сессий базы, называют «горизонтом базы». Это старейшее значение по всем горизонтам сессий базы, исключая сессии, которые выполняют VACUUM и декодирование потока логической репликации.

Ниже приведен пример просмотра горизонтов баз, функция age(xid) возвращает «возраст» транзакции в единицах внутреннего счетчика – количество транзакций от заданного XID до текущего:

First_db=# SELECT pid, datname, usename, state, backend_xmin FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY datname, age(backend_xmin) DESC;
pid | datname | usename | state | backend_xmin
-------+----------+----------+--------+--------------
13283 | First_db | postgres | active | 290747
(1 row)

Первая запись по каждой из баз будет содержать ее горизонт: backend_xmin той из подключенных к базе сессий, которая выполняет самую старую из транзакций.

Правила сохранения устаревших версий записей различаются для очистки разделяемых таблиц (таких таблиц системного каталога, как pg_database) и всех остальных (неразделяемых) таблиц. Именно:

  • если pg_class.relisshared = true (pg_database и некоторые другие объекты каталога в табличном пространстве pg_global) - определяется общий горизонт событий сервера, старейший из горизонтов баз;
  • иначе (все остальные таблицы) - актуален горизонт событий по той базе, которая содержит таблицу, поскольку сессии, подключенные к другим базам, эту таблицу не увидят. Если же открытых транзакций нет - определяется последняя завершенная транзакция.

За горизонтом сервера/базы действительные данные одинаково видны в каждой из активных транзакций, а недействительные - не могут быть видны ни одной из активных транзакций.

Недействительные версии, окончание срока действия которых находится за горизонтом событий сервера/базы, дальше будем называть «полностью устаревшими». По коду сервера они называются DEAD, в отличие от еще удерживаемых RECENTLY_DEAD.

Только полностью устаревшие записи могут быть удалены без потери данных.

Мультитранзакции

В архитектуре Pangolin не предусмотрено хранение в блоке данных списка нескольких транзакций, которые обрабатывают блок или запись. Нет раздела блока, похожего на Interested Transaction List – ITL в Oracle. Всего два поля по 32 бита отведены под идентификаторы транзакций срока действия в заголовке версии. Поэтому в случае, когда строка интересна двум и более транзакциям одновременно, Pangolin помещает список этих транзакций в новый объект - мультитранзакцию. Полученному списку присваивается новый идентификатор из отдельного системного счетчика, который устроен похожим на основной счетчик транзакций образом. Именно этот идентификатор мультитранзакции далее используется в поле срока действия xmax. Массив статусов мультитранзакций и списки объединенных в них транзакций хранятся в подкаталоге pg_multixact каталога данных (PGDATA).

Мультитранзакции также требуют хранения видимой им истории версий записей. Идентификаторы устаревших мультитранзакций должны периодически освобождаться (замораживаться) для предотвращения оборота их счетчика.

Подготовленные транзакции

Pangolin поддерживает двухфазную фиксацию транзакций – 2 Phase Commit, 2PC. Эта операция делится на два раздельных шага:

  1. Выполнение PREPARE TRANSACTION и сохранение в БД «заготовки» транзакции.
  2. Выполнение COMMIT PREPARED в тот момент, когда бизнес-логика приложений принимает окончательное решение о готовности транзакции.

Транзакции 2PC устойчивы и рассчитаны на то, чтобы пережить внезапный перезапуск сервера. Поэтому для снимков подготовленных транзакций в промежуточной стадии также удерживается видимая в них история версий записей.

Удалить ненужные подготовленные транзакции вручную можно командой ROLLBACK PREPARED.

Реплики и слоты репликации

Реплики в кластере Pangolin активны и могут выполнять запросы. Если удалить на лидере такие устаревшие версии данных, которые уже используются или могут быть использованы в запросах - возникнет конфликт репликации.

Параметр hot_standby_feedback = on предотвращает подобные ситуации. Он обеспечивает хранение лидером истории версий по потребностям реплики. При включенном параметре каждая из реплик по мере продвижения по потоку WAL оповещает лидера об изменениях ее общего «горизонта», а лидер удерживает старые версии записей до старейшего из горизонтов известных реплик. Известные отметки можно просмотреть на лидере в представлении pg_replication_slots.

Если обновление отметок прекратилось, то ненужные регистрации реплик придется убрать при помощи pg_drop_replication_slot(), иначе лидер продолжит ошибочно удерживать историю версий.

Если же параметр hot_standby_feedback выключен, то история версий удерживаться не будет, но возможны конфликты репликации, для которых будет применяться обычный механизм их разрешения.

Особенности Pangolin

В физической репликации в кластерной конфигурации по умолчанию параметр hot_standby_feedback включен, при этом слотами репликации автоматически управляет Pangolin Manager.

Отдельного внимания могут потребовать пользовательские конфигурации логической и физической репликации, которые не относятся к кластеру высокой доступности. А также кластеры, где Pangolin Manager остался в отключенном состоянии (Maintenance mode).