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

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

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

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

    Оставайтесь с нами, продолжение следует!
    • +20
    • 3.6k
    • 2
    Postgres Professional
    132.78
    Разработчик СУБД Postgres Pro
    Share post

    Similar posts

    Comments 2

      +1
      Егор, большое Вам спасибо за отличный цикл статей. И самое главное — отдельное спасибо за обратную связь, ответы на вопросы. С нетерпением жду новых статей.

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

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


      Заморозку можно вызвать вручную командой VACUUM FREEZE — при этом будут заморожены все версии строк, без оглядки на возраст транзакций


      * То есть, у этой строки установлено 2 бита — бит фиксации и бит отмены
      * Пусть все-таки строку начала менять транзакция.
      * устанавливается xmax
      * снимаются биты — фиксации и отмены, потому что они используются в MVCC
      * xmin начинает уже иметь значение — с какой транзакции строка видна.

      Как быть? ведь xmin уже полностью нерелевантен, но его надо использовать для видимости.
      Думаю, что его надо каким-то числом перезаписать или установить еще какие-то некие служебные биты, чтобы «на xmin никто не обращал внимания».

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

        Владимир, спасибо и вам за содержательные вопросы!


        * снимаются биты — фиксации и отмены, потому что они используются в MVCC

        Неа.


        Мы замораживаем xmin: устанавливаем биты xmin committed + xmin aborted. Это означает, что транзакция, которая создала данную версию строки, произошла так давно, что ее номер нас больше не интересует.


        Если мы затем изменяем строку, то устанавливаем xmax, чтобы показать, что время действия этой (теперь уже не актуальной) версии закончилось. Но xmin и его биты не трогаем — зачем? Нас устраивает, что эта версия началась в незапамятные времена.


        А xmin будет установлен у новой версии этой строки.

      Only users with full accounts can post comments. Log in, please.