- PVSM.RU - https://www.pvsm.ru -
Рассмотрев вопросы, связанные с изоляцией [1], и сделав отступление об организации данных на низком уровне [2], мы в прошлый раз подробно поговорили о версих строк [3] и проследили, как изменяется служебная информация в заголовке версии при различных операциях.
Сегодня мы посмотрим на то, как из версий строк получаются согласованные снимки данных.
Физически в страницах данных могут находиться несколько версий одной и той же строки. При этом каждая транзакция должна видеть только одну (или ни одной) версию каждой строки так, чтобы вместе они составляли согласованную в ACID-смысле картину данных на определенный момент времени.
Изоляция в PostgreSQL строится на основе снимков данных (snapshot): каждая транзакция работает со своим снимком данных, который «содержит» данные, которые были зафиксированы до момента создания снимка, и не «содержит» еще не зафиксированные на этот момент данные. Мы уже видели [1], что изоляция при этом получается более строгая, чем требует стандарт, но не лишенная аномалий.
На уровне изоляции Read Committed снимок создается в начале каждого оператора транзакции. Такой снимок активен, пока выполняется оператор. На рисунке момент создания снимка (который, как мы помним, определяется номером транзакции) показан синим цветом.
На уровнях Repeatable Read и Serializable снимок создается один раз в начале первого оператора транзакции. Такой снимок остается активным до самого конца транзакции.
Конечно, снимок не является физической копией всех необходимых версий строк. Фактически снимок задается несколькими числами, а видимость версий строк в снимке определяется правилами.
Будет или нет данная версия строки видна в снимке, зависит от двух полей ее заголовка — xmin и xmax, — то есть от номеров создавшей и удалившей транзакций. Такие интервалы не пересекаются, поэтому одна строка представлена в любом снимке максимум одной своей версией.
Точные правила видимости довольно сложны и учитывают множество различных ситуаций и крайних случаев.
В этом легко убедиться, заглянув в src/backend/utils/time/tqual.c (в версии 12 проверка переехала в src/access/heap/heapam_visibility.c).
Упрощая, можно сказать, что версия строки видна, когда в снимке видны изменения, сделанные транзакцией xmin, и не видны изменения, сделанные транзакцией xmax (иными словами, уже видно, что версия строки появилась, но еще не видно, что ее удалили).
В свою очередь, изменения транзакции видны в снимке, если либо это та же самая транзакция, что создала снимок (она сама видит свои собственные изменения), либо транзакция была зафиксирована до момента создания снимка.
Можно изобразить транзакции графически в виде отрезков (от момента начала до момента фиксации):
Здесь:
К сожалению, момент фиксации транзакций неизвестен системе. Известен только момент ее начала (он определяется номером транзакции и отмечен на рисунках выше пунктирной линией), но факт завершения нигде не записывается.
Все, что мы можем — это узнать текущий статус транзакций при создании снимка. Эта информация есть в общей памяти сервера в структуре ProcArray, которая содержит список всех активных сеансов и их транзакций.
А постфактум мы уже не сможем понять, была ли какая-то транзакция активна в момент создания снимка или нет. Поэтому список всех текущих активных транзакций приходится запоминать в снимке.
Из сказанного следует, что в PostgreSQL нельзя создать снимок, показывающий согласованные данные по состоянию на произвольное время назад, даже если все необходимые для этого версии строк существуют в табличных страницах. Часто приходится слышать вопрос, почему в PostgreSQL нет ретроспективных (или темпоральных; в Oracle это называется flashback query) запросов — вот одна из причин.
Забавно, что изначально такая функциональность была, но позже ее убрали из СУБД. Про это можно прочитать в статье Джозефа Хеллерштейна [4].
Итак, снимок данных определяется несколькими параметрами:
Для удобства и оптимизации отдельно сохраняется и номер самой ранней из активных транзакций (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 < 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) имеет важный смысл — он определяет «горизонт событий» транзакции. А именно, за своим горизонтом транзакция всегда видит только актуальные версии строк.
Действительно, неактуальную версию требуется видеть только в том случае, когда актуальная создана еще не завершившейся транзакцией, и поэтому пока не видна. Но за «горизонтом» все транзакции уже гарантированно завершились.
«Горизонт событий» транзакции можно увидеть в системном каталоге:
=> 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, доступны два параметра:
Бывают ситуации, когда несколько параллельных транзакций должны гарантированно видеть одну и ту же картину данных. В качестве примера можно привести утилиту 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
Нажмите здесь для печати.