MVCC-3. Струнни версии

И така, разгледахме въпроси, свързани с изолация, и направи отстъпление около организиране на данни на ниско ниво. И накрая стигнахме до най-интересната част - низовите версии.

Заглавие

Както вече казахме, всеки ред може да съществува едновременно в няколко версии в базата данни. Една версия трябва по някакъв начин да се разграничи от друга, като за целта всяка версия има две маркировки, които определят „времето” на действие на тази версия (xmin и xmax). В кавички - защото не се използва времето като такова, а специален нарастващ брояч. И този брояч е номерът на транзакцията.

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

Когато се създаде ред, xmin се задава на номера на транзакцията, който е издал командата INSERT, а xmax се оставя празно.

Когато ред бъде изтрит, xmax стойността на текущата версия се маркира с номера на транзакцията, която е извършила DELETE.

Когато ред се модифицира чрез команда UPDATE, всъщност се изпълняват две операции: DELETE и INSERT. Текущата версия на реда задава xmax равен на номера на транзакцията, която е извършила АКТУАЛИЗАЦИЯТА. След това се създава нова версия на същия низ; неговата xmin стойност съвпада с xmax стойността на предишната версия.

Полетата xmin и xmax са включени в заглавката на версията на реда. В допълнение към тези полета, заглавката съдържа други, например:

  • infomask е поредица от битове, които определят свойствата на тази версия. Има доста от тях; Постепенно ще разгледаме основните.
  • ctid е връзка към следващата, по-нова версия на същия ред. За най-новата, най-актуална версия на низ, ctid препраща към самата тази версия. Числото има формата (x,y), където x е номерът на страницата, y е номерът на индекса в масива.
  • null bitmap – Маркира онези колони от дадена версия, които съдържат нулева стойност (NULL). NULL не е една от нормалните стойности на типа данни, така че атрибутът трябва да се съхранява отделно.

В резултат на това заглавната част е доста голяма - най-малко 23 байта за всяка версия на реда и обикновено повече поради NULL bitmap. Ако таблицата е "тясна" (т.е. съдържа малко колони), режийните разходи може да заемат повече от полезна информация.

вмъкнете

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

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

=> 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. Тук е "нормално" - това означава, че указателят всъщност препраща към версията на низа. По-късно ще разгледаме други значения.
  • От всички информационни битове досега са идентифицирани само две двойки. Битовете 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. Те имат два бита за всяка транзакция: извършена и прекъсната - точно както в заглавката на версията на реда. Тази информация е разделена на няколко файла единствено за удобство; ще се върнем към този проблем, когато обмислим замразяването. И работата с тези файлове се извършва страница по страница, както с всички останали.

Така че, когато транзакция е ангажирана в XACT, битът за ангажиране е зададен за тази транзакция. И това е всичко, което се случва по време на ангажиране (въпреки че все още не говорим за дневника за предварително записване).

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

  1. Завършена ли е xmin транзакцията? Ако не, тогава създадената версия на низа не трябва да се вижда.
    Тази проверка се извършва чрез разглеждане на друга структура, която се намира в споделената памет на екземпляра и се нарича ProcArray. Съдържа списък на всички активни процеси, като за всеки е посочен номера на текущата му (активна) транзакция.
  2. Ако е изпълнено, тогава как - чрез ангажиране или отмяна? Ако бъде отменено, тогава версията на реда също не трябва да се вижда.
    Точно за това е XACT. Но въпреки че последните страници на XACT се съхраняват в буфери в RAM, все още е скъпо да проверявате 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 да завърши. Ще говорим повече за блокирането по-късно. Засега отбелязваме само, че броят на заключванията на редове е неограничен. Те не заемат място в RAM и производителността на системата не страда от броя им. Вярно е, че „дългите“ транзакции имат и други недостатъци, но повече за това по-късно.

Нека изтрием линията.

=> 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 прекъснатият бит е зададен за транзакцията. Отмяната е толкова бърза, колкото и ангажирането. Въпреки че командата се нарича 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 използва оптимизации, които му позволяват да „запазва“ номерата на транзакциите.

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

За да се приложи тази функционалност, транзакция с точка за запис се разделя на няколко отделни вложени транзакции (подтранзакция), чийто статус може да се управлява отделно.

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

Продължение.

Източник: www.habr.com

Добавяне на нов коментар