Справочник DBA: базы данных изнутри читается по порядку

Статья 1.4 · раздел 1 «Что делает DBA и как база лежит на диске» · 4 из 109

Страница 8 КБ в PostgreSQL: заголовок, указатели строк и ctid

опубликовано
читать
≈ 8 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; расширение pageinspect; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

После DELETE файл таблицы остался ровно в 8 192 байта, а у строки ctid равен (0,3), хотя в таблице всего две строки. Оба факта объясняет устройство страницы: PostgreSQL читает и пишет файл блоками по 8 КБ, внутри блока указатели строк растут от начала, сами строки лежат от конца, а ctid адресует указатель, а не смещение в байтах.

DBA / 01

Страница 8 192 байта: числа из опыта

Страница 8 192 байта: числа из опыта Сверху вниз: заголовок 24 байта, три указателя строк до смещения 36, свободное место 8 028 байт до смещения 8 064, три кортежа с 8 064 до 8 192 и пустая область special. Указатель lp 1 ведёт к нижнему кортежу со смещением 8 152, lp 2 к удалённому кортежу со смещением 8 104, lp 3 к верхнему кортежу со смещением 8 064. 0 24 pd_lower 36 pd_upper 8064 pd_special 8192 заголовок, 24 байта lp 1 lp 2 lp 3 свободно 8 028 байт, 98% страницы кортеж 3: charlie кортеж 2: bravo bravo bravo кортеж 1: alpha special: 0 байт указатель: 4 байта, 36 = 24 + 3 × 4 lp 3: off 8064, len 36 xmin 757, живая lp 2: off 8104, len 46 xmin 756, xmax 758: удалена lp 1: off 8152, len 34 xmin 755, живая Не в масштабе: адреса растут сверху вниз, кортежи заняли 128 байт.
Указатели заполняют страницу сверху, кортежи снизу, а pd_lower и pd_upper сходятся навстречу друг другу. Удалённая строка (янтарный кортеж) по-прежнему на месте: до VACUUM её убирать некому.

Опыт: три вставки и одно удаление

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

docker run -d --name dba-postgres-page-layout -e POSTGRES_PASSWORD=pg postgres:18
docker exec -it dba-postgres-page-layout psql -U postgres
CREATE EXTENSION pageinspect;
CREATE TABLE t (id int, name text);
INSERT INTO t VALUES (1, 'alpha');
INSERT INTO t VALUES (2, 'bravo bravo bravo');
INSERT INTO t VALUES (3, 'charlie');
DELETE FROM t WHERE id = 2;
SELECT pg_relation_filepath('t') AS file, pg_relation_size('t') AS bytes;
     file     | bytes
--------------+-------
 base/5/16430 |  8192

Три строки поместились в один блок: файл base/5/16430 весит 8 192 байта. Его нулевую страницу можно прочитать целиком функцией get_raw_page('t', 0), а page_header и heap_page_items разбирают её на поля.

Заголовок: lower, upper, special, pagesize, lsn

SELECT lsn, checksum, flags, lower, upper, special, pagesize
FROM page_header(get_raw_page('t', 0));
    lsn    | checksum | flags | lower | upper | special | pagesize
-----------+----------+-------+-------+-------+---------+----------
 0/17EE358 |        0 |     0 |    36 |  8064 |    8192 |     8192
  • pagesize — 8 192, размер блока сборки (block_size). Заголовок хранит его вместе с версией формата страницы.
  • lower — 36. Это конец массива указателей: 24 байта заголовка плюс 3 указателя по 4 байта. Указатель создаётся при каждом INSERT, DELETE его не убрал.
  • upper — 8 064. Это начало самого нижнего кортежа, то есть последней вставленной строки. Между lower и upper лежит свободное место: 8 064 − 36 = 8 028 байт.
  • special — 8 192, равно размеру страницы: у страниц таблицы (heap) особой области в конце нет. Она нужна индексам, которым надо хранить ссылки на соседние страницы.
  • lsn — позиция в журнале WAL, до которой нужно сбросить журнал перед записью этой страницы на диск. Здесь это запись об изменении, которое последним тронуло страницу, то есть о DELETE.

Колонка checksum показывает 0, хотя data_checksums включён: в PostgreSQL 18 initdb включает контрольные суммы по умолчанию. get_raw_page читает страницу из shared_buffers, а сумма проставляется при записи страницы на диск: FlushBuffer вызывает PageSetChecksumCopy (bufmgr.c, строка 4386). После CHECKPOINT та же страница, прочитанная прямо из файла, уже подписана:

CHECKPOINT;
SELECT lsn, checksum
FROM page_header(substr(pg_read_binary_file('base/5/16430'), 1, 8192));
    lsn    | checksum
-----------+----------
 0/17EE358 |    -2930

Значение отрицательное, потому что pageinspect показывает pd_checksum как знаковое 16-битное число. Так что нулевая контрольная сумма в выводе page_header(get_raw_page(...)) — не признак выключенных сумм.

Указатели и кортежи

SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('t', 0));
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid
----+--------+----------+--------+--------+--------+--------
  1 |   8152 |        1 |     34 |    755 |      0 | (0,1)
  2 |   8104 |        1 |     46 |    756 |    758 | (0,2)
  3 |   8064 |        1 |     36 |    757 |      0 | (0,3)
  • lp — номер указателя, он же номер строки в ctid. Нумерация идёт с 1.
  • lp_off — смещение кортежа от начала страницы. У первой вставленной строки оно самое большое, у последней самое маленькое: кортежи ложатся от конца страницы к началу.
  • lp_len — длина кортежа без выравнивания: 24 байта заголовка, 4 байта id и text с однобайтовым заголовком (alpha занимает 1 + 5 байт). Отсюда 24 + 4 + 6 = 34.
  • lp_flags — состояние указателя; 1 означает LP_NORMAL, то есть «указывает на кортеж».
  • t_xmin и t_xmax — номера транзакций, создавшей и удалившей версию строки. Удалённая строка 2 осталась на месте с t_xmax = 758.

Смещения подтверждают выравнивание. Длина 34 округляется вверх до кратного 8 (MAXALIGN), то есть до 40: 8 192 − 40 = 8 152. Строка 2: 46 → 48, 8 152 − 48 = 8 104. Строка 3: 36 → 40, 8 104 − 40 = 8 064, и это в точности upper из заголовка.

Сами указатели — это 12 байт сразу после заголовка. Прочитаем их сырыми:

SELECT encode(substr(get_raw_page('t', 0), 25, 12), 'hex') AS item_ids;
         item_ids
--------------------------
 d89f4400a89f5c00809f4800

Первый указатель — d89f4400. Байты идут от младшего, значит число 0x00449fd8. Его младшие 15 бит дают 0x1fd8 = 8 152 (lp_off), следующие два бита дают 1 (lp_flags), оставшиеся 15 бит дают 34 (lp_len). Именно так описана структура ItemIdData:

typedef struct ItemIdData
{
	unsigned	lp_off:15,		/* offset to tuple (from start of page) */
				lp_flags:2,		/* state of line pointer, see below */
				lp_len:15;		/* byte length of tuple */
} ItemIdData;

Источник: src/include/storage/itemid.h, строки 25–30, тег REL_18_6.

ctid (0,3) — это номер указателя

ctid состоит из номера страницы и номера указателя:

SELECT ctid, id, name FROM t;
 ctid  | id |  name
-------+----+---------
 (0,1) |  1 | alpha
 (0,3) |  3 | charlie

(0,3) читается так: нулевая страница файла, указатель lp = 3. Чтобы найти кортеж, исполнитель берёт третий указатель из массива, читает из него lp_off и идёт по смещению 8 064. В коде это и делает PageGetItem: прибавляет ItemIdGetOffset(itemId) к адресу страницы (bufpage.h, строки 354–361); в 18-й версии это static inline-функция, не макрос.

Зачем промежуточный указатель? Чтобы кортеж можно было двигать внутри страницы, не меняя его адрес. Проверим: запустим VACUUM и посмотрим на ту же страницу.

VACUUM t;
SELECT lower, upper FROM page_header(get_raw_page('t', 0));
SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('t', 0));
SELECT ctid, id, name FROM t;
 lower | upper
-------+-------
    36 |  8112

 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid
----+--------+----------+--------+--------+--------+--------
  1 |   8152 |        1 |     34 |    755 |      0 | (0,1)
  2 |      0 |        0 |      0 |        |        |
  3 |   8112 |        1 |     36 |    757 |      0 | (0,3)

 ctid  | id |  name
-------+----+---------
 (0,1) |  1 | alpha
 (0,3) |  3 | charlie

Место удалённой версии освобождено: указатель 2 получил lp_flags = 0 (LP_UNUSED) и нулевые lp_off и lp_len, а upper вырос с 8 064 до 8 112. Страница сдвинула кортеж 3 вплотную к кортежу 1 (lp_off стал 8 112 вместо 8 064), и ctid у charlie по-прежнему (0,3). Адрес не изменился, потому что он указывает на номер указателя, а не на смещение. Как VACUUM решает, что версию можно убрать, разобрано в статье про VACUUM и autovacuum.

Освободившийся указатель не пропадает: следующая вставка займёт его.

INSERT INTO t VALUES (4, 'delta') RETURNING ctid;
SELECT lower, upper FROM page_header(get_raw_page('t', 0));
 ctid
-------
 (0,2)

 lower | upper
-------+-------
    36 |  8072

Новая строка получила (0,2). lower остался равен 36: массив указателей не вырос, upper уменьшился на 40 байт (34 → 40 после выравнивания), 8 112 − 40 = 8 072.

Как код двигает pd_lower и pd_upper

Функция PageAddItemExtended в src/backend/storage/page/bufpage.c добавляет кортеж на страницу. Ниже её середина: вычисление новых границ и проверка, что кортеж помещается.

	if (offsetNumber == limit || needshuffle)
		lower = phdr->pd_lower + sizeof(ItemIdData);
	else
		lower = phdr->pd_lower;

	alignedSize = MAXALIGN(size);

	upper = (int) phdr->pd_upper - (int) alignedSize;

	if (lower > upper)
		return InvalidOffsetNumber;

Источник: bufpage.c, строки 309–319, REL_18_6. Здесь видны все наблюдения из опыта. lower растёт на 4 байта, только если создаётся новый указатель (offsetNumber == limit); если взят освободившийся, как (0,2) выше, он остаётся прежним. upper всегда уменьшается на длину, округлённую до 8. Если границы пересеклись, кортеж не помещается, и вызывающий код ищет другую страницу.

Запись самого кортежа и границ:

	/* set the line pointer */
	ItemIdSetNormal(itemId, upper, size);
	/* copy the item's data onto the page */
	memcpy((char *) page + upper, item, size);

	/* adjust page header */
	phdr->pd_lower = (LocationIndex) lower;
	phdr->pd_upper = (LocationIndex) upper;

Источник: bufpage.c, строки 330–352 (фрагмент без комментариев и проверки Valgrind). Указатель получает upper как lp_off, а size — без выравнивания — как lp_len; поэтому в выводе lp_len равен 34, а шаг между смещениями — 48.

Структура заголовка из bufpage.h (24 байта) собирает все поля, которые мы читали через page_header:

typedef struct PageHeaderData
{
	PageXLogRecPtr pd_lsn;
	uint16		pd_checksum;
	uint16		pd_flags;
	LocationIndex pd_lower;
	LocationIndex pd_upper;
	LocationIndex pd_special;
	uint16		pd_pagesize_version;
	TransactionId pd_prune_xid;
	ItemIdData	pd_linp[FLEXIBLE_ARRAY_MEMBER];
} PageHeaderData;

Источник: bufpage.h, строки 159–172 (комментарии убраны). Восемь байт pd_lsn, по два байта на шесть полей и четыре байта pd_prune_xid дают 24; pd_linp — это уже начало массива указателей.

Что из этого следует

  • Удаление ничего не освобождает. До VACUUM удалённая версия занимает место в кортежах и указатель в массиве, а размер файла не меняется. Видно по lp_off = 8104 и t_xmax = 758.
  • Указатель в середине массива не исчезает. После VACUUM он остаётся свободным (LP_UNUSED) и ждёт вставки, как (0,2) выше; усекаются только свободные указатели в хвосте массива (PageTruncateLinePointerArray в том же bufpage.c). Номера строк на странице не сдвигаются.
  • Свободное место одно. Все 8 028 байт лежат единым куском между lower и upper. Чтобы получить его после DELETE, страницу надо дефрагментировать; это делает VACUUM, как показал сдвиг upper с 8 064 до 8 112.
  • ctid — адрес на сегодня. UPDATE создаёт новую версию с новым ctid, а VACUUM FULL или CLUSTER переписывают таблицу и меняют ctid всем строкам. Хранить его как идентификатор нельзя.
  • Нулевая сумма в page_header — шум. Читай контрольную сумму из файла после CHECKPOINT, а не из буфера.

Чек-лист для работы со страницей

  1. Для lp_off, lp_len и lower с upper проверь равенство: lower = 24 + 4 × число указателей, а свободное место равно upper − lower.
  2. Ненулевой t_xmax у кортежа с lp_flags = 1 — версию удалили (или заблокировали через SELECT … FOR UPDATE); жива ли она, решает исход транзакции из t_xmax.
  3. Не используй ctid как ключ: он меняется при UPDATE, VACUUM FULL и CLUSTER.
  4. Сумму страницы проверяй по файлу после CHECKPOINT; в get_raw_page она не проставлена.
  5. Эксперименты с pageinspect делай в отдельном контейнере: docker rm -f dba-postgres-page-layout убирает всё, что ты создал.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru