- PVSM.RU - https://www.pvsm.ru -

MVCC-4. Снимки данных

Рассмотрев вопросы, связанные с изоляцией [1], и сделав отступление об организации данных на низком уровне [2], мы в прошлый раз подробно поговорили о версих строк [3] и проследили, как изменяется служебная информация в заголовке версии при различных операциях.

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

Что такое снимок данных

Физически в страницах данных могут находиться несколько версий одной и той же строки. При этом каждая транзакция должна видеть только одну (или ни одной) версию каждой строки так, чтобы вместе они составляли согласованную в ACID-смысле картину данных на определенный момент времени.

Изоляция в PostgreSQL строится на основе снимков данных (snapshot): каждая транзакция работает со своим снимком данных, который «содержит» данные, которые были зафиксированы до момента создания снимка, и не «содержит» еще не зафиксированные на этот момент данные. Мы уже видели [1], что изоляция при этом получается более строгая, чем требует стандарт, но не лишенная аномалий.

На уровне изоляции Read Committed снимок создается в начале каждого оператора транзакции. Такой снимок активен, пока выполняется оператор. На рисунке момент создания снимка (который, как мы помним, определяется номером транзакции) показан синим цветом.

MVCC-4. Снимки данных - 1

На уровнях Repeatable Read и Serializable снимок создается один раз в начале первого оператора транзакции. Такой снимок остается активным до самого конца транзакции.

MVCC-4. Снимки данных - 2

Видимость версий строк в снимке

Правила видимости

Конечно, снимок не является физической копией всех необходимых версий строк. Фактически снимок задается несколькими числами, а видимость версий строк в снимке определяется правилами.

Будет или нет данная версия строки видна в снимке, зависит от двух полей ее заголовка — xmin и xmax, — то есть от номеров создавшей и удалившей транзакций. Такие интервалы не пересекаются, поэтому одна строка представлена в любом снимке максимум одной своей версией.

Точные правила видимости довольно сложны и учитывают множество различных ситуаций и крайних случаев.

В этом легко убедиться, заглянув в src/backend/utils/time/tqual.c (в версии 12 проверка переехала в src/access/heap/heapam_visibility.c).

Упрощая, можно сказать, что версия строки видна, когда в снимке видны изменения, сделанные транзакцией xmin, и не видны изменения, сделанные транзакцией xmax (иными словами, уже видно, что версия строки появилась, но еще не видно, что ее удалили).

В свою очередь, изменения транзакции видны в снимке, если либо это та же самая транзакция, что создала снимок (она сама видит свои собственные изменения), либо транзакция была зафиксирована до момента создания снимка.

Можно изобразить транзакции графически в виде отрезков (от момента начала до момента фиксации):

MVCC-4. Снимки данных - 3

Здесь:

  • изменения транзакции 2 будут видны, потому что она завершились до создания снимка,
  • изменения транзакции 1 не будут видны, потому что она была активна на момент создания снимка,
  • изменения транзакции 3 не будут видны, потому что она начались позже создания снимка (не важно, закончилась она или нет).

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

Все, что мы можем — это узнать текущий статус транзакций при создании снимка. Эта информация есть в общей памяти сервера в структуре ProcArray, которая содержит список всех активных сеансов и их транзакций.

А постфактум мы уже не сможем понять, была ли какая-то транзакция активна в момент создания снимка или нет. Поэтому список всех текущих активных транзакций приходится запоминать в снимке.

Из сказанного следует, что в PostgreSQL нельзя создать снимок, показывающий согласованные данные по состоянию на произвольное время назад, даже если все необходимые для этого версии строк существуют в табличных страницах. Часто приходится слышать вопрос, почему в PostgreSQL нет ретроспективных (или темпоральных; в Oracle это называется flashback query) запросов — вот одна из причин.

Забавно, что изначально такая функциональность была, но позже ее убрали из СУБД. Про это можно прочитать в статье Джозефа Хеллерштейна [4].

Итак, снимок данных определяется несколькими параметрами:

  • моментом создания снимка, а именно, номером следующей, еще не существующей в системе, транзакции (snapshot.xmax);
  • списком активных транзакций на момент создания снимка (snapshot.xip).

Для удобства и оптимизации отдельно сохраняется и номер самой ранней из активных транзакций (snapshot.xmin). Это значение имеет важный смысл, о котором мы поговорим ниже.

Также в снимке сохраняются еще несколько параметров, но они для нас не важны.

MVCC-4. Снимки данных - 4

Пример

Чтобы посмотреть, как видимость определяется снимком, воспроизведем ситуацию с тремя транзакциями, рассмотренную выше. В таблице будут три строки, причем:

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

=> TRUNCATE TABLE accounts;

Первая транзакция (еще не завершилась):

=> BEGIN;
=> INSERT INTO accounts VALUES (1, '1001', 'alice', 1000.00);
=> SELECT txid_current();
=> SELECT txid_current();
 txid_current 
--------------
         3695
(1 row)

Вторая транзакция (завершилась до создания снимка):

|  => BEGIN;
|  => INSERT INTO accounts VALUES (2, '2001', 'bob', 100.00);
|  => SELECT txid_current();
|   txid_current 
|  --------------
|           3696
|  (1 row)
|  => COMMIT;

Создаем снимок в транзакции в другом сеансе.

||    => BEGIN ISOLATION LEVEL REPEATABLE READ;
||    => SELECT xmin, xmax, * FROM accounts;
||     xmin | xmax | id | number | client | amount 
||    ------+------+----+--------+--------+--------
||     3696 |    0 |  2 | 2001   | bob    | 100.00
||    (1 row)

Завершаем первую транзакцию после того, как создан снимок:

=> COMMIT;

И третья транзакция (появилась позже создания снимка):

|  => BEGIN;
|  => INSERT INTO accounts VALUES (3, '2002', 'bob', 900.00);
|  => SELECT txid_current();
|   txid_current 
|  --------------
|           3697
|  (1 row)
|  => COMMIT;

Очевидно, что в нашем снимке по-прежнему видна одна строка:

||    => SELECT xmin, xmax, * FROM accounts;
||     xmin | xmax | id | number | client | amount 
||    ------+------+----+--------+--------+--------
||     3696 |    0 |  2 | 2001   | bob    | 100.00
||    (1 row)

Вопрос в том, как это понимает PostgreSQL.

Все определяется снимком. Посмотрим на него:

||    => SELECT txid_current_snapshot();
||     txid_current_snapshot 
||    -----------------------
||     3695:3697:3695
||    (1 row)

Здесь через двоеточие перечислены snapshot.xmin, snapshot.xmax и snapshot.xip (в данном случае один номер, но в общем — список).

По сформулированным выше правилам, в снимке должны быть видны изменения, сделанные транзакциями с номерами snapshot.xmin <= xid < snapshot.xmax, за исключением попавших в список snapshot.xip. Посмотрим на все строки таблицы (в новом снимке):

=> SELECT xmin, xmax, * FROM accounts ORDER BY id;
 xmin | xmax | id | number | client | amount  
------+------+----+--------+--------+---------
 3695 |    0 |  1 | 1001   | alice  | 1000.00
 3696 |    0 |  2 | 2001   | bob    |  100.00
 3697 |    0 |  3 | 2002   | bob    |  900.00
(3 rows)

Первая строка не видна — она создана транзакцией, которая входит в список активных (xip).
Вторая строка видна — она создана транзакцией, которая попадает в диапазон снимка.
Третья строка не видна — она создана транзакцией, которая не входит в диапазон снимка.

||    => COMMIT;

Собственные изменения

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

Для этого в заголовке версии строки есть специальное поле (которое отображается в псевдостолбцах cmin и cmax), показывающее порядковый номер операции внутри транзакции. Cmin представляет номер для вставки, cmax — для удаления, но для экономии места в заголовке строки это на самом деле одно поле, а не два разных. Считается, что вставка и удаление той же строки в одной транзакции выполняется редко.

Если же это все-таки происходит, то в то же самое поле вставляется специальный «комбо»-номер, про который обслуживающий процесс запоминает реальные cmin и cmax. Но это уже совсем экзотика.

Простой пример. Начнем транзакцию и добавим в таблицу строку:

=> BEGIN;
=> SELECT txid_current();
 txid_current 
--------------
         3698
(1 row)
INSERT INTO accounts(id, number, client, amount) VALUES (4, 3001, 'charlie', 100.00);

Выведем содержимое таблицы вместе с полем cmin (но только для строк, добавленных нашей транзакцией — для других оно не имеет смысла):

=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
 xmin | cmin | id | number | client  | amount  
------+------+----+--------+---------+---------
 3695 |      |  1 | 1001   | alice   | 1000.00
 3696 |      |  2 | 2001   | bob     |  100.00
 3697 |      |  3 | 2002   | bob     |  900.00
 3698 |    0 |  4 | 3001   | charlie |  100.00
(4 rows)

Теперь откроем курсор для запроса, возвращающего число строк в таблице.

=> DECLARE c CURSOR FOR SELECT count(*) FROM accounts;

И после этого добавим еще одну строку:

=> INSERT INTO accounts(id, number, client, amount) VALUES (5, 3002, 'charlie', 200.00);

Запрос вернет 4 — строка, добавленная после открытия курсора, не попадет в снимок данных:

=> FETCH c;
 count 
-------
     4
(1 row)

Почему? Потому что в снимке учитываются только версии строк с cmin < 1.

=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
 xmin | cmin | id | number | client  | amount  
------+------+----+--------+---------+---------
 3695 |      |  1 | 1001   | alice   | 1000.00
 3696 |      |  2 | 2001   | bob     |  100.00
 3697 |      |  3 | 2002   | bob     |  900.00
 3698 |    0 |  4 | 3001   | charlie |  100.00
 3698 |    1 |  5 | 3002   | charlie |  200.00
(5 rows)
=> ROLLBACK;

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

Номер самой ранней из активных транзакций (snapshot.xmin) имеет важный смысл — он определяет «горизонт событий» транзакции. А именно, за своим горизонтом транзакция всегда видит только актуальные версии строк.

Действительно, неактуальную версию требуется видеть только в том случае, когда актуальная создана еще не завершившейся транзакцией, и поэтому пока не видна. Но за «горизонтом» все транзакции уже гарантированно завершились.

MVCC-4. Снимки данных - 5

«Горизонт событий» транзакции можно увидеть в системном каталоге:

=> BEGIN;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3699
(1 row)

Также можно определить и «горизонт событий» на уровне базы данных. Для этого надо взять все активные снимки и среди них найти наиболее старый xmin. Он и будет определять горизонт, за которым неактуальные версии строк в этой БД уже никогда не будут видны ни одной транзакции. Такие версии строк могут быть очищены — именно поэтому понятие горизонта так важно с практической точки зрения.

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

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

Если теперь в виде отрезка изобразить не транзакции, а снимки (от snapshot.xmin до snapshot.xmax), то ситуацию можно представить себе так:

MVCC-4. Снимки данных - 6

На этом рисунке самый нижний снимок относится к незавершенной транзакции, а в остальных снимках snapshot.xmin не может быть больше ее номера.

В нашем примере была начата транзакция с уровнем изоляции Read Committed. Даже несмотря на то, что в ней нет никакого активного снимка данных, она продолжает удерживать горизонт:

|  => BEGIN;
|  => UPDATE accounts SET amount = amount + 1.00;
|  => COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3699
(1 row)

И только после завершения транзакции горизонт продвигается вперед, позволяя очищать неактуальные версии строк:

=> COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3700
(1 row)

Если описанная ситуация действительно создает проблемы и нет способа избежать ее на уровне приложения, то, начиная с версии 9.6, доступны два параметра:

  • old_snapshot_threshold определяет максимальное время жизни снимка. После этого времени сервер получает право удалять неактуальные версии строк, а если они понадобятся «долгоиграющей» транзакции, но она получит ошибку snapshot too old.
  • idle_in_transaction_session_timeout определяет максимальное время жизни бездействующей транзакции. После этого времени транзакция прерывается.

Экспорт снимка данных

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

Разумеется, нельзя полагаться на то, что картины данных совпадут просто из-за того, что транзакции запущены «одновременно». Для этого есть механизм экспорта и импорта снимка.

Функция pg_export_snapshot возвращает идентификатор снимка, который может быть передан (внешними по отношению к СУБД средствами) в другую транзакцию.

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT count(*) FROM accounts; -- любой запрос
 count 
-------
     3
(1 row)
=> SELECT pg_export_snapshot();
 pg_export_snapshot  
---------------------
 00000004-00000E7B-1
(1 row)

Другая транзакция может импортировать снимок с помощью команды SET TRANSACTION SNAPSHOT до выполнения первого запроса в ней. Предварительно надо установить и уровень изоляции Repeatable Read или Serializable, потому что на уровне Read Committed операторы будут использовать собственные снимки.

|  => DELETE FROM accounts;
|  => BEGIN ISOLATION LEVEL REPEATABLE READ;
|  => SET TRANSACTION SNAPSHOT '00000004-00000E7B-1';

Теперь вторая транзакция будет работать со снимком первой и, соответственно, видеть три строки (а не ноль):

|  => SELECT count(*) FROM accounts;
|   count 
|  -------
|       3
|  (1 row)

Время жизни экспортированного снимка совпадает со временем жизни экспортирующей транзакции.

|    => COMMIT;
=> COMMIT;

Продолжение следует.

Автор: erogov

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/postgresql/315098

Ссылки в тексте:

[1] изоляцией: https://habr.com/ru/company/postgrespro/blog/442804/

[2] организации данных на низком уровне: https://habr.com/ru/company/postgrespro/blog/444536/

[3] версих строк: https://habr.com/ru/company/postgrespro/blog/445820/

[4] статье Джозефа Хеллерштейна: https://habr.com/ru/company/postgrespro/blog/438890/

[5] Источник: https://habr.com/ru/post/446652/?utm_campaign=446652