TOAST — хранение больших значений

Предпосылки: страницы и кортежи, типы данных и NULL.

Страницы и кортежи | Физическая структура хранения

Страница вмещает ~8 КБ. Одна версия строки (heap tuple) должна поместиться в одну страницу. Как хранить TEXT на мегабайты?

Проблема: tuple не влезает в страницу

CREATE TABLE documents (
    id SERIAL,
    content TEXT  -- может быть мегабайтами
);
 
INSERT INTO documents (content) VALUES ('... 5 MB текста ...');

5 MB не влезет в 8 KB страницу. Более того, PostgreSQL старается сжимать/выносить большие поля так, чтобы итоговый heap tuple обычно укладывался в несколько килобайт (порядка пары KB) — так на странице помещается больше строк, и уменьшается давление на I/O и кеш.

TOAST — решение

TOAST (The Oversized-Attribute Storage Technique) — техника хранения слишком больших атрибутов.

Почему такое название:

  • Oversized — «слишком большой» (превышает порог)
  • Attribute — атрибут (колонка) строки
  • Storage Technique — техника хранения

Также игра слов: данные «нарезаются ломтиками» (chunks), как хлеб для тостов.

Как работает TOAST

Идея: большие значения выносятся в отдельную TOAST-таблицу, нарезанные на куски. В основной таблице остаётся маленький указатель.

Основная таблица documents:
┌─────────────────────────────────────┐
│ Tuple:                              │
│   id = 1                            │
│   content = [TOAST pointer] ─────────────┐
└─────────────────────────────────────┘    │
     десятки байт вместо 5 MB              │
                                           v
TOAST-таблица pg_toast.pg_toast_XXXX (отдельный файл):
┌─────────────────────────────────────┐
│ Chunk 0: первые ~2000 байт          │
│ Chunk 1: следующие ~2000 байт       │
│ ...                                 │
│ Chunk 2499: последние байты         │
└─────────────────────────────────────┘

TOAST-таблица — отдельный файл

TOAST-таблица создаётся автоматически при создании таблицы с колонками, которые могут быть большими (TEXT, BYTEA, JSONB, массивы, составные типы).

base/16384/                      ← директория базы данных
    ├── 16385                    ← основная таблица documents
    ├── 16385_fsm
    ├── 16385_vm

    ├── 16388                    ← TOAST-таблица для documents
    ├── 16388_fsm                   (pg_toast.pg_toast_16385)
    └── 16388_vm

У TOAST-таблицы своя FSM (карта свободного места), своя VM (карта видимости). Фоновая очистка (VACUUM) основной таблицы автоматически обрабатывает и TOAST-таблицу.

Структура TOAST-таблицы

TOAST-таблица имеет фиксированную структуру из трёх колонок:

chunk_id   -- уникальный числовой идентификатор значения (какому значению принадлежит кусок)
chunk_seq  -- порядковый номер куска (0, 1, 2, ...)
chunk_data -- сами данные (до ~2000 байт)

TOAST-таблица имеет индекс по (chunk_id, chunk_seq) — для быстрого чтения всех кусков одного значения.

Значение 5 MB с va_extsize = 1,200,000 (после сжатия) разбивается на сотни chunks (порядка ceil(1_200_000 / ~2 KB) ≈ 600).

TOAST pointer

Вместо огромного значения в tuple основной таблицы хранится небольшой TOAST pointer (порядка десятков байт):

TOAST pointer (varatt_external):
┌─────────────────────────────────────┐
│ va_rawsize: 5,000,000               │  ← размер оригинальных данных
│ va_extsize: 1,200,000               │  ← размер в TOAST-таблице (после сжатия)
│ va_toastrelid: 16388                │  ← OID TOAST-таблицы (числовой идентификатор объекта)
│ va_valueid: 12345                   │  ← chunk_id (идентификатор значения)
└─────────────────────────────────────┘

PostgreSQL знает из va_extsize, сколько байт читать из TOAST-таблицы, а значит сколько chunks.

Четыре стратегии TOAST

Для каждой колонки PostgreSQL выбирает стратегию обработки больших значений.

СтратегияКодПоведениеКогда использовать
PLAINpБез TOAST, значение хранится как естьТипы фиксированного размера (INTEGER, BOOLEAN)
EXTENDEDxСжать, если большое — вынести в TOASTПо умолчанию для TEXT, JSONB, BYTEA
EXTERNALeВынести без сжатияДанные уже сжаты (JPEG, ZIP), или нужен быстрый доступ к части значения
MAINmСжать, выносить только в крайнем случаеХотим держать данные вместе для производительности

Порог TOAST: ~2 KB (TOAST_TUPLE_THRESHOLD). Значения меньше порога хранятся в tuple. Больше — применяется стратегия.

-- Посмотреть стратегии колонок
SELECT attname, attstorage FROM pg_attribute
WHERE attrelid = 'documents'::regclass AND attnum > 0;
 
-- p = PLAIN, x = EXTENDED, e = EXTERNAL, m = MAIN
 
-- Изменить стратегию
ALTER TABLE documents ALTER COLUMN content SET STORAGE EXTERNAL;

Жизненный цикл TOAST-значения

1. INSERT с большим значением:
   - PostgreSQL видит: значение > 2 KB
   - Применяет стратегию (сжатие, вынос)
   - Создаёт chunks в TOAST-таблице
   - В основную таблицу пишет TOAST pointer
 
2. SELECT с TOAST-колонкой:
   - Читает tuple из основной таблицы
   - Видит TOAST pointer
   - По chunk_id читает все chunks из TOAST-таблицы
   - Собирает значение, распаковывает если сжато
 
3. UPDATE значения:
   - Старые chunks помечаются как неактуальные
   - Создаются новые chunks с новым chunk_id
 
4. DELETE строки или UPDATE другой колонки:
   - Chunks остаются (могут быть видны старым транзакциям)
 
5. VACUUM основной таблицы:
   - Автоматически запускает VACUUM TOAST-таблицы
   - Удаляет осиротевшие chunks (чей chunk_id больше не нужен)

Влияние на производительность

SELECT без TOAST-колонки — TOAST-таблица не читается:

SELECT id FROM documents WHERE id = 5;
-- Читает только основную таблицу. Быстро.

SELECT с TOAST-колонкой — дополнительный I/O:

SELECT id, content FROM documents WHERE id = 5;
 
1. Прочитать tuple из основной таблицы
2. Найти TOAST pointer
3. Index scan по TOAST-индексу: найти chunks с chunk_id = 12345
4. Прочитать все chunks (сотни строк TOAST-таблицы, обычно это десятки–сотни страниц)
5. Собрать значение из chunks
6. Распаковать (если было сжато)

Даже после сжатия мегабайтное значение — это сотни chunks и заметный объём I/O. Пользователь видит: запрос, который «логически» должен быть быстрым, иногда внезапно упирается в чтение с диска и занимает секунды.

Рекомендация: Не SELECT * для таблиц с большими колонками.

-- Плохо: читаем TOAST для каждой строки
SELECT * FROM documents WHERE created_at > '2024-01-01';
 
-- Хорошо: читаем только нужные колонки
SELECT id, title, created_at FROM documents WHERE created_at > '2024-01-01';

Когда EXTERNAL лучше EXTENDED:

EXTENDED сжимает данные. Чтобы прочитать 100 байт из середины 5 MB текста — нужно распаковать всё значение.

EXTERNAL не сжимает. Можно читать произвольные куски без распаковки. Полезно для уже сжатых данных (изображения, архивы) или когда нужен частичный доступ.

TOAST решает проблему хранения больших значений в рамках страничной организации. Полная картина физического хранения — от кластера до форков — описана в заметке о физической структуре хранения.

Sources


Страницы и кортежи | Физическая структура хранения