Learning Platform
Урок 02.02 · 24 мин
Продвинутый
StorageHeap tupleTOASTMVCC headerAlignmentpg_toast

В первом уроке мы посмотрели на страницу снаружи: 24-байтный header, line pointers, цепочка кортежей, special area. Теперь зум на следующий уровень — что внутри одного кортежа. Это критично, потому что любая интуиция про размер таблицы (pg_relation_size, плотность строк на странице, скорость Seq Scan) растёт именно отсюда: из того, что один INT на диске занимает не 4 байта, а 8, а одна TEXT колонка с длинной строкой может вообще уехать в отдельную таблицу.

Heap tuple: 23 байта заголовка плюс данные

Каждый

heap tuple
начинается с фиксированного 23-байтного заголовка (HeapTupleHeader). Эти 23 байта — overhead, который ты платишь за каждую строку, и это первое, что должно отрезвлять, когда ты собираешься сделать таблицу из миллиарда строк с одной колонкой INT.

Состав заголовка:

HeapTupleHeader: 23 байта на каждую строку

Поля идут плотно, без alignment. После заголовка может идти null bitmap, далее — сами колонки с выравниванием.

xmin4 байта
xmax4 байта
cid / xvac (union)4 байта
ctid6 байтов
infomask2 байта (флаги)
infomask22 байта (natts)
hoff1 байт (offset)
итого23 байта

Поля разберём по делу:

  1. xmintransaction id, который создал этот кортеж. Используется в MVCC: ты видишь строку, только если xmin уже закоммичен и меньше твоего snapshot.
  2. xmaxtransaction id, который удалил этот кортеж (или нуль, если строка живая). При UPDATE старая версия получает xmax, новая — новый xmin — это та самая «копия при апдейте».
  3. cid / xvac — union: command id внутри транзакции (для видимости в пределах одной транзакции) либо vacuum-id (для FREEZE).
  4. ctidфизический указатель на следующую версию. Для живой строки это просто её собственный адрес. Для апдейтированной — указывает на новую версию (HOT-chain или indirect tuple).
  5. infomask / infomask2 — флаги: HEAP_HASNULL (есть NULL bitmap), HEAP_HASVARWIDTH (есть varlena колонка), HEAP_HOT_UPDATED, HEAP_ONLY_TUPLE, и т.д. infomask2 хранит количество атрибутов и HOT-флаги.
  6. hoff — байт-offset, с которого начинаются данные колонок (т.е. насколько большие у нас header + null bitmap, с учётом padding до MAXALIGN, обычно 8 байт).

После заголовка идёт null bitmap — но только если хотя бы одна колонка имеет NULL в этой конкретной строке. Битмап имеет один бит на колонку, округляется до байта, занимает ceil(N/8) байт. Если NULL в строке нет — битмапа нет совсем, экономия. Это объясняет, почему NOT NULL колонки не «дешевле» сами по себе, но строка с одним NULL стоит на один байт дороже строки без NULL.

Далее — сами данные колонок, в порядке CREATE TABLE, но с alignment-паддингом между ними.

Alignment: почему (int, bigint, int) хуже, чем (bigint, int, int)

Каждый Postgres-тип имеет требование к выравниванию: int — 4 байта, bigint/timestamp/double — 8 байт, int2 — 2 байта, text/bytea — 4 байта (header varlena). Перед записью значения Postgres дополняет смещение нулями до нужной кратности.

Сравним две таблицы с одинаковым логическим содержанием:

Две таблицы хранят одни и те же значения, но раскладка колонок отличается. Посмотрим, какая займёт меньше места:

PostgreSQL

В bad_order после каждого int (4 байта) идёт bigint (требует 8-байтное выравнивание) — значит, добавляется 4 байта паддинга. И так дважды. Итого: 4 + 4пад + 8 + 4 + 4пад + 8 + 4 = 36 байт данных вместо ожидаемых 28. В good_order сначала идут все 8-байтные колонки, потом 4-байтные — паддинга нет: 8 + 8 + 4 + 4 + 4 = 28 байт. На 100K строк это уже разница в ~800 KiB, на миллиардах — гигабайты.

Правило большого пальца: при проектировании таблицы сортируй колонки по убыванию размера: bigint/timestamp/doubleint/text-pointerint2bool/char. Постгрес ничего не пересортирует за тебя — на диске они лежат в порядке CREATE TABLE.

Массив на уровне памяти: непрерывный блок и random access

Когда строка не помещается: TOAST

Страница — 8 KiB. Что делать, если строка содержит большой TEXT или BYTEA, например, JSON на 500 KiB или PNG-аватарку? Просто не сохранить нельзя.

Решение —

TOAST
(The Oversized-Attribute Storage Technique). Когда Postgres собирается записать кортеж и видит, что он больше TOAST_TUPLE_THRESHOLD (по умолчанию ~2 KiB, точнее TOAST_TUPLE_TARGET = 2032 байта), он включает «тостер»: пытается сжать varlena-колонки (LZ4 или pglz), и если этого мало — выносит их в отдельную TOAST-таблицу, оставляя в основной строке 18-байтный pointer.

TOAST-таблица создаётся автоматически для каждой таблицы, у которой есть хотя бы одна varlena-колонка. Имя: pg_toast.pg_toast_<table_oid>. Структура одинаковая для всех:

chunk_id   OID    -- идентификатор «большого значения»
chunk_seq  INT    -- номер чанка (0, 1, 2, ...)
chunk_data BYTEA  -- ~2000 байт данных

Большое значение режется на куски ~2000 байт и пишется в эту таблицу с общим chunk_id. В основной строке остаётся pointer (chunk_id, общая длина, oid TOAST-таблицы). Для индексации chunk_id есть автоматический B-tree.

TOAST: вынос большого значения в pg_toast

Большой text/jsonb режется на чанки ~2 KiB и попадает в pg_toast.pg_toast_NNN. В исходной строке остаётся 18-байтный pointer.

articles (heap)row: id=1, body=<TOAST pointer 18B>
pointer → chunk_id1234567
pg_toast.pg_toast_18402chunk_id=1234567 разрезан на seq 0..N (по ~2 KiB)
seq 02000 B
seq 12000 B
seq 22000 B
......

Четыре стратегии: PLAIN, MAIN, EXTERNAL, EXTENDED

Для каждой varlena-колонки можно задать стратегию TOAST через ALTER TABLE ... ALTER COLUMN ... SET STORAGE ...:

  • PLAIN — никакого TOAST’а. Колонка хранится только inline; если значение больше TOAST_TUPLE_THRESHOLD — будет ошибка row is too big. Используется для не-varlena типов (int, timestamp) автоматически.
  • EXTENDED (по умолчанию для большинства varlena) — сжимать, и если всё ещё много — выносить out-of-line. Это поведение «как обычно» для text, bytea, jsonb, массивов.
  • EXTERNAL — выносить out-of-line, но не сжимать. Полезно для bytea с уже сжатыми данными (картинки JPEG, gzip-логи) — повторное сжатие бесполезно и тратит CPU.
  • MAIN — сжимать, но стараться хранить inline. Out-of-line вынесет только если без этого никак не помещается на страницу.

Посмотрим текущие стратегии:

Стратегии TOAST для каждой колонки таблицы customers:

PostgreSQL

text-колонки получают EXTENDED по умолчанию, int/date/boolPLAIN. Это нормально.

Если у тебя в bytea лежит уже сжатый контент (JPEG, ZIP), стоит выставить EXTERNAL:

Меняем стратегию для bytea-колонки с уже сжатыми данными:

PostgreSQL

Где живёт TOAST-таблица и как её найти

У каждой таблицы с varlena-колонками есть reltoastrelid в pg_class, указывающий на её TOAST-таблицу:

Найдём TOAST-таблицу для customers (если есть):

PostgreSQL

Если ни одна колонка не вытолкнулась в TOAST (потому что строки маленькие), toast_size будет ~0 — но сама TOAST-таблица существует, она просто пустая. Её размер начнёт расти, как только в основной строке появится колонка > ~2 KiB.

TOAST-нюансы, которые ловят в продакшене

  • TOAST скрыт от pg_relation_size. Эта функция возвращает размер только основного heap. Чтобы получить полную картину — нужен pg_total_relation_size (см. урок 5). На JSONB-тяжёлых таблицах разница может быть 10x.
  • TOAST-доступ — это лишний I/O. Чтобы прочитать поле, которое уехало в TOAST, нужно: (1) прочитать heap-кортеж, (2) пойти в TOAST-таблицу по pointer’у, (3) собрать чанки, (4) разжать. Если ты часто читаешь огромный JSONB, но в большинстве запросов он не нужен — выноси его в отдельную таблицу.
  • TOAST детоастится автоматически при SELECT *. Когда ты делаешь SELECT *, Postgres вытащит все TOAST-значения, даже если ты их не используешь. SELECT id, name FROM articles — детоаст не произойдёт. Поэтому SELECT * на TOAST-тяжёлой таблице вреден сразу с двух сторон: и сеть, и I/O.
  • Сжатие выбирается параметром default_toast_compression. С PG14 умолчание стало lz4 (быстрее, лучше для современного железа), до этого был pglz. Можно переопределить на колонку: ALTER TABLE ... ALTER COLUMN ... SET COMPRESSION lz4.
  • На pglite TOAST работает, но создание pg_toast-таблицы прозрачное — обычно её не замечаешь, пока строки маленькие. В этом курсе для демонстрации TOAST’а потребовалась бы таблица с большим текстом — мы посмотрим её ниже.

Триггерим TOAST: вставляем строку с большим текстом и смотрим, как растёт TOAST-таблица:

PostgreSQL

Видишь, что total существенно больше heap — это и есть TOAST в действии. Heap содержит только pointer’ы (18 байт на каждое выехавшее значение), реальные данные лежат в pg_toast.pg_toast_<oid>.

Проверка знанийKnowledge check
У тебя таблица events с колонкой metadata jsonb (в среднем ~3 KiB на строку), 10M строк. Ты заметил, что pg_relation_size('events') показывает 800 MiB, а pg_total_relation_size('events') — 35 GiB. Объясни, почему такая разница и какой одной командой можно проверить, сколько именно занимает TOAST.
ОтветAnswer
pg_relation_size возвращает только основной heap (без TOAST, без индексов, без FSM/VM). Для metadata размером ~3 KiB значение почти наверняка выезжает в TOAST: в самом heap остаётся 18-байтный pointer на каждую строку. Heap = 10M × (overhead на строку ~80 байт + 18 байт pointer + остальные колонки) ≈ 800 MiB. Реальный размер metadata лежит в pg_toast.pg_toast_<oid> и составляет ~34 GiB. pg_total_relation_size суммирует heap + TOAST + все индексы. Проверить TOAST отдельно: SELECT pg_size_pretty(pg_relation_size(reltoastrelid)) FROM pg_class WHERE relname='events'; или pg_size_pretty(pg_total_relation_size('events') - pg_relation_size('events') - pg_indexes_size('events')).

Чек-лист

  • HeapTupleHeader = 23 байта на каждую строку: xmin/xmax (MVCC), cid, ctid, infomask, infomask2, hoff. Это overhead, который нельзя обойти.
  • Null bitmap есть только если в строке есть NULL; занимает ceil(natts/8) байт.
  • Alignment добавляет padding между колонками. Сортируй колонки по убыванию размера: 8-байтные → 4-байтные → 2-байтные → 1-байтные.
  • TOAST включается, когда tuple > ~2 KiB. Стратегии: PLAIN (только inline), MAIN (предпочитать inline), EXTERNAL (out-of-line, без сжатия), EXTENDED (default: сжать + при необходимости вынести).
  • TOAST-таблица называется pg_toast.pg_toast_<oid>, её oid лежит в pg_class.reltoastrelid.
  • pg_relation_size скрывает TOAST. Для полной картины — pg_total_relation_size.
  • SELECT * детоастит всё, даже неиспользуемые колонки. Выбирай нужные колонки явно.
Числовые типы: INT, BIGINT, NUMERIC и почему деньги — не FLOAT JSONB: гибкая схема внутри строгой JSON: формат и кодирование

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Сколько байт занимает HeapTupleHeader, и какие основные поля он содержит?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5