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

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

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


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

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

5gnznjhyp869w4ls01o0wy5kxlo.png

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

ffdp17xhiqemal2dj8k6a21ql38.png


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


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

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

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

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


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

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

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

w2cqyrzhqwmzzenek8ski3mhbzo.png

Здесь:

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


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

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

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

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

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

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

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


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

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

-r8vaa6fvrltfpd4tjjnv_zxnk4.png

Пример


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

  • первая добавлена транзакцией, которая началась до создания снимка, но завершилась позже,
  • вторая добавлена транзакцией, которая началась и завершилась до создания снимка,
  • третья была добавлена уже после создания снимка.
=> 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

=> 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) имеет важный смысл — он определяет «горизонт событий» транзакции. А именно, за своим горизонтом транзакция всегда видит только актуальные версии строк.

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

8zvpoocv891hhtnv9lcs6j7g4ry.png

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

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


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

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

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

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

ndvewikoeiljposax85zp7mmmn8.png

На этом рисунке самый нижний снимок относится к незавершенной транзакции, а в остальных снимках 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;

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

© Habrahabr.ru