Введение

Данная статья является третьей частью. Предыдущие:

Как и прошлые части, данная является объединением книги и официальной документации с моими рисунками, объясняющими написанное в более наглядном (надеюсь, простом) варианте.

Информация взята из книги Егора Рогова PostgreSQL 16 изнутри, а также из документации PostgreSQL 16.2.

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

Размер страницы данных в PostgreSQL обычно составляет 8 KB (8192 байта). Ссылка на документацию.

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

Вот основные элементы, которые входят в состав страницы (ссылка на документацию, ссылка на исходный код):

  • Заголовок страницы24 байта. Содержит общую информацию о странице.

  • Массив указателей строк. Это массив указателей на отдельные строки (tuples) на странице. Каждый указатель (line pointer) занимает 4 байта и содержит:

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

    • статус версии строки (2 бита);

    • длину версии строки (15 бит).

  • Свободное пространство. Свободное пространство между массивом указателей строк и данными строк. Используется для вставки новых строк или обновления существующих строк.

    Все свободное место всегда представлено одним фрагментом.

  • Данные строк. Включая заголовки строк (tuple headers) и сами данные. Располагаются в конце, продвигаясь к началу.

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

Чтобы подробнее изучить структуру страницы воспользуемся модулем pageinspect. Для этого включим его:

CREATE EXTENSION pageinspect;

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

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

SELECT * FROM page_header(get_raw_page('pg_class', 0));

Получим таблицу со следующими полями:

Поле

Тип

Длина

Описание

pd_lsn

PageXLogRecPtr

8 байт

LSN: Следующий байт после последнего байта записи WAL для последнего изменения на этой странице

pd_checksum

uint16

2 байта

Контрольная сумма страницы

pd_flags

uint16

2 байта

Биты признаков

pd_lower

LocationIndex

2 байта

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

pd_upper

LocationIndex

2 байта

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

pd_special

LocationIndex

2 байта

Смещение до начала специального пространства

pd_pagesize_version

uint16

2 байта

Информация о размере страницы и номере версии компоновки

pd_prune_xid

TransactionId

4 байта

Самый старый неочищенный идентификатор xmax на странице или ноль при отсутствии такового

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

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

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

Tuple id ссылается на номер указателя (line pointer,linp), а уже указатель — на текущую позицию версии строки (data) на странице (см. рисунок ниже).

Рис. 3.1.1. - Связь tuple id со строкой в таблице.
Рис. 3.1.1. - Связь tuple id со строкой в таблице.

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

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

Заголовок версии содержит множество полей, среди которых:

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

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

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

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

Формат данных на диске полностью совпадает с представлением данных в оперативной памяти. Поэтому файлы данных с одной платформы оказываются несовместимыми с другими платформами

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

 Рис. 3.2 - Различные архитектуры процессора влияют на хранение данных.
Рис. 3.2 - Различные архитектуры процессора влияют на хранение данных.

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

Рассмотрим пример. Создадим таблицу с полями:

  • boolean;

  • integer;

  • boolean;

  • integer.

CREATE TABLE padding (b1 boolean, i1 integer, b2 boolean, i2 integer);

-- Добавим одну строку
INSERT INTO padding VALUES (true, 1, false, 2);

-- Посмотрим размер строки
SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
-- Получаем размер 40 байт.

Из них 24 байта уходит на заголовок, столбцы типа integer занимают по 4 байта, boolean— по 1 байту. В сумме имеем 34, а 6 байт пропадают из-за выравнивания integer по границе четырехбайтных строк.

 Рис. 3.3 - Структура табличной строки с выравниванием.
Рис. 3.3 - Структура табличной строки с выравниванием.

Выравнивание происходит из-за того, что integer должен начинаться с адреса, кратного 4 байтам, так как b1 занимает только 1 байт, следующие 3 байта остаются пустыми.

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

  • integer;

  • integer;

  • boolean;

  • boolean.

CREATE TABLE padding (i1 integer, i2 integer, b1 boolean, b2 boolean);

-- Добавим одну строку
INSERT INTO padding VALUES (1, 2, true, false);

-- Посмотрим размер строки
SELECT lp_len FROM heap_page_items(get_raw_page('padding', 0));
-- Получаем размер 34 байт.
Рис. 3.4 - Структура табличной строки без выравнивания.
Рис. 3.4 - Структура табличной строки без выравнивания.

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

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

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

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

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

  • Команду UPDATE можно в некотором приближении рассматривать как две операции: DELETE и INSERT. Точно так же и команда MERGE «распадается» на элементарные вставки и удаления.

Рассмотрим пример на простой таблице с индексом.

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

3.3.1. Вставка данных

Далее начнем транзакцию и добавим одну строку:

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

-- Посмотрим номер текущей странзакции
SELECT pg_current_xact_id();
-- 773

Посмотрим детально структуру только что добавленной строки:

FROM heap_page_items(get_raw_page('t', 0));

Полученная таблица мало что даст понять, поэтому представим её в графическом виде и рассмотрим только заголовок:

Рис. 3.5. - Структура заголовка только что добавленной, но не закоммиченной строки.
Рис. 3.5. - Структура заголовка только что добавленной, но не закоммиченной строки.

Имеем следующее:

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

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

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

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

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

Подробнее про информационные биты (t_infomask) можно посмотреть в исходном коде.

3.3.2. Фиксация транзакции

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

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

При фиксации транзакции в clog (файл в каталоге PGDATA/pg_xact) выставляется бит committed для данной транзакции.

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

COMMIT;

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

Для этой проверки как раз и нужна структура clog.

Рис. 3.6. - Процесс добавления строки и чтение через clog файл.
Рис. 3.6. - Процесс добавления строки и чтение через clog файл.

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

3.3.3. Удаление

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

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

 Рис. 3.7. - Удаление строки.
Рис. 3.7. - Удаление строки.

3.3.4. Отмена транзакции

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

Хоть команда и называется ROLLBACK, отката изменений не происходит: все, что транзакция успела изменить в страницах данных, остается на месте.

Рассмотрим на примере добавления новой записи и её отмены.

Рис. 3.8. - Добавление новой строки и отмена транзакции.
Рис. 3.8. - Добавление новой строки и отмена транзакции.

Аналогичная ситуация происходит при удалении записи и отмене.

Рис. 3.9 - Удаление и отмена транзакции.
Рис. 3.9 - Удаление и отмена транзакции.

Сам номер xmax при этом остается в странице, но смотреть на него уже никто не будет.

3.3.5. Обновление

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

Рис. 3.10. - Обновление строки через добавление новой версии.
Рис. 3.10. - Обновление строки через добавление новой версии.

3.4. Индексы

В индексах любого типа никогда не бывает версий строк, каждая строка представлена ровно одним экземпляром. Иными словами, в заголовке индексной строки не бывает полей xmin и xmax.

Рис. 3.11. - Индексы и версии строк.
Рис. 3.11. - Индексы и версии строк.

3.5. TOAST

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

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

Рис. 3.12. - TOAST имеет свой независимый порядок версионности.
Рис. 3.12. - TOAST имеет свой независимый порядок версионности.

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

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

Каждой транзакции присваивается уникальный идентификатор VirtualTransactionId (также именуемый virtualXID или vxid), который состоит из идентификатора обслуживающего процесса (или backendID) и последовательно назначаемого номера — внутреннего для такого обслуживающего процесса (или localXID). Например, виртуальный идентификатор 1/12532 состоит из следующих компонентов: backendID со значением 1 и localXID со значением 12532.

Рис. 3.13. - Счетчики транзакций.
Рис. 3.13. - Счетчики транзакций.

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

Невиртуальные идентификаторы TransactionId (или xid), например 278394, последовательно выбираются для транзакций из глобального счётчика, который используется всеми базами данных в рамках кластера PostgreSQL. Значение присваивается при первой операции записи транзакции в базу данных.

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

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

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

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

Рис. 3.14. - Вложенные транзакции.
Рис. 3.14. - Вложенные транзакции.

Если подтранзакции присваивается невиртуальный идентификатор, его называют subxid. Значение xid родительской транзакции всегда будет меньше значений subxid подтранзакций.

Подтранзакции могут фиксироваться или прерываться, не влияя на родительские транзакции, которые, соответственно, могут продолжать выполняться.

  • Идентификатор непосредственного родителя каждой подтранзакции записывается в каталог pg_subtrans. Идентификаторы транзакций верхнего уровня не записываются, поскольку у них нет родителя. Также не записываются и идентификаторы подтранзакций в режиме только чтения.

  • Статус вложенных транзакций записывается в clog обычным образом, но зафиксированные вложенные транзакции одновременно отмечаются двумя битами, committed и aborted - 11.

  • При фиксации подтранзакции все зафиксированные дочерние подтранзакции с subxid также считаются зафиксированными в рамках этой подтранзакции. При прерывании подтранзакции все дочерние подтранзакции также считаются прерванными.

  • При фиксации транзакции верхнего уровня с xid зафиксированные подтранзакции записываются как зафиксированные в подкаталоге pg_xact. При прерывании транзакции верхнего уровня все её подтранзакции также прерываются, даже если они были зафиксированы.

Заключение

В данной статье была рассмотрена «Глава 3. Страницы и версии строк» из книги PostgreSQL 16 изнутри.

В дальнейшем будет рассмотрена глава 4 — «Снимки данных» этой же книги.

Комментарии (2)


  1. relen
    30.07.2024 07:58

    Пункт 3.4.
    Рисунок 3.1.1 противоречит рисунку 3.11. Имеет смысл подправить 3.11.


    1. ig_rudenko Автор
      30.07.2024 07:58

      Добавил больше информации на рис. 3.11.