Перейти к содержимому

3 Страницы и версии строк

PostgreSQL 18 · Издание 2026 года · Китайский перевод доступен для PostgreSQL 18

3.1. Структура страниц

Каждая страница имеет внутреннюю разметку и, как правило, содержит следующие разделы 1:

  • заголовок;

  • массив указателей на версии строк;

  • свободное пространство;

  • версии строк;

  • специальную область.

Заголовок страницы

Заголовок страницы располагается в младших адресах и имеет фиксированный размер. Он хранит различную информацию о странице, такую как контрольная сумма, а также размеры всех остальных областей. (с. 127)

Размеры легко получить с помощью расширения pageinspect2. Заглянем в самую первую страницу таблицы (нумерация начинается с нуля):

=> CREATE EXTENSION pageinspect;
=> SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('accounts',0));
 lower | upper | special | pagesize
-------+-------+---------+----------
   152 |  6904 |    8192 |     8192
(1 row)
Заголовок страницы: 0; заголовок; 24; массив указателей на версии строк; lower; свободное пространство; upper; версии строк; special; специальная область; pagesize
Рисунок 3.1 Заголовок страницы

Специальная область

Специальная область расположена в противоположном конце страницы, в старших адресах. Она используется некоторыми типами индексов для хранения вспомогательной информации. В остальных случаях, в том числе в табличных страницах, эта область имеет нулевой размер.

В целом индексные страницы устроены более разнообразно, и их содержимое зависит от конкретного типа индекса. И даже у одного типа индекса бывают разные виды страниц: например, у B-дерева есть нулевая страница метаданных с особой структурой и «обычные» страницы, организованные как табличные. (с. 529)

Версии строк

Перед специальной областью располагаются строки (rows) — те самые данные, которые хранятся в базе, с добавлением некоторой служебной информации.

В случае таблиц мы говорим не просто о строках, а о версиях строк (row versions, tuples), поскольку многоверсионность предполагает существование нескольких версий одной и той же строки. На индексы многоверсионность не распространяется; вместо этого индексы ссылаются на все возможные табличные версии строк, среди которых по правилам видимости выбираются подходящие.

Примечание

Термин tuple заимствован из реляционной теории и переводится как кортеж. Это еще одно наследие академического прошлого PostgreSQL. Чтобы не смешивать теорию с устройством СУБД, я буду использовать перевод версия строки (иногда, если это не вызывает неоднозначностей, заменяя более коротким словом строка).

Указатели на версии строк

Массив указателей на версии строк служит оглавлением страницы. Он располагается сразу за заголовком.

Индексные строки должны как-то ссылаться на версии строк в таблице. Для этого используются шестибайтные идентификаторы версий строк (tuple id, tid). Идентификатор состоит из номера страницы в файле основного слоя и должен еще содержать какое-то указание на версию строки в этой странице. (с. 29)

В качестве такого указания можно было бы использовать смещение относительно начала страницы. Но тогда версию строки нельзя было бы перемещать внутри страницы, не сломав ссылки из индексов. А это привело бы к фрагментации места внутри страниц и другим неприятным последствиям.

Поэтому используется косвенная адресация: идентификатор версии ссылается на номер указателя, а уже указатель — на текущую позицию версии строки в странице. При перемещении версии строки ее идентификатор не меняется; достаточно изменить указатель, который находится на той же странице.

Каждый указатель занимает ровно четыре байта и содержит:

  • смещение версии строки относительно начала страницы;

  • длину версии строки;

  • несколько битов, определяющих статус версии строки.

Свободное место

Между указателями и версиями строк может оставаться свободное место (которое и отмечено в карте свободного пространства). Никакой фрагментации внутри страницы не бывает, все свободное место всегда представлено одним фрагментом 3. (с. 31)

3.2. Структура версий строк

Версия строки состоит из заголовка, за которым следуют собственно данные. Заголовок версии содержит множество полей, среди которых:

xmin, xmax — номера транзакций, которые отличают данную версию от других версий той же строки;

infomask — ряд информационных битов, определяющих свойства версии;

ctid — ссылка на следующую, более новую версию той же строки;

битовая карта неопределенных значений — массив битов, отмечающих столбцы, которые допускают неопределенные значения (NULL).

В результате заголовок получается довольно большой — минимум 23 байта на каждую версию строки, а обычно больше из-за карты неопределенных значений и из-за обязательного выравнивания начала данных. Для «узкой» таблицы объем служебных данных вполне может превышать объем полезной информации.

Формат данных на диске полностью совпадает с представлением данных в оперативной памяти. Страница вместе с версиями строк читается в буферный кеш «как есть», без каких бы то ни было преобразований. Поэтому файлы данных с одной платформы оказываются несовместимыми с другими платформами4.

Одна из причин несовместимости — порядок следования байтов. Например, в архитектуре x86 принят порядок от младших разрядов к старшим (little-endian), z/Architecture использует обратный порядок (big-endian), а в ARM порядок переключаемый.

Несовместимость вызывается также выравниванием данных по границам машинных слов, которое требуется многим архитектурам. Например, в 32-битной системе архитектуры x86 целые числа (тип integer, занимает четыре байта) будут выровнены по границе четырехбайтных слов, как и числа с плавающей точкой двойной точности (тип double precision, восемь байт). А в 64-битной системе значения double будут выровнены по границе восьмибайтных слов.

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

=> CREATE TABLE padding(
  b1 boolean,
  f1 float,
  b2 boolean,
  f2 float
);
=> INSERT INTO padding VALUES (true,1.0,false,2.0);
=> SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
 lp_len
--------
     56
(1 row)

Я использовал функцию heap_page_items расширения pageinspect, которая позволяет получить информацию об указателях и версиях строк.

Примечание

Словом heap (куча) в PostgreSQL обозначаются таблицы. Это еще одно не вполне очевидное употребление термина, намекающее на сходство механизма выделения места под версии строк с динамическим распределением оперативной памяти. Конечно, некоторую аналогию усмотреть можно, но для управления таблицей используются совсем другие алгоритмы. Можно считать, что слово употребляется в смысле «все свалено в кучу», подчеркивая отличие от упорядоченных индексов.

Строка занимает 56 байт. Из них 24 байта уходит на заголовок, столбцы типа float занимают по 8 байт, boolean — по 1 байту. В сумме имеем 42, а 14 байт пропадают из-за выравнивания float по границе восьмибайтных слов. Перестроив таблицу, можно использовать место более эффективно:

=> DROP TABLE padding;
=> CREATE TABLE padding(
  f1 float,
  f2 float,
  b1 boolean,
  b2 boolean
);

Надо, однако, учитывать, что выравниваются и версии строк внутри страницы. Как и предполагалось, строки стали занимать 42 байта, но 6 байт все-таки пропадают между строками:

=> INSERT INTO padding VALUES (1.0,2.0,true,false);
=> SELECT lp_len, 8192 - lp_off AS allocated, lp_off
FROM heap_page_items(get_raw_page('padding', 0));
 lp_len | allocated | lp_off
--------+-----------+--------
     42 |        48 |   8144
(1 row)

Еще одна возможная микрооптимизация — перенести в начало таблицы все столбцы фиксированного размера, не допускающие неопределенных значений. Доступ к таким полям будет более эффективным благодаря возможности закешировать смещение поля от начала версии строки5.

3.3. Выполнение операций над версиями строк

Чтобы разные версии одной и той же строки можно было различить, каждая из версий имеет две отметки, определяющие ее «время действия», — xmin и xmax. Но используется не время как таковое, а постоянно увеличивающийся счетчик номеров транзакций. (с. 134)

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

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

Команду UPDATE можно в некотором приближении рассматривать как две операции: DELETE и INSERT. В текущей версии строки устанавливается значение xmax, равное номеру транзакции, выполнившей UPDATE. Затем создается новая версия той же строки; значение xmin у нее совпадает со значением xmax предыдущей версии. Точно так же и команда MERGE «распадается» на элементарные вставки и удаления. (v. 15)

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

Для экспериментов понадобится таблица с двумя столбцами и индексом по одному из них:

=> CREATE TABLE t(
  id integer GENERATED ALWAYS AS IDENTITY,
  s text
);
=> CREATE INDEX ON t(s);

Вставка

Вставляем одну строку, предварительно начав транзакцию:

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

Вот номер нашей текущей транзакции:

=> --  txid_current() до v.13
SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                795
(1 row)

Примечание

Для понятия транзакции в PostgreSQL часто используется сокращение xact, которое можно найти и в именах функций, и в коде. Соответственно, номер транзакции обозначается как xact id, txid или даже просто xid. Эти названия еще не раз нам встретятся.

Заглянем в содержимое страницы. Функция heap_page_items дает нам всю необходимую информацию, но показывает данные «как есть», в формате, сложном для восприятия:

=> 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      | 795
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

Чтобы разобраться, оставим только часть информации и расшифруем некоторые столбцы:

=> 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_committed,
     (t_infomask & 512) > 0  AS xmin_aborted,
     (t_infomask & 1024) > 0 AS xmax_committed,
     (t_infomask & 2048) > 0 AS xmax_aborted
FROM heap_page_items(get_raw_page('t',0))
\gx
-[ RECORD 1 ]--+-------
ctid           | (0,1)
state          | normal
xmin           | 795
xmax           | 0
xmin_committed | f
xmin_aborted   | f
xmax_committed | f
xmax_aborted   | t

Вот что здесь сделано:

  • Указатель lp приведен к обычному виду идентификатора версии строки: (номер страницы, номер указателя).

  • Расшифровано состояние указателя lp_flags. Здесь он имеет значение normal — это значит, что указатель действительно ссылается на версию строки.

  • Из всех информационных битов пока выделены только две пары. Биты xmin_committed и xmin_aborted показывают, зафиксирована ли и отменена ли транзакция с номером xmin. Аналогичную информацию о транзакции xmax дают биты xmax_committed и xmax_aborted.

В расширении pageinspect есть функция heap_tuple_infomask_flags, расшиф- (v. 13)

Примечание

ровывающая все информационные биты, но я буду выводить только те, что нужны в данный момент, и в более компактном виде.

Итак, при вставке строки в табличной странице появился указатель с номером 1, ссылающийся на первую и пока единственную версию строки.

Поле xmin в версии строки заполнено номером текущей транзакции. Транзакция еще активна, поэтому биты xmin_committed и xmin_aborted не установлены.

Поле xmax заполнено фиктивным номером 0, поскольку данная версия строки не удалена и является актуальной. Транзакции не будут обращать внимания на этот номер, поскольку установлен бит xmax_aborted.

Примечание

Может показаться странным, что бит оборванной транзакции установлен у транзакции, которой не было. Но с точки зрения изоляции это одно и то же: отмененная транзакция не оставляет следов и, следовательно, не существовала.

Такой запрос нам еще не раз понадобится, поэтому я оберну его в функцию. А заодно для компактности вывода уберу столбцы информационных битов, приписав статус транзакции к ее номеру:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text)
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
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

В таком виде уже значительно понятнее, что творится в заголовке версии:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax
-------+--------+------+------
 (0,1) | normal | 795  | 0 a
(1 row)

Похожую, но существенно менее детальную информацию можно получить и из самой таблицы, используя псевдостолбцы xmin и xmax:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s
------+------+----+-----
  795 |    0 |  1 | FOO
(1 row)

Фиксация

При успешном завершении транзакции нужно запомнить ее статус — отметить, что она зафиксирована. Для этого используется структура, называемая clog 7 (commit log). Это не таблица системного каталога, а специальные файлы в каталоге PGDATA/pg_xact.

На несколько файлов clog разбит исключительно для удобства. Работа с этими файлами ведется постранично, через буферы в общей памяти сервера. Размер кеша clog настраивается параметром transaction_buffers, а по умолчанию составляет 1512\frac{1}{512} размера буферного кеша. (с. 309) (v. 17)

В clog, как и в заголовке версии строки, для каждой транзакции отведено два бита: committed и aborted.

Итак, при фиксации транзакции в clog выставляется бит committed для данной транзакции. Когда какая-либо другая транзакция обратится к нашей табличной странице, ей придется ответить на вопрос: завершилась ли транзакция с номером xmin?

  • Если нет, то созданная версия строки не должна быть видна.

Чтобы проверить, выполняется ли транзакция, просматривается еще одна структура, которая располагается в общей памяти экземпляра и называется ProcArray. В ней находится список всех активных процессов, и для каждого указан номер его текущей (активной) транзакции.

  • Если завершилась, то фиксацией или отменой? При отмене версия строки тоже не должна быть видна.

Для этой проверки как раз и нужна структура clog. Но хотя последние страницы clog сохраняются в буферах в оперативной памяти, все равно такую проверку накладно выполнять каждый раз. Поэтому выясненный однажды статус транзакции записывается в заголовок версии строки в информационные биты xmin_committed и xmin_aborted; их еще называют битами-подсказками (hint bits). Если один из этих битов установлен, то состояние транзакции xmin считается известным, и следующей транзакции уже не придется обращаться ни к clog, ни к ProcArray.

Почему эти биты не устанавливаются той транзакцией, которая вставляет строку? Дело в том, что в это время транзакция еще не знает, завершится ли она успешно. А в момент фиксации уже не понятно, какие именно строки в каких именно страницах были изменены. Таких страниц может оказаться много, и запоминать их невыгодно. К тому же часть страниц может быть вытеснена из буферного кеша на диск; читать их заново, чтобы изменить биты, означало бы существенно замедлить фиксацию.

Обратная сторона экономии состоит в том, что любая транзакция (даже выполняющая простое чтение — SELECT) может начать выставлять информационные биты в страницах данных. Конечно, страницы в буферном кеше при этом становятся грязными.

Зафиксируем наконец вставку строки, с которой мы начали транзакцию:

=> COMMIT;

В странице ничего не изменилось (но мы знаем, что статус транзакции уже записан в clog):

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax
-------+--------+------+------
 (0,1) | normal | 795  | 0 a
(1 row)

Теперь транзакция, первой обратившаяся к странице («нормальным» образом, не с помощью pageinspect), должна будет определить статус транзакции xmin и записать его в информационные биты:

=> SELECT * FROM t;
 id |  s
----+-----
  1 | FOO
(1 row)
=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | normal | 795 c | 0 a
(1 row)

Удаление

При удалении строки в поле xmax актуальной версии записывается номер удаляющей транзакции, а бит xmax_aborted сбрасывается. (с. 268)

Примечание

Это же значение xmax, соответствующее активной транзакции, выступает в качестве блокировки строки. Если другая транзакция собирается обновить или удалить эту строку, она будет вынуждена дождаться завершения транзакции xmax.

Удалим строку:

=> BEGIN;
=> DELETE FROM t;
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                796
(1 row)

Номер транзакции записался в поле xmax, но информационные биты еще не установлены:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | normal | 795 c | 796
(1 row)

Отмена

Отмена изменений работает аналогично фиксации и выполняется так же быстро, только в clog вместо бита committed выставляется бит aborted. Хоть команда и называется ROLLBACK, отката изменений не происходит: все, что транзакция успела изменить в страницах данных, остается на месте.

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | normal | 795 c | 796
(1 row)

При обращении к странице проверяется статус, и в версии строки устанавливается бит-подсказка xmax_aborted. Сам номер xmax при этом остается в странице, но смотреть на него уже никто не будет:

=> SELECT * FROM t;
 id |  s
----+-----
  1 | FOO
(1 row)
=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+-------
 (0,1) | normal | 795 c | 796 a
(1 row)

Обновление

Обновление работает так, как будто сначала выполнилось удаление текущей версии строки, а затем — вставка новой:

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                797
(1 row)

Запрос выдает одну строку (новую версию):

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

Но в странице хранятся обе версии:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | normal | 795 c | 797
 (0,2) | normal | 797   | 0 a
(2 rows)

Удаленная ранее версия помечена номером текущей транзакции в поле xmax. Это значение записано поверх старого, поскольку предыдущая транзакция была отменена. А бит xmax_aborted сброшен, так как статус текущей транзакции еще неизвестен.

Ну и завершим транзакцию.

=> COMMIT;

3.4. Индексы

В индексах любого типа никогда не бывает версий строк, каждая строка представлена ровно одним экземпляром. Иными словами, в заголовке индексной строки не бывает полей xmin и xmax. Ссылки из индекса ведут на все табличные версии строк. Транзакции требуется заглянуть в таблицу, чтобы разобраться, какая из версий ей видна (если только страница не отмечена в карте видимости). (с. 115)

Для удобства создадим простую функцию, показывающую с помощью расширения pageinspect все индексные записи на странице (в пределах страницы B-дерева записи располагаются в виде плоского списка): (с. 523)

=> CREATE FUNCTION index_page(relname text, pageno integer)
RETURNS TABLE(itemoffset smallint, htid tid)
AS $$
SELECT itemoffset,
       htid -- ctid до v.13
FROM bt_page_items(relname,pageno);
$$ LANGUAGE sql;

Обнаруживаем на странице указатели на обе версии табличной строки, как на актуальную, так и на старую:

=> SELECT * FROM index_page('t_s_idx',1);
 itemoffset | htid
------------+-------
          1 | (0,2)
          2 | (0,1)
(2 rows)

Поскольку BAR < FOO, указатель на вторую версию строки стоит в индексе на первом месте.

3.5. TOAST

Toast-таблица является, по сути, обычной таблицей, и для нее поддерживается собственная версионность: версии «тостов» не связаны с версиями строк основной таблицы. Но внутренняя работа с toast-таблицей построена так, что строки никогда не обновляются, а только добавляются и удаляются, так что версионность в данном случае несколько вырожденная. (с. 33)

Когда данные меняются, в основной таблице всегда создается новая версия строки. Но если обновление не затрагивает «длинное» значение, хранящееся в TOAST, то новая версия строки будет ссылаться на прежнее значение в toast-таблице. И только когда обновление поменяет «длинное» значение, будут созданы и новая версия строки в основной таблице, и новые «тосты».

3.6. Виртуальные транзакции

PostgreSQL использует оптимизацию, позволяющую «экономить» номера транзакций.

Если транзакция только читает данные, то она никак не влияет на видимость версий строк. Поэтому вначале обслуживающий процесс выдает транзакции виртуальный номер 8 (virtual xid). Номер состоит из идентификатора обслуживающего процесса и последовательного числа. Выдача этого номера не требует синхронизации между всеми процессами и поэтому выполняется очень быстро. Настоящего номера у транзакции еще нет: (с. 259)

=> BEGIN;
=> --  txid_current_if_assigned() до v.13
SELECT pg_current_xact_id_if_assigned();
 pg_current_xact_id_if_assigned
--------------------------------
(1 row)

В разные моменты времени в системе вполне могут оказаться виртуальные транзакции с номерами, которые уже использовались. И это нормально, поскольку виртуальные номера существуют только в оперативной памяти, пока транзакция активна; они никогда не записываются в страницы данных и не попадают на диск.

Если же транзакция начинает менять данные, ей выдается настоящий, уникальный номер транзакции:

=> UPDATE accounts
SET amount = amount - 1.00;
=> SELECT pg_current_xact_id_if_assigned();
 pg_current_xact_id_if_assigned
--------------------------------
                            799
(1 row)
=> COMMIT;

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

Точки сохранения

В SQL определены точки сохранения (savepoint), которые позволяют отменить часть операций транзакции, не прерывая ее полностью. Но это не укладывается в приведенную выше схему, поскольку статус транзакции один на все изменения, а физически никакие данные не откатываются.

Чтобы реализовать такой функционал, транзакция с точкой сохранения разбивается на несколько вложенных транзакций 9 (subtransaction), статусом которых можно управлять отдельно.

Вложенные транзакции имеют свой собственный номер (больший, чем номер основной транзакции). Статус вложенных транзакций записывается в clog обычным образом, но зафиксированные вложенные транзакции одновременно отмечаются двумя битами, committed и aborted. Финальный статус зависит от статуса основной транзакции: если она отменена, то и все вложенные транзакции считаются отмененными.

Информация о вложенности транзакций хранится в файлах внутри каталога PGDATA/pg_subtrans, а обращение к страницам этих файлов происходит через SLRU-кеш. Его размер настраивается параметром subtransaction_buffers, а по умолчанию составляет 1512\frac{1}{512} размера буферного кеша. (с. 309)

Примечание

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

Опустошим таблицу, начнем транзакцию и вставим строку:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                801
(1 row)

Теперь поставим точку сохранения и вставим еще одну строку:

=> SAVEPOINT sp;
=> INSERT INTO t(s) VALUES ('XYZ');
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                801
(1 row)

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

=> SELECT *
FROM heap_page('t',0) p
  LEFT JOIN t ON p.ctid = t.ctid;
 ctid  | state  | xmin | xmax | id |  s
-------+--------+------+------+----+-----
 (0,1) | normal | 801  | 0 a  |  2 | FOO
 (0,2) | normal | 802  | 0 a  |  3 | XYZ
(2 rows)

Откатимся к точке сохранения и вставим третью строку:

=> ROLLBACK TO sp;
=> INSERT INTO t(s) VALUES ('BAR');
=> SELECT *
FROM heap_page('t',0) p
  LEFT JOIN t ON p.ctid = t.ctid;
 ctid  | state  | xmin | xmax | id |  s
-------+--------+------+------+----+-----
 (0,1) | normal | 801  | 0 a  |  2 | FOO
 (0,2) | normal | 802  | 0 a  |    |
 (0,3) | normal | 803  | 0 a  |  4 | BAR
(3 rows)

В странице мы продолжаем видеть строку, добавленную отмененной вложенной транзакцией.

Фиксируем изменения:

=> COMMIT;
=> SELECT * FROM t;
 id |  s
----+-----
  2 | FOO
  4 | BAR
(2 rows)
=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin  | xmax
-------+--------+-------+------
 (0,1) | normal | 801 c | 0 a
 (0,2) | normal | 802 a | 0 a
 (0,3) | normal | 803 c | 0 a
(3 rows)

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

Вложенные транзакции нельзя использовать в SQL напрямую, то есть нельзя начать новую транзакцию, не завершив текущую:

=> BEGIN;
BEGIN
=> BEGIN;
WARNING:  there is already a transaction in progress
BEGIN
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  there is no transaction in progress
COMMIT

Этот механизм задействуется неявно: при использовании точек сохранения, при обработке исключений PL/pgSQL и в некоторых других, более экзотических случаях.

Ошибки и атомарность операций

Что случится, если в процессе выполнения операции произойдет ошибка?

Например:

=> 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
-------+--------+-------+------
 (0,1) | normal | 801 c | 804
 (0,2) | normal | 802 a | 0 a
 (0,3) | normal | 803 c | 0 a
 (0,4) | normal | 804   | 0 a
(4 rows)

К слову, в psql имеется режим, который все-таки позволяет продолжать работу транзакции после сбоя так, как будто действия ошибочного оператора откатываются:

=> \set ON_ERROR_ROLLBACK on
=> BEGIN;
=> 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;
COMMIT

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