MVCC-3. Версії рядків

Отже, ми розглянули питання, пов'язані з ізоляцією, і зробили відступ про організації даних на низькому рівні. І нарешті дісталися найцікавішого — до версій рядків.

Заголовок

Як ми вже говорили, кожен рядок може одночасно бути присутнім у базі даних у кількох версіях. Одну версію від іншої треба якось відрізняти З цією метою кожна версія має дві позначки, що визначають час дії даної версії (xmin і xmax). У лапках — тому, що використовується не час як такий, а спеціальний лічильник, що збільшується. І цей лічильник номер транзакції.

(Як зазвичай, насправді все складніше: номер транзакцій не може постійно збільшуватися через обмежену розрядність лічильника. Але ці деталі ми розглянемо докладно, коли дійдемо до заморозки.)

Коли рядок створюється, значення xmin встановлюється в номер транзакції, яка виконала команду INSERT, а xmax не заповнюється.

Коли рядок видаляється, значення xmax поточної версії позначається номером транзакції DELETE.

Коли рядок змінюється командою UPDATE, фактично виконуються дві операції: DELETE та INSERT. У поточній версії рядка встановлюється xmax, що дорівнює номеру транзакції, яка виконала UPDATE. Потім створюється нова версія того ж рядка; значення 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(s) 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 фактично ставить перед кожною командою неявну точку збереження, а разі збою ініціює відкат до неї. Такий режим не використовується за замовчуванням, оскільки встановлення точок збереження (навіть без відкату до них) пов'язане з накладними витратами.

Продовження.

Джерело: habr.com

Додати коментар або відгук