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 байта на всяка версия на реда, а обикновено повече поради битовата карта на НУЛ. Ако таблицата е "тясна" (т.е. съдържа малко колони), разходите могат да заемат повече от полезната информация.

Вмъкване

Нека да разгледаме по-подробно как се извършват операциите със строки на ниско ниво и да започнем с вмъкването.

За експериментите ще създадем нова таблица с два колони и индекс по една от тях:

=> 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 ред)

Нека погледнем съдържанието на страницата. Функцията 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)

Ето какво направихме:

  • Добавихме нолик към номера на указателя, за да го приведем в един и същ формат, както т_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 строка)

Подобна, но значително по-малко подробна информация може да се получи и от самата таблица, като се използват псевдостълбците xmin и xmax:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3664 |    0 |  1 | FOO
(1 строка)

Фиксиране

При успешно завършване на транзакцията е необходимо да се запомни нейният статус — да се отбележи, че е фиксирана. За това се използва структура, наречена 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 строка)

Сега транзакцията, която първа е осъществила достъп до страницата, трябва да определи статусът на транзакцията xmin и да запише това в информационните битове:

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 ред)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 ред)

Удаление

При изтриване на ред в полето xmax на актуална версия се записва номера на текущата изтриваща транзакция, а бит xmax_aborted се нулира.

Забелязваме, че зададеното значение xmax, отговарящо на активната транзакция, служи като блокировка на реда. Ако друга транзакция се опита да обнови или изтрие този ред, тя ще трябва да изчака завършването на транзакцията xmax. По-подробно за блокировките ще говорим по-късно. За момента само ще отбележим, че броят на блокировките на редове не е ограничен. Те не заемат място в оперативната памет и производителността на системата не страда от тяхното количество. Вярно, че

Изтриваме реда.

=> НАЧАЛО;
=> ИЗТРИВАНЕ ОТ t;
=> ИЗБРАНЕ txid_current();
 txid_current 
--------------
         3665
(1 ред)

Виждаме, че номерът на транзакцията се е записал в полето xmax, но информационните битове не са зададени:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 ред)

Отмяна

Отмяната на измененията работи аналогично на фиксирането, само че в XACT за транзакцията се задава бит aborted. Отмяната се извършва толкова бързо, колкото и фиксирането. Въпреки че командата се нарича ROLLBACK, отмяна на изменения не се случва: всичко, което транзакцията е успяла да промени на страниците данни, остава без промяна.

=> ROLLBACK;
=> ИЗБРАНЕ * ОТ heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 ред)

При достъп до страницата статусът ще бъде проверен и битът за подсказка xmax_aborted ще бъде зададен на версията на реда. Самият номер xmax остава на страницата, но вече никой не ще се интересува от него.

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 ред)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   |   xmax   | t_ctid 
-------+--------+----------+----------+--------
 (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 ред)

Актуализация

Обновлението работи така, сякаш първо е извършено изтриването на текущата версия на реда, а след това вмъкването на нова.

=> НАЧАЛО;
=> АПДЕЙТ t SET s = 'BAR';
=> ИЗБРАНЕ txid_current();
 txid_current 
--------------
         3666
(1 ред)

Запитването връща един ред (новата версия):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(1 ред)

Но на страницата виждаме и двете версии:

=> 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 реда)

Изтритата версия е маркирана с номер на текущата транзакция в полето 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 реда)

Виртуални транзакции

На практика PostgreSQL използва оптимизация, която позволява 'спестяване' на номера на транзакциите.

Ако транзакцията само чете данни, това по никакъв начин не влияе на видимостта на версиите на редовете. Следователно в началото обслужващият процес предоставя на транзакцията виртуален номер (virtual xid). Номерът се състои от идентификатора на процеса и последователно число.

Издаването на този номер не изисква синхронизация между всички процеси и следователно се извършва много бързо. С друга причина за използването на виртуални номера ще се запознаем, когато говорим за замразяване.

Виртуалните номера не се отчитат в снимките на данните.

В различни моменти във времето в системата могат да се окажат виртуални транзакции с номера, които вече са били използвани, и това е нормално. Но такъв номер не може да се записва в страниците на данните, тъй като при следващото обръщение към страницата той може да загуби всякакъв смисъл.

=> НАЧАЛО;
=> ИЗБЕРИ txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 ред)

Ако транзакцията започне да променя данни, на нея се дава истински уникален номер на транзакцията.

=> АКТУАЛИЗИРАЙ accounts SET amount = amount - 1.00;
=> ИЗБЕРИ txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 ред)

=> 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  | состояние |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | нормално | 3669 (c) | 3672  | (0,4)
 (0,2) | нормално | 3670 (a) | 0 (a) | (0,2)
 (0,3) | нормално | 3671 (c) | 0 (a) | (0,3)
 (0,4) | нормално | 3672     | 0 (a) | (0,4)
(4 реда)

Трябва да се каже, че в psql има режим, който все пак позволява да се продължи работата на транзакцията след грешка, така сякаш действието на грешния оператор се отменя.

=> set ON_ERROR_ROLLBACK on
=> ЗАПОЧВАМ;
=> ИЗБЕРИ * ОТ 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

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster