Мы начали с вопросов, связанных с изоляцией, сделали отступление про организацию данных на низком уровне, подробно поговорили о версиях строк и о том, как из версий получаются снимки данных.
Затем мы рассмотрели разные виды очистки: внутристраничную (вместе с HOT-обновлениями), обычную и автоматическую.
И добрались до последней темы этого цикла. Сегодня мы поговорим о проблеме переполнения счетчика транзакций (transaction id wraparound) и заморозке.
Переполнение счетчика транзакций
Под номер транзакции в PostgreSQL выделено 32 бита. Это довольно большое число (около 4 млрд), но при активной работе сервера оно вполне может быть исчерпано. Например при нагрузке 1000 транзакций в секунду это произойдет всего через полтора месяца непрерывной работы.
Но мы говорили о том, что механизм многоверсионности полагается на последовательность нумерации — тогда из двух транзакций транзакцию с меньшим номером можно считать начавшейся раньше. Поэтому понятно, что нельзя просто так обнулить счетчик и продолжить нумерацию заново.
Почему под номер транзакции не выделено 64 бита — ведь это полностью исключило бы проблему? Дело в том, что (как рассматривалось ранее) в заголовке каждой версии строки хранятся два номера транзакций — xmin и xmax. Заголовок и так достаточно большой, минимум 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 карта видимости содержала один бит на страницу; в ней отмечались страницы, содержащие только «достаточно старые» версии строк, которые уже гарантированно видны во всех снимках. Идея здесь в том, что если страница отмечена в карте видимости, то для ее версий строк не надо проверять правила видимости.
Начиная с версии 9.6 в этот же слой была добавлена карта заморозки — еще один бит на страницу. Карта заморозки отмечает страницы, а которых все версии строк заморожены.
Вставляем в таблицу несколько строк и сразу выполняем очистку, чтобы создалась карта видимости:
=> 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. Он определяет возраст транзакции, при котором очистка игнорирует карту видимости и проходится по всем страницам таблицы, чтобы выполнить заморозку.
Каждая таблица хранит номер транзакции, для которого известно, что все более старые транзакции гарантированно заморожены (pg_class.relfrozenxid). С возрастом этой запомненной транзакции и сравнивается значение параметра vacuum_freeze_table_age.
=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
relfrozenxid | age
--------------+-----
694 | 5
(1 row)
До версии PostgreSQL 9.6 очистка выполняла полное сканирование таблицы, чтобы гарантированно обойти все страницы. Для таблиц, имеющих большой размер, эта операция выполнялась долго и печально. Дело усугублялось тем, что если очистке не удавалось дойти до конца (например, нетерпеливый администратор прерывал выполнение команды), приходилось начинать с самого начала.
Начиная с версии 9.6, благодаря карте заморозки (которую мы наблюдаем в столбце all_frozen в выводе pg_visibility_map), очистка обходит только те страницы, которые еще не отмечены в карте. Это не только существенно меньший объем работы, но и устойчивость к прерываниям: если процесс очистки остановить и начать заново, ему не придется снова просматривать страницы, которые он уже успел отметить в карте заморозки в прошлый раз.
Так или иначе, заморозка всех страниц в таблице выполняется раз
в (vacuum_freeze_table_age − vacuum_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 требует перезапуска сервера. Но все рассмотренные выше параметры также можно устанавливать и на уровне отдельных таблиц с помощью параметров хранения. Обычно это имеет смысл делать только в особенных случаях, когда таблица действительно требует особого обхождения.
Итак, мы установим autovacuum_freeze_max_age на уровне таблицы (а заодно вернем и нормальный fillfactor). К сожалению, минимально возможное значение составляет 100 000:
=> 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 FREEZE — при этом будут заморожены все версии строк, без оглядки на возраст транзакций (как будто параметр autovacuum_freeze_min_age = 0). При перестройке таблицы командами VACUUM FULL или CLUSTER все строки также замораживаются.
Чтобы заморозить все базы данных, можно воспользоваться утилитой:
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. Спасибо за ваше внимание и особенно за комментарии — они позволяют улучшить материал и зачастую указывают на те области, которые требуют более тщательного внимания с моей стороны.
Оставайтесь с нами, продолжение следует!
Автор: erogov