Введение
Данная статья является третьей частью. Предыдущие:
Как и прошлые части, данная является объединением книги и официальной документации с моими рисунками, объясняющими написанное в более наглядном (надеюсь, простом) варианте.
Информация взята из книги Егора Рогова PostgreSQL 16 изнутри, а также из документации PostgreSQL 16.2.
3.1. Структура страниц
Размер страницы данных в PostgreSQL обычно составляет 8 KB (8192 байта). Ссылка на документацию.
Вот основные элементы, которые входят в состав страницы (ссылка на документацию, ссылка на исходный код):
Заголовок страницы — 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 |
|
8 байт |
LSN: Следующий байт после последнего байта записи WAL для последнего изменения на этой странице |
pd_checksum |
|
2 байта |
Контрольная сумма страницы |
pd_flags |
|
2 байта |
Биты признаков |
pd_lower |
|
2 байта |
Смещение до начала свободного пространства |
pd_upper |
|
2 байта |
Смещение до конца свободного пространства |
pd_special |
|
2 байта |
Смещение до начала специального пространства |
pd_pagesize_version |
|
2 байта |
Информация о размере страницы и номере версии компоновки |
pd_prune_xid |
|
4 байта |
Самый старый неочищенный идентификатор |
3.1.2. Указатели на версии строк
Массив указателей (line pointer array
) на версии строк служит оглавлением страницы. Он располагается сразу за заголовком.
Индексные строки должны как-то ссылаться на версии строк в таблице. Для этого используются шестибайтные уникальные идентификаторы версии строки в таблице. (tuple id
, tid
).
Tuple id
ссылается на номер указателя (line pointer
,linp
), а уже указатель — на текущую позицию версии строки (data
) на странице (см. рисунок ниже).
При перемещении версии строки её идентификатор не меняется, достаточно изменить указатель, который находится на той же странице. Каждый указатель занимает ровно четыре байта.
3.2 Структура версий строк
Заголовок версии содержит множество полей, среди которых:
xmin, xmax — номера транзакций, которые отличают данную версию от других версий той же строки;
infomask — ряд информационных битов, определяющих свойства версии;
ctid — ссылка на следующую, более новую версию той же строки;
битовая карта неопределенных значений — массив битов, отмечающих столбцы, которые допускают неопределенные значения (NULL).
Формат данных на диске полностью совпадает с представлением данных в оперативной памяти. Поэтому файлы данных с одной платформы оказываются несовместимыми с другими платформами
Одна из причин несовместимости — порядок следования байтов. Например, в архитектуре x86
принят порядок от младших разрядов к старшим (little-endian), z/Architecture
использует обратный порядок (big-endian), а в ARM
порядок переключаемый.
Несовместимость вызывается также выравниванием данных по границам машинных слов, которое требуется многим архитектурам. Например, в 32-битной системе архитектуры x86 целые числа будут выровнены по границе четырехбайтных слов, как и числа с плавающей точкой двойной точности. А в 64-битной системе значения double
будут выровнены по границе восьмибайтных слов.
Из-за выравнивания размер табличной строки зависит от порядка расположения полей. Обычно этот эффект не сильно заметен, но в некоторых случаях он может привести к существенному увеличению размера.
Рассмотрим пример. Создадим таблицу с полями:
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 по границе четырехбайтных строк.
Выравнивание происходит из-за того, что 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.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));
Полученная таблица мало что даст понять, поэтому представим её в графическом виде и рассмотрим только заголовок:
Имеем следующее:
При вставке строки в табличной странице появился указатель
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
.
Хоть последние страницы clog
и сохраняются в буферах в оперативной памяти, все равно такую проверку накладно выполнять каждый раз. Поэтому выясненный однажды статус транзакции записывается в заголовок версии строки в информационные биты xmin_committed
или xmin_aborted
; их еще называют битами-подсказками (hint bits). Если один из этих битов установлен, то состояние транзакции xmin
считается известным, и следующей транзакции уже не придется обращаться ни к clog
, ни к ProcArray
.
3.3.3. Удаление
При удалении строки в поле t_xmax
актуальной версии записывается номер удаляющей транзакции, а бит xmax_aborted
сбрасывается.
Это же значение
xmax
, соответствующее активной транзакции, выступает в качестве блокировки строки. Если другая транзакция собирается обновить или удалить эту строку, она будет вынуждена дождаться завершения транзакцииxmax
.
3.3.4. Отмена транзакции
Отмена изменений работает аналогично фиксации и выполняется так же быстро, только в clog
вместо бита committed
выставляется бит aborted
.
Хоть команда и называется
ROLLBACK
, отката изменений не происходит: все, что транзакция успела изменить в страницах данных, остается на месте.
Рассмотрим на примере добавления новой записи и её отмены.
Аналогичная ситуация происходит при удалении записи и отмене.
Сам номер xmax
при этом остается в странице, но смотреть на него уже никто не будет.
3.3.5. Обновление
Обновление работает так, как будто сначала выполнилось удаление текущей версии строки, а затем — вставка новой.
3.4. Индексы
В индексах любого типа никогда не бывает версий строк, каждая строка представлена ровно одним экземпляром. Иными словами, в заголовке индексной строки не бывает полей xmin
и xmax
.
3.5. TOAST
Toast-таблица является, по сути, обычной таблицей, и для нее поддерживается собственная версионность: версии «тостов» не связаны с версиями строк основной таблицы. Но внутренняя работа с toast-таблицей построена так, что строки никогда не обновляются, а только добавляются и удаляются, так что версионность в данном случае несколько вырожденная.
Когда данные меняются, в основной таблице всегда создается новая версия строки. Но если обновление не затрагивает «длинное» значение, хранящееся в TOAST, то новая версия строки будет ссылаться на прежнее значение в toast-таблице. И только когда обновление поменяет «длинное» значение, будут созданы и новая версия строки в основной таблице, и новые «тосты».
3.6. Виртуальные транзакции
Если транзакция только читает данные, то она никак не влияет на видимость версий строк.
Каждой транзакции присваивается уникальный идентификатор VirtualTransactionId
(также именуемый virtualXID
или vxid
), который состоит из идентификатора обслуживающего процесса (или backendID
) и последовательно назначаемого номера — внутреннего для такого обслуживающего процесса (или localXID
). Например, виртуальный идентификатор 1/12532
состоит из следующих компонентов: backendID
со значением 1
и localXID
со значением 12532
.
В разные моменты времени в системе вполне могут оказаться виртуальные транзакции с номерами, которые уже использовались. И это нормально, поскольку виртуальные номера существуют только в оперативной памяти, пока транзакция активна; они никогда не записываются в страницы данных и не попадают на диск.
Невиртуальные идентификаторы TransactionId
(или xid
), например 278394
, последовательно выбираются для транзакций из глобального счётчика, который используется всеми базами данных в рамках кластера PostgreSQL. Значение присваивается при первой операции записи транзакции в базу данных.
Это означает, что транзакции с меньшими xid
начинают запись раньше транзакций с большими xid
. Обратите внимание, что порядок, в котором транзакции выполняют запись в базу данных впервые, может отличаться от порядка, в котором они запускаются, особенно если транзакции начинаются с операторов, выполняющих только операции чтения.
3.7. Вложенные транзакции
В SQL определены точки сохранения (savepoint), которые позволяют отменить часть операций транзакции, не прерывая ее полностью. Но это не укладывается в приведенную выше схему, поскольку статус транзакции один на все изменения, а физически никакие данные не откатываются.
Чтобы реализовать такой функционал, транзакция с точкой сохранения разбивается на несколько вложенных транзакций (subtransaction), статусом которых можно управлять отдельно.
Если подтранзакции присваивается невиртуальный идентификатор, его называют subxid
. Значение xid
родительской транзакции всегда будет меньше значений subxid
подтранзакций.
Подтранзакции могут фиксироваться или прерываться, не влияя на родительские транзакции, которые, соответственно, могут продолжать выполняться.
Идентификатор непосредственного родителя каждой подтранзакции записывается в каталог
pg_subtrans
. Идентификаторы транзакций верхнего уровня не записываются, поскольку у них нет родителя. Также не записываются и идентификаторы подтранзакций в режиме только чтения.Статус вложенных транзакций записывается в
clog
обычным образом, но зафиксированные вложенные транзакции одновременно отмечаются двумя битами,committed
иaborted
-11
.При фиксации подтранзакции все зафиксированные дочерние подтранзакции с
subxid
также считаются зафиксированными в рамках этой подтранзакции. При прерывании подтранзакции все дочерние подтранзакции также считаются прерванными.При фиксации транзакции верхнего уровня с
xid
зафиксированные подтранзакции записываются как зафиксированные в подкаталогеpg_xact
. При прерывании транзакции верхнего уровня все её подтранзакции также прерываются, даже если они были зафиксированы.
Заключение
В данной статье была рассмотрена «Глава 3. Страницы и версии строк» из книги PostgreSQL 16 изнутри.
В дальнейшем будет рассмотрена глава 4 — «Снимки данных» этой же книги.
relen
Пункт 3.4.
Рисунок 3.1.1 противоречит рисунку 3.11. Имеет смысл подправить 3.11.
ig_rudenko Автор
Добавил больше информации на рис. 3.11.