Хабрахабр

MVCC в PostgreSQL-8. Заморозка

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

Затем мы рассмотрели разные виды очистки: внутристраничную (вместе с HOT-обновлениями), обычную и автоматическую.

Сегодня мы поговорим о проблеме переполнения счетчика транзакций (transaction id wraparound) и заморозке.
И добрались до последней темы этого цикла.

Под номер транзакции в PostgreSQL выделено 32 бита. Это довольно большое число (около 4 млрд), но при активной работе сервера оно вполне может быть исчерпано. Например при нагрузке 1000 транзакций в секунду это произойдет всего через полтора месяца непрерывной работы.

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

Дело в том, что (как рассматривалось ранее) в заголовке каждой версии строки хранятся два номера транзакций — xmin и xmax. Почему под номер транзакции не выделено 64 бита — ведь это полностью исключило бы проблему? Это уже совсем ни в какие ворота. Заголовок и так достаточно большой, минимум 23 байта, а увеличение разрядности привело бы к его увеличению еще на 8 байт.

64-битные номера транзакций реализованы в продукте нашей компании — Postgres Pro Enterprise, — но и там они не вполне честные: xmin и xmax остаются 32-битными, а в заголовке страницы находится общее для всей страницы «начало эпохи».

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

Когда мы хотим понять, старше одна транзакция другой или нет, мы сравниваем их возраст, а не номера. Возрастом транзакции называется число транзакций, прошедших с момента ее появления в системе (независимо от того, переходил ли счетчик через ноль или нет). (Поэтому, кстати, для типа данных xid не определены операции «больше» и «меньше».)

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

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

Для того, чтобы пометить номер транзакции xmin как замороженный, выставляются одновременно оба бита-подсказки — бит фиксации и бит отмены.

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

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

=> CREATE TABLE tfreeze( id integer, s char(300)
) WITH (fillfactor = 10, autovacuum_enabled = off);

Мы уже создавали несколько вариантов функцию, которая с помощью расширения pageinspect показывала версии строк, находящиеся на странице. Сейчас мы создадим еще один вариант той же функции: теперь она будет отображать сразу несколько страниц и показывать возраст транзакции xmin (для этого используется системная функция age):

=> CREATE FUNCTION heap_page(relname text, pageno_from integer, pageno_to integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmin_age integer, 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+512) = 256+512 THEN ' (f)' WHEN (t_infomask & 256) > 0 THEN ' (c)' WHEN (t_infomask & 512) > 0 THEN ' (a)' ELSE '' END AS xmin, age(t_xmin) xmin_age, t_xmax || CASE WHEN (t_infomask & 1024) > 0 THEN ' (c)' WHEN (t_infomask & 2048) > 0 THEN ' (a)' ELSE '' END AS xmax, t_ctid
FROM generate_series(pageno_from, pageno_to) p(pageno), heap_page_items(get_raw_page(relname, pageno))
ORDER BY pageno, lp;
$$ LANGUAGE SQL;

Обратите внимание, что признак заморозки (который мы показываем буквой f в скобках) определяется одновременной установкой битов-подсказок committed и aborted. Многие источники (включая документацию) упоминают специальный номер FrozenTransactionId = 2, которым помечаются замороженные транзакции. Такая система действовала до версии 9.4, но сейчас она заменена на биты-подсказки — это позволяет сохранить в версии строки исходный номер транзакции, что удобно для целей поддержки и отладки. Однако транзакции с номером 2 еще могут встретиться в старых системах, даже обновленных до последних версий.

Еще нам потребуется расширения pg_visibility, которое позволяет заглянуть в карту видимости:

=> CREATE EXTENSION pg_visibility;

До версии PostgreSQL 9.6 карта видимости содержала один бит на страницу; в ней отмечались страницы, содержащие только «достаточно старые» версии строк, которые уже гарантированно видны во всех снимках. Идея здесь в том, что если страница отмечена в карте видимости, то для ее версий строк не надо проверять правила видимости.

6 в этот же слой была добавлена карта заморозки — еще один бит на страницу. Начиная с версии 9. Карта заморозки отмечает страницы, а которых все версии строк заморожены.

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

=> INSERT INTO tfreeze(id, s) SELECT g.id, 'FOO' FROM generate_series(1,100) g(id);
=> VACUUM tfreeze;

И мы видим, что обе страницы теперь отмечены в карте видимости (all_visible), но еще не заморожены (all_frozen):

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;

blkno | all_visible | all_frozen -------+-------------+------------ 0 | t | f 1 | t | f
(2 rows)

Возраст транзакции, создавшей строки (xmin_age), равен 1 — это последняя транзакция, которая выполнялась в системе:

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

ctid | state | xmin | xmin_age | xmax | t_ctid -------+--------+---------+----------+-------+-------- (0,1) | normal | 697 (c) | 1 | 0 (a) | (0,1) (0,2) | normal | 697 (c) | 1 | 0 (a) | (0,2) (1,1) | normal | 697 (c) | 1 | 0 (a) | (1,1) (1,2) | normal | 697 (c) | 1 | 0 (a) | (1,2)
(4 rows)

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

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

Значение по умолчанию для этого параметра устанавливает, что транзакции начинают замораживаться после того, как с их появления пройдет 50 миллионов других транзакций:

=> SHOW vacuum_freeze_min_age;

vacuum_freeze_min_age ----------------------- 50000000
(1 row)

Для того, чтобы посмотреть, как происходит заморозка, уменьшим значение этого параметра до единички.

=> ALTER SYSTEM SET vacuum_freeze_min_age = 1;
=> SELECT pg_reload_conf();

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

=> UPDATE tfreeze SET s = 'BAR' WHERE id = 1;

Вот что мы видим теперь в страницах данных:

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

ctid | state | xmin | xmin_age | xmax | t_ctid -------+--------+---------+----------+-------+-------- (0,1) | normal | 697 (c) | 2 | 698 | (0,3) (0,2) | normal | 697 (c) | 2 | 0 (a) | (0,2) (0,3) | normal | 698 | 1 | 0 (a) | (0,3) (1,1) | normal | 697 (c) | 2 | 0 (a) | (1,1) (1,2) | normal | 697 (c) | 2 | 0 (a) | (1,2)
(5 rows)

Теперь строки старше vacuum_freeze_min_age = 1 подлежат заморозке. Но обратите внимание на то, что нулевая строка не отмечена в карте видимости (бит был сброшен командой UPDATE, изменившей страницу), а первая — остается отмеченной:

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;

blkno | all_visible | all_frozen -------+-------------+------------ 0 | f | f 1 | t | f
(2 rows)

Мы уже говорили о том, что очистка просматривает только страницы, не отмеченные в карте видимости. Так и получается:

=> VACUUM tfreeze;
=> SELECT * FROM heap_page('tfreeze',0,1);

ctid | state | xmin | xmin_age | xmax | t_ctid -------+---------------+---------+----------+-------+-------- (0,1) | redirect to 3 | | | | (0,2) | normal | 697 (f) | 2 | 0 (a) | (0,2) (0,3) | normal | 698 (c) | 1 | 0 (a) | (0,3) (1,1) | normal | 697 (c) | 2 | 0 (a) | (1,1) (1,2) | normal | 697 (c) | 2 | 0 (a) | (1,2)
(5 rows)

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

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;

blkno | all_visible | all_frozen -------+-------------+------------ 0 | t | f 1 | t | f
(2 rows)

Чтобы все-таки заморозить версии строк, оставшиеся в страницах, в которые очистка просто так не заглядывает, предусмотрен второй параметр: vacuum_freeze_table_age. Он определяет возраст транзакции, при котором очистка игнорирует карту видимости и проходится по всем страницам таблицы, чтобы выполнить заморозку.

С возрастом этой запомненной транзакции и сравнивается значение параметра vacuum_freeze_table_age. Каждая таблица хранит номер транзакции, для которого известно, что все более старые транзакции гарантированно заморожены (pg_class.relfrozenxid).

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';

relfrozenxid | age --------------+----- 694 | 5
(1 row)

До версии PostgreSQL 9.6 очистка выполняла полное сканирование таблицы, чтобы гарантированно обойти все страницы. Для таблиц, имеющих большой размер, эта операция выполнялась долго и печально. Дело усугублялось тем, что если очистке не удавалось дойти до конца (например, нетерпеливый администратор прерывал выполнение команды), приходилось начинать с самого начала.

6, благодаря карте заморозки (которую мы наблюдаем в столбце all_frozen в выводе pg_visibility_map), очистка обходит только те страницы, которые еще не отмечены в карте. Начиная с версии 9. Это не только существенно меньший объем работы, но и устойчивость к прерываниям: если процесс очистки остановить и начать заново, ему не придется снова просматривать страницы, которые он уже успел отметить в карте заморозки в прошлый раз.

При значениях по умолчанию это происходит раз в миллион транзакций: Так или иначе, заморозка всех страниц в таблице выполняется раз
в (vacuum_freeze_table_agevacuum_freeze_min_age) транзакций.

=> SHOW vacuum_freeze_table_age;

vacuum_freeze_table_age ------------------------- 150000000
(1 row)

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

Давайте посмотрим, как происходит заморозка всей таблицы, и для этого уменьшим vacuum_freeze_table_age до 5 — так, чтобы выполнилось условие для заморозки.

=> ALTER SYSTEM SET vacuum_freeze_table_age = 5;
=> SELECT pg_reload_conf();

Выполним очистку:

=> VACUUM tfreeze;

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

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';

relfrozenxid | age --------------+----- 698 | 1
(1 row)

Теперь все версии строк на первой странице заморожены:

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

ctid | state | xmin | xmin_age | xmax | t_ctid -------+---------------+---------+----------+-------+-------- (0,1) | redirect to 3 | | | | (0,2) | normal | 697 (f) | 2 | 0 (a) | (0,2) (0,3) | normal | 698 (c) | 1 | 0 (a) | (0,3) (1,1) | normal | 697 (f) | 2 | 0 (a) | (1,1) (1,2) | normal | 697 (f) | 2 | 0 (a) | (1,2)
(5 rows)

Кроме того, первая страница отмечена в карте заморозки:

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;

blkno | all_visible | all_frozen -------+-------------+------------ 0 | t | f 1 | t | t
(2 rows)

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

Есть разные причины. Из-за чего это может произойти?

  • Может быть отключена автоочистка, и обычная очистка тоже не запускается. Мы уже говорили, что так делать не надо, но технически это возможно.
  • Даже включенная автоочистка не приходит в базы данных, которые не используются (вспомните про параметр track_counts и базу данных template0).
  • Как мы видели в прошлый раз, очистка пропускает таблицы, в которые данные только добавляются, но не удаляются и не изменяются.

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

Значение по умолчанию довольно консервативно:

=> SHOW autovacuum_freeze_max_age;

autovacuum_freeze_max_age --------------------------- 200000000
(1 row)

Предел для autovacuum_freeze_max_age составляет 2 млрд транзакций, а используется значение, в 10 раз меньшее. В этом есть смысл: увеличивая значение мы увеличиваем и риск того, что за оставшееся время автоочистка просто не успеет заморозить все необходимые версии строк.

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

Для этой таблицы автоочистка вообще отключена, но и это не будет помехой. Поглядим, как очистка справляется с append-only-таблицами, на примере tfreeze.

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

К сожалению, минимально возможное значение составляет 100 000: Итак, мы установим autovacuum_freeze_max_age на уровне таблицы (а заодно вернем и нормальный fillfactor).

=> ALTER TABLE tfreeze SET (autovacuum_freeze_max_age = 100000, fillfactor = 100);

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

И снова вынужден оговориться, что на практике так делать не следует. Поскольку мы собираемся добавлять данные, вставим в таблицу 100 000 строк — каждую в своей транзакции. Но мы сейчас просто исследуем, нам можно.

=> CREATE PROCEDURE foo(id integer) AS $$
BEGIN INSERT INTO tfreeze VALUES (id, 'FOO'); COMMIT;
END;
$$ LANGUAGE plpgsql; => DO $$
BEGIN FOR i IN 101 .. 100100 LOOP CALL foo(i); END LOOP;
END;
$$;

Как мы видим, возраст последней замороженной транзакции в таблице перевалил за пороговое значение:

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';

relfrozenxid | age --------------+-------- 698 | 100006
(1 row)

Но если теперь немного подождать, то в журнале сообщений сервера появится запись про automatic aggressive vacuum of table «test.public.tfreeze», номер замороженной транзакции изменится, а ее возраст вернется в рамки приличий:

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';

relfrozenxid | age --------------+----- 100703 | 3
(1 row)

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

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

При перестройке таблицы командами VACUUM FULL или CLUSTER все строки также замораживаются. Заморозку можно вызвать вручную командой VACUUM FREEZE — при этом будут заморожены все версии строк, без оглядки на возраст транзакций (как будто параметр autovacuum_freeze_min_age = 0).

Чтобы заморозить все базы данных, можно воспользоваться утилитой:

vacuumdb --all --freeze

Данные можно заморозить и при начальной загрузке с помощью команды COPY, указав параметр FREEZE. Для этого таблица должна быть создана (или опустошена командой TRUNCATE) в той же
транзакции, что и COPY.

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

Чтобы убедиться в этом, в другом сеансе начнем транзакцию с уровнем изоляции Repeatable Read:

| => BEGIN ISOLATION LEVEL REPEATABLE READ;
| => SELECT txid_current();

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

=> BEGIN;
=> TRUNCATE tfreeze;
=> COPY tfreeze FROM stdin WITH FREEZE;

1 FOO
2 BAR
3 BAZ
\.

=> COMMIT;

Теперь параллельная транзакция видит новые данные, хотя это и нарушает изоляцию:

| => SELECT count(*) FROM tfreeze;

| count | -------
| 3
| (1 row)

| => COMMIT;

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

Поэтому при первом обращении к алице очистка вынуждена заново ее всю обработать и создать карту видимости. Значительно хуже то, что COPY WITH FREEZE не работает с картой видимости — загруженные страницы не отмечаются, как содержащие только видимые всем версии строк. К сожалению, решение этой проблемы не приходится ждать раньше версии 13 (обсуждение). Что еще хуже, страницы данных имеют признак полной видимости и в своем собственном заголовке, поэтому очистка не только читает всю таблицу, но и полностью переписывает ее, проставляя нужный бит.

На этом я завершаю цикл статей об изоляции и многоверсионности PostgreSQL. Спасибо за ваше внимание и особенно за комментарии — они позволяют улучшить материал и зачастую указывают на те области, которые требуют более тщательного внимания с моей стороны.

Оставайтесь с нами, продолжение следует!

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть