PostgreSQL

Storage: pages, tuples, TOAST

Каждая строка которую добавляют в PostgreSQL - это не просто данные. Это структурированный объект с 23-байтным заголовком, физическим адресом, ссылками на транзакции. Каждый UPDATE создаёт новую копию. Каждое большое поле прозрачно перемещается в отдельную таблицу. Понимание этого уровня объясняет почему база 'пухнет', почему UPDATE медленнее INSERT и как оптимизировать хранение для конкретного паттерна нагрузки.

  • **GitLab (2021):** обнаружили что таблица merge_request_diffs занимала 4TB из-за хранения больших JSON-диффов с EXTENDED-стратегией. Переход на EXTERNAL для binary_data колонки + внешнее объектное хранилище снизил размер таблицы до 800GB.
  • **Shopify:** для таблицы orders с миллиардами записей установили fillfactor=70. Это дало 85% HOT updates для колонки status, убрав нагрузку на индексы при массовых обновлениях статуса заказов во время Black Friday.
  • **Notion (2022):** при росте базы до 10TB обнаружили что таблица blocks раздулась до 30% dead tuples из-за частых updates контента. Настройка autovacuum scale factor с 20% до 5% для этой таблицы снизила bloat до 3%.

Страница 8KB: анатомия хранения

PostgreSQL хранит все данные в страницах фиксированного размера 8192 байт (8KB). Каждый файл таблицы или индекса состоит из последовательности таких страниц. Понимание layout страницы объясняет почему MVCC создаёт bloat и как работают heap-only tuple updates.

Размер страницы 8KB выбирался при создании PostgreSQL как компромисс между эффективностью для больших объектов и накладными расходами на маленькие. Изменить его можно только при компиляции (--with-blocksize). RDS и большинство managed-провайдеров используют стандартные 8KB.

В каком направлении растут item pointers и tuples внутри страницы PostgreSQL?

Heap tuples: что внутри каждой строки

Каждая строка таблицы - это heap tuple. Tuple состоит из заголовка (HeapTupleHeader, 23 байта) и данных колонок. Заголовок содержит всю MVCC-информацию: xmin, xmax, ctid.

UPDATE в PostgreSQL не изменяет строку на месте. Создаётся новый tuple с новыми данными, старый помечается как мёртвый (t_xmax заполняется). Это основа MVCC - читатели видят старую версию, пока пишущая транзакция не закоммитится. Но это означает bloat: каждый UPDATE оставляет мёртвый tuple до следующего VACUUM.

Зачем старый tuple после UPDATE помечается как мёртвый (t_xmax заполняется) вместо того чтобы сразу удаляться?

TOAST: большие значения за пределами страницы

Tuple не может быть больше страницы (8KB). Но колонки типа text, bytea, jsonb могут быть гигабайтами. TOAST (The Oversized-Attribute Storage Technique) решает это: большие значения хранятся в отдельной toast-таблице, а в основной строке только pointer.

GitLab хранит git blobs в PostgreSQL через TOAST. При анализе performance они обнаружили что TOAST с EXTENDED-стратегией тратил ~15% CPU на сжатие/распаковку бинарных данных. Смена на EXTERNAL для колонки blob_data снизила CPU на 8% при тех же запросах.

Когда стоит использовать EXTERNAL вместо EXTENDED для TOAST-колонки?

Fillfactor: место для HOT updates

Fillfactor определяет до какого процента заполняется страница при INSERT. Оставшееся место резервируется для UPDATE. Это критично для HOT (Heap Only Tuple) updates - механизма, позволяющего обновлять строку без обновления индексов.

Таблица orders с fillfactor=100 показывает 0% HOT updates. Почему снижение fillfactor до 70 должно помочь?

ctid и TID Scan: прямой доступ к tuple

ctid (current tuple ID) - физический адрес строки в формате (page, offset). Это единственный встроенный способ напрямую обратиться к конкретному tuple на конкретной странице, минуя индекс.

ctid используется в HOT chain: если строка обновлялась несколько раз, старые tuple'ы через t_ctid указывают на следующую версию. PostgreSQL следует по цепочке до актуального tuple. VACUUM убирает мёртвые звенья цепочки.

TOAST всегда медленнее чем хранение данных inline в основной таблице

TOAST с EXTENDED-стратегией часто быстрее для больших значений: сжатые данные занимают меньше места, что снижает I/O при чтении. Если запросы обращаются только к малым колонкам (id, status) и никогда не читают большое поле, TOAST даёт выигрыш - основная страница меньше, в неё помещается больше строк.

Производительность TOAST зависит от паттерна доступа. Если почти все запросы читают большое поле - TOAST добавляет лишний lookup в pg_toast таблицу. Если большое поле редко читается - TOAST делает основную таблицу компактнее и ускоряет запросы по другим колонкам.

Что произойдёт с ctid строки после VACUUM FULL?

Итоги

  • **8KB страница - unit of I/O:** всё хранится в страницах. Item pointers и tuples растут навстречу друг другу. Свободное место между ними - ресурс для новых строк.
  • **UPDATE = новый tuple:** MVCC требует immutable tuples. Каждый UPDATE создаёт новую копию и оставляет мёртвый tuple до VACUUM. fillfactor=70-80 резервирует место для HOT updates без обновления индексов.
  • **TOAST прозрачен но не бесплатен:** значения >2KB автоматически уходят в отдельную таблицу. Стратегию хранения (EXTENDED/EXTERNAL) стоит настраивать под тип данных.

Связанные темы

Storage-уровень PostgreSQL связан с несколькими механизмами выше:

  • MVCC и видимость строк — xmin/xmax в HeapTupleHeader - это физическая реализация MVCC. Mёртвые tuples от UPDATE - прямое следствие MVCC-архитектуры
  • VACUUM и bloat — VACUUM убирает мёртвые tuples из heap страниц и восстанавливает свободное место. Bloat - накопленные мёртвые tuples без своевременного VACUUM
  • Buffer Cache и Shared Buffers — Страницы таблиц кешируются в shared_buffers. Buffer manager работает именно с 8KB страницами - единицей кеширования совпадает с единицей хранения

Вопросы для размышления

  • Таблица events имеет 100M строк, 80% UPDATE (обновление поля processed=true). Как fillfactor, autovacuum и HOT updates взаимодействуют в этом сценарии? Какой fillfactor был бы оптимален?
  • Почему индекс на JSONB-колонку с большими документами может быть значительно меньше самой таблицы? Как TOAST влияет на размер индексов?
  • Можно ли использовать ctid для пагинации ('keyset pagination по физическому порядку')? Какие риски несёт такой подход?

Связанные уроки

  • db-09-indexes-btree
Storage: pages, tuples, TOAST

0

1

Войти