Postgres Professional corporate blog
PostgreSQL
SQL
April 6

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

Итак, мы рассмотрели вопросы, связанные с изоляцией, и сделали отступление об организации данных на низком уровне. И наконец добрались до самого интересного — до версий строк.

Заголовок


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

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

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

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

Когда строка изменяется командой UPDATE, фактически выполняются две операции: DELETE и INSERT. В текущей версии строки устанавливается xmax, равный номеру транзакции, выполнившей DELETE. Затем создается новая версия той же строки; значение 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 заполнено номером текущей транзакции. Транзакция еще активна, поэтому оба бита xmin_committed и xmin_aborted не установлены.

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

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

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

=> 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) и это название еще может встречаться в разных местах).

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

Итак, при фиксации транзакции в 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 txid_current();
 txid_current 
--------------
         3666
(1 row)

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

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(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, однако финальный статус зависит от статуса основной транзакции: если она отменена, то отменяются также и все вложенные транзакции.

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

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

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

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

=> 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 фактически ставит перед каждой командой неявную точку сохранения, а в случае сбоя инициирует откат к ней. Такой режим не используется по умолчанию, поскольку установка точек сохранения (даже без отката к ним) сопряжена с существенными накладными расходами.

Продолжение.
+22
3.5k 46
Comments 19
Top of the day