Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Здесь:

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

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

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

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

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

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

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

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

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

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

Пример

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

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

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

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

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

=> BEGIN;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();

backend_xmin -------------- 3699
(1 row)

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

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

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»