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

    Продолжение.
    Postgres Professional
    208,00
    Разработчик СУБД Postgres Pro
    Поделиться публикацией

    Похожие публикации

    Комментарии 19

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

      Немного вопросов:

      №1
      XACT — это аббревиатура. Как ее можно расшифровать, пусть даже и условно?

      №2
      Поэтому выясненный однажды статус транзакции записывается в биты xmin_committed и xmin_aborted версии строки. Если один из этих битов установлен, то состояние транзакции xmin считается известным и следующей транзакции уже не придется обращаться к XACT.


      Что будет происходить при race conditions?
      Несколько транзакций параллельно (в рамках разных соединений) пытаются посмотреть эти биты, не найдя их — идут в XACT. И далее одновременно пишут биты в заголовок. Одна транзакция установит биты, вторая — установит те же самые значения. И все.
      Видимо, никаких проблем тут не будет кроме лишнего похода в XACT, что не является думаю критичным.
      Поправьте, пожалуйста, если что-то не так понял.

      №3
      XACT — не таблица системного каталога; это файлы в каталоге PGDATA/pg_xact.
      А работа с этими файлами ведется постранично, как и со всеми другими.

      Используется ли буферный кеш для XACТ, как и для таблиц? Или работаем как с обычными файлами, средствами ОС (файловый кеш в RAM на уровне ОС). Если это так, то то интересно, почему так решили? Насколько я успел привыкнуть — в PostgreSQL стараются всю информацию представлять в едином, табличном виде (способе хранения и работы с данными)

      №4
      Например, для B-дерева строки, относящиеся к листовым страницам, содержат значение ключа индексирования и ссылку (ctid) на соответствующую строку таблицы. В общем случае индекс может быть устроен совсем другим образом.


      А далее:

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


      Поясните, пожалуйста, для полноты картины. Получается, что одному значению ctid соответствует несколько записей? Хотя в указанных примерах это вроде бы не так. По какому свойству строки индекс находит все возможные версии? Видимо, это приватный ключ — ID.
      Или же — при создании новой версии строки происходит какое-то изменение индекса чтобы он знал о новой версии?

      №5
      И немного не в тему статьи — индекс перестраивается в рамках транзакции но сразу после COMMIT? То есть в рамках снимка индекс, условно говоря, может быть «устаревшим»?

      №6
      Когда создается новая версия строки при UPDATE — создается полная копия строки в базе данных? Соответственно, если есть строка с большим значением внутри TEXT, то UPDATE создаст его копию даже если изменилось другое поле — например, у поста счетчик number_of_upvotes увеличился на единицу, но сам текст поста никто не изменял.
      Поэтому частые UPDATE могут существенно влиять на размер таблицы?

      Спасибо
        +1
        Владимир, спасибо!
        Пожалуй, отвечу на каждый из вопросов отдельно.

        1.
        XACT — не аббревиатура, а сокращение, образованное по непонятным мне правилам. Дело в том, что в исходных кодах PostgreSQL транзакция часто обозначается буквой «икс». Отсюда всякие xmin, xmax, xid и пр. И «xact» обозначает ровно то же (в «act» угадывается часть слова trans-act-ion). Отсюда multixact — мультитранзакция (про этого зверя я планирую написать, когда дойду до блокировок). В общем, расшифровка такая: нечто, имеющее отношение к транзакциям.
          +1
          2.
          У меня такое же понимание. Исключительная блокировка на строку при проверке видимости не накладывается, так что по идее возможна ситуация, когда обе транзакции обновят те же биты.
          «Лишние» походы в XACT случаются (и не только по этой причине), и в этом действительно ничего страшного нет.
            +1
            3.
            Да, кеш там конечно же есть, но отдельный. XACT — довольно специфическая штука, ее под таблицу сложно замаскировать.
              +1
              4.
              Пока мы для простоты считаем, что каждой табличной версии строки в индексе (B-дереве) соответствует своя строка, которая ссылается на эту версию. (И если появляется новая версия строки, она тоже добавляется в индекс.)
              Когда мы обращаемся к индексу (дай мне строку, где id = 1), мы получаем ссылки на все версии строки с id = 1. И дальше проверяем по таблице, какую из этих версий нам действительно можно увидеть.

              Но это упрощенная картина. Есть карта видимости, есть HOT-цепочки… Это мы все рассмотрим, но позже, чтобы не погрязнуть в деталях.
                0
                Получается тогда, что VACUUM очищает не только более «невидимые никому» версии строк, но вычищает и ненужные записи в индексе. Но это я видимо забегаю вперед, насколько помню будет статья о VACUUM. Очень хотелось бы почитать об этой особенности там :)
                  0

                  Так и есть.
                  Все будет в подробностях.

                  0
                  Теперь немного понятнее стало, почему массовый апдейт даже небольшого числового поля (INTEGER) привел однажды к тому, что индекс «сломался» — эффективность его работы существенно (на порядок) просела и пришлось делать VACUUM FULL (не то написал) перестроение индекса с блокировкой. Кейс к сожалению (или к счастью) не мой. Но что делать и как обновлять — не понятно пока
                    0

                    Индекс действительно может распухнуть, но чтобы "на порядок" — это что-то странное. Возможно, в том случае были ещё какие-то обстоятельства.
                    В общем, про это тоже будет.

                      0

                      Вообще, всегда при массовых изменениях в таблице (будь то UPDATE, INSERT или DELETE) рекомендую делать:


                      1. VACUUM ANALYZE для актуализации статистики, иначе у планировщика будут устаревшие данные и он может выбирать странные и не эффективные (для новых данных) планы запросов.
                      2. Пересоздание всех индексов без блокировки: CREATE INDEX CONCURRENTLY "new" + DROP INDEX "old" + ALTER INDEX "new" RENAME TO "old".

                      Причем обе процедуры можно смело делать "на бою" без простоя.


                      У меня был случай, когда после массовой операции (вычищение устаревших данных в таблице) только на пересоздании индексов удалось выиграть 100 Гб места на диске.

                        0

                        Всегда и никогда — нехорошие слова. Лучше, когда действия осознанные.

                          0
                          Пересоздание всех индексов без блокировки: CREATE INDEX CONCURRENTLY «new» + DROP INDEX «old» + ALTER INDEX «new» RENAME TO «old».


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

                          Причем обе процедуры можно смело делать «на бою» без простоя.


                          Эти процедуры могут расходовать ресурсы и таким образом влиять на производительность БД на продакшене. Думаю, выполнять их нужно с осторожностью.

                          У меня был случай, когда после массовой операции (вычищение устаревших данных в таблице) только на пересоздании индексов удалось выиграть 100 Гб места на диске.


                          Если есть возможность — опишите свой кейс более подробно, будет интересно.
                      +1
                      5.
                      Индекс перестраивается сразу же по ходу транзакции, не дожидаясь фиксации. Какой смысл откладывать?
                        0
                        Если предполагать, что транзакции откатываются намного реже, чем происходит коммит, то откладывать действительно нет смысла, не задумался об этом. Наличие большого количества ROLLBACK — это индикатор проблем в архитектуре.
                          0

                          Не-не. В PostgreSQL обрыв транзакции не связан с откатом изменений. Все, что транзакция наделала, так и остаётся лежать (никому не видимое) до тех пор, пока vacuum не вычистит. В том числе и в индексах.

                        +1
                        6.
                        Это мы возвращаемся к TOAST-у?
                        Если длинное значение лежит в toast-таблице, а изменилось только поле в основной таблице, то только в основной таблице и появится новая версия строки. И она будет ссылаться на ту же самую строку в toast-таблице. Иными словами, длинное значение в этом случае не дублируется.
                          0
                          отличное объяснение, спасибо
                            0
                            На ум приходит ошибочный кейс «чистки БД»:
                            * Делаем массовый апдейт description поля типа TEXT, например вычищают XSS injections (или решили ругательные слова заменить точками постфактум, как бывает в стартапах)
                            * Сразу получаем минимум 2х к размеру TOAST-таблицы.
                            * И это место видимо без FULL VACUUM оказывается очень тяжело высвободить для ОС (и для других таблиц). Даже если обновлять пачками.

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

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

                              Ок, принял к сведению, спасибо!

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