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

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

Строка в PostgreSQL: заголовок 23 байта, infomask и выравнивание столбцов

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

Таблица из шести столбцов и миллиона строк занимает 76,6 МБ, а с теми же данными в другом порядке столбцов — 60,2 МБ. Разница не в данных, а в служебных байтах: у каждой строки есть заголовок из 23 байт, а перед каждым значением ставятся пустые байты выравнивания. Страницу целиком мы разобрали в статье про раскладку страницы 8 КБ; здесь — одна строка внутри неё.

Из чего состоят 23 байта

Заголовок строки описан структурой HeapTupleHeaderData в src/include/access/htup_details.h. Вот её хвост, строки 161–175 дословно:

	ItemPointerData t_ctid;		/* current TID of this or newer tuple (or a
								 * speculative insertion token) */

	/* Fields below here must match MinimalTupleData! */

#define FIELDNO_HEAPTUPLEHEADERDATA_INFOMASK2 2
	uint16		t_infomask2;	/* number of attributes + various flags */

#define FIELDNO_HEAPTUPLEHEADERDATA_INFOMASK 3
	uint16		t_infomask;		/* various flag bits, see below */

#define FIELDNO_HEAPTUPLEHEADERDATA_HOFF 4
	uint8		t_hoff;			/* sizeof header incl. bitmap, padding */

	/* ^ - 23 bytes - ^ */

Комментарий 23 bytes стоит сразу после t_hoff. Выше, в объединении t_choice (строки 155–159), лежат t_xmin, t_xmax и t_cid, по 4 байта каждое. Сумма: 4 + 4 + 4 + 6 (t_ctid) + 2 (t_infomask2) + 2 (t_infomask) + 1 (t_hoff) = 23. Следом идёт t_bits — битовая карта NULL, но только если в t_infomask взведён HEAP_HASNULL. Данные столбцов начинаются со смещения t_hoff, а оно всегда кратно 8 (на этой машине MAXALIGN равен 8): поэтому у строки без NULL между заголовком и данными остаётся один пустой байт.

Опыт 1: заголовок в pageinspect

Подними контейнер и создай таблицу с двумя строками, у второй note равен NULL:

docker run -d --name dba-heap-tuple-header -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-heap-tuple-header psql -U postgres
SELECT version();
CREATE EXTENSION pageinspect;
CREATE TABLE t (id int4, note text);
INSERT INTO t VALUES (1,'abc'),(2,NULL);

SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask2,
       to_hex(t_infomask) AS infomask, t_hoff, t_bits, lp_len
FROM heap_page_items(get_raw_page('t',0));
 lp | t_xmin | t_xmax | t_ctid | t_infomask2 | infomask | t_hoff |  t_bits  | lp_len 
----+--------+--------+--------+-------------+----------+--------+----------+--------
  1 |    755 |      0 | (0,1)  |           2 | 802      |     24 |          |     32
  2 |    755 |      0 | (0,2)  |           2 | 801      |     24 | 10000000 |     28
(2 rows)

version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu. Что видно в выводе:

t_infomask2 = 2. Младшие 11 бит (HEAP_NATTS_MASK, 0x07FF) хранят число столбцов в строке: два. Старшие биты этого поля — флаги, ниже мы увидим их взведёнными.

t_hoff = 24. Заголовок 23 байта плюс байт выравнивания у обеих строк. У второй строки в этом байте лежит битовая карта: t_bits = 10000000 — первый столбец не NULL (единица), второй NULL (ноль). Одного байта на 8 столбцов хватает, поэтому t_hoff не вырос.

infomask = 802 и 801. Расшифровка по определениям из заголовочного файла:

#define HEAP_HASNULL			0x0001	/* has null attribute(s) */
#define HEAP_HASVARWIDTH		0x0002	/* has variable-width attribute(s) */
  • 0x0002 у первой строки: есть значение переменной длины (text).
  • 0x0001 у второй строки: есть NULL, и потому есть t_bits.
  • 0x0800 у обеих — HEAP_XMAX_INVALID (t_xmax невалиден): строку никто не удалял, в t_xmax нули.

lp_len совпадает с тем, что считает pg_column_size для строки целиком:

SELECT pg_column_size(ROW(1::int4,'abc'::text)) AS r1,
       pg_column_size(ROW(2::int4,NULL::text)) AS r2,
       pg_column_size(ROW()) AS r0,
       pg_column_size(ROW(1::int4)) AS r_int4;
 r1 | r2 | r0 | r_int4 
----+----+----+--------
 32 | 28 | 24 |     28
(1 row)

Пустая строка без столбцов весит 24: это заголовок с выравниванием. int4 прибавляет 4, строка abc — ещё 4 (один байт заголовка значения text и три байта текста). NULL не прибавляет ничего: ради него лишь выставлен бит в t_bits.

Хинт-биты: infomask меняется от чтения

Первая строка вставлена и закоммичена, но бита «t_xmin закоммитлен» в ней нет. Прочитай таблицу и посмотри заново:

SELECT * FROM t;
SELECT lp, to_hex(t_infomask) AS infomask, to_hex(t_infomask2) AS infomask2
FROM heap_page_items(get_raw_page('t',0));
 lp | infomask | infomask2 
----+----------+-----------
  1 | 902      | 2
  2 | 901      | 2
(2 rows)

Появился 0x0100: HEAP_XMIN_COMMITTED.

#define HEAP_XMIN_COMMITTED		0x0100	/* t_xmin committed */
#define HEAP_XMIN_INVALID		0x0200	/* t_xmin invalid/aborted */
#define HEAP_XMIN_FROZEN		(HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID)
#define HEAP_XMAX_COMMITTED		0x0400	/* t_xmax committed */
#define HEAP_XMAX_INVALID		0x0800	/* t_xmax invalid/aborted */

Так читатель кэширует ответ на вопрос «закоммитлена ли транзакция 755»: узнав его один раз в журнале коммитов, он записывает результат в саму строку, и следующим читателям не надо туда ходить. Эти биты называют хинт-битами. В этом опыте бит поставил SELECT; после массовой загрузки его обычно ставит первый SELECT по таблице или VACUUM. Отсюда практическое следствие: первый SELECT после загрузки большой таблицы пишет в неё, и это нормально.

UPDATE: HOT_UPDATED и ctid

Обнови первую строку и посмотри все версии:

BEGIN; UPDATE t SET note='xyz' WHERE id=1; COMMIT;
SELECT * FROM t;
SELECT lp, t_xmin, t_xmax, t_ctid, to_hex(t_infomask2) AS infomask2,
       to_hex(t_infomask) AS infomask
FROM heap_page_items(get_raw_page('t',0));
 lp | t_xmin | t_xmax | t_ctid | infomask2 | infomask 
----+--------+--------+--------+-----------+----------
  1 |    755 |    756 | (0,3)  | 4002      | 502
  2 |    755 |      0 | (0,2)  | 2         | 901
  3 |    756 |      0 | (0,3)  | 8002      | 2902
(3 rows)

Здесь три строки, а не две: UPDATE не меняет версию на месте, а пишет новую (lp = 3) и помечает старую.

  • У старой версии t_xmax = 756 (транзакция UPDATE) и t_ctid = (0,3): теперь ctid указывает не на себя, а на новую версию.
  • infomask2 = 4002: младшее 2 — два столбца, 0x4000 — HEAP_HOT_UPDATED. Столбец note не входит ни в один индекс, поэтому обновление «горячее» (HOT): новая версия осталась на той же странице.
  • У новой версии infomask2 = 8002: 0x8000 — HEAP_ONLY_TUPLE, на неё нет ссылок из индексов.
  • infomask = 502 у старой: 0x0400 (HEAP_XMAX_COMMITTED) + 0x0100 + 0x0002. У новой 2902: 0x2000 (HEAP_UPDATED, это обновлённая версия) + 0x0800 + 0x0100 + 0x0002.
#define HEAP_KEYS_UPDATED		0x2000	/* tuple was updated and key cols
										 * modified, or tuple deleted */
#define HEAP_HOT_UPDATED		0x4000	/* tuple was HOT-updated */
#define HEAP_ONLY_TUPLE			0x8000	/* this is heap-only tuple */

Осторожнее с числами: 0x2000 и 0x4000 значат разное в t_infomask (HEAP_UPDATED, HEAP_MOVED_OFF) и в t_infomask2 (HEAP_KEYS_UPDATED, HEAP_HOT_UPDATED). Бит без названия поля прочитать нельзя. Как эти поля решают, видна ли версия строки транзакции, разобрано в статье про MVCC; как старые версии убирает очистка — в VACUUM и autovacuum.

NULL и битовая карта: 9-й столбец

Битовая карта занимает по биту на столбец и округляется до байта. Заголовок 23 байта + 1 байт карты = 24, и t_hoff не меняется до 8 столбцов. Девятый столбец ломает это:

CREATE TABLE n9 (a int, b int, c int, d int, e int, f int, g int, h int, i int);
INSERT INTO n9 VALUES (1,2,3,4,5,6,7,8,NULL);
INSERT INTO n9 VALUES (1,2,3,4,5,6,7,8,9);
SELECT lp, t_hoff, t_bits, lp_len, to_hex(t_infomask)
FROM heap_page_items(get_raw_page('n9',0));
 lp | t_hoff |      t_bits      | lp_len | to_hex 
----+--------+------------------+--------+--------
  1 |     32 | 1111111100000000 |     64 | 801
  2 |     24 |                  |     60 | 800
(2 rows)

Строка с NULL в девятом столбце больше, чем строка со всеми значениями: 64 байта против 60. Данные сократились на 4 байта, но 23 + 2 байта карты = 25 округлились до t_hoff = 32. «NULL экономит место» верно, пока карта умещается в тот же кратный восьми блок заголовка.

Опыт 2: порядок столбцов меняет размер таблицы

Выравнивание значения задаёт тип. Посмотри на шесть типов:

SELECT typname, typlen, typalign FROM pg_type
WHERE typname IN ('bool','int2','int4','int8','timestamptz','text')
ORDER BY typlen DESC, typname;
   typname   | typlen | typalign 
-------------+--------+----------
 int8        |      8 | d
 timestamptz |      8 | d
 int4        |      4 | i
 int2        |      2 | s
 bool        |      1 | c
 text        |     -1 | i
(6 rows)

typalign: d — граница 8 байт, i — 4, s — 2, c — без выравнивания. Значение каждого столбца начинается на кратном своему выравниванию смещении от начала данных, пропущенные байты заполняются нулями. Создай две таблицы с одним набором столбцов и разным порядком: в bad столбцы идут «как придумали», в good — по убыванию выравнивания:

CREATE TABLE bad  (a int4, b int8, c int2, d int8, e bool, f timestamptz);
CREATE TABLE good (b int8, d int8, f timestamptz, a int4, c int2, e bool);
INSERT INTO bad  SELECT g, g, 1, g, true, now() FROM generate_series(1,1000000) g;
INSERT INTO good SELECT g, g, now(), g::int4, 1, true FROM generate_series(1,1000000) g;
VACUUM bad; VACUUM good;

SELECT 'bad' t, pg_relation_size('bad') rel, pg_relation_size('bad')/8192 pages,
       pg_total_relation_size('bad') total
UNION ALL SELECT 'good', pg_relation_size('good'), pg_relation_size('good')/8192,
       pg_total_relation_size('good');
  t   |   rel    | pages |  total   
------+----------+-------+----------
 bad  | 76562432 |  9346 | 76611584
 good | 60235776 |  7353 | 60276736
(2 rows)
SELECT round(100.0*(pg_relation_size('bad')-pg_relation_size('good'))/pg_relation_size('bad'),1) AS saved_pct_rel,
       round(100.0*(pg_total_relation_size('bad')-pg_total_relation_size('good'))/pg_total_relation_size('bad'),1) AS saved_pct_total;
 saved_pct_rel | saved_pct_total 
---------------+-----------------
          21.3 |            21.3
(1 row)

Тот же миллион строк, те же значения: на 21,3 % меньше. pg_total_relation_size больше pg_relation_size на 49 152 байта у bad и на 40 960 у good: индексов нет, это карта свободного места (fsm, 40 960 и 32 768 байт) и карта видимости (vm, по 8 192). Теперь посмотри на первую строку каждой таблицы:

SELECT 'bad' t, t_hoff, lp_len, t_data FROM heap_page_items(get_raw_page('bad',0)) WHERE lp=1
UNION ALL SELECT 'good', t_hoff, lp_len, t_data FROM heap_page_items(get_raw_page('good',0)) WHERE lp=1;
  t   | t_hoff | lp_len |                                               t_data                                               
------+--------+--------+----------------------------------------------------------------------------------------------------
 bad  |     24 |     72 | \x0100000000000000010000000000000001000000000000000100000000000000010000000000000069f4833cd0010300
 good |     24 |     55 | \x010000000000000001000000000000000b728d3cd001030001000000010001
(2 rows)

Разбери t_data строки bad по смещениям от начала данных (смещение в строке — это 24 + эта величина):

  • a (int4) — 4 байта 01000000, затем 4 нуля: b (int8) должен начинаться на границе 8.
  • b — 0100000000000000, 8 байт.
  • c (int2) — 0100, затем 6 нулей до границы 8 для d.
  • d — 8 байт; e (bool) — 01, затем 7 нулей до границы 8 для f.
  • f (timestamptz) — последние 8 байт.

Полезных байт 31, строка 72 − 24 = 48 байт данных: 4 + 6 + 7 = 17 байт ушли на дыры. В good дыр нет: три int8 подряд, потом int4, int2, bool, всего 55 байт. Страница это подтверждает:

SELECT 'bad' t, count(*) FROM heap_page_items(get_raw_page('bad',0))
UNION ALL SELECT 'good', count(*) FROM heap_page_items(get_raw_page('good',0));

SELECT pg_column_size(ROW(1::int4,1::int8,1::int2,1::int8,true,now())) AS bad_row,
       pg_column_size(ROW(1::int8,1::int8,now(),1::int4,1::int2,true)) AS good_row;
  t   | count 
------+-------
 bad  |   107
 good |   136
(2 rows)

 bad_row | good_row 
---------+----------
      72 |       55
(1 row)

На страницу влезает 107 строк против 136. Почему именно столько: после заголовка страницы (24 байта) остаётся 8168 байт; строка занимает кратное 8 число байт и ещё 4 байта на указатель. bad: 72 + 4 = 76, и 8168 / 76 = 107,5. good: 55 округляется до 56, 56 + 4 = 60, и 8168 / 60 = 136,1. Без округления до 56 в страницу влезло бы 138 строк, а их 136.

Битовая карта заполняет тот самый свободный байт 23. Строка bad с NULL в f:

CREATE TABLE bad_null (LIKE bad);
INSERT INTO bad_null VALUES (1,1,1,1,true,NULL);
SELECT t_hoff, t_bits, lp_len, to_hex(t_infomask) AS infomask
FROM heap_page_items(get_raw_page('bad_null',0));
 t_hoff |  t_bits  | lp_len | infomask 
--------+----------+--------+----------
     24 | 11111000 |     57 | 801
(1 row)

Карта 11111000 села в байт, который у строки без NULL был пустым; t_hoff остался 24. Строка стала короче на столбец f и его дыру: 57 байт против 72.

DBA / 01

Строка из 72 байт: 17 уходят на дыры выравнивания

Байты строки таблицы bad и та же строка после перестановки столбцовСтрока таблицы bad занимает 72 байта: заголовок 23 байта, байт 23 свободен под битовую карту NULL, int4 a, дыра 4 байта, int8 b, int2 c, дыра 6 байт, int8 d, bool e, дыра 7 байт, timestamptz f. Строка таблицы good при столбцах по убыванию выравнивания занимает 55 байт: заголовок 24 байта, три 8-байтных столбца, int4, int2 и bool. На странице 107 строк против 136, таблица 9 346 страниц против 7 353, на 21,3 процента меньше.bad: столбцы a, b, c, d, e, f в порядке CREATE TABLE · lp_len 72заголовок 23 Бabcdefbitmapдыра 4дыра 6дыра 7int4 · int8 · int2 · int8 · bool · timestamptzпорядок по убыванию выравниванияgood: b, d, f, a, c, e · lp_len 55заголовок 24 Бb d f · 24 Бa c e−17 ББайт 23 в bad пуст; при NULL в строке там лежит t_bits. Масштаб: 10 единиц на байт.107 → 136 строк на странице9 346 → 7 353 страницы: −21,3 %pg_relation_size 76 562 432 → 60 235 776 байт на 1 000 000 строк
Дыры выравнивания (янтарные) в строке bad занимают 4 + 6 + 7 = 17 байт: каждый столбец начинается на границе своего типа. Те же значения в порядке int8, int8, timestamptz, int4, int2, bool не оставляют дыр, и миллион строк занимает на 21,3 % меньше места.

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

Порядок столбцов — решение при CREATE TABLE. ALTER TABLE не умеет переставлять столбцы, поэтому пересборка таблицы с другим порядком — это новая таблица, перенос данных и переключение. Для широких таблиц с миллионами строк заложи порядок сразу.

Правило сортировки простое. Сначала столбцы с выравниванием d (int8, float8, timestamptz), затем i (int4, date), затем s (int2), затем c (bool, "char"). Типы переменной длины (text, numeric, jsonb, у всех i) ставь в конец: их размер меняется от строки к строке, и всё после них стояло бы на разном смещении.

Выигрыш зависит от таблицы. Мы получили 21,3 % на шести столбцах фиксированной ширины, где дыр было больше всего. В таблице из bigint и text дыр почти нет, и пересборка не окупится. Меряй на своей таблице: pg_column_size(t.*) по строкам и pg_relation_size, как выше, прежде чем что-то переделывать.

Заголовок не отключить. 24 байта на строку — цена MVCC. Для таблиц из двух-трёх узких столбцов заголовок весит больше данных: строка ROW(1::int4) занимает 28 байт, из них полезных 4.

Чек-лист

  1. Заголовок строки — 23 байта, в строке без NULL с выравниванием 24; карта NULL растёт по биту на столбец и сдвигает t_hoff кратно 8.
  2. Читай t_infomask и t_infomask2 только вместе с названием поля: 0x2000 и 0x4000 значат в них разное.
  3. Новую широкую таблицу проектируй по убыванию typalign: d, i, s, c, переменная длина в конце.
  4. Прежде чем пересобирать существующую таблицу, измерь pg_column_size(t.*) и число строк на странице через heap_page_items.
  5. Если смотришь страницу через pageinspect, помни: первый SELECT или VACUUM после вставки меняет infomask (хинт-биты).

Контейнер удаляется командой docker rm -f dba-heap-tuple-header.

Источники

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