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

Вмъкване

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

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

=> СЪЗДАЙ ТАБЛИЦА t(
  id serial,
  s text
);
=> СЪЗДАЙ ИНДЕКС ON t(s);

Вмъкваме един ред, преди да започнем транзакцията.

=> НАЧАЛО;
=> ВМЪКНИ В t(s) СТОЙНОСТИ ('FOO');

Ето номерът на нашата текуща транзакция:

=> ИЗБЕРИ txid_current();
 txid_current 
--------------
         3664
(1 ред)

Нека надникнем в съдържанието на страницата. Функцията heap_page_items от разширението pageinspect позволява да получим информация за указателите и версиите на редовете:

=> ИЗБЕРИ * ОТ heap_page_items(get_raw_page('t',0)) gx
-[ ЗАПИС 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 се обозначават таблиците. Това е още едно странно употребление на термина — купът е известна структура от данни, която няма нищо общо с таблицата. Тук това слово се използва в смисъл на „всичко е натрупано в куп“ в противовес на подредените индекси.

Функцията показва данните „такива, каквито са“, в формат, труден за възприемане. За да разберем, ще оставим само част от информацията и ще я разшифроваме:

=> ИЗБЕРИ '(0,'||lp||')' AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'без ползване'
         WHEN 1 THEN 'нормално'
         WHEN 2 THEN 'пренасочва към '||lp_off
         WHEN 3 THEN 'мъртво'
       END AS state,
       t_xmin като xmin,
       t_xmax като 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
ОТ heap_page_items(get_raw_page('t',0)) gx
-[ ЗАПИС 1 ]-+-------
ctid          | (0,1)
състояние     | нормално
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 ред)

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

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

  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. По-подробно за блокировките ще говорим по-късно. За сега само отбелязваме, че броят на блокировките на редовете не е ограничен. Те не заемат място в оперативната памет и производителността на системата не страда от техния брой. Вярно е, че „дългите“ транзакции имат и други недостатъци, но за това също по-късно.

Нека изтрием ред.

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

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

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

В практиката PostgreSQL използва оптимизация, която позволява да „пести“ номера на транзакциите.

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

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

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

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

=> BEGIN;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 ред)

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

=> UPDATE accounts SET amount = amount - 1.00;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 ред)

=> COMMIT;

Вложени транзакции

Точки на запазване

В SQL са определени точки на запазване (savepoint), които позволяват отменяне на част от операциите на транзакцията, без да я прекъсват напълно. Но това не попада в горната схема, тъй като статусът на транзакцията е един за всичките ѝ промени, а физически никакви данни не се анулират.

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

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

Информацията за вложеността на транзакциите се съхранява в файлове в каталога PGDATA/pg_subtrans. Достъпът до файловете се осъществява чрез буфери в общата памет на инстанцията, организирани по същия начин, както и буферите XACT.

Не бъркайте вложените транзакции с автономните транзакции. Автономните транзакции нямат зависимост помежду си, докато вложените зависят. Автономните транзакции в обикновения PostgreSQL не съществуват и, може би, е за добро: в действителност те са нужни много и много рядко, а тяхното наличие в други СУБД предизвиква злоупотреби, от които след това всички страдат.

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

=> ИЗРЯЗВАТЕ ТАБЛИЦА t;
=> ЗАПОЧВАНЕ;
=> ВМЕСТО t(s) ДОБАВЯТЕ ('FOO');
=> ИЗБЕРЕТЕ txid_current();
 txid_current 
--------------
         3669
(1 ред)

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
(1 ред)

=> SELECT * FROM heap_page('t',0);
 ctid  | статус | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | нормален | 3669 | 0 (а) | (0,1)
(1 ред)

Сега ще поставим точка за запазване и ще добавим още един ред.

=> SAVEPOINT sp;
=> ВМЕСТО t(s) ДОБАВЯТЕ ('XYZ');
=> ИЗБЕРЕТЕ txid_current();
 txid_current 
--------------
         3669
(1 ред)

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

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3670 |    0 |  3 | XYZ
(2 реда)

=> SELECT * FROM heap_page('t',0);
 ctid  | статус | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | нормален | 3669 | 0 (а) | (0,1)
 (0,2) | нормален | 3670 | 0 (а) | (0,2)
(2 реда)

Ще се върнем към точката за запазване и ще добавим третия ред.

=> ROLLBACK TO sp;
=> ВМЕСТО t(s) ДОБАВЯТЕ ('BAR');
=> ИЗБЕРЕТЕ xmin, xmax, * ОТ t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 реда)

=> SELECT * FROM heap_page('t',0);
 ctid  | статус |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | нормален | 3669     | 0 (а) | (0,1)
 (0,2) | нормален | 3670 (а) | 0 (а) | (0,2)
 (0,3) | нормален | 3671     | 0 (а) | (0,3)
(3 реда)

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

Записваме промените.

=> COMMIT;
=> ИЗБЕРЕТЕ xmin, xmax, * ОТ t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 реда)

=> SELECT * FROM heap_page('t',0);
 ctid  | статус |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | нормален | 3669 (c) | 0 (а) | (0,1)
 (0,2) | нормален | 3670 (а) | 0 (а) | (0,2)
 (0,3) | нормален | 3671 (c) | 0 (а) | (0,3)
(3 реда)

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

Забележете, че вложените транзакции не могат да бъдат използвани явно в SQL, т.е. не можете да започнете нова транзакция, без да завършите текущата. Този механизъм се задейства неявно при използване на точки за запазване, а също и при обработка на изключения в PL/pgSQL и при редица други, по-екзотични случаи.

=> ЗАПОЧВАНЕ;
BEGIN
=> ЗАПОЧВАНЕ;
WARNING:  вече има транзакция в ход
ЗАПОЧВАНЕ
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  няма транзакция в ход
COMMIT

Грешки и атомарност на операциите

Какво ще се случи, ако при изпълнение на операция възникне грешка? Например, така:

=> ЗАПОЧВАНЕ;
=> ИЗБЕРЕТЕ * ОТ t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 реда)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  деление на нула

Възникна грешка. Сега транзакцията се счита за прекратена и никаква операция в нея не е разрешена:

=> SELECT * FROM t;
ERROR:  текущата транзакция е анулирана, командите се игнорират до края на блока на транзакцията

И дори и ако опитате да запишете промените, 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 реда)

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

=> set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 реда)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  деление на нула

=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 реда)

=> COMMIT;

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

Продължава.

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

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