После DELETE файл таблицы остался ровно в 8 192 байта, а у строки ctid равен (0,3), хотя в таблице всего две строки. Оба факта объясняет устройство страницы: PostgreSQL читает и пишет файл блоками по 8 КБ, внутри блока указатели строк растут от начала, сами строки лежат от конца, а ctid адресует указатель, а не смещение в байтах.
Страница 8 192 байта: числа из опыта
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, а не из буфера.
Чек-лист для работы со страницей
- Для
lp_off,lp_lenиlowerсupperпроверь равенство:lower = 24 + 4 × число указателей, а свободное место равноupper − lower. - Ненулевой
t_xmaxу кортежа сlp_flags = 1— версию удалили (или заблокировали черезSELECT … FOR UPDATE); жива ли она, решает исход транзакции изt_xmax. - Не используй
ctidкак ключ: он меняется приUPDATE,VACUUM FULLиCLUSTER. - Сумму страницы проверяй по файлу после
CHECKPOINT; вget_raw_pageона не проставлена. - Эксперименты с
pageinspectделай в отдельном контейнере:docker rm -f dba-postgres-page-layoutубирает всё, что ты создал.