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

Затем мы рассмотрели разные виды очистки: внутристраничную (вместе с 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. Спасибо за ваше внимание и особенно за комментарии — они позволяют улучшить материал и зачастую указывают на те области, которые требуют более тщательного внимания с моей стороны.

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

Комментарии (0)