Хабрахабр

MVCC-3. Версии строк

Итак, мы рассмотрели вопросы, связанные с изоляцией, и сделали отступление об организации данных на низком уровне. И наконец добрались до самого интересного — до версий строк.
Как мы уже говорили, каждая строка может одновременно присутствовать в базе данных в нескольких версиях. Одну версию от другой надо как-то отличать С этой целью каждая версия имеет две отметки, определяющие «время» действия данной версии (xmin и xmax). В кавычках — потому, что используется не время как таковое, а специальный увеличивающийся счетчик. И этот счетчик — номер транзакции.

Но эти детали мы рассмотрим подробно, когда дойдем до заморозки.)
Когда строка создается, значение xmin устанавливается в номер транзакции, выполнившей команду INSERT, а xmax не заполняется. (Как обычно, на самом деле все сложнее: номер транзакций не может все время увеличиваться из-за ограниченной разрядности счетчика.

Когда строка удаляется, значение xmax текущей версии помечается номером транзакции, выполнившей DELETE.

В текущей версии строки устанавливается xmax, равный номеру транзакции, выполнившей DELETE. Когда строка изменяется командой UPDATE, фактически выполняются две операции: DELETE и INSERT. Затем создается новая версия той же строки; значение xmin у нее совпадает с значением xmax предыдущей версии.

Кроме этих полей, заголовок.содержит и другие, например: Поля xmin и xmax входят в заголовок версии строки.

  • infomask — ряд битов, определяющих свойства данной версии. Их довольно много; основные из них мы постепенно рассмотрим.
  • ctid — ссылка на следующую, более новую, версию той же строки. У самой новой, актуальной, версии строки ctid ссылается на саму эту версию. Номер имеет вид (x,y), где x — номер страницы, y — порядковый номер указателя в массиве.
  • битовая карта неопределенных значений — отмечает те столбцы данной версии, которые содержат неопределенное значение (NULL). NULL не является одним из обычных значений типов данных, поэтому признак приходится хранить отдельно.

В результате заголовок получается довольно большой — минимум 23 байта на каждую версию строки, а обычно больше из-за битовой карты NULL-ов. Если таблица «узкая» (то есть содержит мало столбцов), накладные расходы могут занимать больше, чем полезная информация.
Рассмотрим подробнее, как выполняются операции со строками на низком уровне, и начнем со вставки.

Для экспериментов создадим новую таблицу с двумя столбцами и индекс по одному из них:

=> CREATE TABLE t( id serial, s text
);
=> CREATE INDEX ON t(s);

Вставим одну строку, предварительно начав транзакцию.

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

Вот номер нашей текущей транзакции:

=> SELECT txid_current();

txid_current -------------- 3664
(1 row)

Заглянем в содержимое страницы. Функция heap_page_items расширения pageinspect позволяет получить информацию об указателях и версиях строк:

=> SELECT * FROM heap_page_items(get_raw_page('t',0)) \gx

-[ RECORD 1 ]-------------------
lp | 1
lp_off | 8160
lp_flags | 1
lp_len | 32
t_xmin | 3664
t_xmax | 0
t_field3 | 0
t_ctid | (0,1)
t_infomask2 | 2
t_infomask | 2050
t_hoff | 24
t_bits | t_oid | t_data | \x0100000009464f4f

Заметим, что словом heap (куча) в PostgreSQL обозначаются таблицы. Это еще одно странное употребление термина — куча является известной структурой данных, которая не имеет с таблицей ничего общего. Здесь это слово употребляется в смысле «все свалено в кучу», в отличие от упорядоченных индексов.

Чтобы разобраться, мы оставим только часть информации и расшифруем ее: Функция показывает данные «как есть», в формате, сложном для восприятия.

=> SELECT '(0,'||lp||')' AS ctid, CASE lp_flags WHEN 0 THEN 'unused' WHEN 1 THEN 'normal' WHEN 2 THEN 'redirect to '||lp_off WHEN 3 THEN 'dead' END AS state, t_xmin as xmin, t_xmax as xmax, (t_infomask & 256) > 0 AS xmin_commited, (t_infomask & 512) > 0 AS xmin_aborted, (t_infomask & 1024) > 0 AS xmax_commited, (t_infomask & 2048) > 0 AS xmax_aborted, t_ctid
FROM heap_page_items(get_raw_page('t',0)) \gx

-[ RECORD 1 ]-+-------
ctid | (0,1)
state | normal
xmin | 3664
xmax | 0
xmin_commited | f
xmin_aborted | f
xmax_commited | f
xmax_aborted | t
t_ctid | (0,1)

Вот что мы сделали:

  • Добавили к номеру указателя нолик, чтобы привести его к такому же виду, как t_ctid: (номер страницы, номер указателя).
  • Расшифровали состояние указателя lp_flags. Здесь он «normal» — это значит, что указатель действительно ссылается на версию строки. Другие значения рассмотрим позже.
  • Из всех информационных битов выделили пока только две пары. Биты xmin_committed и xmin_aborted показывают, зафиксирована ли (отменена ли) транзакция с номером xmin. Два аналогичных бита относятся к транзакции с номером xmax.

Что же мы видим? При вставке строки в табличной странице появится указатель с номером 1, ссылающийся на первую и единственную версию строки.

Транзакция еще активна, поэтому оба бита xmin_committed и xmin_aborted не установлены. В версии строки поле xmin заполнено номером текущей транзакции.

Это означает, что более новой версии не существует. Поле ctid версии строки ссылается на эту же строку.

Транзакции не будут обращать внимание на этот номер, поскольку установлен бит xmax_aborted. Поле xmax заполнено фиктивным номером 0, поскольку данная версия строки не удалена и является актуальной.

И создадим функцию, поскольку запрос нам еще не раз понадобится: Сделаем еще один шаг к улучшению читаемости, дописав информационные биты к номерам транзакций.

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid, CASE lp_flags WHEN 0 THEN 'unused' WHEN 1 THEN 'normal' WHEN 2 THEN 'redirect to '||lp_off WHEN 3 THEN 'dead' END AS state, t_xmin || CASE WHEN (t_infomask & 256) > 0 THEN ' (c)' WHEN (t_infomask & 512) > 0 THEN ' (a)' ELSE '' END AS xmin, t_xmax || CASE WHEN (t_infomask & 1024) > 0 THEN ' (c)' WHEN (t_infomask & 2048) > 0 THEN ' (a)' ELSE '' END AS xmax, t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

В таком виде значительно понятнее, что творится в заголовке версии строки:

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+------+-------+-------- (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Похожую, но существенно менее детальную, информацию можно получить и из самой таблицы, используя псевдостолбцы xmin и xmax:

=> SELECT xmin, xmax, * FROM t;

xmin | xmax | id | s ------+------+----+----- 3664 | 0 | 1 | FOO
(1 row)

При успешном завершении транзакции нужно запомнить ее статус — отметить, что она зафиксирована. Для этого используется структура, называемая XACT (а до версии 10 она называлась CLOG (commit log) и это название еще может встречаться в разных местах).

В них для каждой транзакции отведено два бита: committed и aborted — точно так же, как в заголовке версии строки. XACT — не таблица системного каталога; это файлы в каталоге PGDATA/pg_xact. А работа с этими файлами ведется постранично, как и со всеми другими. На несколько файлов эта информация разбита исключительно для удобства, мы еще вернемся к этому вопросу, когда будем рассматривать заморозку.

И это все, что происходит при фиксации (правда, мы пока не говорим про журнал предзаписи). Итак, при фиксации транзакции в XACT выставляется бит committed для данной транзакции.

Когда какая-либо другая транзакция обратится к табличной странице, на которую мы только что смотрели, ей придется ответить на несколько вопросов.

  1. Завершилась ли транзакция xmin? Если нет, то созданная версия строки не должна быть видна.
    Такая проверка выполняется просмотром еще одной структуры, которая располагается в общей памяти экземпляра и называется ProcArray. В ней находится список всех активных процессов, и для каждого указан номер его текущей (активной) транзакции.
  2. Если завершилась, то как — фиксацией или отменой? Если отменой, то версия строки тоже не должны быть видна.
    Вот для этого как раз и нужен XACT. Но, хотя последние страницы XACT сохраняются в буферах в оперативной памяти, все же каждый раз проверять XACT накладно. Поэтому выясненный однажды статус транзакции записывается в биты xmin_committed и xmin_aborted версии строки. Если один из этих битов установлен, то состояние транзакции xmin считается известным и следующей транзакции уже не придется обращаться к XACT.

Почему эти биты не устанавливаются самой транзакцией, выполняющей вставку? Когда происходит вставка, транзакция еще не знает, завершится ли она успешно. А в момент фиксации уже непонятно, какие именно строки в каких именно страницах были изменены. Таких страниц может оказаться много, и запоминать их невыгодно. К тому же часть страниц может быть вытеснена из буферного кеша на диск; читать их заново, чтобы изменить биты, означало бы существенно замедлить фиксацию.

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

Итак, зафиксируем изменение.

=> COMMIT;

В странице ничего не изменилось (но мы значем, что статус транзакции уже записан в XACT):

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+------+-------+-------- (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Теперь транзакция, первой обратившаяся к странице, должна будет определить статус транзакции xmin и запишет его в информационные биты:

=> SELECT * FROM t;

id | s ----+----- 1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+-------+-------- (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 row)

При удалении строки в поле xmax актуальной версии записывается номер текущей удаляющей транзакции, а бит xmax_aborted сбрасывается.

Если другая транзакция собирается обновить или удалить эту строку, она будет вынуждена дождаться завершения транзакции xmax. Заметим, что установленное значение xmax, соответствующее активной транзакции, выступает в качестве блокировки строки. Пока отметим только, что число блокировок строк ничем не ограничено. Подробнее про блокировки мы будем говорить позже. Правда, у “длинных” транзакций есть другие минусы, но об этом тоже позже. Они не занимают место в оперативной памяти и производительность системы не страдает от их количества.

Удалим строку.

=> BEGIN;
=> DELETE FROM t;
=> SELECT txid_current();

txid_current -------------- 3665
(1 row)

Видим, что номер транзакции записался в поле xmax, но информационные биты не установлены:

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+------+-------- (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

Отмена изменений работает аналогично фиксации, только в XACT для транзакции выставляется бит aborted. Отмена выполняется так же быстро, как и фиксация. Хоть команда и называется ROLLBACK, отката изменений не происходит: все, что транзакция успела изменить в страницах данных, остается без изменений.

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+------+-------- (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

При обращении к странице будет проверен статус и в версию строки будет установлен бит подсказки xmax_aborted. Сам номер xmax при этом остается в странице, но смотреть на него уже никто не будет.

=> SELECT * FROM t;

id | s ----+----- 1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+----------+-------- (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 row)

Обновление работает так, как будто сначала выполнилось удаление текущей версии строки, а затем вставка новой.

=> BEGIN;
=> UPDATE t SET s = 'BAR';

Запрос выдает одну строку (новую версию):

=> SELECT * FROM t;

txid_current -------------- 3666
(1 row)

Но в странице мы видим обе версии:

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+-------+-------- (0,1) | normal | 3664 (c) | 3666 | (0,2) (0,2) | normal | 3666 | 0 (a) | (0,2)
(2 rows)

Удаленная версия помечена номером текущей транзакции в поле xmax. Причем это значение записано поверх старого, поскольку предыдущая транзакция была отменена. А бит xmax_aborted сброшен, так как статус текущей транзакции еще неизвестен.

Первая версия строки ссылается теперь на вторую (поле t_ctid), как на более новую.

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

Так же, как и при удалении, значение xmax в первой версии строки служит признаком того, что строка заблокирована.

Ну и завершим транзакцию.

=> COMMIT;

До сих пор мы говорили только о табличных страницах. А что происходит внутри индексов?

И даже у одного типа индекса бывают разные виды страниц. Информация в индексных страницах сильно зависит от конкретного типа индекса. Например, у B-дерева есть страница с метаданными и «обычные» страницы.

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

Например, для B-дерева строки, относящиеся к листовым страницам, содержат значение ключа индексирования и ссылку (ctid) на соответствующую строку таблицы. Строки в индексах тоже могут иметь очень разную структуру в зависимости от типа индекса. В общем случае индекс может быть устроен совсем другим образом.

Ну или можно считать, что каждая строка представлена ровно одной версией. Самый важный момент состоит в том, что в индексах любого типа не бывает версий строк. Можно считать, что ссылки из индекса ведут на все табличные версии строк — так что разобраться, какую из версий увидит транзакций, можно только заглянув в таблицу. Иными словами, в заголовке индексной строки не бывает полей xmin и xmax. В некоторых случаях карта видимости позволяет оптимизировать процесс, но подробнее рассмотрим это позже.) (Как обычно, это не вся правда.

При этом в индексной странице обнаруживаем указатели на обе версии, как на актуальную, так и на старую:

=> SELECT itemoffset, ctid FROM bt_page_items('t_s_idx',1);

itemoffset | ctid ------------+------- 1 | (0,2) 2 | (0,1)
(2 rows)

На практике PostgreSQL использует оптимизацию, позволяющую «экономить» номера транзакций.

Поэтому вначале обслуживающий процесс выдает транзакции виртуальный номер (virtual xid). Если транзакция только читает данные, то она никак не влияет на видимость версий строк. Номер состоит из идентификатора процесса и последовательного числа.

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

Виртуальные номера никак не учитываются в снимках данных.

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

=> BEGIN;
=> SELECT txid_current_if_assigned();

txid_current_if_assigned -------------------------- (1 row)

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

=> UPDATE accounts SET amount = amount - 1.00;
=> SELECT txid_current_if_assigned();

txid_current_if_assigned -------------------------- 3667
(1 row)

=> COMMIT;

Точки сохранения

В SQL определены точки сохранения (savepoint), которые позволяют отменить часть операцией транзакции, не прерывая ее полностью. Но это не укладывается в приведенную выше схему, поскольку статус у транзакции один на все ее изменения, а физически никакие данные не откатываются.

Чтобы реализовать такой функционал, транзакция с точкой сохранения разбивается на несколько отдельных вложенных транзакций (subtransaction), статусом которых можно управлять отдельно.

Статус вложенных транзакций записывается обычным образом в XACT, однако финальный статус зависит от статуса основной транзакции: если она отменена, то отменяются также и все вложенные транзакции. Вложенные транзакции имеют свой собственный номер (бóльший, чем номер основной транзакции).

Обращение к файлам происходит через буферы в общей памяти экземпляра, организованные так же, как и буферы XACT. Информация о вложенности транзакций хранится в файлах в каталоге PGDATA/pg_subtrans.

Автономные транзакции никак не зависят друг от друга, а вложенные — зависят. Не путайте вложенные транзакции и автономные транзакции. Автономных транзакций в обычном PostgreSQL нет, и, пожалуй, к лучшему: по делу они нужны очень и очень редко, а их наличие в других СУБД провоцирует злоупотребление, от которого потом все страдают.

Очистим таблицу, начнем транзакцию и вставим строку:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT txid_current();

txid_current -------------- 3669
(1 row)

=> SELECT xmin, xmax, * FROM t;

xmin | xmax | id | s ------+------+----+----- 3669 | 0 | 2 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+------+-------+-------- (0,1) | normal | 3669 | 0 (a) | (0,1)
(1 row)

Теперь поставим точку сохранения и вставим еще одну строку.

=> SAVEPOINT sp;
=> INSERT INTO t VALUES ('XYZ');
=> SELECT txid_current();

txid_current -------------- 3669
(1 row)

Заметьте, что функция txid_current() выдает номер основной, а не вложенной, транзакции.

=> SELECT xmin, xmax, * FROM t;

xmin | xmax | id | s ------+------+----+----- 3669 | 0 | 2 | FOO 3670 | 0 | 3 | XYZ
(2 rows)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+------+-------+-------- (0,1) | normal | 3669 | 0 (a) | (0,1) (0,2) | normal | 3670 | 0 (a) | (0,2)
(2 rows)

Откатимся к точке сохранения и вставим третью строку.

=> ROLLBACK TO sp;
=> INSERT INTO t VALUES ('BAR');
=> SELECT xmin, xmax, * FROM t;

xmin | xmax | id | s ------+------+----+----- 3669 | 0 | 2 | FOO 3671 | 0 | 4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+-------+-------- (0,1) | normal | 3669 | 0 (a) | (0,1) (0,2) | normal | 3670 (a) | 0 (a) | (0,2) (0,3) | normal | 3671 | 0 (a) | (0,3)
(3 rows)

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

Фиксируем изменения.

=> COMMIT;
=> SELECT xmin, xmax, * FROM t;

xmin | xmax | id | s ------+------+----+----- 3669 | 0 | 2 | FOO 3671 | 0 | 4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+-------+-------- (0,1) | normal | 3669 (c) | 0 (a) | (0,1) (0,2) | normal | 3670 (a) | 0 (a) | (0,2) (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
(3 rows)

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

Этот механизм задействуется неявно при использовании точек сохранения, а еще при обработке исключений PL/pgSQL и в ряде других, более экзотических, случаев. Заметим, что вложенные транзакции нельзя использовать в SQL явно, то есть нельзя начать новую транзакцию, не завершив текущую.

=> BEGIN;

BEGIN

=> BEGIN;

WARNING: there is already a transaction in progress
BEGIN

=> COMMIT;

COMMIT

=> COMMIT;

WARNING: there is no transaction in progress
COMMIT

Ошибки и атомарность операций

Что случится, если при выполнении операции произойдет ошибка? Например, так:

=> BEGIN;
=> SELECT * FROM t;

id | s ----+----- 2 | FOO 4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));

ERROR: division by zero

Произошла ошибка. Теперь транзакция считается прерванной и ни одна операция в ней не допускается:

=> SELECT * FROM t;

ERROR: current transaction is aborted, commands ignored until end of transaction block

И даже если попытаться зафиксировать изменения, PostgreSQL сообщит об отмене:

=> COMMIT;

ROLLBACK

Почему нельзя продолжить выполнение транзакции после сбоя? Дело в том, что ошибка могла возникнуть так, что мы получили бы доступ к части изменений — была бы нарушена атомарность даже не транзакции, а оператора. Как в нашем примере, где оператор до ошибки успел обновить одну строку:

=> SELECT * FROM heap_page('t',0);

ctid | state | xmin | xmax | t_ctid -------+--------+----------+-------+-------- (0,1) | normal | 3669 (c) | 3672 | (0,4) (0,2) | normal | 3670 (a) | 0 (a) | (0,2) (0,3) | normal | 3671 (c) | 0 (a) | (0,3) (0,4) | normal | 3672 | 0 (a) | (0,4)
(4 rows)

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

=> \set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;

id | s ----+----- 2 | FOO 4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));

ERROR: division by zero

=> SELECT * FROM t;

id | s ----+----- 2 | FOO 4 | BAR
(2 rows)

=> COMMIT;

Нетрудно догадаться, что в таком режиме psql фактически ставит перед каждой командой неявную точку сохранения, а в случае сбоя инициирует откат к ней. Такой режим не используется по умолчанию, поскольку установка точек сохранения (даже без отката к ним) сопряжена с существенными накладными расходами.

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

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

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

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

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

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