В первом уроке мы посмотрели на страницу снаружи: 24-байтный header, line pointers, цепочка кортежей, special area. Теперь зум на следующий уровень — что внутри одного кортежа. Это критично, потому что любая интуиция про размер таблицы (pg_relation_size, плотность строк на странице, скорость Seq Scan) растёт именно отсюда: из того, что один INT на диске занимает не 4 байта, а 8, а одна TEXT колонка с длинной строкой может вообще уехать в отдельную таблицу.
Heap tuple: 23 байта заголовка плюс данные
Каждый
HeapTupleHeader). Эти 23 байта — overhead, который ты платишь за каждую строку, и это первое, что должно отрезвлять, когда ты собираешься сделать таблицу из миллиарда строк с одной колонкой INT.
Состав заголовка:
Поля идут плотно, без alignment. После заголовка может идти null bitmap, далее — сами колонки с выравниванием.
Поля разберём по делу:
- xmin —
transaction id, который создал этот кортеж. Используется в MVCC: ты видишь строку, только еслиxminуже закоммичен и меньше твоего snapshot. - xmax —
transaction id, который удалил этот кортеж (или нуль, если строка живая). ПриUPDATEстарая версия получаетxmax, новая — новыйxmin— это та самая «копия при апдейте». - cid / xvac — union: command id внутри транзакции (для видимости в пределах одной транзакции) либо vacuum-id (для FREEZE).
- ctid — физический указатель на следующую версию. Для живой строки это просто её собственный адрес. Для апдейтированной — указывает на новую версию (HOT-chain или indirect tuple).
- infomask / infomask2 — флаги:
HEAP_HASNULL(есть NULL bitmap),HEAP_HASVARWIDTH(есть varlena колонка),HEAP_HOT_UPDATED,HEAP_ONLY_TUPLE, и т.д.infomask2хранит количество атрибутов и HOT-флаги. - 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 дополняет смещение нулями до нужной кратности.
Сравним две таблицы с одинаковым логическим содержанием:
Две таблицы хранят одни и те же значения, но раскладка колонок отличается. Посмотрим, какая займёт меньше места:
В 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/double → int/text-pointer → int2 → bool/char. Постгрес ничего не пересортирует за тебя — на диске они лежат в порядке CREATE TABLE.
Когда строка не помещается: TOAST
Страница — 8 KiB. Что делать, если строка содержит большой TEXT или BYTEA, например, JSON на 500 KiB или PNG-аватарку? Просто не сохранить нельзя.
Решение —
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.
Большой text/jsonb режется на чанки ~2 KiB и попадает в pg_toast.pg_toast_NNN. В исходной строке остаётся 18-байтный pointer.
Четыре стратегии: 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:
text-колонки получают EXTENDED по умолчанию, int/date/bool — PLAIN. Это нормально.
Если у тебя в bytea лежит уже сжатый контент (JPEG, ZIP), стоит выставить EXTERNAL:
Меняем стратегию для bytea-колонки с уже сжатыми данными:
Где живёт TOAST-таблица и как её найти
У каждой таблицы с varlena-колонками есть reltoastrelid в pg_class, указывающий на её TOAST-таблицу:
Найдём TOAST-таблицу для customers (если есть):
Если ни одна колонка не вытолкнулась в 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-таблица:
Видишь, что total существенно больше heap — это и есть TOAST в действии. Heap содержит только pointer’ы (18 байт на каждое выехавшее значение), реальные данные лежат в pg_toast.pg_toast_<oid>.
Чек-лист
- 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 *детоастит всё, даже неиспользуемые колонки. Выбирай нужные колонки явно.